mohan #5

Merged
mohanmd merged 46 commits from mohan into master 2026-06-15 09:13:23 +00:00
54 changed files with 938 additions and 436 deletions
Showing only changes of commit 3ecd12ed10 - Show all commits

View File

@ -13,7 +13,7 @@ class WorkerController extends Controller
*/ */
public function index() public function index()
{ {
$workers = \App\Models\Worker::with(['category', 'skills']) $workers = \App\Models\Worker::with(['skills'])
->latest() ->latest()
->get() ->get()
->map(function ($worker) { ->map(function ($worker) {
@ -43,7 +43,6 @@ public function index()
'city' => $worker->city, 'city' => $worker->city,
'area' => $worker->area, 'area' => $worker->area,
'live_in_out' => $worker->live_in_out ?? 'Live-in', 'live_in_out' => $worker->live_in_out ?? 'Live-in',
'category' => $worker->category ? $worker->category->name : 'N/A',
'experience' => $worker->experience, 'experience' => $worker->experience,
'salary' => (int)$worker->salary, 'salary' => (int)$worker->salary,
'skills' => $mappedSkills, 'skills' => $mappedSkills,
@ -126,7 +125,6 @@ public function updateProfile(Request $request, $id)
'city' => 'nullable|string', 'city' => 'nullable|string',
'area' => 'nullable|string', 'area' => 'nullable|string',
'live_in_out' => 'nullable|string', 'live_in_out' => 'nullable|string',
'category' => 'required|string',
'experience' => 'required|string', 'experience' => 'required|string',
'salary' => 'nullable|numeric', 'salary' => 'nullable|numeric',
'bio' => 'nullable|string' 'bio' => 'nullable|string'
@ -134,10 +132,7 @@ public function updateProfile(Request $request, $id)
$worker = \App\Models\Worker::findOrFail($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->name = $validated['name'];
$worker->phone = $validated['phone']; $worker->phone = $validated['phone'];

View File

@ -110,10 +110,22 @@ public function login(Request $request)
} }
$user->update($userUpdateData); $user->update($userUpdateData);
if ($sponsor) { if ($sponsor) {
$sponsor->update([ $sponsorUpdateData = [
'api_token' => $apiToken, 'api_token' => $apiToken,
'last_login_at' => now(), '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([ return response()->json([
@ -127,10 +139,22 @@ public function login(Request $request)
], 200); ], 200);
} else { } else {
// Pure Sponsor account // Pure Sponsor account
$sponsor->update([ $sponsorUpdateData = [
'api_token' => $apiToken, 'api_token' => $apiToken,
'last_login_at' => now(), '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([ return response()->json([
'success' => true, 'success' => true,
@ -166,6 +190,7 @@ public function register(Request $request)
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users,email', 'email' => 'required|string|email|max:255|unique:users,email',
'phone' => 'required|string|max:50', 'phone' => 'required|string|max:50',
'fcm_token' => 'nullable|string|max:255',
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
@ -195,6 +220,7 @@ public function register(Request $request)
'role' => 'employer', 'role' => 'employer',
'subscription_status' => 'none', 'subscription_status' => 'none',
'subscription_expires_at' => null, 'subscription_expires_at' => null,
'fcm_token' => $request->fcm_token,
]); ]);
// Create pending Profile // Create pending Profile
@ -223,8 +249,17 @@ public function register(Request $request)
'is_verified' => false, 'is_verified' => false,
'otp_verified_at' => null, 'otp_verified_at' => null,
'status' => 'inactive', '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 sending email
try { try {
\Illuminate\Support\Facades\Mail::to($request->email)->send(new \App\Mail\EmployerOtpMail( \Illuminate\Support\Facades\Mail::to($request->email)->send(new \App\Mail\EmployerOtpMail(
@ -257,6 +292,7 @@ public function verify(Request $request)
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'email' => 'required|email', 'email' => 'required|email',
'otp' => 'required|string|size:6', 'otp' => 'required|string|size:6',
'fcm_token' => 'nullable|string|max:255',
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
@ -293,11 +329,31 @@ public function verify(Request $request)
try { try {
// Update Sponsor verification status // Update Sponsor verification status
$sponsor = \App\Models\Sponsor::where('email', $request->email)->first(); $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) { if ($sponsor) {
$sponsor->update([ $sponsorUpdateData = [
'is_verified' => true, 'is_verified' => true,
'otp_verified_at' => now(), '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 // Clear Cache
@ -494,6 +550,7 @@ public function password(Request $request)
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'email' => 'required|email', 'email' => 'required|email',
'password' => 'required|string|min:8|confirmed', 'password' => 'required|string|min:8|confirmed',
'fcm_token' => 'nullable|string|max:255',
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
@ -526,16 +583,25 @@ public function password(Request $request)
\Illuminate\Support\Facades\DB::transaction(function () use ($request, $user, $sponsor, $apiToken) { \Illuminate\Support\Facades\DB::transaction(function () use ($request, $user, $sponsor, $apiToken) {
$hashedPassword = Hash::make($request->password); $hashedPassword = Hash::make($request->password);
$fcmToken = $request->fcm_token;
$user->update([ $userUpdateData = [
'password' => $hashedPassword, 'password' => $hashedPassword,
'api_token' => $apiToken, 'api_token' => $apiToken,
]); ];
if ($fcmToken) {
$userUpdateData['fcm_token'] = $fcmToken;
}
$user->update($userUpdateData);
$sponsor->update([ $sponsorUpdateData = [
'password' => $hashedPassword, 'password' => $hashedPassword,
'status' => 'active', 'status' => 'active',
]); ];
if ($fcmToken) {
$sponsorUpdateData['fcm_token'] = $fcmToken;
}
$sponsor->update($sponsorUpdateData);
// Approve profile verification // Approve profile verification
$profile = EmployerProfile::where('user_id', $user->id)->first(); $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([ return response()->json([
'success' => true, 'success' => true,
'message' => 'Password created successfully. Registration finalized.', 'message' => 'Password created successfully. Registration finalized.',

View File

@ -83,7 +83,7 @@ public function getConversations(Request $request)
try { try {
$dbConversations = Conversation::where('employer_id', $employer->id) $dbConversations = Conversation::where('employer_id', $employer->id)
->with(['worker.category', 'messages']) ->with(['worker', 'messages'])
->latest('updated_at') ->latest('updated_at')
->get(); ->get();
@ -99,7 +99,6 @@ public function getConversations(Request $request)
'id' => $conv->id, 'id' => $conv->id,
'worker_id' => $conv->worker_id, 'worker_id' => $conv->worker_id,
'worker_name' => $conv->worker->name ?? 'Candidate', 'worker_name' => $conv->worker->name ?? 'Candidate',
'category' => $conv->worker->category->name ?? 'General Helper',
'nationality' => $conv->worker->nationality ?? 'Unknown', 'nationality' => $conv->worker->nationality ?? 'Unknown',
'worker_status' => strtolower($conv->worker->status ?? 'active'), 'worker_status' => strtolower($conv->worker->status ?? 'active'),
'salary' => ($conv->worker->salary ?? $conv->worker->expected_salary ?? 2000) . ' AED', 'salary' => ($conv->worker->salary ?? $conv->worker->expected_salary ?? 2000) . ' AED',
@ -143,7 +142,7 @@ public function getMessages(Request $request, $id)
try { try {
$conv = Conversation::where('employer_id', $employer->id) $conv = Conversation::where('employer_id', $employer->id)
->where('id', $id) ->where('id', $id)
->with(['worker.category', 'messages']) ->with(['worker', 'messages'])
->first(); ->first();
if (!$conv) { if (!$conv) {
@ -175,7 +174,6 @@ public function getMessages(Request $request, $id)
$conversationDetail = [ $conversationDetail = [
'id' => $conv->id, 'id' => $conv->id,
'worker_name' => $conv->worker->name ?? 'Candidate', 'worker_name' => $conv->worker->name ?? 'Candidate',
'category' => $conv->worker->category->name ?? 'General Helper',
'nationality' => $conv->worker->nationality ?? 'Unknown', 'nationality' => $conv->worker->nationality ?? 'Unknown',
'worker_status' => strtolower($conv->worker->status ?? 'active'), 'worker_status' => strtolower($conv->worker->status ?? 'active'),
'salary' => ($conv->worker->salary ?? $conv->worker->expected_salary ?? 2000) . ' AED', 'salary' => ($conv->worker->salary ?? $conv->worker->expected_salary ?? 2000) . ' AED',

View File

@ -224,7 +224,7 @@ public function getDashboard(Request $request)
]; ];
// Recent Announcements / Events // 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) { $recentAnnouncements = $dbAnnouncements->map(function ($ann) {
$body = $ann->body; $body = $ann->body;
$eventDate = null; $eventDate = null;

View File

@ -6,7 +6,6 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\User; use App\Models\User;
use App\Models\Worker; use App\Models\Worker;
use App\Models\WorkerCategory;
use App\Models\Shortlist; use App\Models\Shortlist;
use App\Models\JobOffer; use App\Models\JobOffer;
use App\Models\JobApplication; use App\Models\JobApplication;
@ -91,7 +90,7 @@ private function formatWorker(Worker $w)
public function getWorkers(Request $request) public function getWorkers(Request $request)
{ {
try { try {
$query = Worker::with(['category', 'skills', 'documents']) $query = Worker::with(['skills', 'documents'])
->where('status', '!=', 'Hired') ->where('status', '!=', 'Hired')
->where('status', '!=', 'hidden'); ->where('status', '!=', 'hidden');
@ -355,11 +354,11 @@ public function getCandidates(Request $request)
// Fetch Job Applications // Fetch Job Applications
$jobIds = JobPost::where('employer_id', $employerId)->pluck('id'); $jobIds = JobPost::where('employer_id', $employerId)->pluck('id');
$applicationsQuery = JobApplication::whereIn('job_id', $jobIds) $applicationsQuery = JobApplication::whereIn('job_id', $jobIds)
->with(['worker.category', 'jobPost']); ->with(['worker', 'jobPost']);
// Fetch Direct Hiring Offers // Fetch Direct Hiring Offers
$directOffersQuery = JobOffer::where('employer_id', $employerId) $directOffersQuery = JobOffer::where('employer_id', $employerId)
->with('worker.category'); ->with('worker');
// Apply search filter if provided // Apply search filter if provided
if ($request->filled('search')) { if ($request->filled('search')) {
@ -841,7 +840,7 @@ public function getWorkerDetail(Request $request, $id)
{ {
/** @var User $employer */ /** @var User $employer */
$employer = $request->attributes->get('employer'); $employer = $request->attributes->get('employer');
$w = Worker::with(['category', 'skills', 'documents'])->find($id); $w = Worker::with(['skills', 'documents'])->find($id);
if (!$w) { if (!$w) {
return response()->json([ return response()->json([
@ -919,7 +918,7 @@ public function getWorkerDetail(Request $request, $id)
} }
// Similar workers matching web view // Similar workers matching web view
$simDb = Worker::with('category') $simDb = Worker::query()
->where('id', '!=', $w->id) ->where('id', '!=', $w->id)
->where('status', '!=', 'Hired') ->where('status', '!=', 'Hired')
->where('status', '!=', 'hidden') ->where('status', '!=', 'hidden')
@ -1002,7 +1001,7 @@ public function getShortlist(Request $request)
$perPage = (int)$request->input('per_page', 15); $perPage = (int)$request->input('per_page', 15);
$shortlists = Shortlist::where('employer_id', $employer->id) $shortlists = Shortlist::where('employer_id', $employer->id)
->with(['worker.category', 'worker.skills']) ->with(['worker.skills'])
->get(); ->get();
$formattedWorkers = $shortlists->map(function ($s) { $formattedWorkers = $shortlists->map(function ($s) {

View File

@ -35,6 +35,7 @@ public function register(Request $request)
'address' => 'required|string|max:255', 'address' => 'required|string|max:255',
'country_code' => 'required|string|max:10', 'country_code' => 'required|string|max:10',
'license_expiry' => 'required|date_format:Y-m-d', 'license_expiry' => 'required|date_format:Y-m-d',
'fcm_token' => 'nullable|string|max:255',
], [ ], [
'mobile.unique' => 'This mobile number is already registered.', 'mobile.unique' => 'This mobile number is already registered.',
'email.unique' => 'This email address is already registered.', 'email.unique' => 'This email address is already registered.',
@ -98,9 +99,18 @@ public function register(Request $request)
'is_verified' => false, 'is_verified' => false,
'subscription_status' => 'none', 'subscription_status' => 'none',
'api_token' => $apiToken, '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([ return response()->json([
'success' => true, 'success' => true,
'message' => 'Sponsor account registered successfully. Your license is pending admin review.', '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) public function login(Request $request)
{ {
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'mobile' => 'required|string', 'mobile' => 'required|string',
'password' => 'required|string', 'password' => 'required|string',
'fcm_token' => 'nullable|string|max:255',
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
@ -159,10 +170,22 @@ public function login(Request $request)
// Rotate token on each login for security // Rotate token on each login for security
$apiToken = Str::random(80); $apiToken = Str::random(80);
$sponsor->update([ $updateData = [
'api_token' => $apiToken, 'api_token' => $apiToken,
'last_login_at' => now(), '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([ return response()->json([
'success' => true, 'success' => true,

View File

@ -175,7 +175,7 @@ public function getTicketsForEmployer(Request $request)
return response()->json(['success' => false, 'message' => 'Unauthorized.'], 401); 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') ->orderBy('created_at', 'desc')
->get() ->get()
->map(function ($ticket) { ->map(function ($ticket) {
@ -184,6 +184,9 @@ public function getTicketsForEmployer(Request $request)
'ticket_number' => $ticket->ticket_number, 'ticket_number' => $ticket->ticket_number,
'subject' => $ticket->subject, 'subject' => $ticket->subject,
'description' => $ticket->description, '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, 'status' => $ticket->status,
'priority' => $ticket->priority, 'priority' => $ticket->priority,
'created_at' => $ticket->created_at->toIso8601String(), 'created_at' => $ticket->created_at->toIso8601String(),
@ -207,6 +210,8 @@ public function createTicketFromEmployer(Request $request)
'subject' => 'required|string|max:255', 'subject' => 'required|string|max:255',
'description' => 'required|string', 'description' => 'required|string',
'priority' => 'nullable|string|in:low,medium,high', '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()) { if ($validator->fails()) {
@ -217,15 +222,24 @@ public function createTicketFromEmployer(Request $request)
], 422); ], 422);
} }
$voiceNotePath = null;
if ($request->hasFile('voice_note')) {
$voiceNotePath = $request->file('voice_note')->store('support_voice_notes', 'public');
}
$ticket = SupportTicket::create([ $ticket = SupportTicket::create([
'ticket_number' => 'TKT-' . rand(100000, 999999), 'ticket_number' => 'TKT-' . rand(100000, 999999),
'user_id' => $employer->id, 'user_id' => $employer->id,
'reason_id' => $request->reason_id,
'subject' => $request->subject, 'subject' => $request->subject,
'description' => $request->description, 'description' => $request->description,
'voice_note_path' => $voiceNotePath,
'priority' => $request->priority ?? 'medium', 'priority' => $request->priority ?? 'medium',
'status' => 'open', 'status' => 'open',
]); ]);
$ticket->load('reason');
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'message' => 'Ticket created successfully.', 'message' => 'Ticket created successfully.',
@ -234,6 +248,9 @@ public function createTicketFromEmployer(Request $request)
'ticket_number' => $ticket->ticket_number, 'ticket_number' => $ticket->ticket_number,
'subject' => $ticket->subject, 'subject' => $ticket->subject,
'description' => $ticket->description, '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, 'status' => $ticket->status,
'priority' => $ticket->priority, 'priority' => $ticket->priority,
'created_at' => $ticket->created_at->toIso8601String(), 'created_at' => $ticket->created_at->toIso8601String(),

View File

@ -4,7 +4,6 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Worker; use App\Models\Worker;
use App\Models\WorkerCategory;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -181,6 +180,7 @@ public function setupProfile(Request $request)
'preferred_location' => 'nullable|string|max:255', 'preferred_location' => 'nullable|string|max:255',
'skills' => 'nullable|array', 'skills' => 'nullable|array',
'skills.*' => 'integer|exists:skills,id', 'skills.*' => 'integer|exists:skills,id',
'fcm_token' => 'nullable|string|max:255',
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
@ -244,10 +244,10 @@ public function setupProfile(Request $request)
'experience' => 'Not Specified', 'experience' => 'Not Specified',
'religion' => 'Not Specified', 'religion' => 'Not Specified',
'bio' => 'New worker on Migrant platform.', '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, 'verified' => false,
'status' => 'active', 'status' => 'active',
'api_token' => $apiToken, 'api_token' => $apiToken,
'fcm_token' => $request->fcm_token,
]); ]);
// Sync skills if provided // Sync skills if provided
@ -258,10 +258,18 @@ public function setupProfile(Request $request)
return $worker; 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 // Clear the OTP verification gate
Cache::forget('worker_otp_verified_' . $identifier); Cache::forget('worker_otp_verified_' . $identifier);
$worker->load(['category', 'skills']); $worker->load(['skills']);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
@ -305,6 +313,7 @@ public function register(Request $request)
'gender' => 'nullable|string|in:male,female,other', 'gender' => 'nullable|string|in:male,female,other',
'live_in_out' => 'nullable|string|in:live_in,live_out', 'live_in_out' => 'nullable|string|in:live_in,live_out',
'preferred_location' => 'nullable|string|max:255', 'preferred_location' => 'nullable|string|max:255',
'fcm_token' => 'nullable|string|max:255',
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
@ -377,13 +386,13 @@ public function register(Request $request)
'experience' => $request->experience ?? 'Not Specified', 'experience' => $request->experience ?? 'Not Specified',
'religion' => 'Not Specified', 'religion' => 'Not Specified',
'bio' => 'Hardworking and reliable General Helper available for immediate hire.', '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, 'verified' => false,
'status' => 'active', 'status' => 'active',
'api_token' => $apiToken, 'api_token' => $apiToken,
'in_country' => $inCountry, 'in_country' => $inCountry,
'visa_status' => $inCountry ? $request->visa_status : null, 'visa_status' => $inCountry ? $request->visa_status : null,
'preferred_job_type' => $request->preferred_job_type, 'preferred_job_type' => $request->preferred_job_type,
'fcm_token' => $request->fcm_token,
]); ]);
if (!empty($skillsArray)) { if (!empty($skillsArray)) {
@ -414,7 +423,15 @@ public function register(Request $request)
return $worker; 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([ return response()->json([
'success' => true, 'success' => true,
@ -471,11 +488,19 @@ public function login(Request $request)
} }
$worker->update($updateData); $worker->update($updateData);
if ($request->filled('fcm_token')) {
\App\Services\FCMService::sendPushNotification(
$request->fcm_token,
'Successful Login',
'Worker logged in successfully.'
);
}
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'message' => 'Worker logged in successfully.', 'message' => 'Worker logged in successfully.',
'data' => [ 'data' => [
'worker' => $worker->load(['category', 'skills', 'documents']), 'worker' => $worker->load(['skills', 'documents']),
'token' => $apiToken 'token' => $apiToken
] ]
], 200); ], 200);

View File

@ -28,7 +28,7 @@ public function getProfile(Request $request)
/** @var Worker $worker */ /** @var Worker $worker */
$worker = $request->attributes->get('worker'); $worker = $request->attributes->get('worker');
$worker->load(['category', 'skills', 'documents']); $worker->load(['skills', 'documents']);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
@ -59,7 +59,6 @@ public function updateProfile(Request $request)
'experience' => 'nullable|string|max:100', 'experience' => 'nullable|string|max:100',
'religion' => 'nullable|string|max:100', 'religion' => 'nullable|string|max:100',
'bio' => 'nullable|string|max:1000', 'bio' => 'nullable|string|max:1000',
'category_id' => 'nullable|exists:worker_categories,id',
'skills' => 'nullable|array', 'skills' => 'nullable|array',
'skills.*' => 'exists:skills,id', 'skills.*' => 'exists:skills,id',
'in_country' => 'nullable', 'in_country' => 'nullable',
@ -95,7 +94,6 @@ public function updateProfile(Request $request)
'experience', 'experience',
'religion', 'religion',
'bio', 'bio',
'category_id',
'visa_status', 'visa_status',
'preferred_job_type', 'preferred_job_type',
'gender', 'gender',
@ -124,7 +122,7 @@ public function updateProfile(Request $request)
} }
}); });
$worker->load(['category', 'skills', 'documents']); $worker->load(['skills', 'documents']);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
@ -168,7 +166,7 @@ public function goLive(Request $request)
'success' => true, 'success' => true,
'message' => 'Your profile is now live! Status set to Active.', 'message' => 'Your profile is now live! Status set to Active.',
'data' => [ 'data' => [
'worker' => $worker->load(['category', 'skills', 'documents']) 'worker' => $worker->load(['skills', 'documents'])
] ]
], 200); ], 200);
} catch (\Exception $e) { } catch (\Exception $e) {
@ -221,7 +219,7 @@ public function toggleAvailability(Request $request)
'still_looking' => $stillLooking, 'still_looking' => $stillLooking,
'status' => $newStatus, 'status' => $newStatus,
'data' => [ 'data' => [
'worker' => $worker->load(['category', 'skills', 'documents']) 'worker' => $worker->load(['skills', 'documents'])
] ]
], 200); ], 200);
@ -255,7 +253,7 @@ public function markHired(Request $request)
'success' => true, 'success' => true,
'message' => 'Congratulations! You have been successfully marked as Hired. Your profile has been automatically removed from active search.', 'message' => 'Congratulations! You have been successfully marked as Hired. Your profile has been automatically removed from active search.',
'data' => [ 'data' => [
'worker' => $worker->load(['category', 'skills', 'documents']) 'worker' => $worker->load(['skills', 'documents'])
] ]
], 200); ], 200);
} catch (\Exception $e) { } catch (\Exception $e) {

View File

@ -46,7 +46,7 @@ public function index(Request $request)
// Fetch applications for those jobs // Fetch applications for those jobs
$applications = JobApplication::whereIn('job_id', $jobIds) $applications = JobApplication::whereIn('job_id', $jobIds)
->with(['worker.category', 'jobPost']) ->with(['worker', 'jobPost'])
->get(); ->get();
$selectedWorkers = $applications->map(function ($app) { $selectedWorkers = $applications->map(function ($app) {
@ -66,7 +66,6 @@ public function index(Request $request)
'worker_id' => $w->id, 'worker_id' => $w->id,
'name' => $w->name, 'name' => $w->name,
'nationality' => $w->nationality, 'nationality' => $w->nationality,
'category' => $w->category ? $w->category->name : 'General Helper',
'salary' => (int)$w->salary, 'salary' => (int)$w->salary,
'status' => $status, 'status' => $status,
'applied_at' => $app->created_at->format('M d, Y'), '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 // 2. Fetch direct hiring offers sent by this employer
$directOffers = JobOffer::where('employer_id', $employerId) $directOffers = JobOffer::where('employer_id', $employerId)
->with('worker.category') ->with('worker')
->get(); ->get();
$directWorkers = $directOffers->map(function ($offer) { $directWorkers = $directOffers->map(function ($offer) {
@ -98,7 +97,6 @@ public function index(Request $request)
'worker_id' => $w->id, 'worker_id' => $w->id,
'name' => $w->name, 'name' => $w->name,
'nationality' => $w->nationality, 'nationality' => $w->nationality,
'category' => $w->category ? $w->category->name : 'General Helper',
'salary' => (int)$offer->salary, 'salary' => (int)$offer->salary,
'status' => $status, 'status' => $status,
'applied_at' => $offer->created_at->format('M d, Y'), 'applied_at' => $offer->created_at->format('M d, Y'),

View File

@ -84,7 +84,7 @@ public function index(Request $request)
// 2. Shortlisted workers // 2. Shortlisted workers
$shortlists = Shortlist::where('employer_id', $user->id) $shortlists = Shortlist::where('employer_id', $user->id)
->with(['worker.category', 'worker.skills']) ->with(['worker.skills'])
->get(); ->get();
$shortlistedWorkers = $shortlists->map(function ($s) { $shortlistedWorkers = $shortlists->map(function ($s) {
@ -100,7 +100,6 @@ public function index(Request $request)
'id' => $w->id, 'id' => $w->id,
'name' => $w->name, 'name' => $w->name,
'nationality' => $w->nationality, 'nationality' => $w->nationality,
'category' => $w->category ? $w->category->name : 'General Helper',
'skills' => $w->skills->pluck('name')->toArray(), 'skills' => $w->skills->pluck('name')->toArray(),
'photo_url' => null, 'photo_url' => null,
'verified' => (bool)$w->verified, 'verified' => (bool)$w->verified,
@ -122,6 +121,8 @@ public function index(Request $request)
return [ return [
'id' => $conv->id, 'id' => $conv->id,
'worker_name' => $worker->name, 'worker_name' => $worker->name,
'worker_nationality' => $worker->nationality,
'sender_type' => $lastMsg->sender_type,
'last_message' => $lastMsg->text, 'last_message' => $lastMsg->text,
'unread' => $lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at), 'unread' => $lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at),
'sent_at' => $lastMsg->created_at->diffForHumans(), 'sent_at' => $lastMsg->created_at->diffForHumans(),
@ -130,7 +131,7 @@ public function index(Request $request)
})->filter()->sortByDesc('timestamp')->values()->toArray(); })->filter()->sortByDesc('timestamp')->values()->toArray();
// 4. Charity Events // 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) { $announcements = $dbAnnouncements->map(function ($ann) {
$body = $ann->body; $body = $ann->body;
$eventDate = null; $eventDate = null;
@ -174,7 +175,7 @@ public function index(Request $request)
})->toArray(); })->toArray();
// 5. Recommended Workers // 5. Recommended Workers
$recWorkers = \App\Models\Worker::with(['category', 'skills']) $recWorkers = \App\Models\Worker::with(['skills'])
->where('status', 'active') ->where('status', 'active')
->orderBy('verified', 'desc') ->orderBy('verified', 'desc')
->limit(3) ->limit(3)
@ -190,7 +191,7 @@ public function index(Request $request)
'id' => $w->id, 'id' => $w->id,
'name' => $w->name, 'name' => $w->name,
'nationality' => $w->nationality, 'nationality' => $w->nationality,
'category' => $w->category ? $w->category->name : 'General Helper', 'category' => 'General Helper',
'skills' => $w->skills->pluck('name')->toArray(), 'skills' => $w->skills->pluck('name')->toArray(),
'salary' => (int)$w->salary, 'salary' => (int)$w->salary,
'rating' => 4.8, 'rating' => 4.8,

View File

@ -8,7 +8,6 @@
use App\Models\User; use App\Models\User;
use App\Models\JobPost; use App\Models\JobPost;
use App\Models\JobApplication; use App\Models\JobApplication;
use App\Models\WorkerCategory;
class JobController extends Controller class JobController extends Controller
{ {
@ -44,7 +43,7 @@ public function index(Request $request)
} }
$dbJobs = JobPost::where('employer_id', $user->id) $dbJobs = JobPost::where('employer_id', $user->id)
->with(['category', 'applications']) ->with(['applications'])
->latest() ->latest()
->get(); ->get();
@ -52,7 +51,6 @@ public function index(Request $request)
return [ return [
'id' => $job->id, 'id' => $job->id,
'title' => $job->title, 'title' => $job->title,
'category' => $job->category->name ?? 'General',
'location' => $job->location, 'location' => $job->location,
'salary' => (int) $job->salary, 'salary' => (int) $job->salary,
'workers_needed' => $job->workers_needed, 'workers_needed' => $job->workers_needed,
@ -69,14 +67,7 @@ public function index(Request $request)
public function create() public function create()
{ {
$categories = WorkerCategory::pluck('name')->toArray(); return Inertia::render('Employer/Jobs/Create');
if (empty($categories)) {
$categories = ['Electrician', 'Mason', 'Plumber', 'Cleaner', 'Site Supervisor', 'Driver', 'General Helper'];
}
return Inertia::render('Employer/Jobs/Create', [
'categories' => $categories,
]);
} }
public function store(Request $request) public function store(Request $request)
@ -88,7 +79,6 @@ public function store(Request $request)
$request->validate([ $request->validate([
'title' => 'required|string|max:255', 'title' => 'required|string|max:255',
'category' => 'required|string',
'workers_needed' => 'required|integer|min:1', 'workers_needed' => 'required|integer|min:1',
'location' => 'required|string|max:255', 'location' => 'required|string|max:255',
'salary' => 'required|numeric|min:0', 'salary' => 'required|numeric|min:0',
@ -98,15 +88,9 @@ public function store(Request $request)
'requirements' => 'nullable|string', 'requirements' => 'nullable|string',
]); ]);
$category = WorkerCategory::where('name', $request->category)->first();
if (!$category) {
$category = WorkerCategory::create(['name' => $request->category]);
}
JobPost::create([ JobPost::create([
'employer_id' => $user->id, 'employer_id' => $user->id,
'title' => $request->title, 'title' => $request->title,
'category_id' => $category->id,
'workers_needed' => $request->workers_needed, 'workers_needed' => $request->workers_needed,
'job_type' => $request->job_type, 'job_type' => $request->job_type,
'location' => $request->location, 'location' => $request->location,
@ -130,7 +114,7 @@ public function applicants($id)
$job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail(); $job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail();
$dbApplications = JobApplication::where('job_id', $id) $dbApplications = JobApplication::where('job_id', $id)
->with(['worker.category', 'worker.skills']) ->with(['worker.skills'])
->get(); ->get();
$applicants = $dbApplications->map(function ($app) { $applicants = $dbApplications->map(function ($app) {
@ -139,7 +123,6 @@ public function applicants($id)
'id' => $worker->id, 'id' => $worker->id,
'name' => $worker->name, 'name' => $worker->name,
'nationality' => $worker->nationality, 'nationality' => $worker->nationality,
'category' => $worker->category->name ?? 'General',
'salary' => (int) $worker->expected_salary, 'salary' => (int) $worker->expected_salary,
'experience' => ($worker->experience_years ?? 3) . ' Years', 'experience' => ($worker->experience_years ?? 3) . ' Years',
'status' => ucfirst($app->status), // Applied, Shortlisted, Hired, Rejected 'status' => ucfirst($app->status), // Applied, Shortlisted, Hired, Rejected
@ -151,7 +134,6 @@ public function applicants($id)
'job' => [ 'job' => [
'id' => $job->id, 'id' => $job->id,
'title' => $job->title, 'title' => $job->title,
'category' => $job->category->name ?? 'General',
'salary' => (int) $job->salary, 'salary' => (int) $job->salary,
], ],
'applicants' => $applicants, 'applicants' => $applicants,

View File

@ -101,7 +101,7 @@ public function index(Request $request)
} }
$dbConversations = Conversation::where('employer_id', $user->id) $dbConversations = Conversation::where('employer_id', $user->id)
->with(['worker.category', 'messages']) ->with(['worker', 'messages'])
->latest('updated_at') ->latest('updated_at')
->get(); ->get();
@ -111,7 +111,6 @@ public function index(Request $request)
'id' => $conv->id, 'id' => $conv->id,
'worker_id' => $conv->worker_id, 'worker_id' => $conv->worker_id,
'worker_name' => $conv->worker->name ?? 'Candidate', 'worker_name' => $conv->worker->name ?? 'Candidate',
'category' => $conv->worker->category->name ?? 'General Helper',
'worker_status' => strtolower($conv->worker->status ?? 'active'), 'worker_status' => strtolower($conv->worker->status ?? 'active'),
'last_message' => $lastMsg->text ?? 'No messages yet.', 'last_message' => $lastMsg->text ?? 'No messages yet.',
'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false, 'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false,
@ -133,7 +132,7 @@ public function show($id)
} }
$dbConversations = Conversation::where('employer_id', $user->id) $dbConversations = Conversation::where('employer_id', $user->id)
->with(['worker.category', 'messages']) ->with(['worker', 'messages'])
->latest('updated_at') ->latest('updated_at')
->get(); ->get();
@ -143,7 +142,6 @@ public function show($id)
'id' => $conv->id, 'id' => $conv->id,
'worker_id' => $conv->worker_id, 'worker_id' => $conv->worker_id,
'worker_name' => $conv->worker->name ?? 'Candidate', 'worker_name' => $conv->worker->name ?? 'Candidate',
'category' => $conv->worker->category->name ?? 'General Helper',
'worker_status' => strtolower($conv->worker->status ?? 'active'), 'worker_status' => strtolower($conv->worker->status ?? 'active'),
'last_message' => $lastMsg->text ?? 'No messages yet.', 'last_message' => $lastMsg->text ?? 'No messages yet.',
'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false, 'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false,
@ -154,7 +152,7 @@ public function show($id)
$activeConv = Conversation::where('employer_id', $user->id) $activeConv = Conversation::where('employer_id', $user->id)
->where('id', $id) ->where('id', $id)
->with(['worker.category', 'messages']) ->with(['worker', 'messages'])
->firstOrFail(); ->firstOrFail();
// Mark incoming messages as read // Mark incoming messages as read
@ -167,7 +165,6 @@ public function show($id)
'id' => $activeConv->id, 'id' => $activeConv->id,
'worker_id' => $activeConv->worker_id, 'worker_id' => $activeConv->worker_id,
'worker_name' => $activeConv->worker->name ?? 'Candidate', 'worker_name' => $activeConv->worker->name ?? 'Candidate',
'category' => $activeConv->worker->category->name ?? 'General Helper',
'worker_status' => strtolower($activeConv->worker->status ?? 'active'), 'worker_status' => strtolower($activeConv->worker->status ?? 'active'),
'online' => true, 'online' => true,
'salary' => ($activeConv->worker->expected_salary ?? 2000) . ' AED', 'salary' => ($activeConv->worker->expected_salary ?? 2000) . ' AED',

View File

@ -43,7 +43,7 @@ public function index(Request $request)
$employerId = $user ? $user->id : 2; $employerId = $user ? $user->id : 2;
$shortlists = Shortlist::where('employer_id', $employerId) $shortlists = Shortlist::where('employer_id', $employerId)
->with(['worker.category', 'worker.skills', 'worker.documents']) ->with(['worker.skills', 'worker.documents'])
->get(); ->get();
$shortlistedWorkers = $shortlists->map(function ($s) { $shortlistedWorkers = $shortlists->map(function ($s) {
@ -96,9 +96,9 @@ public function index(Request $request)
'photo' => $photo, 'photo' => $photo,
'emirates_id_status' => $emiratesIdStatus, 'emirates_id_status' => $emiratesIdStatus,
'passport_status' => $w->passport_status, 'passport_status' => $w->passport_status,
'category' => $w->category ? $w->category->name : 'Domestic Worker',
'skills' => $mappedSkills, 'skills' => $mappedSkills,
'visa_status' => $visaStatus, 'visa_status' => $visaStatus,
'gender' => $w->gender ?? 'Female',
'experience' => $w->experience, 'experience' => $w->experience,
'salary' => (int) $w->salary, 'salary' => (int) $w->salary,
'religion' => $w->religion, 'religion' => $w->religion,

View File

@ -92,14 +92,21 @@ public function store(Request $request)
'subject' => 'required|string|max:255', 'subject' => 'required|string|max:255',
'description' => 'required|string', 'description' => 'required|string',
'priority' => 'required|string|in:low,medium,high', '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 = SupportTicket::create([
'ticket_number' => 'TKT-' . rand(100000, 999999), 'ticket_number' => 'TKT-' . rand(100000, 999999),
'user_id' => $user->id, 'user_id' => $user->id,
'reason_id' => $request->reason_id, 'reason_id' => $request->reason_id,
'subject' => $request->subject, 'subject' => $request->subject,
'description' => $request->description, 'description' => $request->description,
'voice_note_path' => $voiceNotePath,
'priority' => $request->priority, 'priority' => $request->priority,
'status' => 'open', 'status' => 'open',
]); ]);
@ -128,6 +135,7 @@ public function show(Request $request, $id)
'sender_name' => $reply->sender_name, 'sender_name' => $reply->sender_name,
'is_admin' => $reply->user && $reply->user->role === 'admin', 'is_admin' => $reply->user && $reply->user->role === 'admin',
'is_developer_response' => (bool)$reply->is_developer_response, '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'), 'created_at' => $reply->created_at->format('Y-m-d H:i'),
]; ];
}); });
@ -139,6 +147,7 @@ public function show(Request $request, $id)
'subject' => $ticket->subject, 'subject' => $ticket->subject,
'reason' => $ticket->reason ? $ticket->reason->reason : null, 'reason' => $ticket->reason ? $ticket->reason->reason : null,
'description' => $ticket->description, 'description' => $ticket->description,
'voice_note_url' => $ticket->voice_note_path ? asset('storage/' . $ticket->voice_note_path) : null,
'status' => $ticket->status, 'status' => $ticket->status,
'priority' => $ticket->priority, 'priority' => $ticket->priority,
'created_at' => $ticket->created_at->format('Y-m-d H:i'), 'created_at' => $ticket->created_at->format('Y-m-d H:i'),
@ -161,13 +170,20 @@ public function reply(Request $request, $id)
} }
$request->validate([ $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([ SupportTicketReply::create([
'support_ticket_id' => $ticket->id, 'support_ticket_id' => $ticket->id,
'user_id' => $user->id, 'user_id' => $user->id,
'message' => $request->message, 'message' => $request->message,
'voice_note_path' => $voiceNotePath,
]); ]);
// Reopen ticket if resolved/closed was updated // Reopen ticket if resolved/closed was updated

View File

@ -7,7 +7,6 @@
use Inertia\Inertia; use Inertia\Inertia;
use App\Models\User; use App\Models\User;
use App\Models\Worker; use App\Models\Worker;
use App\Models\WorkerCategory;
use App\Models\Shortlist; use App\Models\Shortlist;
use App\Models\JobOffer; use App\Models\JobOffer;
use App\Models\Review; use App\Models\Review;
@ -46,7 +45,7 @@ public function index(Request $request)
$user = $this->resolveCurrentUser(); $user = $this->resolveCurrentUser();
$employerId = $user ? $user->id : 2; $employerId = $user ? $user->id : 2;
$dbWorkers = Worker::with(['category', 'skills', 'documents']) $dbWorkers = Worker::with(['skills', 'documents'])
->where('status', '!=', 'Hired') ->where('status', '!=', 'Hired')
->where('status', '!=', 'hidden') ->where('status', '!=', 'hidden')
->get(); ->get();
@ -98,7 +97,7 @@ public function index(Request $request)
'photo' => $photo, 'photo' => $photo,
'emirates_id_status' => $emiratesIdStatus, 'emirates_id_status' => $emiratesIdStatus,
'passport_status' => $w->passport_status, 'passport_status' => $w->passport_status,
'category' => $w->category ? $w->category->name : 'Domestic Worker', 'category' => 'Domestic Worker',
'skills' => $mappedSkills, 'skills' => $mappedSkills,
'visa_status' => $visaStatus, 'visa_status' => $visaStatus,
'experience' => $w->experience, 'experience' => $w->experience,
@ -267,13 +266,11 @@ public function index(Request $request)
$shortlistedIds = Shortlist::where('employer_id', $employerId)->pluck('worker_id')->toArray(); $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])); $nationalitiesResponse = app(\App\Http\Controllers\Api\WorkerAuthController::class)->nationalities(new \Illuminate\Http\Request(['per_page' => 500]));
$nationalitiesData = json_decode($nationalitiesResponse->getContent(), true); $nationalitiesData = json_decode($nationalitiesResponse->getContent(), true);
$dbNationalities = collect($nationalitiesData['data']['nationalities'] ?? [])->pluck('name')->filter()->toArray(); $dbNationalities = collect($nationalitiesData['data']['nationalities'] ?? [])->pluck('name')->filter()->toArray();
$filtersMetadata = [ $filtersMetadata = [
'categories' => array_merge(['All Categories'], $dbCategories),
'nationalities' => array_merge(['All Nationalities'], $dbNationalities), 'nationalities' => array_merge(['All Nationalities'], $dbNationalities),
'experienceLevels' => ['All Experience', '1-2 Years', '3-5 Years', '5+ Years'], 'experienceLevels' => ['All Experience', '1-2 Years', '3-5 Years', '5+ Years'],
'religions' => ['All Religions', 'Christian', 'Muslim', 'Hindu', 'Buddhist'], 'religions' => ['All Religions', 'Christian', 'Muslim', 'Hindu', 'Buddhist'],
@ -292,7 +289,7 @@ public function index(Request $request)
public function show($id) 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'); $isPending = str_contains(strtolower($w->passport_status), 'pending');
if ($w->status === 'hidden' || $isPending) { if ($w->status === 'hidden' || $isPending) {
@ -345,7 +342,7 @@ public function show($id)
$reviewsCount = count($reviews); $reviewsCount = count($reviews);
$rating = $reviewsCount > 0 ? round($dbReviews->avg('rating'), 1) : 0; $rating = $reviewsCount > 0 ? round($dbReviews->avg('rating'), 1) : 0;
$simDb = Worker::with('category') $simDb = Worker::query()
->where('id', '!=', $w->id) ->where('id', '!=', $w->id)
->where('status', '!=', 'Hired') ->where('status', '!=', 'Hired')
->where('status', '!=', 'hidden') ->where('status', '!=', 'hidden')
@ -361,7 +358,7 @@ public function show($id)
'id' => $sw->id, 'id' => $sw->id,
'name' => $sw->name, 'name' => $sw->name,
'nationality' => $sw->nationality, 'nationality' => $sw->nationality,
'category' => $sw->category ? $sw->category->name : 'Helper', 'category' => 'Helper',
'salary' => (int) $sw->salary, 'salary' => (int) $sw->salary,
'rating' => 4.7, 'rating' => 4.7,
'verified' => (bool) $sw->verified, 'verified' => (bool) $sw->verified,
@ -379,7 +376,7 @@ public function show($id)
'photo' => $photo, 'photo' => $photo,
'emirates_id_status' => $emiratesIdStatus, 'emirates_id_status' => $emiratesIdStatus,
'passport_status' => $w->passport_status, 'passport_status' => $w->passport_status,
'category' => $w->category ? $w->category->name : 'Domestic Worker', 'category' => 'Domestic Worker',
'skills' => $mappedSkills, 'skills' => $mappedSkills,
'visa_status' => $visaStatus, 'visa_status' => $visaStatus,
'experience' => $w->experience, 'experience' => $w->experience,

View File

@ -13,7 +13,6 @@ class JobPost extends Model
protected $fillable = [ protected $fillable = [
'employer_id', 'employer_id',
'title', 'title',
'category_id',
'workers_needed', 'workers_needed',
'job_type', 'job_type',
'location', 'location',
@ -35,10 +34,6 @@ public function employer()
return $this->belongsTo(User::class, 'employer_id'); return $this->belongsTo(User::class, 'employer_id');
} }
public function category()
{
return $this->belongsTo(WorkerCategory::class, 'category_id');
}
public function applications() public function applications()
{ {

View File

@ -34,6 +34,7 @@ class Sponsor extends Authenticatable
'license_file', 'license_file',
'emirates_id_file', 'emirates_id_file',
'license_expiry', 'license_expiry',
'fcm_token',
]; ];
protected $hidden = [ protected $hidden = [

View File

@ -29,7 +29,6 @@ class Worker extends Model
'experience', 'experience',
'religion', 'religion',
'bio', 'bio',
'category_id',
'verified', 'verified',
'status', 'status',
'api_token', 'api_token',
@ -128,10 +127,6 @@ public function getDocumentExpiryStatusAttribute()
return 'No Documents'; return 'No Documents';
} }
public function category()
{
return $this->belongsTo(WorkerCategory::class, 'category_id');
}
public function skills() public function skills()
{ {

View File

@ -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');
}
}

View File

@ -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');
});
}
};

View File

@ -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');
});
}
};

View File

@ -29,7 +29,6 @@ public function run(): void
// Call Seeders // Call Seeders
$this->call([ $this->call([
WorkerCategorySeeder::class,
SkillSeeder::class, SkillSeeder::class,
WorkerSeeder::class, WorkerSeeder::class,
EmployerProfileSeeder::class, EmployerProfileSeeder::class,

View File

@ -19,7 +19,6 @@ public function run(): void
[ [
'employer_id' => $employerId, 'employer_id' => $employerId,
'title' => 'Senior Electrician for Villa Project', 'title' => 'Senior Electrician for Villa Project',
'category_id' => 1, // Electrician
'workers_needed' => 2, 'workers_needed' => 2,
'job_type' => 'Full Time', 'job_type' => 'Full Time',
'location' => 'Dubai Marina', 'location' => 'Dubai Marina',
@ -31,7 +30,6 @@ public function run(): void
[ [
'employer_id' => $employerId, 'employer_id' => $employerId,
'title' => 'Mason for Commercial Building', 'title' => 'Mason for Commercial Building',
'category_id' => 2, // Mason
'workers_needed' => 5, 'workers_needed' => 5,
'job_type' => 'Contract', 'job_type' => 'Contract',
'location' => 'Business Bay', 'location' => 'Business Bay',

View File

@ -27,7 +27,6 @@ public function run(): void
// 2. Run static reference seeders // 2. Run static reference seeders
$this->call([ $this->call([
WorkerCategorySeeder::class,
SkillSeeder::class, SkillSeeder::class,
]); ]);
} }

View File

@ -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(),
]);
}
}
}

View File

@ -31,7 +31,6 @@ public function run(): void
'experience' => '5+ Years', 'experience' => '5+ Years',
'religion' => 'Christian', 'religion' => 'Christian',
'bio' => '', 'bio' => '',
'category_id' => 1, // Electrician
'verified' => true, 'verified' => true,
'status' => 'active', 'status' => 'active',
], ],
@ -53,7 +52,6 @@ public function run(): void
'experience' => '3 Years', 'experience' => '3 Years',
'religion' => 'Muslim', 'religion' => 'Muslim',
'bio' => 'Skilled mason with experience in block work and plastering.', 'bio' => 'Skilled mason with experience in block work and plastering.',
'category_id' => 2, // Mason
'verified' => true, 'verified' => true,
'status' => 'active', 'status' => 'active',
], ],
@ -63,7 +61,7 @@ public function run(): void
'phone' => '+971 50 555 6666', 'phone' => '+971 50 555 6666',
'nationality' => 'Filipino', 'nationality' => 'Filipino',
'country' => 'United Arab Emirates', 'country' => 'United Arab Emirates',
'city' => 'Abu Dhabi', 'city' => 'Yas Island',
'area' => 'Yas Island', 'area' => 'Yas Island',
'preferred_location' => 'Yas Island', 'preferred_location' => 'Yas Island',
'live_in_out' => 'Live-in (Stay with family)', 'live_in_out' => 'Live-in (Stay with family)',
@ -75,7 +73,6 @@ public function run(): void
'experience' => '5+ Years', 'experience' => '5+ Years',
'religion' => 'Christian', 'religion' => 'Christian',
'bio' => 'Experienced nanny with 6 years working with expatriate families in Dubai. Certified in pediatric first aid.', 'bio' => 'Experienced nanny with 6 years working with expatriate families in Dubai. Certified in pediatric first aid.',
'category_id' => 8, // Childcare
'verified' => true, 'verified' => true,
'status' => 'active', 'status' => 'active',
], ],
@ -97,7 +94,6 @@ public function run(): void
'experience' => '3-5 Years', 'experience' => '3-5 Years',
'religion' => 'Hindu', 'religion' => 'Hindu',
'bio' => 'Patient caregiver specializing in elderly assistance, mobility support, and vegetarian cooking.', 'bio' => 'Patient caregiver specializing in elderly assistance, mobility support, and vegetarian cooking.',
'category_id' => 11, // Elderly Care
'verified' => true, 'verified' => true,
'status' => 'active', 'status' => 'active',
], ],
@ -119,7 +115,6 @@ public function run(): void
'experience' => '3-5 Years', 'experience' => '3-5 Years',
'religion' => 'Muslim', 'religion' => 'Muslim',
'bio' => 'Hardworking and meticulous housekeeper with excellent references from Abu Dhabi households.', 'bio' => 'Hardworking and meticulous housekeeper with excellent references from Abu Dhabi households.',
'category_id' => 9, // Housekeeping
'verified' => true, 'verified' => true,
'status' => 'active', 'status' => 'active',
], ],
@ -141,7 +136,6 @@ public function run(): void
'experience' => '1-2 Years', 'experience' => '1-2 Years',
'religion' => 'Christian', 'religion' => 'Christian',
'bio' => 'Energetic and educated nanny. Fluent in English, great with toddlers and assisting with homework.', 'bio' => 'Energetic and educated nanny. Fluent in English, great with toddlers and assisting with homework.',
'category_id' => 8, // Childcare
'verified' => false, 'verified' => false,
'status' => 'active', 'status' => 'active',
], ],
@ -163,7 +157,6 @@ public function run(): void
'experience' => '5+ Years', 'experience' => '5+ Years',
'religion' => 'Buddhist', 'religion' => 'Buddhist',
'bio' => 'Professional domestic cook skilled in Arabic, Continental, and Asian cuisine. Highly organized.', 'bio' => 'Professional domestic cook skilled in Arabic, Continental, and Asian cuisine. Highly organized.',
'category_id' => 10, // Cooking
'verified' => true, 'verified' => true,
'status' => 'active', 'status' => 'active',
], ],
@ -185,42 +178,11 @@ public function run(): void
'experience' => '5+ Years', 'experience' => '5+ Years',
'religion' => 'Hindu', 'religion' => 'Hindu',
'bio' => 'Valid UAE driving license with clean record. Familiar with all Dubai and Sharjah school routes.', 'bio' => 'Valid UAE driving license with clean record. Familiar with all Dubai and Sharjah school routes.',
'category_id' => 6, // Driver
'verified' => true, 'verified' => true,
'status' => 'active', '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 // Dynamic Skill Resolution
$skillIds = \Illuminate\Support\Facades\DB::table('skills')->pluck('id')->toArray(); $skillIds = \Illuminate\Support\Facades\DB::table('skills')->pluck('id')->toArray();
if (empty($skillIds)) { if (empty($skillIds)) {
@ -237,15 +199,12 @@ public function run(): void
$sId2 = $skillIds[1] ?? 2; $sId2 = $skillIds[1] ?? 2;
foreach ($workers as $workerData) { foreach ($workers as $workerData) {
$resolvedCategoryId = $categoryIds[$workerData['category_id']] ?? $workerData['category_id'];
// Exclude email duplicates to avoid unique constraint violations // Exclude email duplicates to avoid unique constraint violations
if (\Illuminate\Support\Facades\DB::table('workers')->where('email', $workerData['email'])->exists()) { if (\Illuminate\Support\Facades\DB::table('workers')->where('email', $workerData['email'])->exists()) {
continue; continue;
} }
$workerId = \Illuminate\Support\Facades\DB::table('workers')->insertGetId(array_merge($workerData, [ $workerId = \Illuminate\Support\Facades\DB::table('workers')->insertGetId(array_merge($workerData, [
'category_id' => $resolvedCategoryId,
'created_at' => now(), 'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),
])); ]));

View File

@ -1686,10 +1686,7 @@
"type": "string", "type": "string",
"example": "Highly certified housekeeper and babysitter with cooking experience." "example": "Highly certified housekeeper and babysitter with cooking experience."
}, },
"category_id": {
"type": "integer",
"example": 8
},
"skills": { "skills": {
"type": "array", "type": "array",
"items": { "items": {
@ -4405,7 +4402,7 @@
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
"application/json": { "multipart/form-data": {
"schema": { "schema": {
"type": "object", "type": "object",
"required": [ "required": [
@ -4429,6 +4426,16 @@
"high" "high"
], ],
"example": "medium" "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", "type": "string",
"example": "Certified infant caregiver and housekeeper with excellent cooking skills." "example": "Certified infant caregiver and housekeeper with excellent cooking skills."
}, },
"category_id": {
"type": "integer",
"example": 7
},
"verified": { "verified": {
"type": "boolean", "type": "boolean",
"example": false "example": false

View File

@ -75,7 +75,6 @@ export default function WorkerManagement({ workers }) {
name: '', name: '',
phone: '', phone: '',
language: '', language: '',
category: '',
experience: '', experience: '',
bio: '', bio: '',
country: '', country: '',
@ -128,7 +127,6 @@ export default function WorkerManagement({ workers }) {
phone: editForm.phone, phone: editForm.phone,
gender: editForm.gender, gender: editForm.gender,
language: editForm.language, language: editForm.language,
category: editForm.category,
experience: editForm.experience, experience: editForm.experience,
salary: editForm.salary, salary: editForm.salary,
bio: editForm.bio, bio: editForm.bio,
@ -170,7 +168,6 @@ export default function WorkerManagement({ workers }) {
phone: fullWorker.phone, phone: fullWorker.phone,
gender: fullWorker.gender || 'Female', gender: fullWorker.gender || 'Female',
language: fullWorker.language || 'English', language: fullWorker.language || 'English',
category: fullWorker.category,
experience: fullWorker.experience, experience: fullWorker.experience,
salary: fullWorker.salary || '', salary: fullWorker.salary || '',
bio: fullWorker.bio, bio: fullWorker.bio,

View File

@ -272,7 +272,7 @@ export default function Dashboard({
<div className="space-y-2.5 flex-1 py-2"> <div className="space-y-2.5 flex-1 py-2">
<Link <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" 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"> <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> </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 <Link
href={`/employer/workers/${worker.id}`} 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" 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>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="font-bold text-xs text-slate-800 group-hover:text-[#185FA5] transition-colors truncate"> <div className="font-bold text-xs text-slate-800 group-hover:text-[#185FA5] transition-colors truncate flex items-center gap-1.5 flex-wrap">
{msg.worker_name} <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>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="text-[9px] font-semibold text-slate-400">{msg.sent_at}</span> <span className="text-[9px] font-semibold text-slate-400">{msg.sent_at}</span>
@ -405,7 +403,12 @@ export default function Dashboard({
)} )}
</div> </div>
</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} {msg.last_message}
</p> </p>
</div> </div>

View File

@ -50,7 +50,7 @@ export default function Confirm({ worker }) {
{worker.name.charAt(0)} {worker.name.charAt(0)}
</div> </div>
<h3 className="font-extrabold text-slate-900 tracking-tight">{worker.name}</h3> <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>
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
<div className="flex items-center justify-between text-xs font-medium"> <div className="flex items-center justify-between text-xs font-medium">

View File

@ -20,10 +20,10 @@ import { Badge } from '@/components/ui/badge';
export default function Applicants({ job, applicants: initialApplicants }) { export default function Applicants({ job, applicants: initialApplicants }) {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [applicants, setApplicants] = useState(initialApplicants || [ const [applicants, setApplicants] = useState(initialApplicants || [
{ id: 101, name: 'Anjali Sharma', nationality: 'Indian', category: 'Nanny', salary: 2500, experience: '5 Years', status: 'Reviewing', match_score: 95 }, { id: 101, name: 'Anjali Sharma', nationality: 'Indian', 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: 102, name: 'Siti Rahma', nationality: 'Indonesian', 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: 103, name: 'Maricel Cruz', nationality: 'Filipino', 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: 104, name: 'Bebeth Santos', nationality: 'Filipino', salary: 3000, experience: '10 Years', status: 'Reviewing', match_score: 98 },
]); ]);
const filteredApplicants = applicants.filter(a => 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> Applicants for <span className="text-[#185FA5]">{job?.title || 'Senior Mason Project'}</span>
</h1> </h1>
<div className="flex items-center space-x-3 text-[10px] font-bold text-slate-400 uppercase tracking-widest"> <div className="flex items-center space-x-3 text-[10px] font-bold text-slate-400 uppercase tracking-widest">
<span className="flex items-center"><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> <span className="flex items-center"><DollarSign className="w-3 h-3 mr-1" /> {job?.salary || '2800'} AED</span>
</div> </div>
</div> </div>

View File

@ -22,7 +22,6 @@ export default function Create({ categories: initialCategories }) {
const { data, setData, post, processing, errors } = useForm({ const { data, setData, post, processing, errors } = useForm({
title: '', title: '',
category: '',
workers_needed: 1, workers_needed: 1,
location: '', location: '',
salary: '', salary: '',
@ -91,21 +90,7 @@ export default function Create({ categories: initialCategories }) {
</div> </div>
</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"> <div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Workers Needed</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Workers Needed</label>

View File

@ -33,16 +33,15 @@ export default function Index({ initialJobs }) {
const [statusFilter, setStatusFilter] = useState('All'); const [statusFilter, setStatusFilter] = useState('All');
const [jobs, setJobs] = useState(initialJobs || [ 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: 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', category: 'Cleaner', location: 'Al Barsha', salary: 1800, workers_needed: 2, applied_count: 12, posted_at: 'Nov 10, 2026', status: 'Active' }, { id: 2, title: 'Villa Cleaner', location: 'Al Barsha', salary: 1800, workers_needed: 2, applied_count: 12, posted_at: 'Nov 10, 2026', status: 'Active' },
{ 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: 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', category: 'Plumber', location: 'Jumeirah', salary: 2600, workers_needed: 3, applied_count: 0, posted_at: 'Nov 14, 2026', status: 'Draft' }, { 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(() => { const filteredJobs = useMemo(() => {
return jobs.filter(job => { return jobs.filter(job => {
const matchesSearch = job.title.toLowerCase().includes(searchTerm.toLowerCase()) || const matchesSearch = job.title.toLowerCase().includes(searchTerm.toLowerCase());
job.category.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'All' || job.status === statusFilter; const matchesStatus = statusFilter === 'All' || job.status === statusFilter;
return matchesSearch && matchesStatus; 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" /> <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input <input
type="text" type="text"
placeholder="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" 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} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} onChange={e => setSearchTerm(e.target.value)}
@ -120,8 +119,6 @@ export default function Index({ initialJobs }) {
<div> <div>
<h3 className="font-black text-slate-900 tracking-tight group-hover:text-[#185FA5] transition-colors line-clamp-1">{job.title}</h3> <h3 className="font-black text-slate-900 tracking-tight group-hover:text-[#185FA5] transition-colors line-clamp-1">{job.title}</h3>
<div className="flex items-center space-x-2 mt-1"> <div className="flex items-center space-x-2 mt-1">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{job.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> <span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{job.posted_at}</span>
</div> </div>
</div> </div>

View File

@ -65,9 +65,6 @@ export default function Index({ conversations }) {
<div className="flex items-center justify-between mb-1"> <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"> <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>{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> </div>
<span className={`text-xs font-semibold whitespace-nowrap ${thread.unread ? 'text-[#185FA5]' : 'text-slate-400'}`}> <span className={`text-xs font-semibold whitespace-nowrap ${thread.unread ? 'text-[#185FA5]' : 'text-slate-400'}`}>
{thread.sent_at} {thread.sent_at}

View File

@ -498,9 +498,6 @@ export default function Show({ conversation, initialMessages, conversations = []
</div> </div>
<div> <div>
<h4 className="font-extrabold text-base text-slate-900 tracking-tight">{conversation.worker_name}</h4> <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>
</div> </div>

View File

@ -418,7 +418,6 @@ export default function SelectedCandidates({
<tr className="border-b border-slate-100"> <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('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('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('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">{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> <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> </div>
</td> </td>
<td className="px-6 py-5 text-[11px] font-bold text-slate-500">{worker.applied_at || '—'}</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"> <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> <span className="text-sm font-bold text-slate-800">{worker.salary} <span className="text-[10px] text-slate-400">AED</span></span>
</td> </td>
@ -510,3 +506,4 @@ export default function SelectedCandidates({
</EmployerLayout> </EmployerLayout>
); );
} }

View File

@ -18,7 +18,8 @@ import {
User, User,
X, X,
HeartHandshake, HeartHandshake,
CheckCircle CheckCircle,
MapPin
} from 'lucide-react'; } from 'lucide-react';
const getLanguageFlag = (lang) => { 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="p-6 space-y-4 flex-1 flex flex-col justify-between">
<div className="space-y-4"> <div className="space-y-4">
{/* Category Pill, Ratings & Cost */} {/* Ratings & Cost */}
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-end 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>
<div className="flex items-center space-x-1 font-bold text-slate-800"> <div className="flex items-center space-x-1 font-bold text-slate-800">
<DollarSign className="w-3.5 h-3.5 text-emerald-600" /> <DollarSign className="w-3.5 h-3.5 text-emerald-600" />
<span className="text-sm font-black">{worker.salary} AED</span> <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" /> <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> <span className="truncate uppercase text-[10px]">{worker.preferred_job_type}</span>
</div> </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" /> <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>
</div> </div>
@ -330,7 +332,7 @@ export default function Shortlist({ shortlistedWorkers }) {
</div> </div>
<div> <div>
<div className="font-bold text-xs text-slate-900 truncate max-w-[120px]">{w.name}</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>
</div> </div>
@ -338,7 +340,7 @@ export default function Shortlist({ shortlistedWorkers }) {
<div>Salary: <span className="text-slate-800">{w.salary} AED</span></div> <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>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>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> </div>
{/* Skills Display in Comparison */} {/* Skills Display in Comparison */}
@ -395,41 +397,46 @@ export default function Shortlist({ shortlistedWorkers }) {
}`} /> }`} />
<span>{previewWorker.passport_status}</span> <span>{previewWorker.passport_status}</span>
</div> </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>
</div> </div>
{previewWorker.bio ? ( <div className="grid grid-cols-2 gap-4 text-xs font-bold text-slate-600">
<p className="text-xs text-slate-600 italic bg-slate-50 p-4 rounded-xl leading-relaxed"> <div className="p-3 bg-slate-50/50 rounded-xl">
"{previewWorker.bio}" <div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('expectations', 'Expectations')}</div>
</p> <div className="text-slate-800">{previewWorker.salary} AED / mo</div>
) : ( </div>
<p className="text-xs text-slate-400 italic bg-slate-50 p-4 rounded-xl leading-relaxed"> <div className="p-3 bg-slate-50/50 rounded-xl">
{t('no_bio_provided', 'No bio details provided.')} <div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('job_type', 'Job Type')}</div>
</p> <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="grid grid-cols-2 gap-4 text-xs font-bold text-slate-600"> <div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('gender', 'Gender')}</div>
<div className="p-3 bg-slate-50/50 rounded-xl"> <div className="text-slate-800 capitalize mt-1">{previewWorker.gender || 'Female'}</div>
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('expectations', 'Expectations')}</div> </div>
<div className="text-slate-800">{previewWorker.salary} AED / mo</div> <div className="p-3 bg-slate-50/50 rounded-xl">
</div> <div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('visa_status', 'Visa Status')}</div>
<div className="p-3 bg-slate-50/50 rounded-xl"> <div className="text-slate-800 capitalize mt-1">{previewWorker.visa_status || 'Residence Visa'}</div>
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('job_preference', 'Job Preference')}</div> </div>
<div className="text-slate-800 uppercase text-[10px] mt-1">{previewWorker.preferred_job_type}</div> <div className="p-3 bg-slate-50/50 rounded-xl col-span-2">
</div> <div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('preferred_location', 'Preferred Location')}</div>
<div className="p-3 bg-slate-50/50 rounded-xl col-span-2"> <div className="text-slate-800 flex items-center space-x-1.5 mt-1">
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('languages', 'Languages')}</div> <MapPin className="w-3.5 h-3.5 text-[#185FA5]" />
<div className="flex flex-wrap gap-1.5 mt-1"> <span>{previewWorker.preferred_location || 'Not Specified'}</span>
{previewWorker.languages?.map(lang => ( </div>
<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"> </div>
<span>{getLanguageFlag(lang)}</span> <div className="p-3 bg-slate-50/50 rounded-xl col-span-2">
<span>{lang}</span> <div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('languages', 'Languages')}</div>
</span> <div className="flex flex-wrap gap-1.5 mt-1">
))} {previewWorker.languages?.map(lang => (
</div> <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">
</div> <span>{getLanguageFlag(lang)}</span>
</div> <span>{lang}</span>
</span>
))}
</div>
</div>
</div>
{/* Core Platform Skills inside Quick Preview */} {/* Core Platform Skills inside Quick Preview */}
<div className="space-y-1 pt-1"> <div className="space-y-1 pt-1">

View File

@ -6,7 +6,10 @@ import {
LifeBuoy, LifeBuoy,
ArrowLeft, ArrowLeft,
AlertTriangle, AlertTriangle,
CheckCircle CheckCircle,
UploadCloud,
X,
Volume2
} from 'lucide-react'; } from 'lucide-react';
export default function Create({ reasons = [] }) { export default function Create({ reasons = [] }) {
@ -17,6 +20,7 @@ export default function Create({ reasons = [] }) {
subject: '', subject: '',
description: '', description: '',
priority: 'medium', priority: 'medium',
voice_note: null,
}); });
const handleSubmit = (e) => { const handleSubmit = (e) => {
@ -124,6 +128,58 @@ export default function Create({ reasons = [] }) {
)} )}
</div> </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 */} {/* Support notice */}
{data.priority === 'high' && ( {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"> <div className="bg-amber-50 border border-amber-200 rounded-2xl p-4 flex items-start space-x-3 text-amber-800 animate-pulse">

View File

@ -11,7 +11,10 @@ import {
Send, Send,
Terminal, Terminal,
UserCheck, UserCheck,
Lock Lock,
UploadCloud,
X,
Volume2
} from 'lucide-react'; } from 'lucide-react';
export default function Show({ ticket, replies }) { export default function Show({ ticket, replies }) {
@ -19,12 +22,13 @@ export default function Show({ ticket, replies }) {
const { data, setData, post, processing, reset, errors } = useForm({ const { data, setData, post, processing, reset, errors } = useForm({
message: '', message: '',
voice_note: null,
}); });
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
post(`/employer/support/${ticket.id}/reply`, { 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> </h2>
</div> </div>
<div className="p-6 md:p-8 space-y-1"> <div className="p-6 md:p-8 space-y-4">
<label className="block text-slate-400 uppercase tracking-widest text-[9px] font-black mb-2">{t('original_message', 'Original Message')}</label> <div className="space-y-1">
<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"> <label className="block text-slate-400 uppercase tracking-widest text-[9px] font-black mb-2">{t('original_message', 'Original Message')}</label>
{ticket.description} <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> </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>
</div> </div>
@ -184,11 +203,25 @@ export default function Show({ ticket, replies }) {
</div> </div>
<span className="opacity-60">{reply.created_at}</span> <span className="opacity-60">{reply.created_at}</span>
</div> </div>
<div className={`text-xs font-semibold leading-relaxed whitespace-pre-wrap ${ {reply.message && (
isSystemDev ? 'text-slate-300 font-mono font-semibold' : 'text-slate-600' <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>
)}
{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> </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 ${ 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' errors.message ? 'border-red-400 bg-red-50/20' : 'border-slate-200'
}`} }`}
required required={!data.voice_note}
/> />
{errors.message && ( {errors.message && (
<p className="text-[10px] text-red-500 font-semibold mt-1">{errors.message}</p> <p className="text-[10px] text-red-500 font-semibold mt-1">{errors.message}</p>
)} )}
</div> </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"> <div className="flex justify-end">
<button <button
type="submit" type="submit"

View File

@ -47,7 +47,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
const { t } = useTranslation(); const { t } = useTranslation();
// Basic Filters // Basic Filters
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState('All Categories');
const [selectedNationalities, setSelectedNationalities] = useState([]); const [selectedNationalities, setSelectedNationalities] = useState([]);
const [selectedGender, setSelectedGender] = useState('All Genders'); const [selectedGender, setSelectedGender] = useState('All Genders');
const [selectedExperience, setSelectedExperience] = useState('All Experience'); const [selectedExperience, setSelectedExperience] = useState('All Experience');
@ -83,8 +82,8 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
// Saved Presets // Saved Presets
const [savedPresets, setSavedPresets] = useState([ const [savedPresets, setSavedPresets] = useState([
{ id: 1, name: 'Live-in Baby Care', category: 'Childcare', workType: 'Live-in', minSalary: 1200 }, { id: 1, name: 'Live-in Baby Care', workType: 'Live-in', minSalary: 1200 },
{ id: 2, name: 'Experienced Driver', category: 'Driver', experience: '5+ Years' } { id: 2, name: 'Experienced Driver', experience: '5+ Years' }
]); ]);
const [presetNameInput, setPresetNameInput] = useState(''); const [presetNameInput, setPresetNameInput] = useState('');
const [showPresetModal, setShowPresetModal] = useState(false); const [showPresetModal, setShowPresetModal] = useState(false);
@ -112,7 +111,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
let hasAdvanced = false; let hasAdvanced = false;
if (cat) setSelectedCategory(cat);
if (nat) { if (nat) {
setSelectedNationalities(nat.split(',').map(x => x.trim()).filter(Boolean)); setSelectedNationalities(nat.split(',').map(x => x.trim()).filter(Boolean));
} }
@ -176,7 +174,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
const applyPreset = (preset) => { const applyPreset = (preset) => {
resetFilters(); resetFilters();
if (preset.category) setSelectedCategory(preset.category);
if (preset.workType) setSelectedWorkTypes([preset.workType]); if (preset.workType) setSelectedWorkTypes([preset.workType]);
if (preset.experience) setSelectedExperience(preset.experience); if (preset.experience) setSelectedExperience(preset.experience);
}; };
@ -188,7 +185,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
const newPreset = { const newPreset = {
id: Date.now(), id: Date.now(),
name: presetNameInput, name: presetNameInput,
category: selectedCategory !== 'All Categories' ? selectedCategory : null,
workType: selectedWorkTypes.length > 0 ? selectedWorkTypes[0] : null, workType: selectedWorkTypes.length > 0 ? selectedWorkTypes[0] : null,
experience: selectedExperience !== 'All Experience' ? selectedExperience : null, experience: selectedExperience !== 'All Experience' ? selectedExperience : null,
}; };
@ -200,7 +196,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
const resetFilters = () => { const resetFilters = () => {
setSearchQuery(''); setSearchQuery('');
setSelectedCategory('All Categories');
setSelectedNationalities([]); setSelectedNationalities([]);
setSelectedGender('All Genders'); setSelectedGender('All Genders');
setSelectedExperience('All Experience'); setSelectedExperience('All Experience');
@ -262,7 +257,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
} }
// Dropdown filters // 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 (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 (selectedGender !== 'All Genders' && worker.gender && worker.gender.toLowerCase() !== selectedGender.toLowerCase()) return false;
if (selectedExperience !== 'All Experience' && worker.experience !== selectedExperience) return false; if (selectedExperience !== 'All Experience' && worker.experience !== selectedExperience) return false;
@ -338,7 +332,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
}, [ }, [
initialWorkers, initialWorkers,
searchQuery, searchQuery,
selectedCategory,
selectedNationalities, selectedNationalities,
selectedGender, selectedGender,
selectedExperience, 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"> <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> <span>{worker.visa_status}</span>
</div> </div>
)}
{worker.document_expiry_status && ( {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 ${ <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 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="p-6 space-y-4 flex-1 flex flex-col justify-between">
<div className="space-y-4"> <div className="space-y-4">
{/* Category Pill, Ratings & Cost */} {/* Ratings & Cost */}
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-end 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>
<div className="flex items-center space-x-1 font-bold text-slate-800"> <div className="flex items-center space-x-1 font-bold text-slate-800">
<DollarSign className="w-3.5 h-3.5 text-emerald-600" /> <DollarSign className="w-3.5 h-3.5 text-emerald-600" />
<span className="text-sm font-black">{worker.salary} {t('aed', 'AED')}</span> <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" /> <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> <span className="truncate uppercase text-[10px]">{worker.preferred_job_type}</span>
</div> </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" /> <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>
</div> </div>
@ -908,7 +903,7 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
</div> </div>
<div> <div>
<div className="font-bold text-xs text-slate-900 truncate max-w-[120px]">{w.name}</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>
</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('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('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('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>
<div className="space-y-1"> <div className="space-y-1">
@ -972,35 +967,46 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
}`} /> }`} />
<span>{previewWorker.passport_status}</span> <span>{previewWorker.passport_status}</span>
</div> </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>
</div> </div>
<p className="text-xs text-slate-600 italic bg-slate-50 p-4 rounded-xl leading-relaxed"> <div className="grid grid-cols-2 gap-4 text-xs font-bold text-slate-600">
"{previewWorker.bio}" <div className="p-3 bg-slate-50/50 rounded-xl">
</p> <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 className="grid grid-cols-2 gap-4 text-xs font-bold text-slate-600"> </div>
<div className="p-3 bg-slate-50/50 rounded-xl"> <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-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('job_type', 'Job Type')}</div>
<div className="text-slate-800">{previewWorker.salary} {t('aed', 'AED')} / {t('mo', 'mo')}</div> <div className="text-slate-800 uppercase text-[10px] mt-1">{previewWorker.preferred_job_type}</div>
</div> </div>
<div className="p-3 bg-slate-50/50 rounded-xl"> <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-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('gender', 'Gender')}</div>
<div className="text-slate-800 uppercase text-[10px] mt-1">{previewWorker.preferred_job_type}</div> <div className="text-slate-800 capitalize mt-1">{previewWorker.gender || 'Female'}</div>
</div> </div>
<div className="p-3 bg-slate-50/50 rounded-xl col-span-2"> <div className="p-3 bg-slate-50/50 rounded-xl">
<div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('languages_spoken', 'Languages')}</div> <div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('visa_status', 'Visa Status')}</div>
<div className="flex flex-wrap gap-1.5 mt-1"> <div className="text-slate-800 capitalize mt-1">{previewWorker.visa_status || 'Residence Visa'}</div>
{previewWorker.languages?.map(lang => ( </div>
<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"> <div className="p-3 bg-slate-50/50 rounded-xl col-span-2">
<span>{getLanguageFlag(lang)}</span> <div className="text-[10px] text-slate-400 uppercase tracking-widest mb-0.5">{t('preferred_location', 'Preferred Location')}</div>
<span>{lang}</span> <div className="text-slate-800 flex items-center space-x-1.5 mt-1">
</span> <MapPin className="w-3.5 h-3.5 text-[#185FA5]" />
))} <span>{previewWorker.preferred_location || 'Not Specified'}</span>
</div> </div>
</div> </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 */} {/* Core Platform Skills inside Quick Preview */}
<div className="space-y-1 pt-1"> <div className="space-y-1 pt-1">

View File

@ -131,7 +131,7 @@ export default function Show({ worker }) {
return ( return (
<EmployerLayout title={t('candidate_profile_detail', 'Candidate Profile Detail')}> <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"> <div className="space-y-6 select-none w-full pb-16">
@ -229,8 +229,6 @@ export default function Show({ worker }) {
</span> </span>
<span></span> <span></span>
<span>{worker.age} {t('years_old', 'Years Old')}</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> </div>
{workerReviews.length > 0 && ( {workerReviews.length > 0 && (
<div className="flex items-center space-x-1 text-amber-500 font-bold text-xs"> <div className="flex items-center space-x-1 text-amber-500 font-bold text-xs">

View File

@ -22,11 +22,6 @@ protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
// Create a category
$category = \App\Models\WorkerCategory::create([
'name' => 'General Helper',
]);
// Create worker with API token // Create worker with API token
$this->workerToken = 'worker-token-xyz'; $this->workerToken = 'worker-token-xyz';
$this->worker = Worker::create([ $this->worker = Worker::create([
@ -43,7 +38,6 @@ protected function setUp(): void
'experience' => '3 Years', 'experience' => '3 Years',
'religion' => 'Hindu', 'religion' => 'Hindu',
'bio' => 'Experienced helper ready to work.', 'bio' => 'Experienced helper ready to work.',
'category_id' => $category->id,
]); ]);
// Create employer with API token // Create employer with API token

View File

@ -22,10 +22,6 @@ protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
// Create a category
$category = \App\Models\WorkerCategory::create([
'name' => 'Housemaid',
]);
// Create a test worker // Create a test worker
$this->worker = Worker::create([ $this->worker = Worker::create([
@ -41,7 +37,6 @@ protected function setUp(): void
'experience' => '2 Years', 'experience' => '2 Years',
'religion' => 'Christian', 'religion' => 'Christian',
'bio' => 'Experienced housemaid seeking employment.', 'bio' => 'Experienced housemaid seeking employment.',
'category_id' => $category->id,
]); ]);
// Create an employer user with token // Create an employer user with token
@ -181,7 +176,6 @@ public function test_employer_can_get_conversations()
'id', 'id',
'worker_id', 'worker_id',
'worker_name', 'worker_name',
'category',
'nationality', 'nationality',
'salary', 'salary',
'last_message', 'last_message',
@ -229,7 +223,6 @@ public function test_employer_can_get_messages_and_mark_as_read()
'conversation' => [ 'conversation' => [
'id', 'id',
'worker_name', 'worker_name',
'category',
'nationality', 'nationality',
'salary', 'salary',
], ],

View File

@ -169,4 +169,53 @@ public function test_employer_password_update_fails_with_incorrect_current_passw
// Verify password was NOT changed // Verify password was NOT changed
$this->assertTrue(Hash::check('Password@123', $this->employer->fresh()->password)); $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'));
}
} }

View File

@ -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() public function test_employer_can_reply_to_ticket_with_voice_note()
{ {
Storage::fake('public'); 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) '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));
}
} }

View File

@ -4,7 +4,6 @@
use App\Models\User; use App\Models\User;
use App\Models\Worker; use App\Models\Worker;
use App\Models\WorkerCategory;
use App\Models\WorkerDocument; use App\Models\WorkerDocument;
use App\Models\JobOffer; use App\Models\JobOffer;
use App\Models\JobPost; use App\Models\JobPost;
@ -18,7 +17,6 @@ class EmployerWorkerFilterApiTest extends TestCase
protected $employer; protected $employer;
protected $token; protected $token;
protected $category;
protected function setUp(): void protected function setUp(): void
{ {
@ -34,10 +32,6 @@ protected function setUp(): void
'api_token' => $this->token, 'api_token' => $this->token,
]); ]);
// Create worker category
$this->category = WorkerCategory::create([
'name' => 'General Helper',
]);
// Seed 3 workers with specific filter attributes // Seed 3 workers with specific filter attributes
// Worker 1: Dubai, full-time, live-in, Indian, in_country=true, Residence Visa, female // Worker 1: Dubai, full-time, live-in, Indian, in_country=true, Residence Visa, female
@ -47,7 +41,6 @@ protected function setUp(): void
'phone' => '+971501111111', 'phone' => '+971501111111',
'password' => bcrypt('password'), 'password' => bcrypt('password'),
'nationality' => 'Indian', 'nationality' => 'Indian',
'category_id' => $this->category->id,
'verified' => true, 'verified' => true,
'status' => 'active', 'status' => 'active',
'preferred_location' => 'Dubai', 'preferred_location' => 'Dubai',
@ -79,7 +72,6 @@ protected function setUp(): void
'phone' => '+971502222222', 'phone' => '+971502222222',
'password' => bcrypt('password'), 'password' => bcrypt('password'),
'nationality' => 'Filipino', 'nationality' => 'Filipino',
'category_id' => $this->category->id,
'verified' => true, 'verified' => true,
'status' => 'active', 'status' => 'active',
'preferred_location' => 'Abu Dhabi', 'preferred_location' => 'Abu Dhabi',
@ -111,7 +103,6 @@ protected function setUp(): void
'phone' => '+971503333333', 'phone' => '+971503333333',
'password' => bcrypt('password'), 'password' => bcrypt('password'),
'nationality' => 'Kenyan', 'nationality' => 'Kenyan',
'category_id' => $this->category->id,
'verified' => true, 'verified' => true,
'status' => 'active', 'status' => 'active',
'preferred_location' => 'Dubai', 'preferred_location' => 'Dubai',
@ -408,7 +399,6 @@ public function test_worker_list_includes_document_expiry_status()
'phone' => '+971509999999', 'phone' => '+971509999999',
'password' => bcrypt('password'), 'password' => bcrypt('password'),
'nationality' => 'Kenyan', 'nationality' => 'Kenyan',
'category_id' => $this->category->id,
'verified' => true, 'verified' => true,
'status' => 'active', 'status' => 'active',
'preferred_location' => 'Dubai', 'preferred_location' => 'Dubai',

View File

@ -4,7 +4,6 @@
use App\Models\User; use App\Models\User;
use App\Models\Worker; use App\Models\Worker;
use App\Models\WorkerCategory;
use App\Models\JobOffer; use App\Models\JobOffer;
use App\Models\WorkerDocument; use App\Models\WorkerDocument;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -15,7 +14,6 @@ class EmployerWorkerWebFilterTest extends TestCase
use RefreshDatabase; use RefreshDatabase;
protected $employer; protected $employer;
protected $category;
protected function setUp(): void protected function setUp(): void
{ {
@ -28,9 +26,6 @@ protected function setUp(): void
'role' => 'employer', 'role' => 'employer',
]); ]);
$this->category = WorkerCategory::create([
'name' => 'General Helper',
]);
// Seed some workers // Seed some workers
$w1 = Worker::create([ $w1 = Worker::create([
@ -39,7 +34,6 @@ protected function setUp(): void
'phone' => '+971501111111', 'phone' => '+971501111111',
'password' => bcrypt('password'), 'password' => bcrypt('password'),
'nationality' => 'Indian', 'nationality' => 'Indian',
'category_id' => $this->category->id,
'verified' => true, 'verified' => true,
'status' => 'active', 'status' => 'active',
'preferred_location' => 'Dubai', 'preferred_location' => 'Dubai',
@ -69,7 +63,6 @@ protected function setUp(): void
'phone' => '+971502222222', 'phone' => '+971502222222',
'password' => bcrypt('password'), 'password' => bcrypt('password'),
'nationality' => 'Filipino', 'nationality' => 'Filipino',
'category_id' => $this->category->id,
'verified' => true, 'verified' => true,
'status' => 'active', 'status' => 'active',
'preferred_location' => 'Abu Dhabi', 'preferred_location' => 'Abu Dhabi',
@ -165,7 +158,6 @@ public function test_employer_can_view_candidates_index_and_apply_filters()
'phone' => '+971503333333', 'phone' => '+971503333333',
'password' => bcrypt('password'), 'password' => bcrypt('password'),
'nationality' => 'Kenyan', 'nationality' => 'Kenyan',
'category_id' => $this->category->id,
'verified' => true, 'verified' => true,
'status' => 'Hired', 'status' => 'Hired',
'preferred_location' => 'Dubai', 'preferred_location' => 'Dubai',

View 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);
}
}

View File

@ -17,16 +17,11 @@ class ReportApiTest extends TestCase
protected $employer; protected $employer;
protected $workerToken; protected $workerToken;
protected $employerToken; protected $employerToken;
protected $category;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
// Create category
$this->category = \App\Models\WorkerCategory::create([
'name' => 'Housemaid',
]);
// Create worker // Create worker
$this->workerToken = 'worker-token-xyz'; $this->workerToken = 'worker-token-xyz';
@ -44,7 +39,6 @@ protected function setUp(): void
'experience' => '2 Years', 'experience' => '2 Years',
'religion' => 'Christian', 'religion' => 'Christian',
'bio' => 'Experienced housemaid seeking employment.', 'bio' => 'Experienced housemaid seeking employment.',
'category_id' => $this->category->id,
]); ]);
// Create employer user // Create employer user
@ -148,7 +142,6 @@ public function test_worker_report_invalid_review_returns_404()
'email' => 'other@example.com', 'email' => 'other@example.com',
'phone' => '+971500000009', 'phone' => '+971500000009',
'password' => bcrypt('password'), 'password' => bcrypt('password'),
'category_id' => $this->category->id,
'nationality' => 'Indian', 'nationality' => 'Indian',
'age' => 30, 'age' => 30,
'salary' => 1800.00, 'salary' => 1800.00,

View File

@ -356,10 +356,6 @@ public function test_sponsor_dashboard_returns_stats()
'subscription_status' => 'none', 'subscription_status' => 'none',
]); ]);
$category = \App\Models\WorkerCategory::create([
'name' => 'Housemaid',
]);
// Create some workers // Create some workers
\App\Models\Worker::create([ \App\Models\Worker::create([
'name' => 'Worker 1', 'name' => 'Worker 1',
@ -375,7 +371,6 @@ public function test_sponsor_dashboard_returns_stats()
'experience' => 'None', 'experience' => 'None',
'religion' => 'Christian', 'religion' => 'Christian',
'bio' => 'Test', 'bio' => 'Test',
'category_id' => $category->id,
'availability' => 'Immediate', 'availability' => 'Immediate',
]); ]);
\App\Models\Worker::create([ \App\Models\Worker::create([
@ -392,7 +387,6 @@ public function test_sponsor_dashboard_returns_stats()
'experience' => 'None', 'experience' => 'None',
'religion' => 'Christian', 'religion' => 'Christian',
'bio' => 'Test', 'bio' => 'Test',
'category_id' => $category->id,
'availability' => 'Immediate', 'availability' => 'Immediate',
]); ]);

View File

@ -3,7 +3,6 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\Worker; use App\Models\Worker;
use App\Models\WorkerCategory;
use App\Models\WorkerDocument; use App\Models\WorkerDocument;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
@ -15,21 +14,12 @@ class WorkerJourneyApiTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
protected $category;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); 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', 'experience' => 'None',
'religion' => 'Christian', 'religion' => 'Christian',
'bio' => 'New helper', 'bio' => 'New helper',
'category_id' => $this->category->id,
'verified' => false, 'verified' => false,
'status' => 'active', 'status' => 'active',
'api_token' => 'test-bearer-token-123', 'api_token' => 'test-bearer-token-123',
@ -227,7 +216,6 @@ public function test_s3_go_live()
'experience' => 'Not Specified', 'experience' => 'Not Specified',
'religion' => 'Not Specified', 'religion' => 'Not Specified',
'bio' => 'Ready to work', 'bio' => 'Ready to work',
'category_id' => $this->category->id,
'verified' => true, 'verified' => true,
'status' => 'inactive', 'status' => 'inactive',
'api_token' => 'test-bearer-token-789', 'api_token' => 'test-bearer-token-789',
@ -266,7 +254,6 @@ public function test_s5_toggle_availability_still_looking()
'experience' => 'Not Specified', 'experience' => 'Not Specified',
'religion' => 'Not Specified', 'religion' => 'Not Specified',
'bio' => 'Test', 'bio' => 'Test',
'category_id' => $this->category->id,
'verified' => true, 'verified' => true,
'status' => 'active', 'status' => 'active',
'api_token' => 'test-bearer-token-abc', 'api_token' => 'test-bearer-token-abc',
@ -329,7 +316,6 @@ public function test_s6_outcome_mark_hired()
'experience' => 'Not Specified', 'experience' => 'Not Specified',
'religion' => 'Not Specified', 'religion' => 'Not Specified',
'bio' => 'Test', 'bio' => 'Test',
'category_id' => $this->category->id,
'verified' => true, 'verified' => true,
'status' => 'active', 'status' => 'active',
'api_token' => 'test-bearer-token-abc', 'api_token' => 'test-bearer-token-abc',
@ -405,7 +391,6 @@ public function test_update_profile_with_new_api_fields()
'experience' => 'None', 'experience' => 'None',
'religion' => 'Christian', 'religion' => 'Christian',
'bio' => 'New helper', 'bio' => 'New helper',
'category_id' => $this->category->id,
'verified' => false, 'verified' => false,
'status' => 'active', 'status' => 'active',
'api_token' => 'bearer-update-token', 'api_token' => 'bearer-update-token',
@ -630,7 +615,6 @@ public function test_worker_dashboard_stats()
'experience' => 'Not Specified', 'experience' => 'Not Specified',
'religion' => 'Not Specified', 'religion' => 'Not Specified',
'bio' => 'Test', 'bio' => 'Test',
'category_id' => $this->category->id,
'verified' => true, 'verified' => true,
'status' => 'active', 'status' => 'active',
'api_token' => 'worker-test-token-dashboard', 'api_token' => 'worker-test-token-dashboard',
@ -722,7 +706,6 @@ public function test_worker_new_announcements_flow()
'experience' => 'Not Specified', 'experience' => 'Not Specified',
'religion' => 'Not Specified', 'religion' => 'Not Specified',
'bio' => 'Test', 'bio' => 'Test',
'category_id' => $this->category->id,
'verified' => true, 'verified' => true,
'status' => 'active', 'status' => 'active',
'api_token' => 'worker-test-token-announcements', 'api_token' => 'worker-test-token-announcements',

View File

@ -21,11 +21,6 @@ protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
// Create a category
$category = \App\Models\WorkerCategory::create([
'name' => 'Housemaid',
]);
// Create a test worker with an API token // Create a test worker with an API token
$this->token = 'test-token-12345'; $this->token = 'test-token-12345';
$this->worker = Worker::create([ $this->worker = Worker::create([
@ -42,7 +37,6 @@ protected function setUp(): void
'experience' => '2 Years', 'experience' => '2 Years',
'religion' => 'Christian', 'religion' => 'Christian',
'bio' => 'Experienced housemaid seeking employment.', 'bio' => 'Experienced housemaid seeking employment.',
'category_id' => $category->id,
]); ]);
// Create an employer user // Create an employer user

View File

@ -23,10 +23,6 @@ protected function setUp(): void
parent::setUp(); parent::setUp();
$this->token = 'worker-test-token-789'; $this->token = 'worker-test-token-789';
$category = \App\Models\WorkerCategory::create([
'name' => 'Housemaid',
]);
$this->worker = Worker::create([ $this->worker = Worker::create([
'name' => 'John Worker', 'name' => 'John Worker',
@ -35,7 +31,6 @@ protected function setUp(): void
'password' => bcrypt('password'), 'password' => bcrypt('password'),
'api_token' => $this->token, 'api_token' => $this->token,
'status' => 'Available', 'status' => 'Available',
'category_id' => $category->id,
'nationality' => 'Ethiopian', 'nationality' => 'Ethiopian',
'age' => 28, 'age' => 28,
'salary' => 1500.00, 'salary' => 1500.00,