api update

This commit is contained in:
mohanmd 2026-06-02 12:54:24 +05:30
parent 9af2d5bc4e
commit b42bd4c6c5
20 changed files with 1586 additions and 108 deletions

View File

@ -22,9 +22,14 @@ public function getAnnouncements(Request $request)
$employer = $request->attributes->get('employer'); $employer = $request->attributes->get('employer');
try { try {
$announcements = Announcement::where('employer_id', $employer->id) $page = (int)$request->input('page', 1);
->latest() $perPage = (int)$request->input('per_page', 15);
->get()
$query = Announcement::where('employer_id', $employer->id)->latest();
$total = $query->count();
$offset = ($page - 1) * $perPage;
$announcements = $query->skip($offset)->take($perPage)->get()
->map(function ($announcement) { ->map(function ($announcement) {
return [ return [
'id' => $announcement->id, 'id' => $announcement->id,
@ -39,7 +44,13 @@ public function getAnnouncements(Request $request)
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'data' => [ 'data' => [
'announcements' => $announcements 'announcements' => $announcements,
'pagination' => [
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => max(1, (int)ceil($total / $perPage)),
]
] ]
], 200); ], 200);

View File

@ -435,4 +435,142 @@ public function plans()
] ]
]); ]);
} }
public function forgotPassword(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => 'required|email',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors()
], 422);
}
try {
$user = User::where('email', $request->email)->where('role', 'employer')->first();
if (!$user) {
return response()->json([
'success' => false,
'message' => 'No employer account found with this email address.'
], 404);
}
$otp = '111111'; // default development OTP
if (app()->environment('production')) {
$otp = strval(rand(100000, 999999));
}
\Illuminate\Support\Facades\Cache::put('employer_reset_otp_' . $request->email, [
'code' => $otp,
'expires_at' => now()->addMinutes(10)->timestamp
], 600);
// Send reset OTP email
try {
\Illuminate\Support\Facades\Mail::to($request->email)->send(new \App\Mail\EmployerResetOtpMail(
$otp,
$user->name
));
} catch (\Exception $mailEx) {
logger()->error('API Employer Forgot Password Mail Failure: ' . $mailEx->getMessage());
}
return response()->json([
'success' => true,
'message' => 'Password reset verification code sent to email successfully.',
'email' => $request->email
], 200);
} catch (\Exception $e) {
logger()->error('API Employer Forgot Password Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred. Please try again.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
public function resetPassword(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => 'required|email',
'otp' => 'required|string|size:6',
'password' => 'required|string|min:8|confirmed',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors()
], 422);
}
$cachedData = \Illuminate\Support\Facades\Cache::get('employer_reset_otp_' . $request->email);
if (!$cachedData || !isset($cachedData['code']) || !isset($cachedData['expires_at'])) {
return response()->json([
'success' => false,
'message' => 'Invalid or expired verification code.'
], 400);
}
if ($cachedData['code'] !== $request->otp || now()->timestamp > $cachedData['expires_at']) {
return response()->json([
'success' => false,
'message' => 'Invalid or expired verification code.'
], 400);
}
try {
$user = User::where('email', $request->email)->where('role', 'employer')->first();
$sponsor = \App\Models\Sponsor::where('email', $request->email)->first();
if (!$user || !$sponsor) {
return response()->json([
'success' => false,
'message' => 'User account not found.'
], 404);
}
$apiToken = Str::random(80);
\Illuminate\Support\Facades\DB::transaction(function () use ($request, $user, $sponsor, $apiToken) {
$hashedPassword = Hash::make($request->password);
$user->update([
'password' => $hashedPassword,
'api_token' => $apiToken,
]);
$sponsor->update([
'password' => $hashedPassword,
]);
});
\Illuminate\Support\Facades\Cache::forget('employer_reset_otp_' . $request->email);
return response()->json([
'success' => true,
'message' => 'Password reset successfully.',
'data' => [
'employer' => $user->load('employerProfile'),
'token' => $apiToken
]
], 200);
} catch (\Exception $e) {
logger()->error('API Employer Reset Password Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'Failed to reset password. Please try again.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
} }

View File

@ -101,6 +101,7 @@ public function getConversations(Request $request)
'worker_name' => $conv->worker->name ?? 'Candidate', 'worker_name' => $conv->worker->name ?? 'Candidate',
'category' => $conv->worker->category->name ?? 'General Helper', 'category' => $conv->worker->category->name ?? 'General Helper',
'nationality' => $conv->worker->nationality ?? 'Unknown', 'nationality' => $conv->worker->nationality ?? 'Unknown',
'worker_status' => strtolower($conv->worker->status ?? 'active'),
'salary' => ($conv->worker->salary ?? $conv->worker->expected_salary ?? 2000) . ' AED', 'salary' => ($conv->worker->salary ?? $conv->worker->expected_salary ?? 2000) . ' AED',
'last_message' => $lastMsg->text ?? 'No messages yet.', 'last_message' => $lastMsg->text ?? 'No messages yet.',
'unread_count' => $unreadCount, 'unread_count' => $unreadCount,
@ -176,6 +177,7 @@ public function getMessages(Request $request, $id)
'worker_name' => $conv->worker->name ?? 'Candidate', 'worker_name' => $conv->worker->name ?? 'Candidate',
'category' => $conv->worker->category->name ?? 'General Helper', 'category' => $conv->worker->category->name ?? 'General Helper',
'nationality' => $conv->worker->nationality ?? 'Unknown', 'nationality' => $conv->worker->nationality ?? 'Unknown',
'worker_status' => strtolower($conv->worker->status ?? 'active'),
'salary' => ($conv->worker->salary ?? $conv->worker->expected_salary ?? 2000) . ' AED', 'salary' => ($conv->worker->salary ?? $conv->worker->expected_salary ?? 2000) . ' AED',
]; ];

View File

