mohan #5
@ -13,7 +13,7 @@ class WorkerController extends Controller
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$workers = \App\Models\Worker::with(['category', 'skills'])
|
||||
$workers = \App\Models\Worker::with(['skills'])
|
||||
->latest()
|
||||
->get()
|
||||
->map(function ($worker) {
|
||||
@ -43,7 +43,6 @@ public function index()
|
||||
'city' => $worker->city,
|
||||
'area' => $worker->area,
|
||||
'live_in_out' => $worker->live_in_out ?? 'Live-in',
|
||||
'category' => $worker->category ? $worker->category->name : 'N/A',
|
||||
'experience' => $worker->experience,
|
||||
'salary' => (int)$worker->salary,
|
||||
'skills' => $mappedSkills,
|
||||
@ -126,7 +125,6 @@ public function updateProfile(Request $request, $id)
|
||||
'city' => 'nullable|string',
|
||||
'area' => 'nullable|string',
|
||||
'live_in_out' => 'nullable|string',
|
||||
'category' => 'required|string',
|
||||
'experience' => 'required|string',
|
||||
'salary' => 'nullable|numeric',
|
||||
'bio' => 'nullable|string'
|
||||
@ -134,10 +132,7 @@ public function updateProfile(Request $request, $id)
|
||||
|
||||
$worker = \App\Models\Worker::findOrFail($id);
|
||||
|
||||
$category = \App\Models\WorkerCategory::where('name', $validated['category'])->first();
|
||||
if ($category) {
|
||||
$worker->category_id = $category->id;
|
||||
}
|
||||
|
||||
|
||||
$worker->name = $validated['name'];
|
||||
$worker->phone = $validated['phone'];
|
||||
|
||||
@ -110,10 +110,22 @@ public function login(Request $request)
|
||||
}
|
||||
$user->update($userUpdateData);
|
||||
if ($sponsor) {
|
||||
$sponsor->update([
|
||||
$sponsorUpdateData = [
|
||||
'api_token' => $apiToken,
|
||||
'last_login_at' => now(),
|
||||
]);
|
||||
];
|
||||
if ($request->has('fcm_token')) {
|
||||
$sponsorUpdateData['fcm_token'] = $request->fcm_token;
|
||||
}
|
||||
$sponsor->update($sponsorUpdateData);
|
||||
}
|
||||
|
||||
if ($request->filled('fcm_token')) {
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$request->fcm_token,
|
||||
'Successful Login',
|
||||
'Welcome back to your Migrant employer account.'
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
@ -127,10 +139,22 @@ public function login(Request $request)
|
||||
], 200);
|
||||
} else {
|
||||
// Pure Sponsor account
|
||||
$sponsor->update([
|
||||
$sponsorUpdateData = [
|
||||
'api_token' => $apiToken,
|
||||
'last_login_at' => now(),
|
||||
]);
|
||||
];
|
||||
if ($request->has('fcm_token')) {
|
||||
$sponsorUpdateData['fcm_token'] = $request->fcm_token;
|
||||
}
|
||||
$sponsor->update($sponsorUpdateData);
|
||||
|
||||
if ($request->filled('fcm_token')) {
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$request->fcm_token,
|
||||
'Successful Login',
|
||||
'Welcome back to your Migrant sponsor account.'
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@ -166,6 +190,7 @@ public function register(Request $request)
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|string|email|max:255|unique:users,email',
|
||||
'phone' => 'required|string|max:50',
|
||||
'fcm_token' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@ -195,6 +220,7 @@ public function register(Request $request)
|
||||
'role' => 'employer',
|
||||
'subscription_status' => 'none',
|
||||
'subscription_expires_at' => null,
|
||||
'fcm_token' => $request->fcm_token,
|
||||
]);
|
||||
|
||||
// Create pending Profile
|
||||
@ -223,8 +249,17 @@ public function register(Request $request)
|
||||
'is_verified' => false,
|
||||
'otp_verified_at' => null,
|
||||
'status' => 'inactive',
|
||||
'fcm_token' => $request->fcm_token,
|
||||
]);
|
||||
|
||||
if ($request->filled('fcm_token')) {
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$request->fcm_token,
|
||||
'Registration Initiated',
|
||||
'Your verification code has been sent to your email.'
|
||||
);
|
||||
}
|
||||
|
||||
// Try sending email
|
||||
try {
|
||||
\Illuminate\Support\Facades\Mail::to($request->email)->send(new \App\Mail\EmployerOtpMail(
|
||||
@ -257,6 +292,7 @@ public function verify(Request $request)
|
||||
$validator = Validator::make($request->all(), [
|
||||
'email' => 'required|email',
|
||||
'otp' => 'required|string|size:6',
|
||||
'fcm_token' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@ -293,11 +329,31 @@ public function verify(Request $request)
|
||||
try {
|
||||
// Update Sponsor verification status
|
||||
$sponsor = \App\Models\Sponsor::where('email', $request->email)->first();
|
||||
$user = User::where('email', $request->email)->where('role', 'employer')->first();
|
||||
$fcmToken = $request->fcm_token;
|
||||
|
||||
if ($sponsor) {
|
||||
$sponsor->update([
|
||||
$sponsorUpdateData = [
|
||||
'is_verified' => true,
|
||||
'otp_verified_at' => now(),
|
||||
]);
|
||||
];
|
||||
if ($fcmToken) {
|
||||
$sponsorUpdateData['fcm_token'] = $fcmToken;
|
||||
}
|
||||
$sponsor->update($sponsorUpdateData);
|
||||
}
|
||||
|
||||
if ($user && $fcmToken) {
|
||||
$user->update(['fcm_token' => $fcmToken]);
|
||||
}
|
||||
|
||||
$tokenToSend = $fcmToken ?? ($sponsor ? $sponsor->fcm_token : null) ?? ($user ? $user->fcm_token : null);
|
||||
if ($tokenToSend) {
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$tokenToSend,
|
||||
'Email Verified',
|
||||
'Proceed to payment selection.'
|
||||
);
|
||||
}
|
||||
|
||||
// Clear Cache
|
||||
@ -494,6 +550,7 @@ public function password(Request $request)
|
||||
$validator = Validator::make($request->all(), [
|
||||
'email' => 'required|email',
|
||||
'password' => 'required|string|min:8|confirmed',
|
||||
'fcm_token' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@ -526,16 +583,25 @@ public function password(Request $request)
|
||||
|
||||
\Illuminate\Support\Facades\DB::transaction(function () use ($request, $user, $sponsor, $apiToken) {
|
||||
$hashedPassword = Hash::make($request->password);
|
||||
$fcmToken = $request->fcm_token;
|
||||
|
||||
$user->update([
|
||||
$userUpdateData = [
|
||||
'password' => $hashedPassword,
|
||||
'api_token' => $apiToken,
|
||||
]);
|
||||
];
|
||||
if ($fcmToken) {
|
||||
$userUpdateData['fcm_token'] = $fcmToken;
|
||||
}
|
||||
$user->update($userUpdateData);
|
||||
|
||||
$sponsor->update([
|
||||
$sponsorUpdateData = [
|
||||
'password' => $hashedPassword,
|
||||
'status' => 'active',
|
||||
]);
|
||||
];
|
||||
if ($fcmToken) {
|
||||
$sponsorUpdateData['fcm_token'] = $fcmToken;
|
||||
}
|
||||
$sponsor->update($sponsorUpdateData);
|
||||
|
||||
// Approve profile verification
|
||||
$profile = EmployerProfile::where('user_id', $user->id)->first();
|
||||
@ -546,6 +612,15 @@ public function password(Request $request)
|
||||
}
|
||||
});
|
||||
|
||||
$tokenToSend = $request->fcm_token ?? ($user ? $user->fcm_token : null) ?? ($sponsor ? $sponsor->fcm_token : null);
|
||||
if ($tokenToSend) {
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$tokenToSend,
|
||||
'Welcome to Migrant',
|
||||
'Your employer registration has been completed successfully.'
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Password created successfully. Registration finalized.',
|
||||
|
||||
@ -83,7 +83,7 @@ public function getConversations(Request $request)
|
||||
|
||||
try {
|
||||
$dbConversations = Conversation::where('employer_id', $employer->id)
|
||||
->with(['worker.category', 'messages'])
|
||||
->with(['worker', 'messages'])
|
||||
->latest('updated_at')
|
||||
->get();
|
||||
|
||||
@ -99,7 +99,6 @@ public function getConversations(Request $request)
|
||||
'id' => $conv->id,
|
||||
'worker_id' => $conv->worker_id,
|
||||
'worker_name' => $conv->worker->name ?? 'Candidate',
|
||||
'category' => $conv->worker->category->name ?? 'General Helper',
|
||||
'nationality' => $conv->worker->nationality ?? 'Unknown',
|
||||
'worker_status' => strtolower($conv->worker->status ?? 'active'),
|
||||
'salary' => ($conv->worker->salary ?? $conv->worker->expected_salary ?? 2000) . ' AED',
|
||||
@ -143,7 +142,7 @@ public function getMessages(Request $request, $id)
|
||||
try {
|
||||
$conv = Conversation::where('employer_id', $employer->id)
|
||||
->where('id', $id)
|
||||
->with(['worker.category', 'messages'])
|
||||
->with(['worker', 'messages'])
|
||||
->first();
|
||||
|
||||
if (!$conv) {
|
||||
@ -175,7 +174,6 @@ public function getMessages(Request $request, $id)
|
||||
$conversationDetail = [
|
||||
'id' => $conv->id,
|
||||
'worker_name' => $conv->worker->name ?? 'Candidate',
|
||||
'category' => $conv->worker->category->name ?? 'General Helper',
|
||||
'nationality' => $conv->worker->nationality ?? 'Unknown',
|
||||
'worker_status' => strtolower($conv->worker->status ?? 'active'),
|
||||
'salary' => ($conv->worker->salary ?? $conv->worker->expected_salary ?? 2000) . ' AED',
|
||||
|
||||
@ -224,7 +224,7 @@ public function getDashboard(Request $request)
|
||||
];
|
||||
|
||||
// Recent Announcements / Events
|
||||
$dbAnnouncements = \App\Models\Announcement::where('status', 'approved')->latest()->limit(5)->get();
|
||||
$dbAnnouncements = \App\Models\Announcement::where('status', 'approved')->latest()->limit(2)->get();
|
||||
$recentAnnouncements = $dbAnnouncements->map(function ($ann) {
|
||||
$body = $ann->body;
|
||||
$eventDate = null;
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\User;
|
||||
use App\Models\Worker;
|
||||
use App\Models\WorkerCategory;
|
||||
use App\Models\Shortlist;
|
||||
use App\Models\JobOffer;
|
||||
use App\Models\JobApplication;
|
||||
@ -91,7 +90,7 @@ private function formatWorker(Worker $w)
|
||||
public function getWorkers(Request $request)
|
||||
{
|
||||
try {
|
||||
$query = Worker::with(['category', 'skills', 'documents'])
|
||||
$query = Worker::with(['skills', 'documents'])
|
||||
->where('status', '!=', 'Hired')
|
||||
->where('status', '!=', 'hidden');
|
||||
|
||||
@ -355,11 +354,11 @@ public function getCandidates(Request $request)
|
||||
// Fetch Job Applications
|
||||
$jobIds = JobPost::where('employer_id', $employerId)->pluck('id');
|
||||
$applicationsQuery = JobApplication::whereIn('job_id', $jobIds)
|
||||
->with(['worker.category', 'jobPost']);
|
||||
->with(['worker', 'jobPost']);
|
||||
|
||||
// Fetch Direct Hiring Offers
|
||||
$directOffersQuery = JobOffer::where('employer_id', $employerId)
|
||||
->with('worker.category');
|
||||
->with('worker');
|
||||
|
||||
// Apply search filter if provided
|
||||
if ($request->filled('search')) {
|
||||
@ -841,7 +840,7 @@ public function getWorkerDetail(Request $request, $id)
|
||||
{
|
||||
/** @var User $employer */
|
||||
$employer = $request->attributes->get('employer');
|
||||
$w = Worker::with(['category', 'skills', 'documents'])->find($id);
|
||||
$w = Worker::with(['skills', 'documents'])->find($id);
|
||||
|
||||
if (!$w) {
|
||||
return response()->json([
|
||||
@ -919,7 +918,7 @@ public function getWorkerDetail(Request $request, $id)
|
||||
}
|
||||
|
||||
// Similar workers matching web view
|
||||
$simDb = Worker::with('category')
|
||||
$simDb = Worker::query()
|
||||
->where('id', '!=', $w->id)
|
||||
->where('status', '!=', 'Hired')
|
||||
->where('status', '!=', 'hidden')
|
||||
@ -1002,7 +1001,7 @@ public function getShortlist(Request $request)
|
||||
$perPage = (int)$request->input('per_page', 15);
|
||||
|
||||
$shortlists = Shortlist::where('employer_id', $employer->id)
|
||||
->with(['worker.category', 'worker.skills'])
|
||||
->with(['worker.skills'])
|
||||
->get();
|
||||
|
||||
$formattedWorkers = $shortlists->map(function ($s) {
|
||||
|
||||
@ -35,6 +35,7 @@ public function register(Request $request)
|
||||
'address' => 'required|string|max:255',
|
||||
'country_code' => 'required|string|max:10',
|
||||
'license_expiry' => 'required|date_format:Y-m-d',
|
||||
'fcm_token' => 'nullable|string|max:255',
|
||||
], [
|
||||
'mobile.unique' => 'This mobile number is already registered.',
|
||||
'email.unique' => 'This email address is already registered.',
|
||||
@ -98,9 +99,18 @@ public function register(Request $request)
|
||||
'is_verified' => false,
|
||||
'subscription_status' => 'none',
|
||||
'api_token' => $apiToken,
|
||||
'fcm_token' => $request->fcm_token,
|
||||
]);
|
||||
});
|
||||
|
||||
if ($request->filled('fcm_token')) {
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$request->fcm_token,
|
||||
'Welcome to Migrant',
|
||||
'Sponsor account registered successfully. Your license is pending admin review.'
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Sponsor account registered successfully. Your license is pending admin review.',
|
||||
@ -129,8 +139,9 @@ public function register(Request $request)
|
||||
public function login(Request $request)
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'mobile' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
'mobile' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
'fcm_token' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@ -159,10 +170,22 @@ public function login(Request $request)
|
||||
|
||||
// Rotate token on each login for security
|
||||
$apiToken = Str::random(80);
|
||||
$sponsor->update([
|
||||
$updateData = [
|
||||
'api_token' => $apiToken,
|
||||
'last_login_at' => now(),
|
||||
]);
|
||||
];
|
||||
if ($request->has('fcm_token')) {
|
||||
$updateData['fcm_token'] = $request->fcm_token;
|
||||
}
|
||||
$sponsor->update($updateData);
|
||||
|
||||
if ($request->filled('fcm_token')) {
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$request->fcm_token,
|
||||
'Successful Login',
|
||||
'Welcome back to your Migrant sponsor account.'
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
|
||||
@ -175,7 +175,7 @@ public function getTicketsForEmployer(Request $request)
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
$tickets = SupportTicket::where('user_id', $employer->id)
|
||||
$tickets = SupportTicket::with('reason')->where('user_id', $employer->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get()
|
||||
->map(function ($ticket) {
|
||||
@ -184,6 +184,9 @@ public function getTicketsForEmployer(Request $request)
|
||||
'ticket_number' => $ticket->ticket_number,
|
||||
'subject' => $ticket->subject,
|
||||
'description' => $ticket->description,
|
||||
'reason_id' => $ticket->reason_id,
|
||||
'reason_name' => $ticket->reason ? $ticket->reason->reason : null,
|
||||
'voice_note_url' => $ticket->voice_note_path ? asset('storage/' . $ticket->voice_note_path) : null,
|
||||
'status' => $ticket->status,
|
||||
'priority' => $ticket->priority,
|
||||
'created_at' => $ticket->created_at->toIso8601String(),
|
||||
@ -207,6 +210,8 @@ public function createTicketFromEmployer(Request $request)
|
||||
'subject' => 'required|string|max:255',
|
||||
'description' => 'required|string',
|
||||
'priority' => 'nullable|string|in:low,medium,high',
|
||||
'reason_id' => 'nullable|exists:report_reasons,id',
|
||||
'voice_note' => 'nullable|file|mimes:mp3,wav,m4a,ogg,webm,aac,3gp,amr|max:10240', // 10MB limit
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@ -217,15 +222,24 @@ public function createTicketFromEmployer(Request $request)
|
||||
], 422);
|
||||
}
|
||||
|
||||
$voiceNotePath = null;
|
||||
if ($request->hasFile('voice_note')) {
|
||||
$voiceNotePath = $request->file('voice_note')->store('support_voice_notes', 'public');
|
||||
}
|
||||
|
||||
$ticket = SupportTicket::create([
|
||||
'ticket_number' => 'TKT-' . rand(100000, 999999),
|
||||
'user_id' => $employer->id,
|
||||
'reason_id' => $request->reason_id,
|
||||
'subject' => $request->subject,
|
||||
'description' => $request->description,
|
||||
'voice_note_path' => $voiceNotePath,
|
||||
'priority' => $request->priority ?? 'medium',
|
||||
'status' => 'open',
|
||||
]);
|
||||
|
||||
$ticket->load('reason');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Ticket created successfully.',
|
||||
@ -234,6 +248,9 @@ public function createTicketFromEmployer(Request $request)
|
||||
'ticket_number' => $ticket->ticket_number,
|
||||
'subject' => $ticket->subject,
|
||||
'description' => $ticket->description,
|
||||
'reason_id' => $ticket->reason_id,
|
||||
'reason_name' => $ticket->reason ? $ticket->reason->reason : null,
|
||||
'voice_note_url' => $ticket->voice_note_path ? asset('storage/' . $ticket->voice_note_path) : null,
|
||||
'status' => $ticket->status,
|
||||
'priority' => $ticket->priority,
|
||||
'created_at' => $ticket->created_at->toIso8601String(),
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Worker;
|
||||
use App\Models\WorkerCategory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@ -181,6 +180,7 @@ public function setupProfile(Request $request)
|
||||
'preferred_location' => 'nullable|string|max:255',
|
||||
'skills' => 'nullable|array',
|
||||
'skills.*' => 'integer|exists:skills,id',
|
||||
'fcm_token' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@ -244,10 +244,10 @@ public function setupProfile(Request $request)
|
||||
'experience' => 'Not Specified',
|
||||
'religion' => 'Not Specified',
|
||||
'bio' => 'New worker on Migrant platform.',
|
||||
'category_id' => \App\Models\WorkerCategory::where('name', 'General Helper')->value('id') ?? (\App\Models\WorkerCategory::firstOrCreate(['name' => 'General Helper'])->id),
|
||||
'verified' => false,
|
||||
'status' => 'active',
|
||||
'api_token' => $apiToken,
|
||||
'fcm_token' => $request->fcm_token,
|
||||
]);
|
||||
|
||||
// Sync skills if provided
|
||||
@ -258,10 +258,18 @@ public function setupProfile(Request $request)
|
||||
return $worker;
|
||||
});
|
||||
|
||||
if ($request->filled('fcm_token')) {
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$request->fcm_token,
|
||||
'Welcome to Migrant',
|
||||
'Registration complete! Welcome to Migrant.'
|
||||
);
|
||||
}
|
||||
|
||||
// Clear the OTP verification gate
|
||||
Cache::forget('worker_otp_verified_' . $identifier);
|
||||
|
||||
$worker->load(['category', 'skills']);
|
||||
$worker->load(['skills']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@ -305,6 +313,7 @@ public function register(Request $request)
|
||||
'gender' => 'nullable|string|in:male,female,other',
|
||||
'live_in_out' => 'nullable|string|in:live_in,live_out',
|
||||
'preferred_location' => 'nullable|string|max:255',
|
||||
'fcm_token' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@ -377,13 +386,13 @@ public function register(Request $request)
|
||||
'experience' => $request->experience ?? 'Not Specified',
|
||||
'religion' => 'Not Specified',
|
||||
'bio' => 'Hardworking and reliable General Helper available for immediate hire.',
|
||||
'category_id' => \App\Models\WorkerCategory::where('name', 'General Helper')->value('id') ?? (\App\Models\WorkerCategory::firstOrCreate(['name' => 'General Helper'])->id),
|
||||
'verified' => false,
|
||||
'status' => 'active',
|
||||
'api_token' => $apiToken,
|
||||
'in_country' => $inCountry,
|
||||
'visa_status' => $inCountry ? $request->visa_status : null,
|
||||
'preferred_job_type' => $request->preferred_job_type,
|
||||
'fcm_token' => $request->fcm_token,
|
||||
]);
|
||||
|
||||
if (!empty($skillsArray)) {
|
||||
@ -414,7 +423,15 @@ public function register(Request $request)
|
||||
return $worker;
|
||||
});
|
||||
|
||||
$result->load(['category', 'skills', 'documents']);
|
||||
if ($request->filled('fcm_token')) {
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$request->fcm_token,
|
||||
'Welcome to Migrant',
|
||||
'Worker registered and authenticated successfully.'
|
||||
);
|
||||
}
|
||||
|
||||
$result->load(['skills', 'documents']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@ -471,11 +488,19 @@ public function login(Request $request)
|
||||
}
|
||||
$worker->update($updateData);
|
||||
|
||||
if ($request->filled('fcm_token')) {
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$request->fcm_token,
|
||||
'Successful Login',
|
||||
'Worker logged in successfully.'
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Worker logged in successfully.',
|
||||
'data' => [
|
||||
'worker' => $worker->load(['category', 'skills', 'documents']),
|
||||
'worker' => $worker->load(['skills', 'documents']),
|
||||
'token' => $apiToken
|
||||
]
|
||||
], 200);
|
||||
|
||||
@ -28,7 +28,7 @@ public function getProfile(Request $request)
|
||||
/** @var Worker $worker */
|
||||
$worker = $request->attributes->get('worker');
|
||||
|
||||
$worker->load(['category', 'skills', 'documents']);
|
||||
$worker->load(['skills', 'documents']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@ -59,7 +59,6 @@ public function updateProfile(Request $request)
|
||||
'experience' => 'nullable|string|max:100',
|
||||
'religion' => 'nullable|string|max:100',
|
||||
'bio' => 'nullable|string|max:1000',
|
||||
'category_id' => 'nullable|exists:worker_categories,id',
|
||||
'skills' => 'nullable|array',
|
||||
'skills.*' => 'exists:skills,id',
|
||||
'in_country' => 'nullable',
|
||||
@ -95,7 +94,6 @@ public function updateProfile(Request $request)
|
||||
'experience',
|
||||
'religion',
|
||||
'bio',
|
||||
'category_id',
|
||||
'visa_status',
|
||||
'preferred_job_type',
|
||||
'gender',
|
||||
@ -124,7 +122,7 @@ public function updateProfile(Request $request)
|
||||
}
|
||||
});
|
||||
|
||||
$worker->load(['category', 'skills', 'documents']);
|
||||
$worker->load(['skills', 'documents']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@ -168,7 +166,7 @@ public function goLive(Request $request)
|
||||
'success' => true,
|
||||
'message' => 'Your profile is now live! Status set to Active.',
|
||||
'data' => [
|
||||
'worker' => $worker->load(['category', 'skills', 'documents'])
|
||||
'worker' => $worker->load(['skills', 'documents'])
|
||||
]
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
@ -221,7 +219,7 @@ public function toggleAvailability(Request $request)
|
||||
'still_looking' => $stillLooking,
|
||||
'status' => $newStatus,
|
||||
'data' => [
|
||||
'worker' => $worker->load(['category', 'skills', 'documents'])
|
||||
'worker' => $worker->load(['skills', 'documents'])
|
||||
]
|
||||
], 200);
|
||||
|
||||
@ -255,7 +253,7 @@ public function markHired(Request $request)
|
||||
'success' => true,
|
||||
'message' => 'Congratulations! You have been successfully marked as Hired. Your profile has been automatically removed from active search.',
|
||||
'data' => [
|
||||
'worker' => $worker->load(['category', 'skills', 'documents'])
|
||||
'worker' => $worker->load(['skills', 'documents'])
|
||||
]
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@ -46,7 +46,7 @@ public function index(Request $request)
|
||||
|
||||
// Fetch applications for those jobs
|
||||
$applications = JobApplication::whereIn('job_id', $jobIds)
|
||||
->with(['worker.category', 'jobPost'])
|
||||
->with(['worker', 'jobPost'])
|
||||
->get();
|
||||
|
||||
$selectedWorkers = $applications->map(function ($app) {
|
||||
@ -66,7 +66,6 @@ public function index(Request $request)
|
||||
'worker_id' => $w->id,
|
||||
'name' => $w->name,
|
||||
'nationality' => $w->nationality,
|
||||
'category' => $w->category ? $w->category->name : 'General Helper',
|
||||
'salary' => (int)$w->salary,
|
||||
'status' => $status,
|
||||
'applied_at' => $app->created_at->format('M d, Y'),
|
||||
@ -80,7 +79,7 @@ public function index(Request $request)
|
||||
|
||||
// 2. Fetch direct hiring offers sent by this employer
|
||||
$directOffers = JobOffer::where('employer_id', $employerId)
|
||||
->with('worker.category')
|
||||
->with('worker')
|
||||
->get();
|
||||
|
||||
$directWorkers = $directOffers->map(function ($offer) {
|
||||
@ -98,7 +97,6 @@ public function index(Request $request)
|
||||
'worker_id' => $w->id,
|
||||
'name' => $w->name,
|
||||
'nationality' => $w->nationality,
|
||||
'category' => $w->category ? $w->category->name : 'General Helper',
|
||||
'salary' => (int)$offer->salary,
|
||||
'status' => $status,
|
||||
'applied_at' => $offer->created_at->format('M d, Y'),
|
||||
|
||||
@ -84,7 +84,7 @@ public function index(Request $request)
|
||||
|
||||
// 2. Shortlisted workers
|
||||
$shortlists = Shortlist::where('employer_id', $user->id)
|
||||
->with(['worker.category', 'worker.skills'])
|
||||
->with(['worker.skills'])
|
||||
->get();
|
||||
|
||||
$shortlistedWorkers = $shortlists->map(function ($s) {
|
||||
@ -100,7 +100,6 @@ public function index(Request $request)
|
||||
'id' => $w->id,
|
||||
'name' => $w->name,
|
||||
'nationality' => $w->nationality,
|
||||
'category' => $w->category ? $w->category->name : 'General Helper',
|
||||
'skills' => $w->skills->pluck('name')->toArray(),
|
||||
'photo_url' => null,
|
||||
'verified' => (bool)$w->verified,
|
||||
@ -122,6 +121,8 @@ public function index(Request $request)
|
||||
return [
|
||||
'id' => $conv->id,
|
||||
'worker_name' => $worker->name,
|
||||
'worker_nationality' => $worker->nationality,
|
||||
'sender_type' => $lastMsg->sender_type,
|
||||
'last_message' => $lastMsg->text,
|
||||
'unread' => $lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at),
|
||||
'sent_at' => $lastMsg->created_at->diffForHumans(),
|
||||
@ -130,7 +131,7 @@ public function index(Request $request)
|
||||
})->filter()->sortByDesc('timestamp')->values()->toArray();
|
||||
|
||||
// 4. Charity Events
|
||||
$dbAnnouncements = Announcement::where('status', 'approved')->latest()->limit(5)->get();
|
||||
$dbAnnouncements = Announcement::where('status', 'approved')->latest()->limit(2)->get();
|
||||
$announcements = $dbAnnouncements->map(function ($ann) {
|
||||
$body = $ann->body;
|
||||
$eventDate = null;
|
||||
@ -174,7 +175,7 @@ public function index(Request $request)
|
||||
})->toArray();
|
||||
|
||||
// 5. Recommended Workers
|
||||
$recWorkers = \App\Models\Worker::with(['category', 'skills'])
|
||||
$recWorkers = \App\Models\Worker::with(['skills'])
|
||||
->where('status', 'active')
|
||||
->orderBy('verified', 'desc')
|
||||
->limit(3)
|
||||
@ -190,7 +191,7 @@ public function index(Request $request)
|
||||
'id' => $w->id,
|
||||
'name' => $w->name,
|
||||
'nationality' => $w->nationality,
|
||||
'category' => $w->category ? $w->category->name : 'General Helper',
|
||||
'category' => 'General Helper',
|
||||
'skills' => $w->skills->pluck('name')->toArray(),
|
||||
'salary' => (int)$w->salary,
|
||||
'rating' => 4.8,
|
||||
|
||||
@ -8,7 +8,6 @@
|
||||
use App\Models\User;
|
||||
use App\Models\JobPost;
|
||||
use App\Models\JobApplication;
|
||||
use App\Models\WorkerCategory;
|
||||
|
||||
class JobController extends Controller
|
||||
{
|
||||
@ -44,7 +43,7 @@ public function index(Request $request)
|
||||
}
|
||||
|
||||
$dbJobs = JobPost::where('employer_id', $user->id)
|
||||
->with(['category', 'applications'])
|
||||
->with(['applications'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
@ -52,7 +51,6 @@ public function index(Request $request)
|
||||
return [
|
||||
'id' => $job->id,
|
||||
'title' => $job->title,
|
||||
'category' => $job->category->name ?? 'General',
|
||||
'location' => $job->location,
|
||||
'salary' => (int) $job->salary,
|
||||
'workers_needed' => $job->workers_needed,
|
||||
@ -69,14 +67,7 @@ public function index(Request $request)
|
||||
|
||||
public function create()
|
||||
{
|
||||
$categories = WorkerCategory::pluck('name')->toArray();
|
||||
if (empty($categories)) {
|
||||
$categories = ['Electrician', 'Mason', 'Plumber', 'Cleaner', 'Site Supervisor', 'Driver', 'General Helper'];
|
||||
}
|
||||
|
||||
return Inertia::render('Employer/Jobs/Create', [
|
||||
'categories' => $categories,
|
||||
]);
|
||||
return Inertia::render('Employer/Jobs/Create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
@ -88,7 +79,6 @@ public function store(Request $request)
|
||||
|
||||
$request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'category' => 'required|string',
|
||||
'workers_needed' => 'required|integer|min:1',
|
||||
'location' => 'required|string|max:255',
|
||||
'salary' => 'required|numeric|min:0',
|
||||
@ -98,15 +88,9 @@ public function store(Request $request)
|
||||
'requirements' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$category = WorkerCategory::where('name', $request->category)->first();
|
||||
if (!$category) {
|
||||
$category = WorkerCategory::create(['name' => $request->category]);
|
||||
}
|
||||
|
||||
JobPost::create([
|
||||
'employer_id' => $user->id,
|
||||
'title' => $request->title,
|
||||
'category_id' => $category->id,
|
||||
'workers_needed' => $request->workers_needed,
|
||||
'job_type' => $request->job_type,
|
||||
'location' => $request->location,
|
||||
@ -130,7 +114,7 @@ public function applicants($id)
|
||||
$job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail();
|
||||
|
||||
$dbApplications = JobApplication::where('job_id', $id)
|
||||
->with(['worker.category', 'worker.skills'])
|
||||
->with(['worker.skills'])
|
||||
->get();
|
||||
|
||||
$applicants = $dbApplications->map(function ($app) {
|
||||
@ -139,7 +123,6 @@ public function applicants($id)
|
||||
'id' => $worker->id,
|
||||
'name' => $worker->name,
|
||||
'nationality' => $worker->nationality,
|
||||
'category' => $worker->category->name ?? 'General',
|
||||
'salary' => (int) $worker->expected_salary,
|
||||
'experience' => ($worker->experience_years ?? 3) . ' Years',
|
||||
'status' => ucfirst($app->status), // Applied, Shortlisted, Hired, Rejected
|
||||
@ -151,7 +134,6 @@ public function applicants($id)
|
||||
'job' => [
|
||||
'id' => $job->id,
|
||||
'title' => $job->title,
|
||||
'category' => $job->category->name ?? 'General',
|
||||
'salary' => (int) $job->salary,
|
||||
],
|
||||
'applicants' => $applicants,
|
||||
|
||||
@ -101,7 +101,7 @@ public function index(Request $request)
|
||||
}
|
||||
|
||||
$dbConversations = Conversation::where('employer_id', $user->id)
|
||||
->with(['worker.category', 'messages'])
|
||||
->with(['worker', 'messages'])
|
||||
->latest('updated_at')
|
||||
->get();
|
||||
|
||||
@ -111,7 +111,6 @@ public function index(Request $request)
|
||||
'id' => $conv->id,
|
||||
'worker_id' => $conv->worker_id,
|
||||
'worker_name' => $conv->worker->name ?? 'Candidate',
|
||||
'category' => $conv->worker->category->name ?? 'General Helper',
|
||||
'worker_status' => strtolower($conv->worker->status ?? 'active'),
|
||||
'last_message' => $lastMsg->text ?? 'No messages yet.',
|
||||
'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false,
|
||||
@ -133,7 +132,7 @@ public function show($id)
|
||||
}
|
||||
|
||||
$dbConversations = Conversation::where('employer_id', $user->id)
|
||||
->with(['worker.category', 'messages'])
|
||||
->with(['worker', 'messages'])
|
||||
->latest('updated_at')
|
||||
->get();
|
||||
|
||||
@ -143,7 +142,6 @@ public function show($id)
|
||||
'id' => $conv->id,
|
||||
'worker_id' => $conv->worker_id,
|
||||
'worker_name' => $conv->worker->name ?? 'Candidate',
|
||||
'category' => $conv->worker->category->name ?? 'General Helper',
|
||||
'worker_status' => strtolower($conv->worker->status ?? 'active'),
|
||||
'last_message' => $lastMsg->text ?? 'No messages yet.',
|
||||
'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false,
|
||||
@ -154,7 +152,7 @@ public function show($id)
|
||||
|
||||
$activeConv = Conversation::where('employer_id', $user->id)
|
||||
->where('id', $id)
|
||||
->with(['worker.category', 'messages'])
|
||||
->with(['worker', 'messages'])
|
||||
->firstOrFail();
|
||||
|
||||
// Mark incoming messages as read
|
||||
@ -167,7 +165,6 @@ public function show($id)
|
||||
'id' => $activeConv->id,
|
||||
'worker_id' => $activeConv->worker_id,
|
||||
'worker_name' => $activeConv->worker->name ?? 'Candidate',
|
||||
'category' => $activeConv->worker->category->name ?? 'General Helper',
|
||||
'worker_status' => strtolower($activeConv->worker->status ?? 'active'),
|
||||
'online' => true,
|
||||
'salary' => ($activeConv->worker->expected_salary ?? 2000) . ' AED',
|
||||
|
||||
@ -43,7 +43,7 @@ public function index(Request $request)
|
||||
$employerId = $user ? $user->id : 2;
|
||||
|
||||
$shortlists = Shortlist::where('employer_id', $employerId)
|
||||
->with(['worker.category', 'worker.skills', 'worker.documents'])
|
||||
->with(['worker.skills', 'worker.documents'])
|
||||
->get();
|
||||
|
||||
$shortlistedWorkers = $shortlists->map(function ($s) {
|
||||
@ -96,9 +96,9 @@ public function index(Request $request)
|
||||
'photo' => $photo,
|
||||
'emirates_id_status' => $emiratesIdStatus,
|
||||
'passport_status' => $w->passport_status,
|
||||
'category' => $w->category ? $w->category->name : 'Domestic Worker',
|
||||
'skills' => $mappedSkills,
|
||||
'visa_status' => $visaStatus,
|
||||
'gender' => $w->gender ?? 'Female',
|
||||
'experience' => $w->experience,
|
||||
'salary' => (int) $w->salary,
|
||||
'religion' => $w->religion,
|
||||
|
||||
@ -92,14 +92,21 @@ public function store(Request $request)
|
||||
'subject' => 'required|string|max:255',
|
||||
'description' => 'required|string',
|
||||
'priority' => 'required|string|in:low,medium,high',
|
||||
'voice_note' => 'nullable|file|mimes:mp3,wav,m4a,ogg,webm,aac,3gp,amr|max:10240', // 10MB limit
|
||||
]);
|
||||
|
||||
$voiceNotePath = null;
|
||||
if ($request->hasFile('voice_note')) {
|
||||
$voiceNotePath = $request->file('voice_note')->store('support_voice_notes', 'public');
|
||||
}
|
||||
|
||||
$ticket = SupportTicket::create([
|
||||
'ticket_number' => 'TKT-' . rand(100000, 999999),
|
||||
'user_id' => $user->id,
|
||||
'reason_id' => $request->reason_id,
|
||||
'subject' => $request->subject,
|
||||
'description' => $request->description,
|
||||
'voice_note_path' => $voiceNotePath,
|
||||
'priority' => $request->priority,
|
||||
'status' => 'open',
|
||||
]);
|
||||
@ -128,6 +135,7 @@ public function show(Request $request, $id)
|
||||
'sender_name' => $reply->sender_name,
|
||||
'is_admin' => $reply->user && $reply->user->role === 'admin',
|
||||
'is_developer_response' => (bool)$reply->is_developer_response,
|
||||
'voice_note_url' => $reply->voice_note_path ? asset('storage/' . $reply->voice_note_path) : null,
|
||||
'created_at' => $reply->created_at->format('Y-m-d H:i'),
|
||||
];
|
||||
});
|
||||
@ -139,6 +147,7 @@ public function show(Request $request, $id)
|
||||
'subject' => $ticket->subject,
|
||||
'reason' => $ticket->reason ? $ticket->reason->reason : null,
|
||||
'description' => $ticket->description,
|
||||
'voice_note_url' => $ticket->voice_note_path ? asset('storage/' . $ticket->voice_note_path) : null,
|
||||
'status' => $ticket->status,
|
||||
'priority' => $ticket->priority,
|
||||
'created_at' => $ticket->created_at->format('Y-m-d H:i'),
|
||||
@ -161,13 +170,20 @@ public function reply(Request $request, $id)
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'message' => 'required|string',
|
||||
'message' => 'nullable|required_without:voice_note|string',
|
||||
'voice_note' => 'nullable|file|mimes:mp3,wav,m4a,ogg,webm,aac,3gp,amr|max:10240', // 10MB limit
|
||||
]);
|
||||
|
||||
$voiceNotePath = null;
|
||||
if ($request->hasFile('voice_note')) {
|
||||
$voiceNotePath = $request->file('voice_note')->store('support_voice_notes', 'public');
|
||||
}
|
||||
|
||||
SupportTicketReply::create([
|
||||
'support_ticket_id' => $ticket->id,
|
||||
'user_id' => $user->id,
|
||||
'message' => $request->message,
|
||||
'voice_note_path' => $voiceNotePath,
|
||||
]);
|
||||
|
||||
// Reopen ticket if resolved/closed was updated
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
use Inertia\Inertia;
|
||||
use App\Models\User;
|
||||
use App\Models\Worker;
|
||||
use App\Models\WorkerCategory;
|
||||
use App\Models\Shortlist;
|
||||
use App\Models\JobOffer;
|
||||
use App\Models\Review;
|
||||
@ -46,7 +45,7 @@ public function index(Request $request)
|
||||
$user = $this->resolveCurrentUser();
|
||||
$employerId = $user ? $user->id : 2;
|
||||
|
||||
$dbWorkers = Worker::with(['category', 'skills', 'documents'])
|
||||
$dbWorkers = Worker::with(['skills', 'documents'])
|
||||
->where('status', '!=', 'Hired')
|
||||
->where('status', '!=', 'hidden')
|
||||
->get();
|
||||
@ -98,7 +97,7 @@ public function index(Request $request)
|
||||
'photo' => $photo,
|
||||
'emirates_id_status' => $emiratesIdStatus,
|
||||
'passport_status' => $w->passport_status,
|
||||
'category' => $w->category ? $w->category->name : 'Domestic Worker',
|
||||
'category' => 'Domestic Worker',
|
||||
'skills' => $mappedSkills,
|
||||
'visa_status' => $visaStatus,
|
||||
'experience' => $w->experience,
|
||||
@ -267,13 +266,11 @@ public function index(Request $request)
|
||||
|
||||
$shortlistedIds = Shortlist::where('employer_id', $employerId)->pluck('worker_id')->toArray();
|
||||
|
||||
$dbCategories = WorkerCategory::pluck('name')->toArray();
|
||||
$nationalitiesResponse = app(\App\Http\Controllers\Api\WorkerAuthController::class)->nationalities(new \Illuminate\Http\Request(['per_page' => 500]));
|
||||
$nationalitiesData = json_decode($nationalitiesResponse->getContent(), true);
|
||||
$dbNationalities = collect($nationalitiesData['data']['nationalities'] ?? [])->pluck('name')->filter()->toArray();
|
||||
|
||||
$filtersMetadata = [
|
||||
'categories' => array_merge(['All Categories'], $dbCategories),
|
||||
'nationalities' => array_merge(['All Nationalities'], $dbNationalities),
|
||||
'experienceLevels' => ['All Experience', '1-2 Years', '3-5 Years', '5+ Years'],
|
||||
'religions' => ['All Religions', 'Christian', 'Muslim', 'Hindu', 'Buddhist'],
|
||||
@ -292,7 +289,7 @@ public function index(Request $request)
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
$w = Worker::with(['category', 'skills', 'documents'])->findOrFail($id);
|
||||
$w = Worker::with(['skills', 'documents'])->findOrFail($id);
|
||||
|
||||
$isPending = str_contains(strtolower($w->passport_status), 'pending');
|
||||
if ($w->status === 'hidden' || $isPending) {
|
||||
@ -345,7 +342,7 @@ public function show($id)
|
||||
$reviewsCount = count($reviews);
|
||||
$rating = $reviewsCount > 0 ? round($dbReviews->avg('rating'), 1) : 0;
|
||||
|
||||
$simDb = Worker::with('category')
|
||||
$simDb = Worker::query()
|
||||
->where('id', '!=', $w->id)
|
||||
->where('status', '!=', 'Hired')
|
||||
->where('status', '!=', 'hidden')
|
||||
@ -361,7 +358,7 @@ public function show($id)
|
||||
'id' => $sw->id,
|
||||
'name' => $sw->name,
|
||||
'nationality' => $sw->nationality,
|
||||
'category' => $sw->category ? $sw->category->name : 'Helper',
|
||||
'category' => 'Helper',
|
||||
'salary' => (int) $sw->salary,
|
||||
'rating' => 4.7,
|
||||
'verified' => (bool) $sw->verified,
|
||||
@ -379,7 +376,7 @@ public function show($id)
|
||||
'photo' => $photo,
|
||||
'emirates_id_status' => $emiratesIdStatus,
|
||||
'passport_status' => $w->passport_status,
|
||||
'category' => $w->category ? $w->category->name : 'Domestic Worker',
|
||||
'category' => 'Domestic Worker',
|
||||
'skills' => $mappedSkills,
|
||||
'visa_status' => $visaStatus,
|
||||
'experience' => $w->experience,
|
||||
|
||||
@ -13,7 +13,6 @@ class JobPost extends Model
|
||||
protected $fillable = [
|
||||
'employer_id',
|
||||
'title',
|
||||
'category_id',
|
||||
'workers_needed',
|
||||
'job_type',
|
||||
'location',
|
||||
@ -35,10 +34,6 @@ public function employer()
|
||||
return $this->belongsTo(User::class, 'employer_id');
|
||||
}
|
||||
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(WorkerCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
public function applications()
|
||||
{
|
||||
|
||||
@ -34,6 +34,7 @@ class Sponsor extends Authenticatable
|
||||
'license_file',
|
||||
'emirates_id_file',
|
||||
'license_expiry',
|
||||
'fcm_token',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
|
||||
@ -29,7 +29,6 @@ class Worker extends Model
|
||||
'experience',
|
||||
'religion',
|
||||
'bio',
|
||||
'category_id',
|
||||
'verified',
|
||||
'status',
|
||||
'api_token',
|
||||
@ -128,10 +127,6 @@ public function getDocumentExpiryStatusAttribute()
|
||||
return 'No Documents';
|
||||
}
|
||||
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(WorkerCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
public function skills()
|
||||
{
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class WorkerCategory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name'];
|
||||
|
||||
public function workers()
|
||||
{
|
||||
return $this->hasMany(Worker::class, 'category_id');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('workers', function (Blueprint $table) {
|
||||
$table->dropForeign(['category_id']);
|
||||
$table->dropColumn('category_id');
|
||||
});
|
||||
|
||||
Schema::table('job_posts', function (Blueprint $table) {
|
||||
$table->dropForeign(['category_id']);
|
||||
$table->dropColumn('category_id');
|
||||
});
|
||||
|
||||
Schema::dropIfExists('worker_categories');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::create('worker_categories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name')->unique();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::table('workers', function (Blueprint $table) {
|
||||
$table->foreignId('category_id')->nullable()->constrained('worker_categories');
|
||||
});
|
||||
|
||||
Schema::table('job_posts', function (Blueprint $table) {
|
||||
$table->foreignId('category_id')->nullable()->constrained('worker_categories');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('sponsors', function (Blueprint $table) {
|
||||
$table->string('fcm_token')->nullable()->after('api_token');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('sponsors', function (Blueprint $table) {
|
||||
$table->dropColumn('fcm_token');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -29,7 +29,6 @@ public function run(): void
|
||||
|
||||
// Call Seeders
|
||||
$this->call([
|
||||
WorkerCategorySeeder::class,
|
||||
SkillSeeder::class,
|
||||
WorkerSeeder::class,
|
||||
EmployerProfileSeeder::class,
|
||||
|
||||
@ -19,7 +19,6 @@ public function run(): void
|
||||
[
|
||||
'employer_id' => $employerId,
|
||||
'title' => 'Senior Electrician for Villa Project',
|
||||
'category_id' => 1, // Electrician
|
||||
'workers_needed' => 2,
|
||||
'job_type' => 'Full Time',
|
||||
'location' => 'Dubai Marina',
|
||||
@ -31,7 +30,6 @@ public function run(): void
|
||||
[
|
||||
'employer_id' => $employerId,
|
||||
'title' => 'Mason for Commercial Building',
|
||||
'category_id' => 2, // Mason
|
||||
'workers_needed' => 5,
|
||||
'job_type' => 'Contract',
|
||||
'location' => 'Business Bay',
|
||||
|
||||
@ -27,7 +27,6 @@ public function run(): void
|
||||
|
||||
// 2. Run static reference seeders
|
||||
$this->call([
|
||||
WorkerCategorySeeder::class,
|
||||
SkillSeeder::class,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class WorkerCategorySeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$categories = [
|
||||
'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Site Supervisor', 'Driver', 'General Helper',
|
||||
'Childcare', 'Housekeeping', 'Cooking', 'Elderly Care'
|
||||
];
|
||||
|
||||
foreach ($categories as $category) {
|
||||
\Illuminate\Support\Facades\DB::table('worker_categories')->insertOrIgnore([
|
||||
'name' => $category,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -31,7 +31,6 @@ public function run(): void
|
||||
'experience' => '5+ Years',
|
||||
'religion' => 'Christian',
|
||||
'bio' => '',
|
||||
'category_id' => 1, // Electrician
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
],
|
||||
@ -53,7 +52,6 @@ public function run(): void
|
||||
'experience' => '3 Years',
|
||||
'religion' => 'Muslim',
|
||||
'bio' => 'Skilled mason with experience in block work and plastering.',
|
||||
'category_id' => 2, // Mason
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
],
|
||||
@ -63,7 +61,7 @@ public function run(): void
|
||||
'phone' => '+971 50 555 6666',
|
||||
'nationality' => 'Filipino',
|
||||
'country' => 'United Arab Emirates',
|
||||
'city' => 'Abu Dhabi',
|
||||
'city' => 'Yas Island',
|
||||
'area' => 'Yas Island',
|
||||
'preferred_location' => 'Yas Island',
|
||||
'live_in_out' => 'Live-in (Stay with family)',
|
||||
@ -75,7 +73,6 @@ public function run(): void
|
||||
'experience' => '5+ Years',
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'Experienced nanny with 6 years working with expatriate families in Dubai. Certified in pediatric first aid.',
|
||||
'category_id' => 8, // Childcare
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
],
|
||||
@ -97,7 +94,6 @@ public function run(): void
|
||||
'experience' => '3-5 Years',
|
||||
'religion' => 'Hindu',
|
||||
'bio' => 'Patient caregiver specializing in elderly assistance, mobility support, and vegetarian cooking.',
|
||||
'category_id' => 11, // Elderly Care
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
],
|
||||
@ -119,7 +115,6 @@ public function run(): void
|
||||
'experience' => '3-5 Years',
|
||||
'religion' => 'Muslim',
|
||||
'bio' => 'Hardworking and meticulous housekeeper with excellent references from Abu Dhabi households.',
|
||||
'category_id' => 9, // Housekeeping
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
],
|
||||
@ -141,7 +136,6 @@ public function run(): void
|
||||
'experience' => '1-2 Years',
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'Energetic and educated nanny. Fluent in English, great with toddlers and assisting with homework.',
|
||||
'category_id' => 8, // Childcare
|
||||
'verified' => false,
|
||||
'status' => 'active',
|
||||
],
|
||||
@ -163,7 +157,6 @@ public function run(): void
|
||||
'experience' => '5+ Years',
|
||||
'religion' => 'Buddhist',
|
||||
'bio' => 'Professional domestic cook skilled in Arabic, Continental, and Asian cuisine. Highly organized.',
|
||||
'category_id' => 10, // Cooking
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
],
|
||||
@ -185,42 +178,11 @@ public function run(): void
|
||||
'experience' => '5+ Years',
|
||||
'religion' => 'Hindu',
|
||||
'bio' => 'Valid UAE driving license with clean record. Familiar with all Dubai and Sharjah school routes.',
|
||||
'category_id' => 6, // Driver
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
],
|
||||
];
|
||||
|
||||
// Dynamic Category Resolution
|
||||
$categoryMapping = [
|
||||
1 => 'Electrician',
|
||||
2 => 'Mason',
|
||||
3 => 'Plumber',
|
||||
4 => 'Cleaner',
|
||||
5 => 'Site Supervisor',
|
||||
6 => 'Driver',
|
||||
7 => 'General Helper',
|
||||
8 => 'Childcare',
|
||||
9 => 'Housekeeping',
|
||||
10 => 'Cooking',
|
||||
11 => 'Elderly Care',
|
||||
];
|
||||
|
||||
$categoryIds = [];
|
||||
foreach ($categoryMapping as $oldId => $name) {
|
||||
$cat = \Illuminate\Support\Facades\DB::table('worker_categories')->where('name', $name)->first();
|
||||
if (!$cat) {
|
||||
$catId = \Illuminate\Support\Facades\DB::table('worker_categories')->insertGetId([
|
||||
'name' => $name,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
} else {
|
||||
$catId = $cat->id;
|
||||
}
|
||||
$categoryIds[$oldId] = $catId;
|
||||
}
|
||||
|
||||
// Dynamic Skill Resolution
|
||||
$skillIds = \Illuminate\Support\Facades\DB::table('skills')->pluck('id')->toArray();
|
||||
if (empty($skillIds)) {
|
||||
@ -237,15 +199,12 @@ public function run(): void
|
||||
$sId2 = $skillIds[1] ?? 2;
|
||||
|
||||
foreach ($workers as $workerData) {
|
||||
$resolvedCategoryId = $categoryIds[$workerData['category_id']] ?? $workerData['category_id'];
|
||||
|
||||
// Exclude email duplicates to avoid unique constraint violations
|
||||
if (\Illuminate\Support\Facades\DB::table('workers')->where('email', $workerData['email'])->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$workerId = \Illuminate\Support\Facades\DB::table('workers')->insertGetId(array_merge($workerData, [
|
||||
'category_id' => $resolvedCategoryId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]));
|
||||
|
||||
@ -1686,10 +1686,7 @@
|
||||
"type": "string",
|
||||
"example": "Highly certified housekeeper and babysitter with cooking experience."
|
||||
},
|
||||
"category_id": {
|
||||
"type": "integer",
|
||||
"example": 8
|
||||
},
|
||||
|
||||
"skills": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@ -4405,7 +4402,7 @@
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@ -4429,6 +4426,16 @@
|
||||
"high"
|
||||
],
|
||||
"example": "medium"
|
||||
},
|
||||
"reason_id": {
|
||||
"type": "integer",
|
||||
"example": 1,
|
||||
"description": "Optional report/support reason ID."
|
||||
},
|
||||
"voice_note": {
|
||||
"type": "string",
|
||||
"format": "binary",
|
||||
"description": "Optional audio/voice note file attachment."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4595,10 +4602,7 @@
|
||||
"type": "string",
|
||||
"example": "Certified infant caregiver and housekeeper with excellent cooking skills."
|
||||
},
|
||||
"category_id": {
|
||||
"type": "integer",
|
||||
"example": 7
|
||||
},
|
||||
|
||||
"verified": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
|
||||
@ -75,7 +75,6 @@ export default function WorkerManagement({ workers }) {
|
||||
name: '',
|
||||
phone: '',
|
||||
language: '',
|
||||
category: '',
|
||||
experience: '',
|
||||
bio: '',
|
||||
country: '',
|
||||
@ -128,7 +127,6 @@ export default function WorkerManagement({ workers }) {
|
||||
phone: editForm.phone,
|
||||
gender: editForm.gender,
|
||||
language: editForm.language,
|
||||
category: editForm.category,
|
||||
experience: editForm.experience,
|
||||
salary: editForm.salary,
|
||||
bio: editForm.bio,
|
||||
@ -170,7 +168,6 @@ export default function WorkerManagement({ workers }) {
|
||||
phone: fullWorker.phone,
|
||||
gender: fullWorker.gender || 'Female',
|
||||
language: fullWorker.language || 'English',
|
||||
category: fullWorker.category,
|
||||
experience: fullWorker.experience,
|
||||
salary: fullWorker.salary || '',
|
||||
bio: fullWorker.bio,
|
||||
|
||||
@ -272,7 +272,7 @@ export default function Dashboard({
|
||||
|
||||
<div className="space-y-2.5 flex-1 py-2">
|
||||
<Link
|
||||
href="/employer/workers?category=Childcare"
|
||||
href="/employer/workers"
|
||||
className="w-full p-3.5 bg-slate-50 hover:bg-blue-50/50 border border-slate-200 hover:border-[#185FA5]/40 rounded-2xl transition-all flex items-center space-x-3 text-left group"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-xl bg-purple-100/50 text-purple-600 flex items-center justify-center flex-shrink-0 group-hover:scale-105 transition-transform">
|
||||
@ -345,13 +345,6 @@ export default function Dashboard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-[10px] font-semibold text-slate-400 uppercase tracking-tighter">{t('category', 'Category')}:</div>
|
||||
<div className="inline-block px-2 py-0.5 bg-white border border-slate-200 rounded-md text-xs font-semibold text-slate-700">
|
||||
{worker.category || 'General Helper'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/employer/workers/${worker.id}`}
|
||||
className="w-full mt-2 bg-white hover:bg-slate-50 text-[#185FA5] border border-slate-200 text-center rounded-lg py-1.5 text-[10px] font-black flex items-center justify-center transition-colors"
|
||||
@ -395,8 +388,13 @@ export default function Dashboard({
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-bold text-xs text-slate-800 group-hover:text-[#185FA5] transition-colors truncate">
|
||||
{msg.worker_name}
|
||||
<div className="font-bold text-xs text-slate-800 group-hover:text-[#185FA5] transition-colors truncate flex items-center gap-1.5 flex-wrap">
|
||||
<span>{msg.worker_name}</span>
|
||||
{msg.worker_nationality && (
|
||||
<span className="text-[9px] font-normal text-slate-500 bg-slate-100/80 px-1.5 py-0.5 rounded-md border border-slate-200/50">
|
||||
{msg.worker_nationality}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-[9px] font-semibold text-slate-400">{msg.sent_at}</span>
|
||||
@ -405,7 +403,12 @@ export default function Dashboard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 truncate mt-0.5 font-medium">
|
||||
<p className="text-xs text-slate-600 truncate mt-1 font-medium">
|
||||
{msg.sender_type === 'employer' ? (
|
||||
<span className="text-slate-400 font-semibold">{t('you', 'You')}: </span>
|
||||
) : (
|
||||
<span className="text-[#185FA5] font-semibold">{msg.worker_name}: </span>
|
||||
)}
|
||||
{msg.last_message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -50,7 +50,7 @@ export default function Confirm({ worker }) {
|
||||
{worker.name.charAt(0)}
|
||||
</div>
|
||||
<h3 className="font-extrabold text-slate-900 tracking-tight">{worker.name}</h3>
|
||||
<p className="text-xs font-bold text-[#185FA5] uppercase tracking-widest mt-1">{worker.category}</p>
|
||||
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between text-xs font-medium">
|
||||
|
||||
@ -20,10 +20,10 @@ import { Badge } from '@/components/ui/badge';
|
||||
export default function Applicants({ job, applicants: initialApplicants }) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [applicants, setApplicants] = useState(initialApplicants || [
|
||||
{ id: 101, name: 'Anjali Sharma', nationality: 'Indian', category: 'Nanny', salary: 2500, experience: '5 Years', status: 'Reviewing', match_score: 95 },
|
||||
{ id: 102, name: 'Siti Rahma', nationality: 'Indonesian', category: 'Housemaid', salary: 1800, experience: '3 Years', status: 'Hired', match_score: 88 },
|
||||
{ id: 103, name: 'Maricel Cruz', nationality: 'Filipino', category: 'Domestic Helper', salary: 2200, experience: '7 Years', status: 'Rejected', match_score: 72 },
|
||||
{ id: 104, name: 'Bebeth Santos', nationality: 'Filipino', category: 'Cook', salary: 3000, experience: '10 Years', status: 'Reviewing', match_score: 98 },
|
||||
{ id: 101, name: 'Anjali Sharma', nationality: 'Indian', salary: 2500, experience: '5 Years', status: 'Reviewing', match_score: 95 },
|
||||
{ id: 102, name: 'Siti Rahma', nationality: 'Indonesian', salary: 1800, experience: '3 Years', status: 'Hired', match_score: 88 },
|
||||
{ id: 103, name: 'Maricel Cruz', nationality: 'Filipino', salary: 2200, experience: '7 Years', status: 'Rejected', match_score: 72 },
|
||||
{ id: 104, name: 'Bebeth Santos', nationality: 'Filipino', salary: 3000, experience: '10 Years', status: 'Reviewing', match_score: 98 },
|
||||
]);
|
||||
|
||||
const filteredApplicants = applicants.filter(a =>
|
||||
@ -50,8 +50,6 @@ export default function Applicants({ job, applicants: initialApplicants }) {
|
||||
Applicants for <span className="text-[#185FA5]">{job?.title || 'Senior Mason Project'}</span>
|
||||
</h1>
|
||||
<div className="flex items-center space-x-3 text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||
<span className="flex items-center"><Briefcase className="w-3 h-3 mr-1" /> {job?.category || 'Mason'}</span>
|
||||
<span className="w-1 h-1 bg-slate-200 rounded-full" />
|
||||
<span className="flex items-center"><DollarSign className="w-3 h-3 mr-1" /> {job?.salary || '2800'} AED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -22,7 +22,6 @@ export default function Create({ categories: initialCategories }) {
|
||||
|
||||
const { data, setData, post, processing, errors } = useForm({
|
||||
title: '',
|
||||
category: '',
|
||||
workers_needed: 1,
|
||||
location: '',
|
||||
salary: '',
|
||||
@ -91,21 +90,7 @@ export default function Create({ categories: initialCategories }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Worker Category</label>
|
||||
<div className="relative">
|
||||
<LayoutGrid className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<select
|
||||
className="w-full pl-11 pr-4 py-3 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all appearance-none"
|
||||
value={data.category}
|
||||
onChange={e => setData('category', e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">Select Category</option>
|
||||
{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Workers Needed</label>
|
||||
|
||||
@ -33,16 +33,15 @@ export default function Index({ initialJobs }) {
|
||||
const [statusFilter, setStatusFilter] = useState('All');
|
||||
|
||||
const [jobs, setJobs] = useState(initialJobs || [
|
||||
{ id: 1, title: 'Experienced Mason', category: 'Mason', location: 'Dubai Marina', salary: 2800, workers_needed: 5, applied_count: 3, posted_at: 'Nov 12, 2026', status: 'Active' },
|
||||
{ id: 2, title: 'Villa Cleaner', category: 'Cleaner', location: 'Al Barsha', salary: 1800, workers_needed: 2, applied_count: 12, posted_at: 'Nov 10, 2026', status: 'Active' },
|
||||
{ id: 3, title: 'Site Supervisor', category: 'Site Supervisor', location: 'Sharjah', salary: 4500, workers_needed: 1, applied_count: 8, posted_at: 'Nov 05, 2026', status: 'Closed' },
|
||||
{ id: 4, title: 'Plumber for Project', category: 'Plumber', location: 'Jumeirah', salary: 2600, workers_needed: 3, applied_count: 0, posted_at: 'Nov 14, 2026', status: 'Draft' },
|
||||
{ id: 1, title: 'Experienced Mason', location: 'Dubai Marina', salary: 2800, workers_needed: 5, applied_count: 3, posted_at: 'Nov 12, 2026', status: 'Active' },
|
||||
{ id: 2, title: 'Villa Cleaner', location: 'Al Barsha', salary: 1800, workers_needed: 2, applied_count: 12, posted_at: 'Nov 10, 2026', status: 'Active' },
|
||||
{ id: 3, title: 'Site Supervisor', location: 'Sharjah', salary: 4500, workers_needed: 1, applied_count: 8, posted_at: 'Nov 05, 2026', status: 'Closed' },
|
||||
{ id: 4, title: 'Plumber for Project', location: 'Jumeirah', salary: 2600, workers_needed: 3, applied_count: 0, posted_at: 'Nov 14, 2026', status: 'Draft' },
|
||||
]);
|
||||
|
||||
const filteredJobs = useMemo(() => {
|
||||
return jobs.filter(job => {
|
||||
const matchesSearch = job.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
job.category.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesSearch = job.title.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'All' || job.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
@ -74,7 +73,7 @@ export default function Index({ initialJobs }) {
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by job title or category..."
|
||||
placeholder="Search by job title..."
|
||||
className="w-full pl-11 pr-4 py-3 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
@ -120,8 +119,6 @@ export default function Index({ initialJobs }) {
|
||||
<div>
|
||||
<h3 className="font-black text-slate-900 tracking-tight group-hover:text-[#185FA5] transition-colors line-clamp-1">{job.title}</h3>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{job.category}</span>
|
||||
<span className="w-1 h-1 bg-slate-300 rounded-full" />
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{job.posted_at}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -65,9 +65,6 @@ export default function Index({ conversations }) {
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center space-x-2 font-bold text-base text-slate-900 group-hover:text-[#185FA5] transition-colors truncate">
|
||||
<span>{thread.worker_name}</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded-md text-[10px] font-bold uppercase tracking-wider hidden sm:inline-block">
|
||||
{thread.category}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`text-xs font-semibold whitespace-nowrap ${thread.unread ? 'text-[#185FA5]' : 'text-slate-400'}`}>
|
||||
{thread.sent_at}
|
||||
|
||||
@ -498,9 +498,6 @@ export default function Show({ conversation, initialMessages, conversations = []
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-extrabold text-base text-slate-900 tracking-tight">{conversation.worker_name}</h4>
|
||||
<span className="inline-block bg-slate-50 px-2 py-0.5 border border-slate-200 rounded text-[9px] uppercase font-black tracking-widest mt-1">
|
||||
{conversation.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -418,7 +418,6 @@ export default function SelectedCandidates({
|
||||
<tr className="border-b border-slate-100">
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('candidate_header', 'Candidate')}</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('selected_header', 'Selected')}</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('category_header', 'Category')}</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('salary_header', 'Salary')}</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('status_header', 'Status')}</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest text-right">{t('actions_header', 'Actions')}</th>
|
||||
@ -443,9 +442,6 @@ export default function SelectedCandidates({
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-5 text-[11px] font-bold text-slate-500">{worker.applied_at || '—'}</td>
|
||||
<td className="px-6 py-5">
|
||||
<span className="px-2 py-1 bg-slate-100 text-slate-600 text-[10px] font-bold rounded-lg">{worker.category}</span>
|
||||
</td>
|
||||
<td className="px-6 py-5">
|
||||
<span className="text-sm font-bold text-slate-800">{worker.salary} <span className="text-[10px] text-slate-400">AED</span></span>
|
||||
</td>
|
||||
@ -510,3 +506,4 @@ export default function SelectedCandidates({
|
||||
</EmployerLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -18,7 +18,8 @@ import {
|
||||
User,
|
||||
X,
|
||||
HeartHandshake,
|
||||
CheckCircle
|
||||
CheckCircle,
|
||||
MapPin
|
||||
} from 'lucide-react';
|
||||
|
||||
const getLanguageFlag = (lang) => {
|
||||
@ -176,11 +177,8 @@ export default function Shortlist({ shortlistedWorkers }) {
|
||||
<div className="p-6 space-y-4 flex-1 flex flex-col justify-between">
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* Category Pill, Ratings & Cost */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="px-2.5 py-1 bg-blue-50 text-[#185FA5] font-black rounded-lg border border-blue-100 uppercase tracking-wider text-[9px]">
|
||||
{worker.category}
|
||||
</span>
|
||||
{/* Ratings & Cost */}
|
||||
<div className="flex items-center justify-end text-xs">
|
||||
<div className="flex items-center space-x-1 font-bold text-slate-800">
|
||||
<DollarSign className="w-3.5 h-3.5 text-emerald-600" />
|
||||
<span className="text-sm font-black">{worker.salary} AED</span>
|
||||
@ -199,9 +197,13 @@ export default function Shortlist({ shortlistedWorkers }) {
|
||||
<Sparkles className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
||||
<span className="truncate uppercase text-[10px]">{worker.preferred_job_type}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 truncate col-span-2">
|
||||
<div className="flex items-center space-x-1.5 truncate">
|
||||
<MapPin className="w-3.5 h-3.5 text-[#185FA5] flex-shrink-0" />
|
||||
<span className="truncate text-[#185FA5]">{worker.preferred_location || 'Not Specified'}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 truncate">
|
||||
<Star className="w-3.5 h-3.5 text-amber-500 flex-shrink-0 fill-amber-500" />
|
||||
<span className="truncate">{worker.rating || '4.5'} ({worker.reviews_count || '12'} reviews)</span>
|
||||
<span className="truncate">{worker.rating || '4.5'} ({worker.reviews_count || '12'})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -330,7 +332,7 @@ export default function Shortlist({ shortlistedWorkers }) {
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-xs text-slate-900 truncate max-w-[120px]">{w.name}</div>
|
||||
<div className="text-[10px] text-slate-500 font-bold">{w.nationality} • {w.category}</div>
|
||||
<div className="text-[10px] text-slate-500 font-bold">{w.nationality}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -338,7 +340,7 @@ export default function Shortlist({ shortlistedWorkers }) {
|
||||
<div>Salary: <span className="text-slate-800">{w.salary} AED</span></div>
|
||||
<div>Age: <span className="text-slate-800">{w.age} yrs</span></div>
|
||||
<div>Job Type: <span className="text-slate-800 uppercase">{w.preferred_job_type}</span></div>
|
||||
<div>Status: <span className="text-slate-800 font-black">{w.availability_status}</span></div>
|
||||
<div className="truncate">Location: <span className="text-slate-800 font-medium" title={w.preferred_location}>{w.preferred_location || 'Not Specified'}</span></div>
|
||||
</div>
|
||||
|
||||
{/* Skills Display in Comparison */}
|
||||
@ -395,41 +397,46 @@ export default function Shortlist({ shortlistedWorkers }) {
|
||||
}`} />
|
||||
<span>{previewWorker.passport_status}</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 font-bold">{previewWorker.nationality} • {previewWorker.category}</div>
|
||||
<div className="text-xs text-slate-500 font-bold">{previewWorker.nationality}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previewWorker.bio ? (
|
||||
<p className="text-xs text-slate-600 italic bg-slate-50 p-4 rounded-xl leading-relaxed">
|
||||
"{previewWorker.bio}"
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400 italic bg-slate-50 p-4 rounded-xl leading-relaxed">
|
||||
{t('no_bio_provided', 'No bio details provided.')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-xs font-bold text-slate-600">
|
||||
<div className="p-3 bg-slate-50/50 rounded-xl">
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('expectations', 'Expectations')}</div>
|
||||
<div className="text-slate-800">{previewWorker.salary} AED / mo</div>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50/50 rounded-xl">
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('job_preference', 'Job Preference')}</div>
|
||||
<div className="text-slate-800 uppercase text-[10px] mt-1">{previewWorker.preferred_job_type}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50/50 rounded-xl col-span-2">
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('languages', 'Languages')}</div>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{previewWorker.languages?.map(lang => (
|
||||
<span key={lang} className="text-[9px] bg-white border border-slate-200 text-slate-700 px-2 py-0.5 rounded font-bold flex items-center space-x-1">
|
||||
<span>{getLanguageFlag(lang)}</span>
|
||||
<span>{lang}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-xs font-bold text-slate-600">
|
||||
<div className="p-3 bg-slate-50/50 rounded-xl">
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('expectations', 'Expectations')}</div>
|
||||
<div className="text-slate-800">{previewWorker.salary} AED / mo</div>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50/50 rounded-xl">
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('job_type', 'Job Type')}</div>
|
||||
<div className="text-slate-800 uppercase text-[10px] mt-1">{previewWorker.preferred_job_type}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50/50 rounded-xl">
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('gender', 'Gender')}</div>
|
||||
<div className="text-slate-800 capitalize mt-1">{previewWorker.gender || 'Female'}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50/50 rounded-xl">
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('visa_status', 'Visa Status')}</div>
|
||||
<div className="text-slate-800 capitalize mt-1">{previewWorker.visa_status || 'Residence Visa'}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50/50 rounded-xl col-span-2">
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('preferred_location', 'Preferred Location')}</div>
|
||||
<div className="text-slate-800 flex items-center space-x-1.5 mt-1">
|
||||
<MapPin className="w-3.5 h-3.5 text-[#185FA5]" />
|
||||
<span>{previewWorker.preferred_location || 'Not Specified'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50/50 rounded-xl col-span-2">
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('languages', 'Languages')}</div>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{previewWorker.languages?.map(lang => (
|
||||
<span key={lang} className="text-[9px] bg-white border border-slate-200 text-slate-700 px-2 py-0.5 rounded font-bold flex items-center space-x-1">
|
||||
<span>{getLanguageFlag(lang)}</span>
|
||||
<span>{lang}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Core Platform Skills inside Quick Preview */}
|
||||
<div className="space-y-1 pt-1">
|
||||
|
||||
@ -6,7 +6,10 @@ import {
|
||||
LifeBuoy,
|
||||
ArrowLeft,
|
||||
AlertTriangle,
|
||||
CheckCircle
|
||||
CheckCircle,
|
||||
UploadCloud,
|
||||
X,
|
||||
Volume2
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function Create({ reasons = [] }) {
|
||||
@ -17,6 +20,7 @@ export default function Create({ reasons = [] }) {
|
||||
subject: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
voice_note: null,
|
||||
});
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
@ -124,6 +128,58 @@ export default function Create({ reasons = [] }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Voice Note Attachment */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-slate-400 uppercase tracking-widest text-[9px]">{t('voice_note_attachment', 'Voice Note Attachment (Optional)')}</label>
|
||||
|
||||
{!data.voice_note ? (
|
||||
<div className="border-2 border-dashed border-slate-200 rounded-2xl p-6 text-center space-y-2 relative hover:border-[#185FA5] transition-all bg-slate-50/50">
|
||||
<UploadCloud className="w-8 h-8 text-slate-400 mx-auto" />
|
||||
<div className="text-xs font-bold text-slate-600">{t('upload_voice_note', 'Upload a voice note or audio file')}</div>
|
||||
<div className="text-[9px] text-slate-400">MP3, WAV, M4A, WEBM (Max 10MB)</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={e => setData('voice_note', e.target.files[0])}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-slate-200 rounded-2xl p-4 bg-slate-50/70 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3 min-w-0">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-50 text-[#185FA5] flex items-center justify-center border border-blue-100 flex-shrink-0">
|
||||
<Volume2 className="w-4 h-4 animate-pulse" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-extrabold text-slate-800 truncate">{data.voice_note.name}</p>
|
||||
<p className="text-[10px] text-slate-400 font-bold">{(data.voice_note.size / 1024 / 1024).toFixed(2)} MB</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setData('voice_note', null)}
|
||||
className="p-1.5 bg-slate-105 bg-slate-100 hover:bg-slate-200 rounded-full text-slate-500 hover:text-slate-700 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Audio preview player */}
|
||||
<div className="pt-1 border-t border-slate-100">
|
||||
<audio
|
||||
src={URL.createObjectURL(data.voice_note)}
|
||||
controls
|
||||
className="w-full h-8 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{errors.voice_note && (
|
||||
<p className="text-[10px] text-red-500 font-semibold mt-1">{errors.voice_note}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Support notice */}
|
||||
{data.priority === 'high' && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-2xl p-4 flex items-start space-x-3 text-amber-800 animate-pulse">
|
||||
|
||||
@ -11,7 +11,10 @@ import {
|
||||
Send,
|
||||
Terminal,
|
||||
UserCheck,
|
||||
Lock
|
||||
Lock,
|
||||
UploadCloud,
|
||||
X,
|
||||
Volume2
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function Show({ ticket, replies }) {
|
||||
@ -19,12 +22,13 @@ export default function Show({ ticket, replies }) {
|
||||
|
||||
const { data, setData, post, processing, reset, errors } = useForm({
|
||||
message: '',
|
||||
voice_note: null,
|
||||
});
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
post(`/employer/support/${ticket.id}/reply`, {
|
||||
onSuccess: () => reset('message'),
|
||||
onSuccess: () => reset('message', 'voice_note'),
|
||||
});
|
||||
};
|
||||
|
||||
@ -133,11 +137,26 @@ export default function Show({ ticket, replies }) {
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-8 space-y-1">
|
||||
<label className="block text-slate-400 uppercase tracking-widest text-[9px] font-black mb-2">{t('original_message', 'Original Message')}</label>
|
||||
<div className="text-xs text-slate-600 font-medium leading-relaxed whitespace-pre-wrap bg-slate-50/50 p-4 rounded-2xl border border-slate-100">
|
||||
{ticket.description}
|
||||
<div className="p-6 md:p-8 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-slate-400 uppercase tracking-widest text-[9px] font-black mb-2">{t('original_message', 'Original Message')}</label>
|
||||
<div className="text-xs text-slate-600 font-medium leading-relaxed whitespace-pre-wrap bg-slate-50/50 p-4 rounded-2xl border border-slate-100">
|
||||
{ticket.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ticket.voice_note_url && (
|
||||
<div className="pt-2">
|
||||
<label className="block text-slate-400 uppercase tracking-widest text-[9px] font-black mb-2">{t('voice_note_attachment', 'Voice Note Attachment')}</label>
|
||||
<div className="flex items-center space-x-3 bg-slate-50 border border-slate-200 p-3 rounded-2xl max-w-md">
|
||||
<audio
|
||||
src={ticket.voice_note_url}
|
||||
controls
|
||||
className="w-full h-8 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -184,11 +203,25 @@ export default function Show({ ticket, replies }) {
|
||||
</div>
|
||||
<span className="opacity-60">{reply.created_at}</span>
|
||||
</div>
|
||||
<div className={`text-xs font-semibold leading-relaxed whitespace-pre-wrap ${
|
||||
isSystemDev ? 'text-slate-300 font-mono font-semibold' : 'text-slate-600'
|
||||
}`}>
|
||||
{reply.message}
|
||||
</div>
|
||||
{reply.message && (
|
||||
<div className={`text-xs font-semibold leading-relaxed whitespace-pre-wrap ${
|
||||
isSystemDev ? 'text-slate-300 font-mono font-semibold' : 'text-slate-600'
|
||||
}`}>
|
||||
{reply.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reply.voice_note_url && (
|
||||
<div className="mt-2.5">
|
||||
<div className="flex items-center space-x-3 bg-slate-50 border border-slate-100 p-2.5 rounded-2xl max-w-xs">
|
||||
<audio
|
||||
src={reply.voice_note_url}
|
||||
controls
|
||||
className="w-full h-8 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -219,13 +252,56 @@ export default function Show({ ticket, replies }) {
|
||||
className={`w-full p-3 border rounded-xl bg-slate-50/50 text-slate-800 outline-none focus:bg-white focus:border-[#185FA5] transition-all leading-relaxed ${
|
||||
errors.message ? 'border-red-400 bg-red-50/20' : 'border-slate-200'
|
||||
}`}
|
||||
required
|
||||
required={!data.voice_note}
|
||||
/>
|
||||
{errors.message && (
|
||||
<p className="text-[10px] text-red-500 font-semibold mt-1">{errors.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Voice Note Attachment */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-slate-400 uppercase tracking-widest text-[9px] font-black">{t('voice_note_reply', 'Voice Note Attachment (Optional)')}</label>
|
||||
{!data.voice_note ? (
|
||||
<div className="border-2 border-dashed border-slate-200 rounded-2xl p-4 text-center space-y-1 relative hover:border-[#185FA5] transition-all bg-slate-50/50">
|
||||
<UploadCloud className="w-6 h-6 text-slate-400 mx-auto" />
|
||||
<div className="text-[11px] font-bold text-slate-600">{t('upload_voice_note', 'Upload a voice note or audio file')}</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={e => setData('voice_note', e.target.files[0])}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-slate-200 rounded-2xl p-3 bg-slate-50/70 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2.5 min-w-0">
|
||||
<Volume2 className="w-4 h-4 text-[#185FA5] flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-extrabold text-slate-800 truncate">{data.voice_note.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setData('voice_note', null)}
|
||||
className="p-1 bg-slate-100 hover:bg-slate-200 rounded-full text-slate-500 transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<audio
|
||||
src={URL.createObjectURL(data.voice_note)}
|
||||
controls
|
||||
className="w-full h-8 outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{errors.voice_note && (
|
||||
<p className="text-[10px] text-red-500 font-semibold mt-1">{errors.voice_note}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@ -47,7 +47,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
const { t } = useTranslation();
|
||||
// Basic Filters
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('All Categories');
|
||||
const [selectedNationalities, setSelectedNationalities] = useState([]);
|
||||
const [selectedGender, setSelectedGender] = useState('All Genders');
|
||||
const [selectedExperience, setSelectedExperience] = useState('All Experience');
|
||||
@ -83,8 +82,8 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
|
||||
// Saved Presets
|
||||
const [savedPresets, setSavedPresets] = useState([
|
||||
{ id: 1, name: 'Live-in Baby Care', category: 'Childcare', workType: 'Live-in', minSalary: 1200 },
|
||||
{ id: 2, name: 'Experienced Driver', category: 'Driver', experience: '5+ Years' }
|
||||
{ id: 1, name: 'Live-in Baby Care', workType: 'Live-in', minSalary: 1200 },
|
||||
{ id: 2, name: 'Experienced Driver', experience: '5+ Years' }
|
||||
]);
|
||||
const [presetNameInput, setPresetNameInput] = useState('');
|
||||
const [showPresetModal, setShowPresetModal] = useState(false);
|
||||
@ -112,7 +111,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
|
||||
let hasAdvanced = false;
|
||||
|
||||
if (cat) setSelectedCategory(cat);
|
||||
if (nat) {
|
||||
setSelectedNationalities(nat.split(',').map(x => x.trim()).filter(Boolean));
|
||||
}
|
||||
@ -176,7 +174,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
|
||||
const applyPreset = (preset) => {
|
||||
resetFilters();
|
||||
if (preset.category) setSelectedCategory(preset.category);
|
||||
if (preset.workType) setSelectedWorkTypes([preset.workType]);
|
||||
if (preset.experience) setSelectedExperience(preset.experience);
|
||||
};
|
||||
@ -188,7 +185,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
const newPreset = {
|
||||
id: Date.now(),
|
||||
name: presetNameInput,
|
||||
category: selectedCategory !== 'All Categories' ? selectedCategory : null,
|
||||
workType: selectedWorkTypes.length > 0 ? selectedWorkTypes[0] : null,
|
||||
experience: selectedExperience !== 'All Experience' ? selectedExperience : null,
|
||||
};
|
||||
@ -200,7 +196,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
|
||||
const resetFilters = () => {
|
||||
setSearchQuery('');
|
||||
setSelectedCategory('All Categories');
|
||||
setSelectedNationalities([]);
|
||||
setSelectedGender('All Genders');
|
||||
setSelectedExperience('All Experience');
|
||||
@ -262,7 +257,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
}
|
||||
|
||||
// Dropdown filters
|
||||
if (selectedCategory !== 'All Categories' && worker.category !== selectedCategory) return false;
|
||||
if (selectedNationalities.length > 0 && (!worker.nationality || !selectedNationalities.map(n => n.toLowerCase()).includes(worker.nationality.toLowerCase()))) return false;
|
||||
if (selectedGender !== 'All Genders' && worker.gender && worker.gender.toLowerCase() !== selectedGender.toLowerCase()) return false;
|
||||
if (selectedExperience !== 'All Experience' && worker.experience !== selectedExperience) return false;
|
||||
@ -338,7 +332,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
}, [
|
||||
initialWorkers,
|
||||
searchQuery,
|
||||
selectedCategory,
|
||||
selectedNationalities,
|
||||
selectedGender,
|
||||
selectedExperience,
|
||||
@ -705,6 +698,7 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
<div className="flex items-center space-x-1 text-[9px] font-black uppercase text-blue-700 bg-blue-50 px-1.5 py-0.5 rounded border border-blue-100">
|
||||
<span>{worker.visa_status}</span>
|
||||
</div>
|
||||
)}
|
||||
{worker.document_expiry_status && (
|
||||
<div className={`flex items-center space-x-1 text-[9px] font-black uppercase px-1.5 py-0.5 rounded border ${
|
||||
worker.document_expiry_days !== null && worker.document_expiry_days <= 30
|
||||
@ -747,11 +741,8 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
<div className="p-6 space-y-4 flex-1 flex flex-col justify-between">
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* Category Pill, Ratings & Cost */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="px-2.5 py-1 bg-blue-50 text-[#185FA5] font-black rounded-lg border border-blue-100 uppercase tracking-wider text-[9px]">
|
||||
{worker.category}
|
||||
</span>
|
||||
{/* Ratings & Cost */}
|
||||
<div className="flex items-center justify-end text-xs">
|
||||
<div className="flex items-center space-x-1 font-bold text-slate-800">
|
||||
<DollarSign className="w-3.5 h-3.5 text-emerald-600" />
|
||||
<span className="text-sm font-black">{worker.salary} {t('aed', 'AED')}</span>
|
||||
@ -770,9 +761,13 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
<Sparkles className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
||||
<span className="truncate uppercase text-[10px]">{worker.preferred_job_type}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 truncate col-span-2">
|
||||
<div className="flex items-center space-x-1.5 truncate">
|
||||
<MapPin className="w-3.5 h-3.5 text-[#185FA5] flex-shrink-0" />
|
||||
<span className="truncate text-[#185FA5]">{worker.preferred_location || 'Not Specified'}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 truncate">
|
||||
<Star className="w-3.5 h-3.5 text-amber-500 flex-shrink-0 fill-amber-500" />
|
||||
<span className="truncate">{worker.rating} ({worker.reviews_count} {t('reviews', 'reviews')})</span>
|
||||
<span className="truncate">{worker.rating} ({worker.reviews_count})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -908,7 +903,7 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-xs text-slate-900 truncate max-w-[120px]">{w.name}</div>
|
||||
<div className="text-[10px] text-slate-500 font-bold">{w.nationality} • {w.category}</div>
|
||||
<div className="text-[10px] text-slate-500 font-bold">{w.nationality}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -916,7 +911,7 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
<div>{t('salary', 'Salary')}: <span className="text-slate-800">{w.salary} {t('aed', 'AED')}</span></div>
|
||||
<div>{t('age', 'Age')}: <span className="text-slate-800">{w.age} {t('yrs', 'yrs')}</span></div>
|
||||
<div>{t('job_type', 'Job Type')}: <span className="text-slate-800 uppercase">{w.preferred_job_type}</span></div>
|
||||
<div>{t('status', 'Status')}: <span className="text-slate-800 font-black">{w.availability_status}</span></div>
|
||||
<div className="truncate">{t('location', 'Location')}: <span className="text-slate-800 font-medium" title={w.preferred_location}>{w.preferred_location || 'Not Specified'}</span></div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
@ -972,35 +967,46 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
}`} />
|
||||
<span>{previewWorker.passport_status}</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 font-bold">{previewWorker.nationality} • {previewWorker.category}</div>
|
||||
<div className="text-xs text-slate-500 font-bold">{previewWorker.nationality}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-600 italic bg-slate-50 p-4 rounded-xl leading-relaxed">
|
||||
"{previewWorker.bio}"
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-xs font-bold text-slate-600">
|
||||
<div className="p-3 bg-slate-50/50 rounded-xl">
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('expectations', 'Expectations')}</div>
|
||||
<div className="text-slate-800">{previewWorker.salary} {t('aed', 'AED')} / {t('mo', 'mo')}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50/50 rounded-xl">
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('job_preference', 'Job Preference')}</div>
|
||||
<div className="text-slate-800 uppercase text-[10px] mt-1">{previewWorker.preferred_job_type}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50/50 rounded-xl col-span-2">
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('languages_spoken', 'Languages')}</div>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{previewWorker.languages?.map(lang => (
|
||||
<span key={lang} className="text-[9px] bg-white border border-slate-200 text-slate-700 px-2 py-0.5 rounded font-bold flex items-center space-x-1">
|
||||
<span>{getLanguageFlag(lang)}</span>
|
||||
<span>{lang}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-xs font-bold text-slate-600">
|
||||
<div className="p-3 bg-slate-50/50 rounded-xl">
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('expectations', 'Expectations')}</div>
|
||||
<div className="text-slate-800">{previewWorker.salary} {t('aed', 'AED')} / {t('mo', 'mo')}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50/50 rounded-xl">
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('job_type', 'Job Type')}</div>
|
||||
<div className="text-slate-800 uppercase text-[10px] mt-1">{previewWorker.preferred_job_type}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50/50 rounded-xl">
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('gender', 'Gender')}</div>
|
||||
<div className="text-slate-800 capitalize mt-1">{previewWorker.gender || 'Female'}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50/50 rounded-xl">
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('visa_status', 'Visa Status')}</div>
|
||||
<div className="text-slate-800 capitalize mt-1">{previewWorker.visa_status || 'Residence Visa'}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50/50 rounded-xl col-span-2">
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('preferred_location', 'Preferred Location')}</div>
|
||||
<div className="text-slate-800 flex items-center space-x-1.5 mt-1">
|
||||
<MapPin className="w-3.5 h-3.5 text-[#185FA5]" />
|
||||
<span>{previewWorker.preferred_location || 'Not Specified'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50/50 rounded-xl col-span-2">
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('languages_spoken', 'Languages')}</div>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{previewWorker.languages?.map(lang => (
|
||||
<span key={lang} className="text-[9px] bg-white border border-slate-200 text-slate-700 px-2 py-0.5 rounded font-bold flex items-center space-x-1">
|
||||
<span>{getLanguageFlag(lang)}</span>
|
||||
<span>{lang}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Core Platform Skills inside Quick Preview */}
|
||||
<div className="space-y-1 pt-1">
|
||||
|
||||
@ -131,7 +131,7 @@ export default function Show({ worker }) {
|
||||
|
||||
return (
|
||||
<EmployerLayout title={t('candidate_profile_detail', 'Candidate Profile Detail')}>
|
||||
<Head title={`${worker.name} - ${worker.category} Profile`} />
|
||||
<Head title={`${worker.name} Profile`} />
|
||||
|
||||
<div className="space-y-6 select-none w-full pb-16">
|
||||
|
||||
@ -229,8 +229,6 @@ export default function Show({ worker }) {
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{worker.age} {t('years_old', 'Years Old')}</span>
|
||||
<span>•</span>
|
||||
<span className="bg-blue-50 text-[#185FA5] border border-blue-100 px-2 py-0.5 rounded text-[10px] uppercase font-black tracking-widest">{worker.category}</span>
|
||||
</div>
|
||||
{workerReviews.length > 0 && (
|
||||
<div className="flex items-center space-x-1 text-amber-500 font-bold text-xs">
|
||||
|
||||
@ -22,11 +22,6 @@ protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create a category
|
||||
$category = \App\Models\WorkerCategory::create([
|
||||
'name' => 'General Helper',
|
||||
]);
|
||||
|
||||
// Create worker with API token
|
||||
$this->workerToken = 'worker-token-xyz';
|
||||
$this->worker = Worker::create([
|
||||
@ -43,7 +38,6 @@ protected function setUp(): void
|
||||
'experience' => '3 Years',
|
||||
'religion' => 'Hindu',
|
||||
'bio' => 'Experienced helper ready to work.',
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
|
||||
// Create employer with API token
|
||||
|
||||
@ -22,10 +22,6 @@ protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create a category
|
||||
$category = \App\Models\WorkerCategory::create([
|
||||
'name' => 'Housemaid',
|
||||
]);
|
||||
|
||||
// Create a test worker
|
||||
$this->worker = Worker::create([
|
||||
@ -41,7 +37,6 @@ protected function setUp(): void
|
||||
'experience' => '2 Years',
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'Experienced housemaid seeking employment.',
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
|
||||
// Create an employer user with token
|
||||
@ -181,7 +176,6 @@ public function test_employer_can_get_conversations()
|
||||
'id',
|
||||
'worker_id',
|
||||
'worker_name',
|
||||
'category',
|
||||
'nationality',
|
||||
'salary',
|
||||
'last_message',
|
||||
@ -229,7 +223,6 @@ public function test_employer_can_get_messages_and_mark_as_read()
|
||||
'conversation' => [
|
||||
'id',
|
||||
'worker_name',
|
||||
'category',
|
||||
'nationality',
|
||||
'salary',
|
||||
],
|
||||
|
||||
@ -169,4 +169,53 @@ public function test_employer_password_update_fails_with_incorrect_current_passw
|
||||
// Verify password was NOT changed
|
||||
$this->assertTrue(Hash::check('Password@123', $this->employer->fresh()->password));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test employer dashboard returns only the latest 2 approved charity drives.
|
||||
*/
|
||||
public function test_employer_dashboard_returns_only_two_charity_drives()
|
||||
{
|
||||
// Create 5 approved announcements
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$ann = new \App\Models\Announcement([
|
||||
'title' => "Charity Drive $i",
|
||||
'body' => "Details of charity drive $i",
|
||||
'type' => 'charity',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
$ann->created_at = now()->subMinutes(6 - $i);
|
||||
$ann->save();
|
||||
}
|
||||
|
||||
// Create 1 pending announcement
|
||||
$pendingAnn = new \App\Models\Announcement([
|
||||
'title' => "Pending Charity Drive",
|
||||
'body' => "Details of pending charity drive",
|
||||
'type' => 'charity',
|
||||
'status' => 'pending',
|
||||
]);
|
||||
$pendingAnn->created_at = now();
|
||||
$pendingAnn->save();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->getJson('/api/employers/dashboard');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
]);
|
||||
|
||||
$announcements = $response->json('data.recent_announcements');
|
||||
$this->assertCount(2, $announcements);
|
||||
|
||||
// Make sure it contains the latest 2 approved ones
|
||||
$titles = collect($announcements)->pluck('title');
|
||||
$this->assertTrue($titles->contains('Charity Drive 5'));
|
||||
$this->assertTrue($titles->contains('Charity Drive 4'));
|
||||
$this->assertFalse($titles->contains('Charity Drive 3'));
|
||||
$this->assertFalse($titles->contains('Charity Drive 2'));
|
||||
$this->assertFalse($titles->contains('Charity Drive 1'));
|
||||
$this->assertFalse($titles->contains('Pending Charity Drive'));
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,6 +31,55 @@ protected function setUp(): void
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_employer_can_create_ticket_with_reason_and_voice_note()
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$reason = \App\Models\ReportReason::create([
|
||||
'reason' => 'Billing Inquiry',
|
||||
'status' => 'active',
|
||||
'type' => 'support',
|
||||
]);
|
||||
|
||||
$voiceFile = UploadedFile::fake()->create('note.mp3', 500, 'audio/mpeg');
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->postJson('/api/employers/tickets', [
|
||||
'subject' => 'Billing query',
|
||||
'description' => 'Need help with invoice.',
|
||||
'priority' => 'high',
|
||||
'reason_id' => $reason->id,
|
||||
'voice_note' => $voiceFile,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'message',
|
||||
'ticket' => [
|
||||
'id',
|
||||
'ticket_number',
|
||||
'subject',
|
||||
'description',
|
||||
'reason_id',
|
||||
'reason_name',
|
||||
'voice_note_url',
|
||||
'status',
|
||||
'priority',
|
||||
'created_at',
|
||||
]
|
||||
]);
|
||||
|
||||
$ticket = SupportTicket::first();
|
||||
$this->assertNotNull($ticket);
|
||||
$this->assertEquals('Billing query', $ticket->subject);
|
||||
$this->assertEquals($reason->id, $ticket->reason_id);
|
||||
$this->assertNotNull($ticket->voice_note_path);
|
||||
|
||||
Storage::disk('public')->assertExists($ticket->voice_note_path);
|
||||
}
|
||||
|
||||
public function test_employer_can_reply_to_ticket_with_voice_note()
|
||||
{
|
||||
Storage::fake('public');
|
||||
@ -82,4 +131,67 @@ public function test_employer_can_reply_to_ticket_with_voice_note()
|
||||
'voice_note_url' => asset('storage/' . $reply->voice_note_path)
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_employer_web_can_create_ticket_with_voice_note()
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$reason = \App\Models\ReportReason::create([
|
||||
'reason' => 'Technical Error',
|
||||
'status' => 'active',
|
||||
'type' => 'support',
|
||||
]);
|
||||
|
||||
$voiceFile = UploadedFile::fake()->create('web_note.mp3', 300, 'audio/mpeg');
|
||||
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->post('/employer/support/create', [
|
||||
'subject' => 'Web ticket subject',
|
||||
'description' => 'Web ticket description.',
|
||||
'priority' => 'low',
|
||||
'reason_id' => $reason->id,
|
||||
'voice_note' => $voiceFile,
|
||||
]);
|
||||
|
||||
$ticket = SupportTicket::first();
|
||||
$this->assertNotNull($ticket);
|
||||
$this->assertEquals('Web ticket subject', $ticket->subject);
|
||||
$this->assertEquals($reason->id, $ticket->reason_id);
|
||||
$this->assertNotNull($ticket->voice_note_path);
|
||||
|
||||
Storage::disk('public')->assertExists($ticket->voice_note_path);
|
||||
|
||||
$response->assertRedirect(route('employer.support.show', $ticket->id));
|
||||
}
|
||||
|
||||
public function test_employer_web_can_reply_to_ticket_with_voice_note()
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$ticket = SupportTicket::create([
|
||||
'ticket_number' => 'TKT-999999',
|
||||
'user_id' => $this->employer->id,
|
||||
'subject' => 'Web ticket reply test',
|
||||
'description' => 'Initial message.',
|
||||
'status' => 'open',
|
||||
'priority' => 'medium',
|
||||
]);
|
||||
|
||||
$voiceFile = UploadedFile::fake()->create('web_reply_note.mp3', 200, 'audio/mpeg');
|
||||
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->post("/employer/support/{$ticket->id}/reply", [
|
||||
'voice_note' => $voiceFile,
|
||||
]);
|
||||
|
||||
$reply = SupportTicketReply::first();
|
||||
$this->assertNotNull($reply);
|
||||
$this->assertNull($reply->message);
|
||||
$this->assertNotNull($reply->voice_note_path);
|
||||
Storage::disk('public')->assertExists($reply->voice_note_path);
|
||||
|
||||
$response->assertRedirect(route('employer.support.show', $ticket->id));
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Worker;
|
||||
use App\Models\WorkerCategory;
|
||||
use App\Models\WorkerDocument;
|
||||
use App\Models\JobOffer;
|
||||
use App\Models\JobPost;
|
||||
@ -18,7 +17,6 @@ class EmployerWorkerFilterApiTest extends TestCase
|
||||
|
||||
protected $employer;
|
||||
protected $token;
|
||||
protected $category;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
@ -34,10 +32,6 @@ protected function setUp(): void
|
||||
'api_token' => $this->token,
|
||||
]);
|
||||
|
||||
// Create worker category
|
||||
$this->category = WorkerCategory::create([
|
||||
'name' => 'General Helper',
|
||||
]);
|
||||
|
||||
// Seed 3 workers with specific filter attributes
|
||||
// Worker 1: Dubai, full-time, live-in, Indian, in_country=true, Residence Visa, female
|
||||
@ -47,7 +41,6 @@ protected function setUp(): void
|
||||
'phone' => '+971501111111',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'Indian',
|
||||
'category_id' => $this->category->id,
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'preferred_location' => 'Dubai',
|
||||
@ -79,7 +72,6 @@ protected function setUp(): void
|
||||
'phone' => '+971502222222',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'Filipino',
|
||||
'category_id' => $this->category->id,
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'preferred_location' => 'Abu Dhabi',
|
||||
@ -111,7 +103,6 @@ protected function setUp(): void
|
||||
'phone' => '+971503333333',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'Kenyan',
|
||||
'category_id' => $this->category->id,
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'preferred_location' => 'Dubai',
|
||||
@ -408,7 +399,6 @@ public function test_worker_list_includes_document_expiry_status()
|
||||
'phone' => '+971509999999',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'Kenyan',
|
||||
'category_id' => $this->category->id,
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'preferred_location' => 'Dubai',
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Worker;
|
||||
use App\Models\WorkerCategory;
|
||||
use App\Models\JobOffer;
|
||||
use App\Models\WorkerDocument;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@ -15,7 +14,6 @@ class EmployerWorkerWebFilterTest extends TestCase
|
||||
use RefreshDatabase;
|
||||
|
||||
protected $employer;
|
||||
protected $category;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
@ -28,9 +26,6 @@ protected function setUp(): void
|
||||
'role' => 'employer',
|
||||
]);
|
||||
|
||||
$this->category = WorkerCategory::create([
|
||||
'name' => 'General Helper',
|
||||
]);
|
||||
|
||||
// Seed some workers
|
||||
$w1 = Worker::create([
|
||||
@ -39,7 +34,6 @@ protected function setUp(): void
|
||||
'phone' => '+971501111111',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'Indian',
|
||||
'category_id' => $this->category->id,
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'preferred_location' => 'Dubai',
|
||||
@ -69,7 +63,6 @@ protected function setUp(): void
|
||||
'phone' => '+971502222222',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'Filipino',
|
||||
'category_id' => $this->category->id,
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'preferred_location' => 'Abu Dhabi',
|
||||
@ -165,7 +158,6 @@ public function test_employer_can_view_candidates_index_and_apply_filters()
|
||||
'phone' => '+971503333333',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'Kenyan',
|
||||
'category_id' => $this->category->id,
|
||||
'verified' => true,
|
||||
'status' => 'Hired',
|
||||
'preferred_location' => 'Dubai',
|
||||
|
||||
200
tests/Feature/FcmAuthenticationTest.php
Normal file
200
tests/Feature/FcmAuthenticationTest.php
Normal file
@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Sponsor;
|
||||
use App\Models\User;
|
||||
use App\Models\Worker;
|
||||
use App\Models\EmployerProfile;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class FcmAuthenticationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Write a temporary fake credential file so FCMService doesn't fail on file check
|
||||
$credentialsPath = base_path('migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json');
|
||||
if (!file_exists($credentialsPath)) {
|
||||
file_put_contents($credentialsPath, json_encode([
|
||||
'private_key' => "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3\n-----END PRIVATE KEY-----",
|
||||
'client_email' => 'fake-fcm@migrant.iam.gserviceaccount.com',
|
||||
'project_id' => 'migrant-fe5a7'
|
||||
]));
|
||||
}
|
||||
|
||||
// Fake Google OAuth and FCM endpoints
|
||||
Http::fake([
|
||||
'https://oauth2.googleapis.com/token' => Http::response(['access_token' => 'fake_token'], 200),
|
||||
'https://fcm.googleapis.com/v1/projects/*' => Http::response(['name' => 'projects/migrant-fe5a7/messages/1234'], 200),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Clean up temporary credentials file if needed, but since it's inside workspace it's fine.
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_employer_registration_and_login_stores_fcm_token_and_sends_notification()
|
||||
{
|
||||
// Register Employer
|
||||
$response = $this->postJson('/api/employers/register', [
|
||||
'name' => 'Employer FCM Test',
|
||||
'email' => 'employer.fcm@example.com',
|
||||
'phone' => '+971500000001',
|
||||
'fcm_token' => 'employer_register_fcm_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$user = User::where('email', 'employer.fcm@example.com')->first();
|
||||
$this->assertEquals('employer_register_fcm_token', $user->fcm_token);
|
||||
|
||||
$sponsor = Sponsor::where('email', 'employer.fcm@example.com')->first();
|
||||
$this->assertEquals('employer_register_fcm_token', $sponsor->fcm_token);
|
||||
|
||||
// Verify OTP
|
||||
$otpData = Cache::get('employer_otp_employer.fcm@example.com');
|
||||
$this->assertNotNull($otpData);
|
||||
|
||||
$verifyResponse = $this->postJson('/api/employers/verify', [
|
||||
'email' => 'employer.fcm@example.com',
|
||||
'otp' => $otpData['code'],
|
||||
'fcm_token' => 'employer_verify_fcm_token'
|
||||
]);
|
||||
$verifyResponse->assertStatus(200);
|
||||
|
||||
$user->refresh();
|
||||
$sponsor->refresh();
|
||||
$this->assertEquals('employer_verify_fcm_token', $user->fcm_token);
|
||||
$this->assertEquals('employer_verify_fcm_token', $sponsor->fcm_token);
|
||||
|
||||
// Set payment/activate status so password can be set
|
||||
$sponsor->update(['payment_status' => 'paid']);
|
||||
|
||||
// Set password
|
||||
$passwordResponse = $this->postJson('/api/employers/password', [
|
||||
'email' => 'employer.fcm@example.com',
|
||||
'password' => 'newpassword123',
|
||||
'password_confirmation' => 'newpassword123',
|
||||
'fcm_token' => 'employer_password_fcm_token'
|
||||
]);
|
||||
$passwordResponse->assertStatus(200);
|
||||
|
||||
$user->refresh();
|
||||
$sponsor->refresh();
|
||||
$this->assertEquals('employer_password_fcm_token', $user->fcm_token);
|
||||
$this->assertEquals('employer_password_fcm_token', $sponsor->fcm_token);
|
||||
|
||||
// Login
|
||||
$loginResponse = $this->postJson('/api/employers/login', [
|
||||
'email' => 'employer.fcm@example.com',
|
||||
'password' => 'newpassword123',
|
||||
'fcm_token' => 'employer_login_fcm_token'
|
||||
]);
|
||||
$loginResponse->assertStatus(200);
|
||||
|
||||
$user->refresh();
|
||||
$sponsor->refresh();
|
||||
$this->assertEquals('employer_login_fcm_token', $user->fcm_token);
|
||||
$this->assertEquals('employer_login_fcm_token', $sponsor->fcm_token);
|
||||
}
|
||||
|
||||
public function test_worker_registration_setup_profile_and_login_stores_fcm_token_and_sends_notification()
|
||||
{
|
||||
// 1. Worker Setup Profile Flow (Requires OTP verified in session cache)
|
||||
$phone = '+971500000002';
|
||||
Cache::put('worker_otp_verified_' . $phone, true, 60);
|
||||
|
||||
$responseSetup = $this->postJson('/api/workers/setup-profile', [
|
||||
'phone' => $phone,
|
||||
'name' => 'Worker Setup FCM',
|
||||
'nationality' => 'Indian',
|
||||
'language' => 'hi',
|
||||
'gender' => 'male',
|
||||
'fcm_token' => 'worker_setup_fcm_token'
|
||||
]);
|
||||
|
||||
$responseSetup->assertStatus(201);
|
||||
$worker = Worker::where('phone', $phone)->first();
|
||||
$this->assertEquals('worker_setup_fcm_token', $worker->fcm_token);
|
||||
|
||||
// Set password for login
|
||||
$worker->update(['password' => Hash::make('workerpassword123')]);
|
||||
|
||||
// Login Setup Worker
|
||||
$loginResponse = $this->postJson('/api/workers/login', [
|
||||
'phone' => $phone,
|
||||
'password' => 'workerpassword123',
|
||||
'fcm_token' => 'worker_login_fcm_token'
|
||||
]);
|
||||
$loginResponse->assertStatus(200);
|
||||
|
||||
$worker->refresh();
|
||||
$this->assertEquals('worker_login_fcm_token', $worker->fcm_token);
|
||||
|
||||
// 2. Worker Unified Register Flow
|
||||
$passportFile = UploadedFile::fake()->create('passport.pdf', 500, 'application/pdf');
|
||||
$visaFile = UploadedFile::fake()->create('visa.pdf', 500, 'application/pdf');
|
||||
|
||||
$responseRegister = $this->postJson('/api/workers/register', [
|
||||
'name' => 'Worker Unified FCM',
|
||||
'phone' => '+971500000003',
|
||||
'salary' => 2000,
|
||||
'password' => 'workerpassword123',
|
||||
'passport_file' => $passportFile,
|
||||
'visa_file' => $visaFile,
|
||||
'fcm_token' => 'worker_register_fcm_token'
|
||||
]);
|
||||
|
||||
$responseRegister->assertStatus(201);
|
||||
$workerReg = Worker::where('phone', '+971500000003')->first();
|
||||
$this->assertEquals('worker_register_fcm_token', $workerReg->fcm_token);
|
||||
}
|
||||
|
||||
public function test_sponsor_registration_and_login_stores_fcm_token_and_sends_notification()
|
||||
{
|
||||
$licenseFile = UploadedFile::fake()->create('license.pdf', 500, 'application/pdf');
|
||||
$emiratesIdFile = UploadedFile::fake()->create('emirates_id.pdf', 500, 'application/pdf');
|
||||
|
||||
$response = $this->postJson('/api/sponsors/register', [
|
||||
'full_name' => 'Sponsor FCM Org',
|
||||
'mobile' => '+971500000004',
|
||||
'password' => 'sponsorpassword123',
|
||||
'license_file' => $licenseFile,
|
||||
'emirates_id_file' => $emiratesIdFile,
|
||||
'organization_name' => 'Sponsor FCM Co',
|
||||
'email' => 'sponsor.fcm@example.com',
|
||||
'nationality' => 'Emirati',
|
||||
'city' => 'Dubai',
|
||||
'address' => 'Test Address',
|
||||
'country_code' => '+971',
|
||||
'license_expiry' => '2027-01-01',
|
||||
'fcm_token' => 'sponsor_register_fcm_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$sponsor = Sponsor::where('mobile', '+971500000004')->first();
|
||||
$this->assertEquals('sponsor_register_fcm_token', $sponsor->fcm_token);
|
||||
|
||||
// Login
|
||||
$loginResponse = $this->postJson('/api/sponsors/login', [
|
||||
'mobile' => '+971500000004',
|
||||
'password' => 'sponsorpassword123',
|
||||
'fcm_token' => 'sponsor_login_fcm_token'
|
||||
]);
|
||||
|
||||
$loginResponse->assertStatus(200);
|
||||
$sponsor->refresh();
|
||||
$this->assertEquals('sponsor_login_fcm_token', $sponsor->fcm_token);
|
||||
}
|
||||
}
|
||||
@ -17,16 +17,11 @@ class ReportApiTest extends TestCase
|
||||
protected $employer;
|
||||
protected $workerToken;
|
||||
protected $employerToken;
|
||||
protected $category;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create category
|
||||
$this->category = \App\Models\WorkerCategory::create([
|
||||
'name' => 'Housemaid',
|
||||
]);
|
||||
|
||||
// Create worker
|
||||
$this->workerToken = 'worker-token-xyz';
|
||||
@ -44,7 +39,6 @@ protected function setUp(): void
|
||||
'experience' => '2 Years',
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'Experienced housemaid seeking employment.',
|
||||
'category_id' => $this->category->id,
|
||||
]);
|
||||
|
||||
// Create employer user
|
||||
@ -148,7 +142,6 @@ public function test_worker_report_invalid_review_returns_404()
|
||||
'email' => 'other@example.com',
|
||||
'phone' => '+971500000009',
|
||||
'password' => bcrypt('password'),
|
||||
'category_id' => $this->category->id,
|
||||
'nationality' => 'Indian',
|
||||
'age' => 30,
|
||||
'salary' => 1800.00,
|
||||
|
||||
@ -356,10 +356,6 @@ public function test_sponsor_dashboard_returns_stats()
|
||||
'subscription_status' => 'none',
|
||||
]);
|
||||
|
||||
$category = \App\Models\WorkerCategory::create([
|
||||
'name' => 'Housemaid',
|
||||
]);
|
||||
|
||||
// Create some workers
|
||||
\App\Models\Worker::create([
|
||||
'name' => 'Worker 1',
|
||||
@ -375,7 +371,6 @@ public function test_sponsor_dashboard_returns_stats()
|
||||
'experience' => 'None',
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'Test',
|
||||
'category_id' => $category->id,
|
||||
'availability' => 'Immediate',
|
||||
]);
|
||||
\App\Models\Worker::create([
|
||||
@ -392,7 +387,6 @@ public function test_sponsor_dashboard_returns_stats()
|
||||
'experience' => 'None',
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'Test',
|
||||
'category_id' => $category->id,
|
||||
'availability' => 'Immediate',
|
||||
]);
|
||||
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Worker;
|
||||
use App\Models\WorkerCategory;
|
||||
use App\Models\WorkerDocument;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
@ -15,21 +14,12 @@ class WorkerJourneyApiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected $category;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create standard General Helper category
|
||||
\Illuminate\Support\Facades\DB::table('worker_categories')->insert([
|
||||
'id' => 7,
|
||||
'name' => 'General Helper',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->category = (object) ['id' => 7];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -183,7 +173,6 @@ public function test_s2_update_experience_and_preferences()
|
||||
'experience' => 'None',
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'New helper',
|
||||
'category_id' => $this->category->id,
|
||||
'verified' => false,
|
||||
'status' => 'active',
|
||||
'api_token' => 'test-bearer-token-123',
|
||||
@ -227,7 +216,6 @@ public function test_s3_go_live()
|
||||
'experience' => 'Not Specified',
|
||||
'religion' => 'Not Specified',
|
||||
'bio' => 'Ready to work',
|
||||
'category_id' => $this->category->id,
|
||||
'verified' => true,
|
||||
'status' => 'inactive',
|
||||
'api_token' => 'test-bearer-token-789',
|
||||
@ -266,7 +254,6 @@ public function test_s5_toggle_availability_still_looking()
|
||||
'experience' => 'Not Specified',
|
||||
'religion' => 'Not Specified',
|
||||
'bio' => 'Test',
|
||||
'category_id' => $this->category->id,
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'api_token' => 'test-bearer-token-abc',
|
||||
@ -329,7 +316,6 @@ public function test_s6_outcome_mark_hired()
|
||||
'experience' => 'Not Specified',
|
||||
'religion' => 'Not Specified',
|
||||
'bio' => 'Test',
|
||||
'category_id' => $this->category->id,
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'api_token' => 'test-bearer-token-abc',
|
||||
@ -405,7 +391,6 @@ public function test_update_profile_with_new_api_fields()
|
||||
'experience' => 'None',
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'New helper',
|
||||
'category_id' => $this->category->id,
|
||||
'verified' => false,
|
||||
'status' => 'active',
|
||||
'api_token' => 'bearer-update-token',
|
||||
@ -630,7 +615,6 @@ public function test_worker_dashboard_stats()
|
||||
'experience' => 'Not Specified',
|
||||
'religion' => 'Not Specified',
|
||||
'bio' => 'Test',
|
||||
'category_id' => $this->category->id,
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'api_token' => 'worker-test-token-dashboard',
|
||||
@ -722,7 +706,6 @@ public function test_worker_new_announcements_flow()
|
||||
'experience' => 'Not Specified',
|
||||
'religion' => 'Not Specified',
|
||||
'bio' => 'Test',
|
||||
'category_id' => $this->category->id,
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'api_token' => 'worker-test-token-announcements',
|
||||
|
||||
@ -21,11 +21,6 @@ protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create a category
|
||||
$category = \App\Models\WorkerCategory::create([
|
||||
'name' => 'Housemaid',
|
||||
]);
|
||||
|
||||
// Create a test worker with an API token
|
||||
$this->token = 'test-token-12345';
|
||||
$this->worker = Worker::create([
|
||||
@ -42,7 +37,6 @@ protected function setUp(): void
|
||||
'experience' => '2 Years',
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'Experienced housemaid seeking employment.',
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
|
||||
// Create an employer user
|
||||
|
||||
@ -24,10 +24,6 @@ protected function setUp(): void
|
||||
|
||||
$this->token = 'worker-test-token-789';
|
||||
|
||||
$category = \App\Models\WorkerCategory::create([
|
||||
'name' => 'Housemaid',
|
||||
]);
|
||||
|
||||
$this->worker = Worker::create([
|
||||
'name' => 'John Worker',
|
||||
'email' => 'worker@example.com',
|
||||
@ -35,7 +31,6 @@ protected function setUp(): void
|
||||
'password' => bcrypt('password'),
|
||||
'api_token' => $this->token,
|
||||
'status' => 'Available',
|
||||
'category_id' => $category->id,
|
||||
'nationality' => 'Ethiopian',
|
||||
'age' => 28,
|
||||
'salary' => 1500.00,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user