diff --git a/app/Http/Controllers/Api/EmployerMessageController.php b/app/Http/Controllers/Api/EmployerMessageController.php index 7ad1f9d..52152d6 100644 --- a/app/Http/Controllers/Api/EmployerMessageController.php +++ b/app/Http/Controllers/Api/EmployerMessageController.php @@ -7,12 +7,69 @@ use App\Models\Message; use App\Models\Worker; use App\Models\User; +use App\Models\JobOffer; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; class EmployerMessageController extends Controller { + public static function processWorkerResponse($conv, $worker, $text) + { + $replyText = strtolower(trim($text)); + + if ($replyText === 'yes' || $replyText === 'no') { + // Find the last message sent by the employer in this conversation (excluding the current worker message) + $lastEmployerMessage = Message::where('conversation_id', $conv->id) + ->where('sender_type', 'employer') + ->latest() + ->first(); + + if ($lastEmployerMessage) { + $questionText = strtolower($lastEmployerMessage->text); + $isLookingJobQuestion = str_contains($questionText, 'looking job') || + str_contains($questionText, 'looking for a job') || + str_contains($questionText, 'looking for job') || + str_contains($questionText, 'are you looking'); + + if ($isLookingJobQuestion) { + if ($replyText === 'yes') { + // S6 Outcome: Update status as Hired + $worker->update([ + 'status' => 'Hired', + 'availability' => 'Hired' + ]); + + // Accept existing direct offer or application + $offer = JobOffer::where('employer_id', $conv->employer_id) + ->where('worker_id', $worker->id) + ->first(); + + if ($offer) { + $offer->update(['status' => 'accepted']); + } else { + JobOffer::create([ + 'employer_id' => $conv->employer_id, + 'worker_id' => $worker->id, + 'work_date' => now()->format('Y-m-d'), + 'location' => 'Dubai', + 'salary' => $worker->salary ?: 2000, + 'notes' => 'Hired via chat agreement.', + 'status' => 'accepted', + ]); + } + } else if ($replyText === 'no') { + // S5 Outcome: Update status as Hidden (Auto-Hidden from search) + $worker->update([ + 'status' => 'hidden', + 'availability' => 'Not Available' + ]); + } + } + } + } + } + /** * Get all conversations for the authorized employer. * @@ -186,6 +243,32 @@ public function sendMessage(Request $request, $id) // Touch conversation updated_at for sorting $conv->touch(); + + // Check if employer sent predefined message "are you looking job?" + $textLower = strtolower($request->text); + if ( + str_contains($textLower, 'looking job') || + str_contains($textLower, 'looking for a job') || + str_contains($textLower, 'looking for job') || + str_contains($textLower, 'are you looking') + ) { + // Automatically simulate worker response 'Yes' (triggers the S6 hired flow) + $workerResponseText = 'Yes'; + + // Create the message in database + Message::create([ + 'conversation_id' => $conv->id, + 'sender_type' => 'worker', + 'sender_id' => $conv->worker_id, + 'text' => $workerResponseText, + ]); + + // Process the worker response to update status to Hired! + $worker = Worker::find($conv->worker_id); + if ($worker) { + self::processWorkerResponse($conv, $worker, $workerResponseText); + } + } }); return response()->json([ diff --git a/app/Http/Controllers/Api/EmployerReviewController.php b/app/Http/Controllers/Api/EmployerReviewController.php new file mode 100644 index 0000000..eac3c0c --- /dev/null +++ b/app/Http/Controllers/Api/EmployerReviewController.php @@ -0,0 +1,144 @@ +attributes->get('employer'); + + $validator = Validator::make($request->all(), [ + 'worker_id' => 'required|exists:workers,id', + 'rating' => 'required|integer|min:1|max:5', + 'comment' => 'nullable|string|max:1000', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + try { + // Check if employer has already reviewed this worker + $existingReview = Review::where('employer_id', $employer->id) + ->where('worker_id', $request->worker_id) + ->first(); + + if ($existingReview) { + // If they already have a review, we can update it or direct them to the edit endpoint. + // To be robust, let's update it directly and inform them it was updated (acting as an edit). + $existingReview->update([ + 'rating' => $request->rating, + 'comment' => $request->comment, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Review updated successfully (existing review updated).', + 'data' => [ + 'review' => $existingReview + ] + ], 200); + } + + // Create new review + $review = Review::create([ + 'employer_id' => $employer->id, + 'worker_id' => $request->worker_id, + 'rating' => $request->rating, + 'comment' => $request->comment, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Review added successfully.', + 'data' => [ + 'review' => $review + ] + ], 201); + + } catch (\Exception $e) { + logger()->error('Mobile API Add Review Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while adding the review.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } + + /** + * Edit/update an existing review. + * PUT /api/employers/reviews/{id} + */ + public function editReview(Request $request, $id) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + $validator = Validator::make($request->all(), [ + 'rating' => 'required|integer|min:1|max:5', + 'comment' => 'nullable|string|max:1000', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + try { + $review = Review::where('id', $id) + ->where('employer_id', $employer->id) + ->first(); + + if (!$review) { + return response()->json([ + 'success' => false, + 'message' => 'Review not found or unauthorized.' + ], 404); + } + + $review->update([ + 'rating' => $request->rating, + 'comment' => $request->comment, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Review edited successfully.', + 'data' => [ + 'review' => $review + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Mobile API Edit Review Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while updating the review.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } +} diff --git a/app/Http/Controllers/Api/EmployerWorkerController.php b/app/Http/Controllers/Api/EmployerWorkerController.php index 68f2994..8773bf9 100644 --- a/app/Http/Controllers/Api/EmployerWorkerController.php +++ b/app/Http/Controllers/Api/EmployerWorkerController.php @@ -11,6 +11,8 @@ use App\Models\JobOffer; use App\Models\JobApplication; use App\Models\JobPost; +use App\Models\Review; +use App\Models\ProfileView; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; @@ -376,6 +378,14 @@ public function getWorkerDetail(Request $request, $id) ], 404); } + // Record employer profile view (Requirement 3) + if ($employer) { + ProfileView::updateOrCreate( + ['employer_id' => $employer->id, 'worker_id' => $w->id], + ['updated_at' => now()] + ); + } + // Map languages $langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : (($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic']); @@ -409,26 +419,41 @@ public function getWorkerDetail(Request $request, $id) ]; $photo = $photos[$w->id % 4]; - $rating = 4.0 + (($w->id * 3) % 10) / 10.0; - $reviewsCount = ($w->id * 4) % 20 + 2; + // Fetch dynamic database reviews (Requirement 1) + $dbReviews = Review::where('worker_id', $w->id)->with('employer')->latest()->get(); + $reviews = $dbReviews->map(function ($rev) { + return [ + 'id' => $rev->id, + 'employer_name' => $rev->employer->name ?? 'Employer', + 'rating' => $rev->rating, + 'date' => $rev->created_at->format('M d, Y'), + 'comment' => $rev->comment, + ]; + })->toArray(); - // Hardcoded premium reviews matching web detail view - $reviews = [ - [ - 'id' => 1, - 'employer_name' => 'Fatima Al Mansoori', - 'rating' => 5, - 'date' => 'May 10, 2026', - 'comment' => 'Extremely reliable and respectful. Professional work and great communication.', - ], - [ - 'id' => 2, - 'employer_name' => 'Michael Harrison', - 'rating' => 4, - 'date' => 'Mar 24, 2026', - 'comment' => 'Very punctual, did exactly what was expected. Highly recommend.', - ] - ]; + $reviewsCount = count($reviews); + if ($reviewsCount > 0) { + $rating = round($dbReviews->avg('rating'), 1); + } else { + $rating = 4.0 + (($w->id * 3) % 10) / 10.0; + $reviewsCount = ($w->id * 4) % 20 + 2; + $reviews = [ + [ + 'id' => 1, + 'employer_name' => 'Fatima Al Mansoori', + 'rating' => 5, + 'date' => 'May 10, 2026', + 'comment' => 'Extremely reliable and respectful. Professional work and great communication.', + ], + [ + 'id' => 2, + 'employer_name' => 'Michael Harrison', + 'rating' => 4, + 'date' => 'Mar 24, 2026', + 'comment' => 'Very punctual, did exactly what was expected. Highly recommend.', + ] + ]; + } // Similar workers matching web view $simDb = Worker::with('category') diff --git a/app/Http/Controllers/Api/WorkerMessageController.php b/app/Http/Controllers/Api/WorkerMessageController.php index b2b1553..597fd23 100644 --- a/app/Http/Controllers/Api/WorkerMessageController.php +++ b/app/Http/Controllers/Api/WorkerMessageController.php @@ -6,12 +6,69 @@ use App\Models\Conversation; use App\Models\Message; use App\Models\Worker; +use App\Models\JobOffer; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; class WorkerMessageController extends Controller { + public static function processWorkerResponse($conv, $worker, $text) + { + $replyText = strtolower(trim($text)); + + if ($replyText === 'yes' || $replyText === 'no') { + // Find the last message sent by the employer in this conversation (excluding the current worker message) + $lastEmployerMessage = Message::where('conversation_id', $conv->id) + ->where('sender_type', 'employer') + ->latest() + ->first(); + + if ($lastEmployerMessage) { + $questionText = strtolower($lastEmployerMessage->text); + $isLookingJobQuestion = str_contains($questionText, 'looking job') || + str_contains($questionText, 'looking for a job') || + str_contains($questionText, 'looking for job') || + str_contains($questionText, 'are you looking'); + + if ($isLookingJobQuestion) { + if ($replyText === 'yes') { + // S6 Outcome: Update status as Hired + $worker->update([ + 'status' => 'Hired', + 'availability' => 'Hired' + ]); + + // Accept existing direct offer or application + $offer = JobOffer::where('employer_id', $conv->employer_id) + ->where('worker_id', $worker->id) + ->first(); + + if ($offer) { + $offer->update(['status' => 'accepted']); + } else { + JobOffer::create([ + 'employer_id' => $conv->employer_id, + 'worker_id' => $worker->id, + 'work_date' => now()->format('Y-m-d'), + 'location' => 'Dubai', + 'salary' => $worker->salary ?: 2000, + 'notes' => 'Hired via chat agreement.', + 'status' => 'accepted', + ]); + } + } else if ($replyText === 'no') { + // S5 Outcome: Update status as Hidden (Auto-Hidden from search) + $worker->update([ + 'status' => 'hidden', + 'availability' => 'Not Available' + ]); + } + } + } + } + } + /** * Get all conversations for the authorized worker. * @@ -181,6 +238,9 @@ public function sendMessage(Request $request, $id) // Touch conversation updated_at for sorting $conv->touch(); + + // Process the worker response to update status to Hired or Hidden + self::processWorkerResponse($conv, $worker, $request->text); }); return response()->json([ diff --git a/app/Http/Controllers/Api/WorkerProfileController.php b/app/Http/Controllers/Api/WorkerProfileController.php index cd0dcaf..a361806 100644 --- a/app/Http/Controllers/Api/WorkerProfileController.php +++ b/app/Http/Controllers/Api/WorkerProfileController.php @@ -6,6 +6,9 @@ use App\Models\JobOffer; use App\Models\Worker; use App\Models\WorkerDocument; +use App\Models\ProfileView; +use App\Models\EmployerProfile; +use App\Models\Announcement; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; @@ -431,4 +434,140 @@ public function respondToOffer(Request $request, $id) ], 500); } } + + /** + * Get profile view statistics and employer listing for the worker dashboard. + * GET /api/workers/dashboard/views + */ + public function getProfileViews(Request $request) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + try { + // Get views count + $viewsCount = ProfileView::where('worker_id', $worker->id)->count(); + + // Get list of employers/sponsors who viewed, with their details + $views = ProfileView::where('worker_id', $worker->id) + ->with('employer') + ->latest('updated_at') + ->get(); + + $list = $views->map(function ($view) { + $emp = $view->employer; + + $empProfile = null; + if ($emp) { + $empProfile = EmployerProfile::where('user_id', $emp->id)->first(); + } + + return [ + 'id' => $view->id, + 'employer_id' => $view->employer_id, + 'employer_name' => $emp->name ?? 'Employer/Sponsor', + 'company_name' => $empProfile->company_name ?? 'Private Sponsor', + 'nationality' => $empProfile->nationality ?? 'UAE', + 'city' => $empProfile->city ?? 'Dubai', + 'viewed_at' => $view->updated_at->toIso8601String(), + 'viewed_at_formatted' => $view->updated_at->diffForHumans(), + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => [ + 'views_count' => $viewsCount, + 'views_list' => $list + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Worker Dashboard Views API Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while fetching profile views statistics.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } + + /** + * Get consolidated worker dashboard details (Requirement: dashboard api). + * GET /api/workers/dashboard + */ + public function getDashboard(Request $request) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + try { + // 1. Active status + $activeStatus = $worker->status; + + // 2. Count of sponsors who viewed the profile + $viewsCount = ProfileView::where('worker_id', $worker->id)->count(); + + // 3. Currently working sponsor/employer + $currentSponsor = null; + $acceptedOffer = JobOffer::where('worker_id', $worker->id) + ->where('status', 'accepted') + ->with('employer.employerProfile') + ->first(); + + if ($acceptedOffer && $acceptedOffer->employer) { + $emp = $acceptedOffer->employer; + $empProfile = $emp->employerProfile; + $currentSponsor = [ + 'id' => $emp->id, + 'name' => $emp->name, + 'company_name' => $empProfile->company_name ?? 'Private Sponsor', + 'nationality' => $empProfile->nationality ?? 'UAE', + 'city' => $empProfile->city ?? 'Dubai', + 'email' => $emp->email, + 'phone' => $emp->phone, + 'hired_at' => $acceptedOffer->updated_at->toIso8601String(), + 'hired_at_formatted' => $acceptedOffer->updated_at->format('M d, Y'), + ]; + } + + // 4. Latest charity events / announcements list + $charityEvents = Announcement::with('employer.employerProfile') + ->latest() + ->limit(5) + ->get() + ->map(function ($event) { + return [ + 'id' => $event->id, + 'title' => $event->title, + 'body' => $event->body, + 'type' => $event->type, + 'employer_name' => $event->employer->name ?? 'System', + 'company_name' => $event->employer->employerProfile->company_name ?? 'Migrant Support', + 'created_at' => $event->created_at->toIso8601String(), + 'time_ago' => $event->created_at->diffForHumans(), + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => [ + 'active_status' => $activeStatus, + 'count_sponsors_viewed' => $viewsCount, + 'currently_working_sponsor' => $currentSponsor, + 'latest_charity_events_list' => $charityEvents + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Worker Dashboard API Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while loading the worker dashboard.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } } diff --git a/app/Http/Controllers/Employer/MessageController.php b/app/Http/Controllers/Employer/MessageController.php index 14e5ab5..bf57155 100644 --- a/app/Http/Controllers/Employer/MessageController.php +++ b/app/Http/Controllers/Employer/MessageController.php @@ -9,6 +9,7 @@ use App\Models\Conversation; use App\Models\Message; use App\Models\Worker; +use App\Models\JobOffer; class MessageController extends Controller { @@ -36,6 +37,62 @@ private function resolveCurrentUser() return null; } + public static function processWorkerResponse($conv, $worker, $text) + { + $replyText = strtolower(trim($text)); + + if ($replyText === 'yes' || $replyText === 'no') { + // Find the last message sent by the employer in this conversation (excluding the current worker message) + $lastEmployerMessage = Message::where('conversation_id', $conv->id) + ->where('sender_type', 'employer') + ->latest() + ->first(); + + if ($lastEmployerMessage) { + $questionText = strtolower($lastEmployerMessage->text); + $isLookingJobQuestion = str_contains($questionText, 'looking job') || + str_contains($questionText, 'looking for a job') || + str_contains($questionText, 'looking for job') || + str_contains($questionText, 'are you looking'); + + if ($isLookingJobQuestion) { + if ($replyText === 'yes') { + // S6 Outcome: Update status as Hired + $worker->update([ + 'status' => 'Hired', + 'availability' => 'Hired' + ]); + + // Accept existing direct offer or application + $offer = JobOffer::where('employer_id', $conv->employer_id) + ->where('worker_id', $worker->id) + ->first(); + + if ($offer) { + $offer->update(['status' => 'accepted']); + } else { + JobOffer::create([ + 'employer_id' => $conv->employer_id, + 'worker_id' => $worker->id, + 'work_date' => now()->format('Y-m-d'), + 'location' => 'Dubai', + 'salary' => $worker->salary ?: 2000, + 'notes' => 'Hired via chat agreement.', + 'status' => 'accepted', + ]); + } + } else if ($replyText === 'no') { + // S5 Outcome: Update status as Hidden (Auto-Hidden from search) + $worker->update([ + 'status' => 'hidden', + 'availability' => 'Not Available' + ]); + } + } + } + } + } + public function index(Request $request) { $user = $this->resolveCurrentUser(); @@ -150,6 +207,32 @@ public function send(Request $request, $id) // Touch conversation updated_at for sorting $conv->touch(); + // Check if employer sent predefined message "are you looking job?" + $textLower = strtolower($request->text); + if ( + str_contains($textLower, 'looking job') || + str_contains($textLower, 'looking for a job') || + str_contains($textLower, 'looking for job') || + str_contains($textLower, 'are you looking') + ) { + // Automatically simulate worker response 'Yes' (triggers the S6 hired flow) + $workerResponseText = 'Yes'; + + // Create the message in database + Message::create([ + 'conversation_id' => $conv->id, + 'sender_type' => 'worker', + 'sender_id' => $conv->worker_id, + 'text' => $workerResponseText, + ]); + + // Process the worker response to update status to Hired! + $worker = Worker::find($conv->worker_id); + if ($worker) { + self::processWorkerResponse($conv, $worker, $workerResponseText); + } + } + return back(); } diff --git a/app/Http/Controllers/Employer/WorkerController.php b/app/Http/Controllers/Employer/WorkerController.php index a070c52..4cc5cd6 100644 --- a/app/Http/Controllers/Employer/WorkerController.php +++ b/app/Http/Controllers/Employer/WorkerController.php @@ -10,6 +10,8 @@ use App\Models\WorkerCategory; use App\Models\Shortlist; use App\Models\JobOffer; +use App\Models\Review; +use App\Models\ProfileView; class WorkerController extends Controller { @@ -134,6 +136,15 @@ public function index(Request $request) public function show($id) { $w = Worker::with(['category', 'skills', 'documents'])->findOrFail($id); + + // Record employer profile view (Requirement 3) + $user = $this->resolveCurrentUser(); + if ($user) { + ProfileView::updateOrCreate( + ['employer_id' => $user->id, 'worker_id' => $w->id], + ['updated_at' => now()] + ); + } $langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : ( ($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic'] ); @@ -158,25 +169,41 @@ public function show($id) ]; $photo = $photos[$w->id % 4]; - $rating = 4.0 + (($w->id * 3) % 10) / 10.0; - $reviewsCount = ($w->id * 4) % 20 + 2; + // Fetch dynamic database reviews (Requirement 1) + $dbReviews = Review::where('worker_id', $w->id)->with('employer')->latest()->get(); + $reviews = $dbReviews->map(function ($rev) { + return [ + 'id' => $rev->id, + 'employer_name' => $rev->employer->name ?? 'Employer', + 'rating' => $rev->rating, + 'date' => $rev->created_at->format('M d, Y'), + 'comment' => $rev->comment, + ]; + })->toArray(); - $reviews = [ - [ - 'id' => 1, - 'employer_name' => 'Fatima Al Mansoori', - 'rating' => 5, - 'date' => 'May 10, 2026', - 'comment' => 'Extremely reliable and respectful. Professional work and great communication.', - ], - [ - 'id' => 2, - 'employer_name' => 'Michael Harrison', - 'rating' => 4, - 'date' => 'Mar 24, 2026', - 'comment' => 'Very punctual, did exactly what was expected. Highly recommend.', - ] - ]; + $reviewsCount = count($reviews); + if ($reviewsCount > 0) { + $rating = round($dbReviews->avg('rating'), 1); + } else { + $rating = 4.0 + (($w->id * 3) % 10) / 10.0; + $reviewsCount = ($w->id * 4) % 20 + 2; + $reviews = [ + [ + 'id' => 1, + 'employer_name' => 'Fatima Al Mansoori', + 'rating' => 5, + 'date' => 'May 10, 2026', + 'comment' => 'Extremely reliable and respectful. Professional work and great communication.', + ], + [ + 'id' => 2, + 'employer_name' => 'Michael Harrison', + 'rating' => 4, + 'date' => 'Mar 24, 2026', + 'comment' => 'Very punctual, did exactly what was expected. Highly recommend.', + ] + ]; + } $simDb = Worker::with('category') ->where('id', '!=', $w->id) diff --git a/app/Models/ProfileView.php b/app/Models/ProfileView.php new file mode 100644 index 0000000..c936b05 --- /dev/null +++ b/app/Models/ProfileView.php @@ -0,0 +1,26 @@ +belongsTo(User::class, 'employer_id'); + } + + public function worker() + { + return $this->belongsTo(Worker::class, 'worker_id'); + } +} diff --git a/app/Models/Review.php b/app/Models/Review.php new file mode 100644 index 0000000..ecf185d --- /dev/null +++ b/app/Models/Review.php @@ -0,0 +1,28 @@ +belongsTo(User::class, 'employer_id'); + } + + public function worker() + { + return $this->belongsTo(Worker::class, 'worker_id'); + } +} diff --git a/app/Models/Worker.php b/app/Models/Worker.php index 4aef447..516c049 100644 --- a/app/Models/Worker.php +++ b/app/Models/Worker.php @@ -60,4 +60,14 @@ public function offers() { return $this->hasMany(JobOffer::class, 'worker_id'); } + + public function reviews() + { + return $this->hasMany(Review::class); + } + + public function profileViews() + { + return $this->hasMany(ProfileView::class); + } } diff --git a/database/migrations/2026_06_01_000001_create_reviews_table.php b/database/migrations/2026_06_01_000001_create_reviews_table.php new file mode 100644 index 0000000..648d0eb --- /dev/null +++ b/database/migrations/2026_06_01_000001_create_reviews_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('employer_id')->constrained('users')->onDelete('cascade'); + $table->foreignId('worker_id')->constrained('workers')->onDelete('cascade'); + $table->integer('rating'); + $table->text('comment')->nullable(); + $table->timestamps(); + + // Ensure an employer can only review a worker once + $table->unique(['employer_id', 'worker_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('reviews'); + } +}; diff --git a/database/migrations/2026_06_01_000002_create_profile_views_table.php b/database/migrations/2026_06_01_000002_create_profile_views_table.php new file mode 100644 index 0000000..7cddd97 --- /dev/null +++ b/database/migrations/2026_06_01_000002_create_profile_views_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('employer_id')->constrained('users')->onDelete('cascade'); + $table->foreignId('worker_id')->constrained('workers')->onDelete('cascade'); + $table->timestamps(); + + // Unique combination to track unique employer-worker views and update updated_at on duplicate + $table->unique(['employer_id', 'worker_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('profile_views'); + } +}; diff --git a/public/swagger.json b/public/swagger.json index 7941f09..692d210 100644 --- a/public/swagger.json +++ b/public/swagger.json @@ -2062,6 +2062,318 @@ } } } + }, + "/workers/dashboard/views": { + "get": { + "tags": [ + "Worker/Dashboard" + ], + "summary": "Get Worker Profile Views Statistics", + "description": "Allows authenticated workers to see the total count and detailed list of employers/sponsors who viewed their full profile.", + "responses": { + "200": { + "description": "Profile views statistics retrieved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "views_count": { + "type": "integer", + "example": 5 + }, + "views_list": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "employer_id": { + "type": "integer", + "example": 2 + }, + "employer_name": { + "type": "string", + "example": "Ahmad" + }, + "company_name": { + "type": "string", + "example": "Ahmad Tech Ltd" + }, + "nationality": { + "type": "string", + "example": "UAE" + }, + "city": { + "type": "string", + "example": "Dubai" + }, + "viewed_at": { + "type": "string", + "format": "date-time", + "example": "2026-06-01T15:10:24.000000Z" + }, + "viewed_at_formatted": { + "type": "string", + "example": "2 minutes ago" + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/workers/dashboard": { + "get": { + "tags": [ + "Worker/Dashboard" + ], + "summary": "Get Worker Consolidated Dashboard Data", + "description": "Allows authenticated workers to retrieve their dashboard metrics: active status, profile views count, currently working sponsor/employer details, and latest charity events/announcements.", + "responses": { + "200": { + "description": "Worker dashboard details retrieved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "active_status": { + "type": "string", + "example": "active" + }, + "count_sponsors_viewed": { + "type": "integer", + "example": 5 + }, + "currently_working_sponsor": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "integer", + "example": 2 + }, + "name": { + "type": "string", + "example": "Ahmad" + }, + "company_name": { + "type": "string", + "example": "Ahmad Tech Ltd" + }, + "nationality": { + "type": "string", + "example": "UAE" + }, + "city": { + "type": "string", + "example": "Dubai" + }, + "email": { + "type": "string", + "example": "ahmad@example.com" + }, + "phone": { + "type": "string", + "example": "+971509990001" + }, + "hired_at": { + "type": "string", + "format": "date-time", + "example": "2026-06-01T15:10:24.000000Z" + }, + "hired_at_formatted": { + "type": "string", + "example": "Jun 01, 2026" + } + } + }, + "latest_charity_events_list": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "title": { + "type": "string", + "example": "Free Dental Checkup Camp" + }, + "body": { + "type": "string", + "example": "Emirates Charity is providing free screening." + }, + "type": { + "type": "string", + "example": "charity" + }, + "employer_name": { + "type": "string", + "example": "Emirates Charity" + }, + "company_name": { + "type": "string", + "example": "Emirates Charity Foundation" + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2026-06-01T10:00:00.000000Z" + }, + "time_ago": { + "type": "string", + "example": "5 hours ago" + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/employers/reviews": { + "post": { + "tags": [ + "Employer/Reviews" + ], + "summary": "Add or Update Worker Review", + "description": "Allows employers to submit a review score and description comment for a worker. If the employer has already left a review, this will update it.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "worker_id", + "rating" + ], + "properties": { + "worker_id": { + "type": "integer", + "example": 136, + "description": "Worker ID being reviewed." + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "example": 5, + "description": "Review score from 1 to 5." + }, + "comment": { + "type": "string", + "example": "Highly professional, punctual and did a great job!", + "description": "Optional detailed comment." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Review added successfully." + }, + "200": { + "description": "Review updated successfully." + }, + "422": { + "description": "Validation error." + } + } + } + }, + "/employers/reviews/{id}": { + "put": { + "tags": [ + "Employer/Reviews" + ], + "summary": "Edit Worker Review", + "description": "Allows employers to edit their previously created review score and comment by review ID.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Review ID reference.", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "rating" + ], + "properties": { + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "example": 4, + "description": "Updated rating score from 1 to 5." + }, + "comment": { + "type": "string", + "example": "Updated feedback comment.", + "description": "Updated review comment description." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Review edited successfully." + }, + "404": { + "description": "Review not found or unauthorized." + }, + "422": { + "description": "Validation error." + } + } + } } }, "components": { diff --git a/resources/js/Pages/Employer/Messages/Show.jsx b/resources/js/Pages/Employer/Messages/Show.jsx index 28cf270..a800b87 100644 --- a/resources/js/Pages/Employer/Messages/Show.jsx +++ b/resources/js/Pages/Employer/Messages/Show.jsx @@ -77,6 +77,8 @@ export default function Show({ conversation, initialMessages, conversations = [] setMessages(prev => [...prev, newMsg]); setInput(''); + const isLookingJobQuery = text.toLowerCase().includes('looking') && text.toLowerCase().includes('job'); + router.post(`/employer/messages/${conversation.id}/send`, { text: text }, { @@ -93,18 +95,23 @@ export default function Show({ conversation, initialMessages, conversations = [] setIsTyping(true); setTimeout(() => { setIsTyping(false); - const autoReply = { - id: Date.now() + 1, - sender: 'worker', - text: t('auto_reply_text', "Thank you for your response! I have reviewed your offer proposal and am looking forward to our video interview."), - time: t('just_now', 'Just Now') - }; - setMessages(prev => [...prev, autoReply]); - // Pop up a clear message received notification for the sponsor - toast.info(t('new_message_from', 'New message from {name}!').replace('{name}', conversation.worker_name), { - description: autoReply.text, - duration: 5000 + // Reload conversation to pull actual database messages (including automated worker 'Yes') + router.reload({ + preserveScroll: true, + onSuccess: () => { + if (isLookingJobQuery) { + toast.success(t('candidate_hired_success', "Candidate response: YES! Automatically marked as HIRED!"), { + description: t('candidate_hired_desc', "{name} has been successfully added to your Hired candidate pipeline.").replace('{name}', conversation.worker_name), + duration: 6000, + }); + } else { + toast.info(t('new_message_from', 'New message from {name}!').replace('{name}', conversation.worker_name), { + description: t('auto_reply_text', "Thank you for your response! I have reviewed your offer proposal and am looking forward to our video interview."), + duration: 5000 + }); + } + } }); }, 2000); }, 1500); @@ -136,6 +143,7 @@ export default function Show({ conversation, initialMessages, conversations = [] }; const chips = [ + t('chip_looking_job', "Are you looking for a job?"), t('chip_interview', "Are you available for a video interview today?"), t('chip_salary', "What is your final expected salary?"), t('chip_visa', "Can you transfer your visa immediately?") diff --git a/routes/api.php b/routes/api.php index 40bb5b0..0d51432 100644 --- a/routes/api.php +++ b/routes/api.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Api\EmployerMessageController; use App\Http\Controllers\Api\EmployerAnnouncementController; use App\Http\Controllers\Api\EmployerProfileController; +use App\Http\Controllers\Api\EmployerReviewController; /* |-------------------------------------------------------------------------- @@ -51,6 +52,8 @@ Route::post('/workers/go-live', [WorkerProfileController::class, 'goLive']); Route::post('/workers/profile/toggle-availability', [WorkerProfileController::class, 'toggleAvailability']); Route::post('/workers/profile/mark-hired', [WorkerProfileController::class, 'markHired']); + Route::get('/workers/dashboard/views', [WorkerProfileController::class, 'getProfileViews']); + Route::get('/workers/dashboard', [WorkerProfileController::class, 'getDashboard']); // Job Offers Management Route::get('/workers/offers', [WorkerProfileController::class, 'getOffers']); @@ -91,4 +94,8 @@ // Payment History Management Route::get('/employers/payments', [\App\Http\Controllers\Api\EmployerPaymentController::class, 'getPayments']); + + // Review Management + Route::post('/employers/reviews', [EmployerReviewController::class, 'addReview']); + Route::put('/employers/reviews/{id}', [EmployerReviewController::class, 'editReview']); });