@ -51,9 +51,14 @@ public function getPayments(Request $request)
]); ]);
} }
$payments = Payment::where('user_id', $employerId) $page = (int)$request->input('page', 1);
->latest() $perPage = (int)$request->input('per_page', 15);
->get()
$query = Payment::where('user_id', $employerId)->latest();
$total = $query->count();
$offset = ($page - 1) * $perPage;
$payments = $query->skip($offset)->take($perPage)->get()
->map(function ($payment) { ->map(function ($payment) {
return [ return [
'id' => $payment->id, 'id' => $payment->id,
@ -69,7 +74,13 @@ public function getPayments(Request $request)
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'data' => [ 'data' => [
'payments' => $payments 'payments' => $payments,
'pagination' => [
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => max(1, (int)ceil($total / $perPage)),
]
] ]
], 200); ], 200);

View File

@ -158,4 +158,106 @@ public function updateProfile(Request $request)
], 500); ], 500);
} }
} }
/**
* Get dashboard analytics and summary for employer.
*/
public function getDashboard(Request $request)
{
/** @var User $employer */
$employer = $request->attributes->get('employer');
try {
$contactedWorkersCount = \App\Models\Conversation::where('employer_id', $employer->id)->count();
$totalHiredWorkers = \App\Models\JobOffer::where('employer_id', $employer->id)->where('status', 'accepted')->count() +
\App\Models\JobApplication::whereHas('jobPost', function($q) use ($employer) {
$q->where('employer_id', $employer->id);
})->where('status', 'hired')->count();
$savedCandidates = \App\Models\Shortlist::where('employer_id', $employer->id)->count();
// Resolve plan
$sub = \Illuminate\Support\Facades\DB::table('subscriptions')
->where('user_id', $employer->id)
->where('status', 'active')
->latest('id')
->first();
$currentPlan = [
'plan_id' => $sub ? $sub->plan_id : 'premium',
'name' => $sub ? (ucfirst($sub->plan_id) . ' Pass') : 'Premium Employer Pass',
'status' => $sub ? $sub->status : 'active',
'starts_at' => $sub ? date('Y-m-d', strtotime($sub->starts_at)) : now()->subDays(6)->format('Y-m-d'),
'expires_at' => $sub ? date('Y-m-d', strtotime($sub->expires_at)) : now()->addDays(24)->format('Y-m-d'),
];
// Recent Announcements / Events
$dbAnnouncements = \App\Models\Announcement::latest()->limit(5)->get();
$recentAnnouncements = $dbAnnouncements->map(function ($ann) {
$body = $ann->body;
$eventDate = null;
$eventTime = null;
$locationDetails = null;
$locationPin = null;
$providedItems = null;
if (strpos($ann->body, '{"type":"Charity"') === 0) {
$decoded = json_decode($ann->body, true);
if ($decoded) {
$body = $decoded['content'] ?? $ann->body;
$eventDate = $decoded['event_date'] ?? null;
$eventTime = $decoded['event_time'] ?? null;
$locationDetails = $decoded['location_details'] ?? null;
$locationPin = $decoded['location_pin'] ?? null;
$providedItems = $decoded['provided_items'] ?? null;
}
} else {
$body = $ann->body;
$eventDate = $ann->created_at->addDays(2)->format('Y-m-d');
$eventTime = '9:00 AM - 3:00 PM';
$locationDetails = 'Al Quoz Community Center, Dubai';
$locationPin = 'https://maps.google.com';
$providedItems = 'Free Medical Checks & Food Supplies';
}
return [
'id' => $ann->id,
'title' => $ann->title,
'body' => $body,
'is_charity' => true,
'event_date' => $eventDate,
'event_time' => $eventTime,
'location_details' => $locationDetails,
'location_pin' => $locationPin,
'provided_items' => $providedItems,
'created_at' => $ann->created_at->toISOString(),
'time_ago' => $ann->created_at->diffForHumans(),
];
});
return response()->json([
'success' => true,
'data' => [
'stats' => [
'contacted_workers_count' => $contactedWorkersCount,
'total_hired_workers' => $totalHiredWorkers,
'saved_candidates' => $savedCandidates,
],
'current_plan' => $currentPlan,
'recent_announcements' => $recentAnnouncements,
'recent_events' => $recentAnnouncements, // alias for ease
]
], 200);
} catch (\Exception $e) {
logger()->error('Mobile Employer Get Dashboard Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while fetching the dashboard statistics.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
} }

View File

@ -140,5 +140,62 @@ public function editReview(Request $request, $id)
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500); ], 500);
} }
}
/**
* Get reviews posted by this employer.
* GET /api/employers/reviews
*/
public function getReviews(Request $request)
{
/** @var User $employer */
$employer = $request->attributes->get('employer');
try {
$page = (int)$request->input('page', 1);
$perPage = (int)$request->input('per_page', 15);
$query = Review::where('employer_id', $employer->id)
->with('worker')
->latest();
$total = $query->count();
$offset = ($page - 1) * $perPage;
$reviews = $query->skip($offset)->take($perPage)->get()
->map(function ($rev) {
return [
'id' => $rev->id,
'worker_id' => $rev->worker_id,
'worker_name' => $rev->worker->name ?? 'Worker',
'rating' => $rev->rating,
'comment' => $rev->comment,
'created_at' => $rev->created_at->toISOString(),
'time_ago' => $rev->created_at->diffForHumans(),
];
});
return response()->json([
'success' => true,
'data' => [
'reviews' => $reviews,
'pagination' => [
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => max(1, (int)ceil($total / $perPage)),
]
]
], 200);
} catch (\Exception $e) {
logger()->error('Mobile API Get Employer Reviews Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while fetching reviews.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
} }
} }

View File

@ -65,12 +65,10 @@ private function formatWorker(Worker $w)
'nationality' => $w->nationality, 'nationality' => $w->nationality,
'photo' => $photo, 'photo' => $photo,
'emirates_id_status' => $emiratesIdStatus, 'emirates_id_status' => $emiratesIdStatus,
'category' => $w->category ? $w->category->name : 'Domestic Worker',
'skills' => $mappedSkills, 'skills' => $mappedSkills,
'availability_status' => $availabilityStatus, 'availability_status' => $availabilityStatus,
'visa_status' => $visaStatus, 'visa_status' => $visaStatus,
'experience' => $w->experience, 'experience' => $w->experience,
'salary' => (int)$w->salary,
'religion' => $w->religion, 'religion' => $w->religion,
'languages' => $langs, 'languages' => $langs,
'age' => $w->age, 'age' => $w->age,
@ -103,34 +101,66 @@ public function getWorkers(Request $request)
}); });
} }
// Apply category filter if provided
if ($request->filled('category_id')) {
$query->where('category_id', $request->category_id);
}
// Apply nationality filter if provided
if ($request->filled('nationality')) {
$query->where('nationality', $request->nationality);
}
// Apply salary filters
if ($request->filled('min_salary')) {
$query->where('salary', '>=', $request->min_salary);
}
if ($request->filled('max_salary')) {
$query->where('salary', '<=', $request->max_salary);
}
$dbWorkers = $query->latest()->get(); $dbWorkers = $query->latest()->get();
$formattedWorkers = $dbWorkers->map(function ($w) { $formattedWorkers = $dbWorkers->map(function ($w) {
return $this->formatWorker($w); return $this->formatWorker($w);
}); })->filter()->values();
$workersArray = $formattedWorkers->toArray();
// Apply filters: skills, language/languages, nationality, availability
if ($request->filled('nationality')) {
$nationality = strtolower($request->nationality);
$workersArray = array_values(array_filter($workersArray, function ($c) use ($nationality) {
return isset($c['nationality']) && strtolower($c['nationality']) === $nationality;
}));
}
if ($request->filled('availability')) {
$availability = strtolower($request->availability);
$workersArray = array_values(array_filter($workersArray, function ($c) use ($availability) {
$val = $c['availability_status'] ?? ($c['availability'] ?? '');
return strtolower($val) === $availability;
}));
}
if ($request->filled('skills')) {
$skills = $request->skills;
$skillsArray = is_array($skills) ? $skills : array_filter(array_map('trim', explode(',', $skills)));
$skillsArray = array_map('strtolower', $skillsArray);
$workersArray = array_values(array_filter($workersArray, function ($c) use ($skillsArray) {
if (!isset($c['skills']) || !is_array($c['skills'])) return false;
foreach ($c['skills'] as $s) {
if (in_array(strtolower($s), $skillsArray)) {
return true;
}
}
return false;
}));
}
$langParam = $request->input('language') ?? $request->input('languages');
if ($langParam) {
$langsArray = is_array($langParam) ? $langParam : array_filter(array_map('trim', explode(',', $langParam)));
$langsArray = array_map('strtolower', $langsArray);
$workersArray = array_values(array_filter($workersArray, function ($c) use ($langsArray) {
if (!isset($c['languages']) || !is_array($c['languages'])) return false;
foreach ($c['languages'] as $l) {
if (in_array(strtolower($l), $langsArray)) {
return true;
}
}
return false;
}));
}
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'data' => [ 'data' => [
'workers' => $formattedWorkers 'workers' => $workersArray
] ]
], 200); ], 200);
@ -159,9 +189,29 @@ public function getCandidates(Request $request)
// Fetch Job Applications // Fetch Job Applications
$jobIds = JobPost::where('employer_id', $employerId)->pluck('id'); $jobIds = JobPost::where('employer_id', $employerId)->pluck('id');
$applications = JobApplication::whereIn('job_id', $jobIds) $applicationsQuery = JobApplication::whereIn('job_id', $jobIds)
->with(['worker.category', 'jobPost']) ->with(['worker.category', 'jobPost']);
->get();
// Fetch Direct Hiring Offers
$directOffersQuery = JobOffer::where('employer_id', $employerId)
->with('worker.category');
// Apply search filter if provided
if ($request->filled('search')) {
$search = $request->search;
$applicationsQuery->whereHas('worker', function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('nationality', 'like', "%{$search}%")
->orWhere('religion', 'like', "%{$search}%");
});
$directOffersQuery->whereHas('worker', function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('nationality', 'like', "%{$search}%")
->orWhere('religion', 'like', "%{$search}%");
});
}
$applications = $applicationsQuery->get();
$selectedWorkers = $applications->map(function ($app) { $selectedWorkers = $applications->map(function ($app) {
$w = $app->worker; $w = $app->worker;
@ -174,23 +224,29 @@ public function getCandidates(Request $request)
elseif ($app->status === 'applied') $status = 'Reviewing'; elseif ($app->status === 'applied') $status = 'Reviewing';
else $status = ucfirst($app->status); else $status = ucfirst($app->status);
$langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : (($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic']);
$skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'];
$mappedSkills = [
$skillsList[$w->id % 6],
$skillsList[($w->id + 2) % 6]
];
return [ return [
'id' => $app->id, 'id' => $app->id,
'worker_id' => $w->id, 'worker_id' => $w->id,
'name' => $w->name, 'name' => $w->name,
'nationality' => $w->nationality, 'nationality' => $w->nationality,
'category' => $w->category ? $w->category->name : 'General Helper',
'salary' => (int)$w->salary,
'status' => $status, 'status' => $status,
'applied_at' => $app->created_at->format('Y-m-d H:i:s'), 'applied_at' => $app->created_at->format('Y-m-d H:i:s'),
'type' => 'application', 'type' => 'application',
'skills' => $mappedSkills,
'languages' => $langs,
'availability' => $w->availability ?? 'Immediate',
]; ];
})->filter()->values()->toArray(); })->filter()->values()->toArray();
// Fetch Direct Hiring Offers // Fetch Direct Hiring Offers
$directOffers = JobOffer::where('employer_id', $employerId) $directOffers = $directOffersQuery->get();
->with('worker.category')
->get();
$directWorkers = $directOffers->map(function ($offer) { $directWorkers = $directOffers->map(function ($offer) {
$w = $offer->worker; $w = $offer->worker;
@ -201,16 +257,24 @@ public function getCandidates(Request $request)
elseif ($offer->status === 'rejected') $status = 'Rejected'; elseif ($offer->status === 'rejected') $status = 'Rejected';
elseif ($offer->status === 'pending') $status = 'Offer Sent'; elseif ($offer->status === 'pending') $status = 'Offer Sent';
$langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : (($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic']);
$skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'];
$mappedSkills = [
$skillsList[$w->id % 6],
$skillsList[($w->id + 2) % 6]
];
return [ return [
'id' => 'offer_' . $offer->id, 'id' => 'offer_' . $offer->id,
'worker_id' => $w->id, 'worker_id' => $w->id,
'name' => $w->name, 'name' => $w->name,
'nationality' => $w->nationality, 'nationality' => $w->nationality,
'category' => $w->category ? $w->category->name : 'General Helper',
'salary' => (int)$offer->salary,
'status' => $status, 'status' => $status,
'applied_at' => $offer->created_at->format('Y-m-d H:i:s'), 'applied_at' => $offer->created_at->format('Y-m-d H:i:s'),
'type' => 'direct_offer', 'type' => 'direct_offer',
'skills' => $mappedSkills,
'languages' => $langs,
'availability' => $w->availability ?? 'Immediate',
]; ];
})->filter()->values()->toArray(); })->filter()->values()->toArray();
@ -222,10 +286,69 @@ public function getCandidates(Request $request)
return $c && $c['status'] === 'Hired'; return $c && $c['status'] === 'Hired';
})); }));
// Apply filters: skills, languages, nationality, availability
if ($request->filled('nationality')) {
$nationality = strtolower($request->nationality);
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($nationality) {
return isset($c['nationality']) && strtolower($c['nationality']) === $nationality;
}));
}
if ($request->filled('availability')) {
$availability = strtolower($request->availability);
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($availability) {
return isset($c['availability']) && strtolower($c['availability']) === $availability;
}));
}
if ($request->filled('skills')) {
$skills = $request->skills;
$skillsArray = is_array($skills) ? $skills : array_filter(array_map('trim', explode(',', $skills)));
$skillsArray = array_map('strtolower', $skillsArray);
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($skillsArray) {
if (!isset($c['skills']) || !is_array($c['skills'])) return false;
foreach ($c['skills'] as $s) {
if (in_array(strtolower($s), $skillsArray)) {
return true;
}
}
return false;
}));
}
if ($request->filled('languages')) {
$languages = $request->languages;
$langsArray = is_array($languages) ? $languages : array_filter(array_map('trim', explode(',', $languages)));
$langsArray = array_map('strtolower', $langsArray);
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($langsArray) {
if (!isset($c['languages']) || !is_array($c['languages'])) return false;
foreach ($c['languages'] as $l) {
if (in_array(strtolower($l), $langsArray)) {
return true;
}
}
return false;
}));
}
$page = (int)$request->input('page', 1);
$perPage = (int)$request->input('per_page', 15);
$total = count($mergedCandidates);
$offset = ($page - 1) * $perPage;
$paginatedCandidates = array_slice($mergedCandidates, $offset, $perPage);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'data' => [ 'data' => [
'candidates' => $mergedCandidates 'candidates' => $paginatedCandidates,
'pagination' => [
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => max(1, (int)ceil($total / $perPage)),
]
] ]
], 200); ], 200);
@ -470,8 +593,6 @@ public function getWorkerDetail(Request $request, $id)
'id' => $sw->id, 'id' => $sw->id,
'name' => $sw->name, 'name' => $sw->name,
'nationality' => $sw->nationality, 'nationality' => $sw->nationality,
'category' => $sw->category ? $sw->category->name : 'Helper',
'salary' => (int)$sw->salary,
'rating' => 4.7, 'rating' => 4.7,
'verified' => (bool)$sw->verified, 'verified' => (bool)$sw->verified,
'availability_status' => $availabilityStatus, 'availability_status' => $availabilityStatus,
@ -489,13 +610,11 @@ public function getWorkerDetail(Request $request, $id)
'nationality' => $w->nationality, 'nationality' => $w->nationality,
'photo' => $photo, 'photo' => $photo,
'emirates_id_status' => $emiratesIdStatus, 'emirates_id_status' => $emiratesIdStatus,
'category' => $w->category ? $w->category->name : 'Domestic Worker',
'skills' => $mappedSkills, 'skills' => $mappedSkills,
'availability_status' => $availabilityStatus, 'availability_status' => $availabilityStatus,
'visa_status' => $visaStatus, 'visa_status' => $visaStatus,
'experience' => $w->experience, 'experience' => $w->experience,
'experience_years' => 5, 'experience_years' => 5,
'salary' => (int)$w->salary,
'religion' => $w->religion, 'religion' => $w->religion,
'languages' => $langs, 'languages' => $langs,
'age' => $w->age, 'age' => $w->age,
@ -526,5 +645,119 @@ public function getWorkerDetail(Request $request, $id)
], 500); ], 500);
} }
} }
/**
* GET /api/employers/shortlist
* Retrieve the list of shortlisted workers for the authenticated employer.
*/
public function getShortlist(Request $request)
{
/** @var User $employer */
$employer = $request->attributes->get('employer');
try {
$page = (int)$request->input('page', 1);
$perPage = (int)$request->input('per_page', 15);
$shortlists = Shortlist::where('employer_id', $employer->id)
->with(['worker.category', 'worker.skills'])
->get();
$formattedWorkers = $shortlists->map(function ($s) {
if ($s->worker) {
return $this->formatWorker($s->worker);
}
return null;
})->filter()->values();
$workersArray = $formattedWorkers->toArray();
$total = count($workersArray);
$offset = ($page - 1) * $perPage;
$paginatedWorkers = array_slice($workersArray, $offset, $perPage);
return response()->json([
'success' => true,
'data' => [
'workers' => $paginatedWorkers,
'pagination' => [
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => max(1, (int)ceil($total / $perPage)),
]
]
], 200);
} catch (\Exception $e) {
logger()->error('Mobile API Employer Get Shortlist Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while fetching the shortlist.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
/**
* POST /api/employers/shortlist
* Toggle a worker's shortlist status for the authenticated employer.
*/
public function toggleShortlist(Request $request)
{
/** @var User $employer */
$employer = $request->attributes->get('employer');
$validator = Validator::make($request->all(), [
'worker_id' => 'required|exists:workers,id',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors()
], 422);
}
try {
$workerId = $request->worker_id;
$existing = Shortlist::where('employer_id', $employer->id)
->where('worker_id', $workerId)
->first();
if ($existing) {
$existing->delete();
$action = 'removed';
} else {
Shortlist::create([
'employer_id' => $employer->id,
'worker_id' => $workerId,
]);
$action = 'added';
}
$shortlistedIds = Shortlist::where('employer_id', $employer->id)->pluck('worker_id')->toArray();
return response()->json([
'success' => true,
'message' => "Worker successfully {$action} from shortlist.",
'data' => [
'action' => $action,
'shortlisted_ids' => $shortlistedIds
]
], 200);
} catch (\Exception $e) {
logger()->error('Mobile API Employer Toggle Shortlist Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while toggling the shortlist.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
} }

View File

@ -17,9 +17,14 @@ class WorkerAnnouncementController extends Controller
public function getAnnouncements(Request $request) public function getAnnouncements(Request $request)
{ {
try { try {
$announcements = Announcement::with('employer.employerProfile') $page = (int)$request->input('page', 1);
->latest() $perPage = (int)$request->input('per_page', 15);
->get()
$query = Announcement::with('employer.employerProfile')->latest();
$total = $query->count();
$offset = ($page - 1) * $perPage;
$announcements = $query->skip($offset)->take($perPage)->get()
->map(function ($announcement) { ->map(function ($announcement) {
return [ return [
'id' => $announcement->id, 'id' => $announcement->id,
@ -36,7 +41,13 @@ public function getAnnouncements(Request $request)
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'data' => [ 'data' => [
'announcements' => $announcements 'announcements' => $announcements,
'pagination' => [
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => max(1, (int)ceil($total / $perPage)),
]
] ]
], 200); ], 200);

View File

@ -53,7 +53,7 @@ public function index(Request $request)
// 1. Stats // 1. Stats
$shortlistedCount = Shortlist::where('employer_id', $user->id)->count(); $shortlistedCount = Shortlist::where('employer_id', $user->id)->count();
$messagesSent = Message::where('sender_id', $user->id)->where('sender_type', 'employer')->count(); $messagesSent = Message::where('sender_id', $user->id)->where('sender_type', 'employer')->count();
$pendingOffersCount = \App\Models\JobOffer::where('employer_id', $user->id)->where('status', 'pending')->count(); $contactedWorkersCount = Conversation::where('employer_id', $user->id)->count();
$hiredCount = \App\Models\JobOffer::where('employer_id', $user->id)->where('status', 'accepted')->count() + $hiredCount = \App\Models\JobOffer::where('employer_id', $user->id)->where('status', 'accepted')->count() +
\App\Models\JobApplication::whereHas('jobPost', function($q) use ($user) { \App\Models\JobApplication::whereHas('jobPost', function($q) use ($user) {
$q->where('employer_id', $user->id); $q->where('employer_id', $user->id);
@ -63,7 +63,7 @@ public function index(Request $request)
'shortlisted_count' => $shortlistedCount, 'shortlisted_count' => $shortlistedCount,
'messages_sent' => $messagesSent, 'messages_sent' => $messagesSent,
'days_remaining' => (int)$daysRemaining, 'days_remaining' => (int)$daysRemaining,
'pending_offers' => $pendingOffersCount, 'contacted_workers_count' => $contactedWorkersCount,
'hired_count' => $hiredCount, 'hired_count' => $hiredCount,
'analytics' => [ 'analytics' => [
'profile_views' => 48, 'profile_views' => 48,

View File

@ -426,4 +426,105 @@ public function logout(Request $request)
return redirect()->route('employer.login') return redirect()->route('employer.login')
->with('success', 'Logged out successfully.'); ->with('success', 'Logged out successfully.');
} }
public function forgotPassword(Request $request)
{
$request->validate([
'email' => 'required|email',
]);
$user = User::where('email', $request->email)->where('role', 'employer')->first();
if (!$user) {
return response()->json([
'message' => 'No employer account found with this email address.'
], 404);
}
$otp = '111111'; // default development OTP
if (app()->environment('production')) {
$otp = strval(rand(100000, 999999));
}
\Illuminate\Support\Facades\Cache::put('employer_reset_otp_' . $request->email, [
'code' => $otp,
'expires_at' => now()->addMinutes(10)->timestamp
], 600);
// Send reset OTP email
try {
Mail::to($request->email)->send(new \App\Mail\EmployerResetOtpMail(
$otp,
$user->name
));
} catch (\Exception $e) {
logger()->error('Web Employer Forgot Password Mail Failure: ' . $e->getMessage());
}
return response()->json([
'success' => true,
'message' => 'Verification code sent to your email.'
]);
}
public function resetPassword(Request $request)
{
$request->validate([
'email' => 'required|email',
'otp' => 'required|string|size:6',
'password' => [
'required',
'string',
'min:8',
'confirmed',
'regex:/[a-z]/', // 1 lowercase
'regex:/[A-Z]/', // 1 uppercase
'regex:/[0-9]/', // 1 number
'regex:/[^a-zA-Z0-9]/', // 1 special character
]
], [
'password.regex' => 'The password must contain at least 8 characters, including 1 uppercase, 1 lowercase, 1 number, and 1 special character.',
]);
$cachedData = \Illuminate\Support\Facades\Cache::get('employer_reset_otp_' . $request->email);
if (!$cachedData || !isset($cachedData['code']) || !isset($cachedData['expires_at'])) {
return response()->json([
'message' => 'Invalid or expired verification code.'
], 400);
}
if ($cachedData['code'] !== $request->otp || now()->timestamp > $cachedData['expires_at']) {
return response()->json([
'message' => 'Invalid or expired verification code.'
], 400);
}
$user = User::where('email', $request->email)->where('role', 'employer')->first();
$sponsor = \App\Models\Sponsor::where('email', $request->email)->first();
if (!$user || !$sponsor) {
return response()->json([
'message' => 'User account not found.'
], 404);
}
\Illuminate\Support\Facades\DB::transaction(function () use ($request, $user, $sponsor) {
$hashedPassword = Hash::make($request->password);
$user->update([
'password' => $hashedPassword,
]);
$sponsor->update([
'password' => $hashedPassword,
]);
});
\Illuminate\Support\Facades\Cache::forget('employer_reset_otp_' . $request->email);
return response()->json([
'success' => true,
'message' => 'Password reset successfully.'
]);
}
} }

View File

@ -109,8 +109,10 @@ public function index(Request $request)
$lastMsg = $conv->messages->last(); $lastMsg = $conv->messages->last();
return [ return [
'id' => $conv->id, 'id' => $conv->id,
'worker_id' => $conv->worker_id,
'worker_name' => $conv->worker->name ?? 'Candidate', 'worker_name' => $conv->worker->name ?? 'Candidate',
'category' => $conv->worker->category->name ?? 'General Helper', 'category' => $conv->worker->category->name ?? 'General Helper',
'worker_status' => strtolower($conv->worker->status ?? 'active'),
'last_message' => $lastMsg->text ?? 'No messages yet.', 'last_message' => $lastMsg->text ?? 'No messages yet.',
'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false, 'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false,
'online' => true, 'online' => true,
@ -139,8 +141,10 @@ public function show($id)
$lastMsg = $conv->messages->last(); $lastMsg = $conv->messages->last();
return [ return [
'id' => $conv->id, 'id' => $conv->id,
'worker_id' => $conv->worker_id,
'worker_name' => $conv->worker->name ?? 'Candidate', 'worker_name' => $conv->worker->name ?? 'Candidate',
'category' => $conv->worker->category->name ?? 'General Helper', 'category' => $conv->worker->category->name ?? 'General Helper',
'worker_status' => strtolower($conv->worker->status ?? 'active'),
'last_message' => $lastMsg->text ?? 'No messages yet.', 'last_message' => $lastMsg->text ?? 'No messages yet.',
'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false, 'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false,
'online' => true, 'online' => true,
@ -161,8 +165,10 @@ public function show($id)
$conversationData = [ $conversationData = [
'id' => $activeConv->id, 'id' => $activeConv->id,
'worker_id' => $activeConv->worker_id,
'worker_name' => $activeConv->worker->name ?? 'Candidate', 'worker_name' => $activeConv->worker->name ?? 'Candidate',
'category' => $activeConv->worker->category->name ?? 'General Helper', 'category' => $activeConv->worker->category->name ?? 'General Helper',
'worker_status' => strtolower($activeConv->worker->status ?? 'active'),
'online' => true, 'online' => true,
'salary' => ($activeConv->worker->expected_salary ?? 2000) . ' AED', 'salary' => ($activeConv->worker->expected_salary ?? 2000) . ' AED',
'nationality' => $activeConv->worker->nationality ?? 'Unknown', 'nationality' => $activeConv->worker->nationality ?? 'Unknown',

View File

@ -0,0 +1,46 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class EmployerResetOtpMail extends Mailable
{
use Queueable, SerializesModels;
public $otp;
public $employerName;
/**
* Create a new message instance.
*/
public function __construct($otp, $employerName)
{
$this->otp = $otp;
$this->employerName = $employerName;
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Password Reset Verification Code: ' . $this->otp,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
view: 'emails.employer-reset-otp',
);
}
}

View File

@ -1478,6 +1478,95 @@
} }
} }
}, },
"/employers/forgot-password": {
"post": {
"tags": [
"Employer/Auth"
],
"summary": "Request Password Reset Code",
"description": "Validates employer email and sends a 6-digit password reset verification code (OTP).",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string",
"example": "ahmad@example.com"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Password reset verification code sent successfully."
},
"404": {
"description": "No employer account found with this email."
}
}
}
},
"/employers/reset-password": {
"post": {
"tags": [
"Employer/Auth"
],
"summary": "Reset Password with Verification Code",
"description": "Verifies the 6-digit OTP code and updates the password for both employer user and sponsor records.",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"email",
"otp",
"password",
"password_confirmation"
],
"properties": {
"email": {
"type": "string",
"example": "ahmad@example.com"
},
"otp": {
"type": "string",
"example": "111111"
},
"password": {
"type": "string",
"example": "SecureNewPassword@123"
},
"password_confirmation": {
"type": "string",
"example": "SecureNewPassword@123"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Password reset successfully."
},
"400": {
"description": "Invalid or expired verification code."
}
}
}
},
"/employers/conversations": { "/employers/conversations": {
"get": { "get": {
"tags": [ "tags": [
@ -1597,6 +1686,28 @@
], ],
"summary": "Get Charity Events (Worker)", "summary": "Get Charity Events (Worker)",
"description": "Allows workers to retrieve the list of active charity events, food drives, and medical support programs scheduled in Dubai.", "description": "Allows workers to retrieve the list of active charity events, food drives, and medical support programs scheduled in Dubai.",
"parameters": [
{
"name": "page",
"in": "query",
"required": false,
"description": "Page number.",
"schema": {
"type": "integer",
"default": 1
}
},
{
"name": "per_page",
"in": "query",
"required": false,
"description": "Number of items per page.",
"schema": {
"type": "integer",
"default": 15
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "List of charity events retrieved successfully." "description": "List of charity events retrieved successfully."
@ -1611,6 +1722,28 @@
], ],
"summary": "Get Posted Charity Events (Employer)", "summary": "Get Posted Charity Events (Employer)",
"description": "Allows employers/sponsors to retrieve the list of charity events they have published.", "description": "Allows employers/sponsors to retrieve the list of charity events they have published.",
"parameters": [
{
"name": "page",
"in": "query",
"required": false,
"description": "Page number.",
"schema": {
"type": "integer",
"default": 1
}
},
{
"name": "per_page",
"in": "query",
"required": false,
"description": "Number of items per page.",
"schema": {
"type": "integer",
"default": 15
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "List of posted charity events retrieved successfully." "description": "List of posted charity events retrieved successfully."
@ -1705,6 +1838,20 @@
} }
} }
}, },
"/employers/dashboard": {
"get": {
"tags": [
"Employer/Profile"
],
"summary": "Get Employer Dashboard stats & recent events",
"description": "Retrieves stats including contacted workers count, total hired workers, saved candidates, current plan details, and recent announcements or events.",
"responses": {
"200": {
"description": "Employer dashboard stats retrieved successfully."
}
}
}
},
"/employers/profile": { "/employers/profile": {
"get": { "get": {
"tags": [ "tags": [
@ -1811,12 +1958,21 @@
} }
}, },
{ {
"name": "category_id", "name": "skills",
"in": "query", "in": "query",
"required": false, "required": false,
"description": "Filter by helper category ID.", "description": "Filter by skills (comma-separated list).",
"schema": { "schema": {
"type": "integer" "type": "string"
}
},
{
"name": "language",
"in": "query",
"required": false,
"description": "Filter by language.",
"schema": {
"type": "string"
} }
}, },
{ {
@ -1829,21 +1985,32 @@
} }
}, },
{ {
"name": "min_salary", "name": "availability",
"in": "query", "in": "query",
"required": false, "required": false,
"description": "Minimum desired salary range.", "description": "Filter by availability status.",
"schema": { "schema": {
"type": "number" "type": "string"
} }
}, },
{ {
"name": "max_salary", "name": "page",
"in": "query", "in": "query",
"required": false, "required": false,
"description": "Maximum desired salary range.", "description": "Page number.",
"schema": { "schema": {
"type": "number" "type": "integer",
"default": 1
}
},
{
"name": "per_page",
"in": "query",
"required": false,
"description": "Number of items per page.",
"schema": {
"type": "integer",
"default": 15
} }
} }
], ],
@ -2016,7 +2183,73 @@
], ],
"summary": "Get Candidate Applications and Offers", "summary": "Get Candidate Applications and Offers",
"description": "Returns all job applications and direct hiring offers connected to the authenticated sponsor account that have achieved 'Hired' status.", "description": "Returns all job applications and direct hiring offers connected to the authenticated sponsor account that have achieved 'Hired' status.",
"parameters": [], "parameters": [
{
"name": "search",
"in": "query",
"required": false,
"description": "Search term matching helper name, nationality, or religion.",
"schema": {
"type": "string"
}
},
{
"name": "skills",
"in": "query",
"required": false,
"description": "Filter candidates by skills (comma-separated list of skill names, e.g. cooking,driving).",
"schema": {
"type": "string"
}
},
{
"name": "languages",
"in": "query",
"required": false,
"description": "Filter candidates by languages spoken (comma-separated list of language names, e.g. English,Arabic).",
"schema": {
"type": "string"
}
},
{
"name": "nationality",
"in": "query",
"required": false,
"description": "Filter candidates by nationality name.",
"schema": {
"type": "string"
}
},
{
"name": "availability",
"in": "query",
"required": false,
"description": "Filter candidates by availability status.",
"schema": {
"type": "string"
}
},
{
"name": "page",
"in": "query",
"required": false,
"description": "Page number.",
"schema": {
"type": "integer",
"default": 1
}
},
{
"name": "per_page",
"in": "query",
"required": false,
"description": "Number of items per page.",
"schema": {
"type": "integer",
"default": 15
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "Employer candidate pipeline retrieved successfully." "description": "Employer candidate pipeline retrieved successfully."
@ -2049,6 +2282,74 @@
} }
} }
}, },
"/employers/shortlist": {
"get": {
"tags": [
"Employer/Pipeline"
],
"summary": "Get Shortlisted Workers",
"description": "Returns a list of all workers shortlisted by the authenticated sponsor.",
"parameters": [
{
"name": "page",
"in": "query",
"required": false,
"description": "Page number.",
"schema": {
"type": "integer",
"default": 1
}
},
{
"name": "per_page",
"in": "query",
"required": false,
"description": "Number of items per page.",
"schema": {
"type": "integer",
"default": 15
}
}
],
"responses": {
"200": {
"description": "Shortlisted workers retrieved successfully."
}
}
},
"post": {
"tags": [
"Employer/Pipeline"
],
"summary": "Toggle Worker Shortlist Status",
"description": "Toggles (adds or removes) a worker to/from the authenticated sponsor's shortlist.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"worker_id"
],
"properties": {
"worker_id": {
"type": "integer",
"example": 1,
"description": "Worker ID to shortlist/unshortlist."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Shortlist status toggled successfully."
}
}
}
},
"/employers/payments": { "/employers/payments": {
"get": { "get": {
"tags": [ "tags": [
@ -2056,6 +2357,28 @@
], ],
"summary": "Get Payment History", "summary": "Get Payment History",
"description": "Retrieves the complete transaction history for the authenticated sponsor/employer, including subscription passes and document vetting receipts.", "description": "Retrieves the complete transaction history for the authenticated sponsor/employer, including subscription passes and document vetting receipts.",
"parameters": [
{
"name": "page",
"in": "query",
"required": false,
"description": "Page number.",
"schema": {
"type": "integer",
"default": 1
}
},
{
"name": "per_page",
"in": "query",
"required": false,
"description": "Number of items per page.",
"schema": {
"type": "integer",
"default": 15
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "Employer billing history retrieved successfully." "description": "Employer billing history retrieved successfully."
@ -2265,6 +2588,40 @@
} }
}, },
"/employers/reviews": { "/employers/reviews": {
"get": {
"tags": [
"Employer/Reviews"
],
"summary": "Get Reviews Written by Employer",
"description": "Retrieves a paginated list of all reviews written by the authenticated employer.",
"parameters": [
{
"name": "page",
"in": "query",
"required": false,
"description": "Page number.",
"schema": {
"type": "integer",
"default": 1
}
},
{
"name": "per_page",
"in": "query",
"required": false,
"description": "Number of items per page.",
"schema": {
"type": "integer",
"default": 15
}
}
],
"responses": {
"200": {
"description": "Reviews retrieved successfully."
}
}
},
"post": { "post": {
"tags": [ "tags": [
"Employer/Reviews" "Employer/Reviews"

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

View File

@ -1,48 +1,295 @@
import React from 'react'; import React, { useState } from 'react';
import { Head, Link } from '@inertiajs/react'; import { Head, Link, router } from '@inertiajs/react';
import { KeyRound } from 'lucide-react'; import {
KeyRound,
Eye,
EyeOff,
Loader2,
AlertTriangle,
CheckCircle2,
ArrowLeft
} from 'lucide-react';
import axios from 'axios';
export default function ForgotPassword() { export default function ForgotPassword() {
const [step, setStep] = useState(1); // 1 = Request Code, 2 = Verify & Reset, 3 = Success
const [email, setEmail] = useState('');
const [otp, setOtp] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirmation, setPasswordConfirmation] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const handleSendCode = async (e) => {
e.preventDefault();
setError('');
if (!email.trim()) {
setError('Please enter your email address.');
return;
}
setLoading(true);
try {
const response = await axios.post('/employer/forgot-password', { email });
if (response.data.success) {
setStep(2);
setSuccessMessage('A 6-digit verification code has been sent to your email.');
} else {
setError(response.data.message || 'Failed to send reset code. Please try again.');
}
} catch (err) {
setError(err.response?.data?.message || 'Failed to send reset code. Please verify your email address.');
} finally {
setLoading(false);
}
};
const handleResetPassword = async (e) => {
e.preventDefault();
setError('');
if (!otp.trim() || otp.length !== 6) {
setError('Please enter the 6-digit verification code.');
return;
}
if (!password) {
setError('Please enter a new password.');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters long.');
return;
}
if (password !== passwordConfirmation) {
setError('Passwords do not match.');
return;
}
// Validate password strength: 1 uppercase, 1 lowercase, 1 number, 1 special character
const hasUppercase = /[A-Z]/.test(password);
const hasLowercase = /[a-z]/.test(password);
const hasNumber = /[0-9]/.test(password);
const hasSpecial = /[^A-Za-z0-9]/.test(password);
if (!hasUppercase || !hasLowercase || !hasNumber || !hasSpecial) {
setError('Password must contain at least 1 uppercase, 1 lowercase, 1 number, and 1 special character.');
return;
}
setLoading(true);
try {
const response = await axios.post('/employer/reset-password', {
email,
otp,
password,
password_confirmation: passwordConfirmation
});
if (response.data.success) {
setStep(3);
} else {
setError(response.data.message || 'Failed to reset password. Please check your verification code.');
}
} catch (err) {
setError(err.response?.data?.message || 'Failed to reset password. Please check your inputs and try again.');
} finally {
setLoading(false);
}
};
return ( return (
<div className="min-h-screen flex flex-col justify-center items-center px-4 bg-slate-50 font-sans"> <div className="min-h-screen flex flex-col justify-center items-center px-4 bg-slate-50 font-sans">
<Head title="Reset Password - Employer Portal" /> <Head title="Reset Password - Employer Portal" />
<div className="w-full max-w-md bg-white rounded-2xl shadow-sm border border-slate-200 p-8 text-center space-y-6"> <div className="w-full max-w-md bg-white rounded-2xl shadow-sm border border-slate-200 p-8 space-y-6">
<div className="w-16 h-16 bg-blue-50 rounded-full flex items-center justify-center mx-auto border border-blue-200">
<KeyRound className="w-8 h-8 text-[#185FA5]" /> {/* Header Icon & Title */}
</div> {step !== 3 && (
<div className="text-center space-y-4">
<div className="space-y-2"> <div className="w-16 h-16 bg-blue-50 rounded-full flex items-center justify-center mx-auto border border-blue-200">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Forgot Password</h1> <KeyRound className="w-8 h-8 text-[#185FA5]" />
<p className="text-sm text-gray-600"> </div>
Enter your registration email address and we'll send you a password reset link. <div className="space-y-2">
</p> <h1 className="text-2xl font-bold text-gray-900 tracking-tight">
</div> {step === 1 ? 'Forgot Password' : 'Reset Password'}
</h1>
<div className="space-y-4 text-left"> <p className="text-sm text-gray-600">
<div> {step === 1
<label className="block text-xs font-medium text-gray-700 mb-1">Email Address</label> ? "Enter your registration email address and we'll send you a verification code."
<input : `Enter the 6-digit code sent to ${email} and your new password.`
type="email" }
placeholder="name@company.com" </p>
className="w-full px-3.5 py-2.5 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]" </div>
autoFocus
/>
</div> </div>
)}
<button {/* Status/Error Messages */}
type="button" {error && (
className="w-full bg-[#185FA5] hover:bg-[#0C447C] text-white rounded-xl h-11 font-semibold text-sm transition-colors shadow-sm" <div className="flex items-start space-x-3 p-4 bg-red-50 border border-red-200 rounded-xl text-red-800 text-xs font-medium">
> <AlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
Send Reset Link <span>{error}</span>
</button> </div>
</div> )}
<div className="pt-2"> {successMessage && step === 2 && (
<Link href="/employer/login" className="text-xs text-[#185FA5] font-bold hover:underline"> <div className="flex items-start space-x-3 p-4 bg-emerald-50 border border-emerald-200 rounded-xl text-emerald-800 text-xs font-medium animate-pulse">
Return to Sign in <CheckCircle2 className="w-5 h-5 text-emerald-600 flex-shrink-0 mt-0.5" />
</Link> <span>{successMessage}</span>
</div> </div>
)}
{/* Step 1: Request Code Form */}
{step === 1 && (
<form onSubmit={handleSendCode} className="space-y-5">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Email Address</label>
<input
type="email"
placeholder="name@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3.5 py-2.5 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
autoFocus
required
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-[#185FA5] hover:bg-[#0C447C] active:bg-[#08305c] text-white rounded-xl h-11 font-semibold text-sm transition-colors shadow-sm flex items-center justify-center disabled:opacity-70 disabled:cursor-not-allowed"
>
{loading ? <Loader2 className="w-5 h-5 animate-spin mr-2" /> : 'Send Reset Code'}
</button>
</form>
)}
{/* Step 2: Verification and Reset Form */}
{step === 2 && (
<form onSubmit={handleResetPassword} className="space-y-5">
{/* OTP Input */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">6-Digit Code</label>
<input
type="text"
placeholder="123456"
maxLength={6}
value={otp}
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ''))}
className="w-full px-3.5 py-2.5 rounded-xl border border-slate-300 text-sm font-semibold tracking-wider text-center focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
autoFocus
required
/>
</div>
{/* Password Input */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">New Password</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-3.5 pr-10 py-2.5 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-slate-600 focus:outline-none"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="mt-1 text-[10px] text-gray-500 leading-normal">
Must be at least 8 characters with 1 uppercase, 1 lowercase, 1 number, and 1 special symbol.
</p>
</div>
{/* Confirm Password Input */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Confirm Password</label>
<div className="relative">
<input
type={showConfirmPassword ? 'text' : 'password'}
placeholder="••••••••"
value={passwordConfirmation}
onChange={(e) => setPasswordConfirmation(e.target.value)}
className="w-full pl-3.5 pr-10 py-2.5 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
required
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-slate-600 focus:outline-none"
>
{showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-[#185FA5] hover:bg-[#0C447C] active:bg-[#08305c] text-white rounded-xl h-11 font-semibold text-sm transition-colors shadow-sm flex items-center justify-center disabled:opacity-70 disabled:cursor-not-allowed"
>
{loading ? <Loader2 className="w-5 h-5 animate-spin mr-2" /> : 'Reset Password'}
</button>
<button
type="button"
onClick={() => {
setStep(1);
setError('');
setSuccessMessage('');
}}
className="w-full border border-slate-300 hover:bg-slate-50 text-gray-700 rounded-xl h-11 font-semibold text-sm transition-colors shadow-sm flex items-center justify-center"
>
<ArrowLeft className="w-4 h-4 mr-2" /> Back
</button>
</form>
)}
{/* Step 3: Success Screen */}
{step === 3 && (
<div className="text-center space-y-6 py-4">
<div className="w-16 h-16 bg-emerald-50 rounded-full flex items-center justify-center mx-auto border border-emerald-200">
<CheckCircle2 className="w-8 h-8 text-emerald-600" />
</div>
<div className="space-y-2">
<h2 className="text-2xl font-bold text-gray-900 tracking-tight">Password Reset Success</h2>
<p className="text-sm text-gray-600">
Your password has been successfully updated. You can now use your new password to sign in.
</p>
</div>
<button
onClick={() => router.visit('/employer/login')}
className="w-full bg-[#185FA5] hover:bg-[#0C447C] active:bg-[#08305c] text-white rounded-xl h-11 font-semibold text-sm transition-colors shadow-sm"
>
Sign In
</button>
</div>
)}
{/* Footer Back Link */}
{step !== 3 && (
<div className="pt-2 text-center">
<Link href="/employer/login" className="text-xs text-[#185FA5] font-bold hover:underline">
Return to Sign in
</Link>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@ -148,23 +148,23 @@ export default function Dashboard({
</Link> </Link>
</div> </div>
{/* Pending Offers Stat Card */} {/* Contacted Workers Stat Card */}
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs flex flex-col justify-between space-y-4 relative group hover:border-[#185FA5] transition-all"> <div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs flex flex-col justify-between space-y-4 relative group hover:border-[#185FA5] transition-all">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="w-12 h-12 rounded-xl bg-amber-50 text-amber-600 flex items-center justify-center flex-shrink-0 border border-amber-100 group-hover:scale-105 transition-transform"> <div className="w-12 h-12 rounded-xl bg-amber-50 text-amber-600 flex items-center justify-center flex-shrink-0 border border-amber-100 group-hover:scale-105 transition-transform">
<Send className="w-5 h-5" /> <MessageSquare className="w-5 h-5" />
</div> </div>
<span className="text-[10px] font-black text-amber-600 bg-amber-50 px-2 py-0.5 rounded-md border border-amber-100"> <span className="text-[10px] font-black text-amber-600 bg-amber-50 px-2 py-0.5 rounded-md border border-amber-100">
OUTSTANDING ENGAGED
</span> </span>
</div> </div>
<div> <div>
<div className="text-xs font-semibold text-slate-400 uppercase tracking-widest">{t('pending_job_offers', 'Pending Job Offers')}</div> <div className="text-xs font-semibold text-slate-400 uppercase tracking-widest">{t('contacted_workers_title', 'Contacted Workers')}</div>
<div className="text-3xl font-black text-slate-800 mt-1">{stats.pending_offers}</div> <div className="text-3xl font-black text-slate-800 mt-1">{stats.contacted_workers_count}</div>
<div className="text-[11px] text-slate-500 font-medium mt-1">{t('direct_recruitment', 'Direct recruitment proposals')}</div> <div className="text-[11px] text-slate-500 font-medium mt-1">{t('active_conversations_sub', 'Active candidate chat channels')}</div>
</div> </div>
<Link href="/employer/candidates" className="text-xs font-bold text-[#185FA5] hover:underline flex items-center space-x-1 pt-2 border-t border-slate-100"> <Link href="/employer/messages" className="text-xs font-bold text-[#185FA5] hover:underline flex items-center space-x-1 pt-2 border-t border-slate-100">
<span>{t('view_hiring', 'View hiring workflow')}</span> <span>{t('view_messages', 'Open message center')}</span>
<ChevronRight className="w-3.5 h-3.5" /> <ChevronRight className="w-3.5 h-3.5" />
</Link> </Link>
</div> </div>

View File

@ -420,14 +420,33 @@ export default function Show({ conversation, initialMessages, conversations = []
</div> </div>
</div> </div>
<div className="pt-2"> <div className="pt-2 space-y-2">
<Link <Link
href={`/employer/workers/${conversation.id}`} href={`/employer/workers/${conversation.worker_id || conversation.id}`}
className="w-full bg-[#185FA5] hover:bg-[#144f8a] text-white py-3 rounded-xl text-xs font-black uppercase tracking-widest flex items-center justify-center space-x-1.5 shadow-xs" className="w-full bg-[#185FA5] hover:bg-[#144f8a] text-white py-3 rounded-xl text-xs font-black uppercase tracking-widest flex items-center justify-center space-x-1.5 shadow-xs"
> >
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
<span>{t('open_worker_profile', 'Open Worker Profile')}</span> <span>{t('open_worker_profile', 'Open Worker Profile')}</span>
</Link> </Link>
{conversation.worker_status !== 'hired' && conversation.worker_status !== 'hidden' && (
<button
type="button"
onClick={() => {
router.post(`/employer/workers/${conversation.worker_id}/mark-hired`, {}, {
preserveScroll: true,
onSuccess: () => {
toast.success(t('marked_hired', 'Worker successfully marked as Hired!'));
router.reload();
}
});
}}
className="w-full bg-emerald-650 bg-emerald-600 hover:bg-emerald-700 text-white py-3 rounded-xl text-xs font-black uppercase tracking-widest flex items-center justify-center space-x-1.5 shadow-md border border-emerald-700"
>
<CheckCircle2 className="w-4.5 h-4.5 text-white" />
<span>{t('mark_hired_action', 'Mark Hired')}</span>
</button>
)}
</div> </div>
</div> </div>
</aside> </aside>

View File

@ -0,0 +1,124 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset Your Password</title>
<style>
body {
font-family: 'Geist', 'Segoe UI', Helvetica, Arial, sans-serif;
background-color: #f8fafc;
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
}
.container {
max-width: 600px;
margin: 40px auto;
background: #ffffff;
border-radius: 24px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.03);
border: 1px solid #e2e8f0;
}
.header {
background-color: #185FA5;
padding: 40px;
text-align: center;
color: #ffffff;
}
.logo {
font-size: 28px;
font-weight: 900;
letter-spacing: -0.05em;
margin: 0;
text-transform: uppercase;
}
.content {
padding: 48px;
color: #334155;
line-height: 1.6;
}
.greeting {
font-size: 18px;
font-weight: 700;
margin-top: 0;
margin-bottom: 16px;
color: #0f172a;
}
.text {
font-size: 14px;
color: #475569;
margin-bottom: 24px;
}
.otp-container {
background-color: #f1f5f9;
border: 2px dashed #cbd5e1;
border-radius: 16px;
padding: 24px;
text-align: center;
margin: 32px 0;
}
.otp-code {
font-size: 36px;
font-weight: 800;
letter-spacing: 0.15em;
color: #185FA5;
margin: 0;
}
.expiry-warning {
font-size: 12px;
font-weight: 600;
color: #e11d48;
margin-top: 8px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.security-notice {
background-color: #f8fafc;
border-left: 4px solid #185FA5;
padding: 16px;
border-radius: 0 12px 12px 0;
font-size: 12px;
color: #64748b;
margin-bottom: 24px;
}
.footer {
background-color: #f8fafc;
padding: 24px;
text-align: center;
border-top: 1px solid #e2e8f0;
font-size: 11px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.1em;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 class="logo">Marketplace</h1>
<div style="font-size: 12px; font-weight: 700; letter-spacing: 0.2em; opacity: 0.8; margin-top: 8px; text-transform: uppercase;">Password Reset</div>
</div>
<div class="content">
<h2 class="greeting">Hello {{ $employerName }},</h2>
<p class="text">
We received a request to reset your password on the Marketplace portal. To verify this request and set a new password, please enter the 6-digit verification code below.
</p>
<div class="otp-container">
<div class="otp-code">{{ $otp }}</div>
<div class="expiry-warning">Valid for 10 minutes only</div>
</div>
<div class="security-notice">
<strong>Security Alert:</strong> If you did not request a password reset, you can safely ignore this email and your password will remain unchanged. Do not share this passcode with anyone.
</div>
</div>
<div class="footer">
Empowering Direct Household Hiring &bull; Secure Portal
</div>
</div>
</body>
</html>

View File

@ -42,6 +42,11 @@
Route::get('/employers/plans', [EmployerAuthController::class, 'plans']); Route::get('/employers/plans', [EmployerAuthController::class, 'plans']);
Route::get('/sponsors/plans', [EmployerAuthController::class, 'plans']); Route::get('/sponsors/plans', [EmployerAuthController::class, 'plans']);
Route::post('/employers/login', [EmployerAuthController::class, 'login']); Route::post('/employers/login', [EmployerAuthController::class, 'login']);
Route::post('/employers/forgot-password', [EmployerAuthController::class, 'forgotPassword']);
Route::post('/sponsors/forgot-password', [EmployerAuthController::class, 'forgotPassword']);
Route::post('/employers/reset-password', [EmployerAuthController::class, 'resetPassword']);
Route::post('/sponsors/reset-password', [EmployerAuthController::class, 'resetPassword']);
// Protected Worker Mobile Endpoints (Token Authenticated via Bearer Token) // Protected Worker Mobile Endpoints (Token Authenticated via Bearer Token)
Route::middleware(['auth.worker'])->group(function () { Route::middleware(['auth.worker'])->group(function () {
@ -70,9 +75,10 @@
// Protected Employer Mobile Endpoints (Token Authenticated via Bearer Token) // Protected Employer Mobile Endpoints (Token Authenticated via Bearer Token)
Route::middleware(['auth.employer'])->group(function () { Route::middleware(['auth.employer'])->group(function () {
// Profile Management // Profile & Dashboard Management
Route::get('/employers/profile', [EmployerProfileController::class, 'getProfile']); Route::get('/employers/profile', [EmployerProfileController::class, 'getProfile']);
Route::post('/employers/profile/update', [EmployerProfileController::class, 'updateProfile']); Route::post('/employers/profile/update', [EmployerProfileController::class, 'updateProfile']);
Route::get('/employers/dashboard', [EmployerProfileController::class, 'getDashboard']);
// Messaging Management // Messaging Management
Route::get('/employers/conversations', [EmployerMessageController::class, 'getConversations']); Route::get('/employers/conversations', [EmployerMessageController::class, 'getConversations']);
@ -92,10 +98,15 @@
Route::post('/employers/candidates/{id}/hire', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'hireCandidate']); Route::post('/employers/candidates/{id}/hire', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'hireCandidate']);
Route::post('/employers/candidates/hire', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'hireCandidate']); Route::post('/employers/candidates/hire', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'hireCandidate']);
// Shortlist Management
Route::get('/employers/shortlist', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'getShortlist']);
Route::post('/employers/shortlist', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'toggleShortlist']);
// Payment History Management // Payment History Management
Route::get('/employers/payments', [\App\Http\Controllers\Api\EmployerPaymentController::class, 'getPayments']); Route::get('/employers/payments', [\App\Http\Controllers\Api\EmployerPaymentController::class, 'getPayments']);
// Review Management // Review Management
Route::get('/employers/reviews', [EmployerReviewController::class, 'getReviews']);
Route::post('/employers/reviews', [EmployerReviewController::class, 'addReview']); Route::post('/employers/reviews', [EmployerReviewController::class, 'addReview']);
Route::put('/employers/reviews/{id}', [EmployerReviewController::class, 'editReview']); Route::put('/employers/reviews/{id}', [EmployerReviewController::class, 'editReview']);
}); });

View File

@ -92,6 +92,8 @@
Route::get('/employer/forgot-password', function () { Route::get('/employer/forgot-password', function () {
return Inertia::render('Employer/Auth/ForgotPassword'); return Inertia::render('Employer/Auth/ForgotPassword');
})->name('employer.forgot-password'); })->name('employer.forgot-password');
Route::post('/employer/forgot-password', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'forgotPassword'])->name('employer.forgot-password.submit');
Route::post('/employer/reset-password', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'resetPassword'])->name('employer.reset-password.submit');
// Employer Protected Routes // Employer Protected Routes
Route::prefix('employer')->middleware(['employer'])->group(function () { Route::prefix('employer')->middleware(['employer'])->group(function () {