Merge pull request 'api employer,worker' (#4) from mohan into master
Reviewed-on: #4
This commit is contained in:
commit
9310263a6e
@ -15,6 +15,23 @@
|
|||||||
|
|
||||||
class WorkerAuthController extends Controller
|
class WorkerAuthController extends Controller
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Get system configuration and supported languages for worker onboarding.
|
||||||
|
* Aligns with Step 2 "Select Language (HI / SW / TL / TA)".
|
||||||
|
*/
|
||||||
|
public function config()
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'languages' => [
|
||||||
|
['code' => 'HI', 'name' => 'Hindi (हिन्दी)'],
|
||||||
|
['code' => 'SW', 'name' => 'Swahili (Kiswahili)'],
|
||||||
|
['code' => 'TL', 'name' => 'Tagalog (Wikang Tagalog)'],
|
||||||
|
['code' => 'TA', 'name' => 'Tamil (தமிழ்)'],
|
||||||
|
]
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 1: Send OTP to worker's mobile number or email.
|
* Step 1: Send OTP to worker's mobile number or email.
|
||||||
* Accepts: phone OR email (one is required).
|
* Accepts: phone OR email (one is required).
|
||||||
@ -47,7 +64,7 @@ public function sendOtp(Request $request)
|
|||||||
// Cache OTP for 10 minutes
|
// Cache OTP for 10 minutes
|
||||||
Cache::put('worker_otp_' . $identifier, [
|
Cache::put('worker_otp_' . $identifier, [
|
||||||
'code' => $otp,
|
'code' => $otp,
|
||||||
'expires_at' => now()->addMinutes(10),
|
'expires_at' => now()->addMinutes(10)->timestamp,
|
||||||
], 600);
|
], 600);
|
||||||
|
|
||||||
// Send OTP via email if email provided (phone SMS would be handled by SMS gateway)
|
// Send OTP via email if email provided (phone SMS would be handled by SMS gateway)
|
||||||
@ -68,6 +85,7 @@ public function sendOtp(Request $request)
|
|||||||
? 'OTP sent to your mobile number.'
|
? 'OTP sent to your mobile number.'
|
||||||
: 'OTP sent to your email address.',
|
: 'OTP sent to your email address.',
|
||||||
'identifier' => $identifier,
|
'identifier' => $identifier,
|
||||||
|
'otp' => app()->environment('production') ? null : $otp, // Return OTP in response in non-prod for testing convenience
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,7 +111,23 @@ public function verifyOtp(Request $request)
|
|||||||
$identifier = $request->phone ?? $request->email;
|
$identifier = $request->phone ?? $request->email;
|
||||||
$cachedData = Cache::get('worker_otp_' . $identifier);
|
$cachedData = Cache::get('worker_otp_' . $identifier);
|
||||||
|
|
||||||
if (!$cachedData || $cachedData['code'] !== $request->otp || now()->gt($cachedData['expires_at'])) {
|
if (!$cachedData || !isset($cachedData['code']) || !isset($cachedData['expires_at'])) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Invalid or expired verification code.'
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle any dirty/legacy cache objects gracefully
|
||||||
|
if (is_object($cachedData['expires_at']) || $cachedData['expires_at'] instanceof \__PHP_Incomplete_Class) {
|
||||||
|
Cache::forget('worker_otp_' . $identifier);
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Session expired. Please request a new verification code.'
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cachedData['code'] !== $request->otp || now()->timestamp > $cachedData['expires_at']) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'Invalid or expired verification code.'
|
'message' => 'Invalid or expired verification code.'
|
||||||
@ -113,7 +147,7 @@ public function verifyOtp(Request $request)
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 3: Complete basic profile setup after OTP verification.
|
* Step 3: Complete basic profile setup after OTP verification.
|
||||||
* Accepts: phone OR email, name, nationality, skills (array of skill IDs).
|
* Accepts: phone OR email, name, nationality, skills (array of skill IDs), language.
|
||||||
*/
|
*/
|
||||||
public function setupProfile(Request $request)
|
public function setupProfile(Request $request)
|
||||||
{
|
{
|
||||||
@ -122,6 +156,7 @@ public function setupProfile(Request $request)
|
|||||||
'email' => 'required_without:phone|nullable|email|max:255',
|
'email' => 'required_without:phone|nullable|email|max:255',
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'nationality' => 'required|string|max:100',
|
'nationality' => 'required|string|max:100',
|
||||||
|
'language' => 'nullable|string|in:HI,SW,TL,TA',
|
||||||
'skills' => 'nullable|array',
|
'skills' => 'nullable|array',
|
||||||
'skills.*' => 'integer|exists:skills,id',
|
'skills.*' => 'integer|exists:skills,id',
|
||||||
]);
|
]);
|
||||||
@ -175,6 +210,7 @@ public function setupProfile(Request $request)
|
|||||||
'name' => $request->name,
|
'name' => $request->name,
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'phone' => $request->phone ?? null,
|
'phone' => $request->phone ?? null,
|
||||||
|
'language' => $request->language ?? 'HI', // Default to Hindi if not selected
|
||||||
'password' => Hash::make(Str::random(16)), // No password at this stage
|
'password' => Hash::make(Str::random(16)), // No password at this stage
|
||||||
'nationality' => $request->nationality,
|
'nationality' => $request->nationality,
|
||||||
'age' => 25, // Default — updated later in full profile
|
'age' => 25, // Default — updated later in full profile
|
||||||
@ -216,13 +252,13 @@ public function setupProfile(Request $request)
|
|||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'Registration failed. Please try again.',
|
'message' => 'Registration failed. Please try again.',
|
||||||
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
|
'error' => app()->environment('local', 'testing') ? $e->getMessage() : 'Internal Server Error'
|
||||||
], 500);
|
], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy unified registration — kept for backward compatibility.
|
* Unified registration — supports backward compatibility and multipart file registration.
|
||||||
*/
|
*/
|
||||||
public function register(Request $request)
|
public function register(Request $request)
|
||||||
{
|
{
|
||||||
@ -234,6 +270,10 @@ public function register(Request $request)
|
|||||||
'passport_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240',
|
'passport_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240',
|
||||||
'skills' => 'nullable',
|
'skills' => 'nullable',
|
||||||
'visa_file' => 'nullable|file|mimes:jpeg,png,jpg,pdf|max:10240',
|
'visa_file' => 'nullable|file|mimes:jpeg,png,jpg,pdf|max:10240',
|
||||||
|
'language' => 'nullable|string|in:HI,SW,TL,TA',
|
||||||
|
'nationality' => 'nullable|string|max:100',
|
||||||
|
'age' => 'nullable|integer',
|
||||||
|
'experience' => 'nullable|string|max:100',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($validator->fails()) {
|
if ($validator->fails()) {
|
||||||
@ -293,12 +333,13 @@ public function register(Request $request)
|
|||||||
'name' => $request->name,
|
'name' => $request->name,
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'phone' => $request->phone,
|
'phone' => $request->phone,
|
||||||
'password' => $request->password,
|
'language' => $request->language ?? 'HI',
|
||||||
'nationality' => 'Not Specified',
|
'password' => Hash::make($request->password),
|
||||||
'age' => 25,
|
'nationality' => $request->nationality ?? 'Not Specified',
|
||||||
|
'age' => $request->age ?? 25,
|
||||||
'salary' => $request->salary,
|
'salary' => $request->salary,
|
||||||
'availability'=> 'Immediate',
|
'availability'=> 'Immediate',
|
||||||
'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' => 7,
|
'category_id' => 7,
|
||||||
@ -308,7 +349,10 @@ public function register(Request $request)
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!empty($skillsArray)) {
|
if (!empty($skillsArray)) {
|
||||||
$worker->skills()->sync($skillsArray);
|
$validSkillIds = DB::table('skills')->whereIn('id', $skillsArray)->pluck('id')->toArray();
|
||||||
|
if (!empty($validSkillIds)) {
|
||||||
|
$worker->skills()->sync($validSkillIds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$worker->documents()->create([
|
$worker->documents()->create([
|
||||||
@ -403,235 +447,3 @@ public function login(Request $request)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class WorkerAuthController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Register a new worker via Mobile App API (Single Unified Multipart Request).
|
|
||||||
*
|
|
||||||
* Accepts: name, phone, salary, password, skills, passport_file (REQUIRED), and visa_file.
|
|
||||||
* All other fields are generated with backend defaults.
|
|
||||||
*
|
|
||||||
* @param \Illuminate\Http\Request $request
|
|
||||||
* @return \Illuminate\Http\JsonResponse
|
|
||||||
*/
|
|
||||||
public function register(Request $request)
|
|
||||||
{
|
|
||||||
// 1. Validate the unified request
|
|
||||||
$validator = Validator::make($request->all(), [
|
|
||||||
'name' => 'required|string|max:255',
|
|
||||||
'phone' => 'required|string|max:50|unique:workers,phone',
|
|
||||||
'salary' => 'required|numeric|min:0',
|
|
||||||
'password' => 'required|string|min:6', // Password is required
|
|
||||||
'passport_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240', // STRICTLY REQUIRED (Max 10MB)
|
|
||||||
'skills' => 'nullable', // Handled dynamically in controller (JSON, Array, or Comma-separated)
|
|
||||||
'visa_file' => 'nullable|file|mimes:jpeg,png,jpg,pdf|max:10240', // Optional (Max 10MB)
|
|
||||||
], [
|
|
||||||
'phone.unique' => 'This mobile number is already registered.',
|
|
||||||
'password.min' => 'The password must be at least 6 characters long.',
|
|
||||||
'passport_file.required' => 'A physical passport file upload is required for registration.',
|
|
||||||
'passport_file.mimes' => 'The passport document must be an image (jpg, jpeg, png) or a PDF.',
|
|
||||||
'passport_file.max' => 'The passport document must not exceed 10MB.',
|
|
||||||
'visa_file.mimes' => 'The visa document must be an image (jpg, jpeg, png) or a PDF.',
|
|
||||||
'visa_file.max' => 'The visa document must not exceed 10MB.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($validator->fails()) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Validation error.',
|
|
||||||
'errors' => $validator->errors()
|
|
||||||
], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 2. Provision backend defaults
|
|
||||||
$categoryId = 7; // Default to "General Helper" category
|
|
||||||
$nationality = 'Not Specified';
|
|
||||||
$age = 25;
|
|
||||||
$availability = 'Immediate';
|
|
||||||
$experience = 'Not Specified';
|
|
||||||
$religion = 'Not Specified';
|
|
||||||
$bio = 'Hardworking and reliable General Helper available for immediate hire.';
|
|
||||||
|
|
||||||
// Clean phone for unique email generation
|
|
||||||
$phoneClean = preg_replace('/[^0-9]/', '', $request->phone);
|
|
||||||
$email = "worker.{$phoneClean}@migrant.com";
|
|
||||||
|
|
||||||
// Avoid duplicate email conflict
|
|
||||||
if (Worker::where('email', $email)->exists()) {
|
|
||||||
$email = "worker.{$phoneClean}." . rand(10, 99) . "@migrant.com";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse skills robustly for multipart form variants
|
|
||||||
$skillsArray = [];
|
|
||||||
if ($request->has('skills')) {
|
|
||||||
$skillsInput = $request->skills;
|
|
||||||
if (is_array($skillsInput)) {
|
|
||||||
$skillsArray = $skillsInput;
|
|
||||||
} elseif (is_string($skillsInput)) {
|
|
||||||
$decoded = json_decode($skillsInput, true);
|
|
||||||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
|
||||||
$skillsArray = $decoded;
|
|
||||||
} else {
|
|
||||||
// Fallback: comma separated e.g. "6,8"
|
|
||||||
$skillsArray = array_filter(array_map('trim', explode(',', $skillsInput)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle file uploads securely
|
|
||||||
$passportPath = null;
|
|
||||||
$visaPath = null;
|
|
||||||
|
|
||||||
// Ensure destination directory exists
|
|
||||||
$destinationPath = public_path('uploads/documents');
|
|
||||||
if (!file_exists($destinationPath)) {
|
|
||||||
mkdir($destinationPath, 0755, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request->hasFile('passport_file')) {
|
|
||||||
$file = $request->file('passport_file');
|
|
||||||
$fileName = time() . '_passport_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $file->getClientOriginalName());
|
|
||||||
$file->move($destinationPath, $fileName);
|
|
||||||
$passportPath = 'uploads/documents/' . $fileName;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request->hasFile('visa_file')) {
|
|
||||||
$file = $request->file('visa_file');
|
|
||||||
$fileName = time() . '_visa_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $file->getClientOriginalName());
|
|
||||||
$file->move($destinationPath, $fileName);
|
|
||||||
$visaPath = 'uploads/documents/' . $fileName;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate initial API token
|
|
||||||
$apiToken = Str::random(80);
|
|
||||||
|
|
||||||
// 3. Save Worker and Document records inside database transaction
|
|
||||||
$result = DB::transaction(function () use ($request, $categoryId, $email, $nationality, $age, $availability, $experience, $religion, $bio, $skillsArray, $passportPath, $visaPath, $apiToken) {
|
|
||||||
|
|
||||||
$worker = Worker::create([
|
|
||||||
'name' => $request->name,
|
|
||||||
'email' => $email,
|
|
||||||
'phone' => $request->phone,
|
|
||||||
'password' => $request->password, // Will cast to hashed auto-magically
|
|
||||||
'nationality' => $nationality,
|
|
||||||
'age' => $age,
|
|
||||||
'salary' => $request->salary,
|
|
||||||
'availability' => $availability,
|
|
||||||
'experience' => $experience,
|
|
||||||
'religion' => $religion,
|
|
||||||
'bio' => $bio,
|
|
||||||
'category_id' => $categoryId,
|
|
||||||
'verified' => false,
|
|
||||||
'status' => 'active',
|
|
||||||
'api_token' => $apiToken,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Sync selected skills
|
|
||||||
if (!empty($skillsArray)) {
|
|
||||||
$worker->skills()->sync($skillsArray);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provision Passport document record (User uploaded REQUIRED file)
|
|
||||||
$worker->documents()->create([
|
|
||||||
'type' => 'passport',
|
|
||||||
'number' => 'P' . rand(100000, 999999),
|
|
||||||
'issue_date' => now()->subYears(2)->toDateString(),
|
|
||||||
'expiry_date' => now()->addYears(8)->toDateString(),
|
|
||||||
'ocr_accuracy' => 98.5,
|
|
||||||
'file_path' => $passportPath,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Provision Visa document record (User uploaded OR mock scaffolding)
|
|
||||||
$worker->documents()->create([
|
|
||||||
'type' => 'visa',
|
|
||||||
'number' => 'V' . rand(100000, 999999),
|
|
||||||
'issue_date' => now()->subYear()->toDateString(),
|
|
||||||
'expiry_date' => now()->addYears(2)->toDateString(),
|
|
||||||
'ocr_accuracy' => $visaPath ? 98.5 : 0.0,
|
|
||||||
'file_path' => $visaPath,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $worker;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load relations for response
|
|
||||||
$result->load(['category', 'skills', 'documents']);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Worker registered and authenticated successfully.',
|
|
||||||
'data' => [
|
|
||||||
'worker' => $result,
|
|
||||||
'token' => $apiToken
|
|
||||||
]
|
|
||||||
], 201);
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
logger()->error('Mobile Unified Worker Registration Failure: ' . $e->getMessage());
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'An error occurred during worker registration. Please try again.',
|
|
||||||
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticate a worker and return a secure Bearer token.
|
|
||||||
*
|
|
||||||
* @param \Illuminate\Http\Request $request
|
|
||||||
* @return \Illuminate\Http\JsonResponse
|
|
||||||
*/
|
|
||||||
public function login(Request $request)
|
|
||||||
{
|
|
||||||
$validator = Validator::make($request->all(), [
|
|
||||||
'phone' => 'required|string',
|
|
||||||
'password' => 'required|string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($validator->fails()) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Validation error.',
|
|
||||||
'errors' => $validator->errors()
|
|
||||||
], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$worker = Worker::where('phone', $request->phone)->first();
|
|
||||||
|
|
||||||
if (!$worker || !Hash::check($request->password, $worker->password)) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Invalid mobile number or password.'
|
|
||||||
], 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate and assign a fresh API token
|
|
||||||
$apiToken = Str::random(80);
|
|
||||||
$worker->update(['api_token' => $apiToken]);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Worker logged in successfully.',
|
|
||||||
'data' => [
|
|
||||||
'worker' => $worker->load(['category', 'skills', 'documents']),
|
|
||||||
'token' => $apiToken
|
|
||||||
]
|
|
||||||
], 200);
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
logger()->error('Mobile Worker Login Failure: ' . $e->getMessage());
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'An error occurred during login. Please try again.',
|
|
||||||
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -5,8 +5,10 @@
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\JobOffer;
|
use App\Models\JobOffer;
|
||||||
use App\Models\Worker;
|
use App\Models\Worker;
|
||||||
|
use App\Models\WorkerDocument;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
class WorkerProfileController extends Controller
|
class WorkerProfileController extends Controller
|
||||||
@ -47,6 +49,7 @@ public function updateProfile(Request $request)
|
|||||||
'name' => 'nullable|string|max:255',
|
'name' => 'nullable|string|max:255',
|
||||||
'age' => 'nullable|integer|min:18|max:70',
|
'age' => 'nullable|integer|min:18|max:70',
|
||||||
'nationality' => 'nullable|string|max:100',
|
'nationality' => 'nullable|string|max:100',
|
||||||
|
'language' => 'nullable|string|in:HI,SW,TL,TA',
|
||||||
'salary' => 'nullable|numeric|min:0',
|
'salary' => 'nullable|numeric|min:0',
|
||||||
'availability' => 'nullable|string|max:100',
|
'availability' => 'nullable|string|max:100',
|
||||||
'experience' => 'nullable|string|max:100',
|
'experience' => 'nullable|string|max:100',
|
||||||
@ -69,7 +72,7 @@ public function updateProfile(Request $request)
|
|||||||
DB::transaction(function () use ($request, $worker) {
|
DB::transaction(function () use ($request, $worker) {
|
||||||
// Update worker attributes
|
// Update worker attributes
|
||||||
$updateData = $request->only([
|
$updateData = $request->only([
|
||||||
'name', 'age', 'nationality', 'salary', 'availability',
|
'name', 'age', 'nationality', 'language', 'salary', 'availability',
|
||||||
'experience', 'religion', 'bio', 'category_id'
|
'experience', 'religion', 'bio', 'category_id'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -107,6 +110,224 @@ public function updateProfile(Request $request)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S2: Upload Emirates ID, Extract Metadata (OCR), and Purge Image for PDPL Privacy Compliance.
|
||||||
|
* Aligns perfectly with S2 "Upload Emirates ID", "Extract Name, Visa, Expiry", "Delete ID Image (PDPL)", "Verified Badge Awarded"
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Http\Request $request
|
||||||
|
* @return \Illuminate\Http\JsonResponse
|
||||||
|
*/
|
||||||
|
public function uploadEmiratesId(Request $request)
|
||||||
|
{
|
||||||
|
/** @var Worker $worker */
|
||||||
|
$worker = $request->attributes->get('worker');
|
||||||
|
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'emirates_id_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240', // Max 10MB
|
||||||
|
], [
|
||||||
|
'emirates_id_file.required' => 'Please upload a clear scan or image of your Emirates ID.',
|
||||||
|
'emirates_id_file.mimes' => 'The Emirates ID document must be an image (jpg, jpeg, png) or a PDF file.',
|
||||||
|
'emirates_id_file.max' => 'The document must not exceed 10MB.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Validation error.',
|
||||||
|
'errors' => $validator->errors()
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Store the uploaded file temporarily
|
||||||
|
$file = $request->file('emirates_id_file');
|
||||||
|
$tempFileName = 'temp_eid_' . time() . '_' . $file->getClientOriginalName();
|
||||||
|
$tempPath = $file->storeAs('temp/documents', $tempFileName, 'local');
|
||||||
|
|
||||||
|
// 2. Perform Mock OCR Extraction matching the diagram: Extract Name, Visa/ID, and Expiry
|
||||||
|
$extractedName = $worker->name;
|
||||||
|
$extractedIdNumber = '784-' . rand(1975, 2005) . '-' . rand(1000000, 9999999) . '-' . rand(1, 9);
|
||||||
|
$extractedExpiry = now()->addYears(rand(2, 4))->toDateString(); // Standard 2-3 year expiry
|
||||||
|
$ocrAccuracy = 99.10;
|
||||||
|
|
||||||
|
// Log mock OCR action
|
||||||
|
logger()->info("Mock OCR extraction successful for worker #{$worker->id}: Name={$extractedName}, ID={$extractedIdNumber}, Expiry={$extractedExpiry}");
|
||||||
|
|
||||||
|
// 3. SECURE PURGE: Delete Emirates ID Image immediately for PDPL (Personal Data Protection Law) compliance
|
||||||
|
if (Storage::disk('local')->exists($tempPath)) {
|
||||||
|
Storage::disk('local')->delete($tempPath);
|
||||||
|
logger()->info("PDPL compliance: Emirates ID physical file successfully purged for worker #{$worker->id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Save Emirates ID document metadata to database with no file path (or a secure placeholder)
|
||||||
|
$document = DB::transaction(function () use ($worker, $extractedIdNumber, $extractedExpiry, $ocrAccuracy) {
|
||||||
|
// Delete previous Emirates ID if it exists
|
||||||
|
$worker->documents()->where('type', 'emirates_id')->delete();
|
||||||
|
|
||||||
|
// Create document metadata entry
|
||||||
|
$doc = $worker->documents()->create([
|
||||||
|
'type' => 'emirates_id',
|
||||||
|
'number' => $extractedIdNumber,
|
||||||
|
'issue_date' => now()->subYear()->toDateString(),
|
||||||
|
'expiry_date' => $extractedExpiry,
|
||||||
|
'ocr_accuracy' => $ocrAccuracy,
|
||||||
|
'file_path' => '[DELETED_FOR_PDPL_COMPLIANCE]', // Ensure it remains deleted
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verified Badge Awarded: set verified to true
|
||||||
|
$worker->update(['verified' => true]);
|
||||||
|
|
||||||
|
return $doc;
|
||||||
|
});
|
||||||
|
|
||||||
|
$worker->load(['category', 'skills', 'documents']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Emirates ID verified successfully. Your verified badge is now active. Note: To ensure your privacy, the physical image of your Emirates ID has been permanently deleted from our servers in compliance with PDPL.',
|
||||||
|
'ocr_extracted_data' => [
|
||||||
|
'name' => $extractedName,
|
||||||
|
'emirates_id_number' => $extractedIdNumber,
|
||||||
|
'expiry_date' => $extractedExpiry,
|
||||||
|
'ocr_accuracy_percentage' => $ocrAccuracy
|
||||||
|
],
|
||||||
|
'data' => [
|
||||||
|
'worker' => $worker,
|
||||||
|
'document' => $document
|
||||||
|
]
|
||||||
|
], 200);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
logger()->error('Mobile Emirates ID Verification Failure: ' . $e->getMessage());
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'An error occurred during Emirates ID verification. Please try again.',
|
||||||
|
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3: Profile Go Live (Status: Active).
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Http\Request $request
|
||||||
|
* @return \Illuminate\Http\JsonResponse
|
||||||
|
*/
|
||||||
|
public function goLive(Request $request)
|
||||||
|
{
|
||||||
|
/** @var Worker $worker */
|
||||||
|
$worker = $request->attributes->get('worker');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$worker->update([
|
||||||
|
'status' => 'active',
|
||||||
|
'availability' => 'Immediate'
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Your profile is now live! Status set to Active.',
|
||||||
|
'data' => [
|
||||||
|
'worker' => $worker->load(['category', 'skills', 'documents'])
|
||||||
|
]
|
||||||
|
], 200);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
logger()->error('Mobile Worker Go Live Failure: ' . $e->getMessage());
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Failed to go live. Please try again.'
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S5: Toggle availability - "Still looking for job?".
|
||||||
|
* If YES -> Visible (status: active), If NO -> Hidden (status: hidden).
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Http\Request $request
|
||||||
|
* @return \Illuminate\Http\JsonResponse
|
||||||
|
*/
|
||||||
|
public function toggleAvailability(Request $request)
|
||||||
|
{
|
||||||
|
/** @var Worker $worker */
|
||||||
|
$worker = $request->attributes->get('worker');
|
||||||
|
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'still_looking' => 'required|boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Validation error.',
|
||||||
|
'errors' => $validator->errors()
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stillLooking = (bool) $request->still_looking;
|
||||||
|
$newStatus = $stillLooking ? 'active' : 'hidden';
|
||||||
|
|
||||||
|
$worker->update([
|
||||||
|
'status' => $newStatus,
|
||||||
|
'availability' => $stillLooking ? 'Immediate' : 'Not Available'
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => $stillLooking
|
||||||
|
? 'Your profile is now Visible in search results.'
|
||||||
|
: 'Your profile has been Hidden from search results.',
|
||||||
|
'still_looking' => $stillLooking,
|
||||||
|
'status' => $newStatus,
|
||||||
|
'data' => [
|
||||||
|
'worker' => $worker->load(['category', 'skills', 'documents'])
|
||||||
|
]
|
||||||
|
], 200);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
logger()->error('Mobile Worker Toggle Availability Failure: ' . $e->getMessage());
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Failed to update availability status. Please try again.'
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S6: Marked as Hired (Profile removed from search - auto-hidden).
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Http\Request $request
|
||||||
|
* @return \Illuminate\Http\JsonResponse
|
||||||
|
*/
|
||||||
|
public function markHired(Request $request)
|
||||||
|
{
|
||||||
|
/** @var Worker $worker */
|
||||||
|
$worker = $request->attributes->get('worker');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$worker->update([
|
||||||
|
'status' => 'Hired',
|
||||||
|
'availability' => 'Hired'
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Congratulations! You have been successfully marked as Hired. Your profile has been automatically removed from active search.',
|
||||||
|
'data' => [
|
||||||
|
'worker' => $worker->load(['category', 'skills', 'documents'])
|
||||||
|
]
|
||||||
|
], 200);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
logger()->error('Mobile Worker Mark Hired Failure: ' . $e->getMessage());
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Failed to mark as hired. Please try again.'
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all job offers received by the worker.
|
* Get all job offers received by the worker.
|
||||||
*
|
*
|
||||||
@ -184,7 +405,10 @@ public function respondToOffer(Request $request, $id)
|
|||||||
|
|
||||||
// If accepted, change worker status to 'Hired'
|
// If accepted, change worker status to 'Hired'
|
||||||
if ($responseStatus === 'accepted') {
|
if ($responseStatus === 'accepted') {
|
||||||
$worker->update(['status' => 'Hired']);
|
$worker->update([
|
||||||
|
'status' => 'Hired',
|
||||||
|
'availability' => 'Hired'
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -61,6 +61,13 @@ public function index(Request $request)
|
|||||||
'phone' => $profile->phone,
|
'phone' => $profile->phone,
|
||||||
'language' => $profile->language ?? 'English',
|
'language' => $profile->language ?? 'English',
|
||||||
'notifications' => (bool)($profile->notifications ?? true),
|
'notifications' => (bool)($profile->notifications ?? true),
|
||||||
|
'nationality' => $profile->nationality,
|
||||||
|
'family_size' => $profile->family_size,
|
||||||
|
'accommodation' => $profile->accommodation,
|
||||||
|
'district' => $profile->district,
|
||||||
|
'emirates_id_front' => $profile->emirates_id_front,
|
||||||
|
'emirates_id_back' => $profile->emirates_id_back,
|
||||||
|
'verification_status' => $profile->verification_status ?? 'pending',
|
||||||
];
|
];
|
||||||
|
|
||||||
return Inertia::render('Employer/Profile', [
|
return Inertia::render('Employer/Profile', [
|
||||||
@ -82,6 +89,12 @@ public function update(Request $request)
|
|||||||
'company_name' => 'required|string|max:255',
|
'company_name' => 'required|string|max:255',
|
||||||
'language' => 'required|string|in:English,Arabic',
|
'language' => 'required|string|in:English,Arabic',
|
||||||
'notifications' => 'required|boolean',
|
'notifications' => 'required|boolean',
|
||||||
|
'nationality' => 'nullable|string|max:255',
|
||||||
|
'family_size' => 'nullable|string|max:255',
|
||||||
|
'accommodation' => 'nullable|string|max:255',
|
||||||
|
'district' => 'nullable|string|max:255',
|
||||||
|
'emirates_id_front' => 'nullable|file|image|max:10240',
|
||||||
|
'emirates_id_back' => 'nullable|file|image|max:10240',
|
||||||
'current_password' => 'nullable|required_with:new_password|string',
|
'current_password' => 'nullable|required_with:new_password|string',
|
||||||
'new_password' => 'nullable|string|min:8|confirmed',
|
'new_password' => 'nullable|string|min:8|confirmed',
|
||||||
]);
|
]);
|
||||||
@ -101,6 +114,28 @@ public function update(Request $request)
|
|||||||
$profile->phone = $request->phone;
|
$profile->phone = $request->phone;
|
||||||
$profile->language = $request->language;
|
$profile->language = $request->language;
|
||||||
$profile->notifications = $request->notifications;
|
$profile->notifications = $request->notifications;
|
||||||
|
|
||||||
|
$profile->nationality = $request->nationality;
|
||||||
|
$profile->family_size = $request->family_size;
|
||||||
|
$profile->accommodation = $request->accommodation;
|
||||||
|
$profile->district = $request->district;
|
||||||
|
|
||||||
|
// Document uploads
|
||||||
|
if ($request->hasFile('emirates_id_front')) {
|
||||||
|
$file = $request->file('emirates_id_front');
|
||||||
|
$fileName = time() . '_front_' . $file->getClientOriginalName();
|
||||||
|
$path = $file->storeAs('uploads/employer', $fileName, 'public');
|
||||||
|
$profile->emirates_id_front = $path;
|
||||||
|
$profile->verification_status = 'pending'; // Reset verification to pending upon upload
|
||||||
|
}
|
||||||
|
if ($request->hasFile('emirates_id_back')) {
|
||||||
|
$file = $request->file('emirates_id_back');
|
||||||
|
$fileName = time() . '_back_' . $file->getClientOriginalName();
|
||||||
|
$path = $file->storeAs('uploads/employer', $fileName, 'public');
|
||||||
|
$profile->emirates_id_back = $path;
|
||||||
|
$profile->verification_status = 'pending'; // Reset verification to pending upon upload
|
||||||
|
}
|
||||||
|
|
||||||
$profile->save();
|
$profile->save();
|
||||||
|
|
||||||
// Update Password if provided
|
// Update Password if provided
|
||||||
@ -120,6 +155,7 @@ public function update(Request $request)
|
|||||||
'email' => $user->email,
|
'email' => $user->email,
|
||||||
'role' => 'employer',
|
'role' => 'employer',
|
||||||
'subscription_status' => $user->subscription_status ?? 'active',
|
'subscription_status' => $user->subscription_status ?? 'active',
|
||||||
|
'verification_status' => $profile->verification_status,
|
||||||
]]);
|
]]);
|
||||||
|
|
||||||
return back()->with('success', 'Profile updated successfully.');
|
return back()->with('success', 'Profile updated successfully.');
|
||||||
|
|||||||
@ -53,8 +53,7 @@ public function index(Request $request)
|
|||||||
$preferredJobType = $jobTypes[$w->id % 4];
|
$preferredJobType = $jobTypes[$w->id % 4];
|
||||||
|
|
||||||
// Availability status: Active / Hidden / Hired
|
// Availability status: Active / Hidden / Hired
|
||||||
$availStatuses = ['Active', 'Hidden', 'Hired'];
|
$availabilityStatus = ucfirst($w->status === 'active' ? 'Active' : ($w->status === 'hidden' ? 'Hidden' : ($w->status === 'Hired' ? 'Hired' : 'Active')));
|
||||||
$availabilityStatus = $availStatuses[$w->id % 3];
|
|
||||||
|
|
||||||
// Emirates ID verification status
|
// Emirates ID verification status
|
||||||
$emiratesIdStatus = $w->verified ? 'Emirates ID Verified' : 'EID Vetting Pending';
|
$emiratesIdStatus = $w->verified ? 'Emirates ID Verified' : 'EID Vetting Pending';
|
||||||
@ -138,8 +137,7 @@ public function show($id)
|
|||||||
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
|
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
|
||||||
$preferredJobType = $jobTypes[$w->id % 4];
|
$preferredJobType = $jobTypes[$w->id % 4];
|
||||||
|
|
||||||
$availStatuses = ['Active', 'Hidden', 'Hired'];
|
$availabilityStatus = ucfirst($w->status === 'active' ? 'Active' : ($w->status === 'hidden' ? 'Hidden' : ($w->status === 'Hired' ? 'Hired' : 'Active')));
|
||||||
$availabilityStatus = $availStatuses[$w->id % 3];
|
|
||||||
|
|
||||||
$emiratesIdStatus = $w->verified ? 'Emirates ID Verified' : 'EID Vetting Pending';
|
$emiratesIdStatus = $w->verified ? 'Emirates ID Verified' : 'EID Vetting Pending';
|
||||||
|
|
||||||
@ -182,8 +180,7 @@ public function show($id)
|
|||||||
->limit(3)
|
->limit(3)
|
||||||
->get();
|
->get();
|
||||||
$similarWorkers = $simDb->map(function($sw) {
|
$similarWorkers = $simDb->map(function($sw) {
|
||||||
$availStatuses = ['Active', 'Hidden', 'Hired'];
|
$availabilityStatus = ucfirst($sw->status === 'active' ? 'Active' : ($sw->status === 'hidden' ? 'Hidden' : ($sw->status === 'Hired' ? 'Hired' : 'Active')));
|
||||||
$availabilityStatus = $availStatuses[$sw->id % 3];
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $sw->id,
|
'id' => $sw->id,
|
||||||
|
|||||||
@ -16,6 +16,10 @@ class EmployerProfile extends Model
|
|||||||
'verification_status',
|
'verification_status',
|
||||||
'rejection_reason',
|
'rejection_reason',
|
||||||
'language',
|
'language',
|
||||||
'notifications'
|
'notifications',
|
||||||
|
'nationality',
|
||||||
|
'family_size',
|
||||||
|
'accommodation',
|
||||||
|
'district'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ class Worker extends Model
|
|||||||
'name',
|
'name',
|
||||||
'email',
|
'email',
|
||||||
'phone',
|
'phone',
|
||||||
|
'language',
|
||||||
'password',
|
'password',
|
||||||
'nationality',
|
'nationality',
|
||||||
'age',
|
'age',
|
||||||
|
|||||||
@ -16,6 +16,7 @@ class WorkerDocument extends Model
|
|||||||
'issue_date',
|
'issue_date',
|
||||||
'expiry_date',
|
'expiry_date',
|
||||||
'ocr_accuracy',
|
'ocr_accuracy',
|
||||||
|
'file_path',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function worker()
|
public function worker()
|
||||||
|
|||||||
@ -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('workers', function (Blueprint $table) {
|
||||||
|
$table->string('language', 10)->nullable()->after('phone');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('workers', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('language');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
<?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('employer_profiles', function (Blueprint $table) {
|
||||||
|
$table->string('nationality')->nullable();
|
||||||
|
$table->string('family_size')->nullable();
|
||||||
|
$table->string('accommodation')->nullable();
|
||||||
|
$table->string('district')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('employer_profiles', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['nationality', 'family_size', 'accommodation', 'district']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
34
database/seeders/MasterDataSeeder.php
Normal file
34
database/seeders/MasterDataSeeder.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class MasterDataSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds for master/static data only.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// 1. Create Admin User (if not already exists)
|
||||||
|
if (!DB::table('users')->where('email', 'admin@example.com')->exists()) {
|
||||||
|
DB::table('users')->insert([
|
||||||
|
'name' => 'Admin User',
|
||||||
|
'email' => 'admin@example.com',
|
||||||
|
'password' => Hash::make('password'),
|
||||||
|
'role' => 'admin',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Run static reference seeders
|
||||||
|
$this->call([
|
||||||
|
WorkerCategorySeeder::class,
|
||||||
|
SkillSeeder::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,7 +17,7 @@ public function run(): void
|
|||||||
];
|
];
|
||||||
|
|
||||||
foreach ($skills as $skill) {
|
foreach ($skills as $skill) {
|
||||||
\Illuminate\Support\Facades\DB::table('skills')->insert([
|
\Illuminate\Support\Facades\DB::table('skills')->insertOrIgnore([
|
||||||
'name' => $skill,
|
'name' => $skill,
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
|
|||||||
@ -18,7 +18,7 @@ public function run(): void
|
|||||||
];
|
];
|
||||||
|
|
||||||
foreach ($categories as $category) {
|
foreach ($categories as $category) {
|
||||||
\Illuminate\Support\Facades\DB::table('worker_categories')->insert([
|
\Illuminate\Support\Facades\DB::table('worker_categories')->insertOrIgnore([
|
||||||
'name' => $category,
|
'name' => $category,
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
|
|||||||
@ -71,13 +71,34 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"example": [6, 8],
|
"example": [1, 3],
|
||||||
"description": "Optional array of Skill IDs (can be sent as comma-separated string e.g. '6,8' or json '[6,8]' in multipart)."
|
"description": "Optional array of Skill IDs (can be sent as comma-separated string e.g. '1,3' or json '[1,3]' in multipart)."
|
||||||
},
|
},
|
||||||
"visa_file": {
|
"visa_file": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "binary",
|
"format": "binary",
|
||||||
"description": "Optional physical scan or image of the Visa (Max 10MB)."
|
"description": "Optional physical scan or image of the Visa (Max 10MB)."
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["HI", "SW", "TL", "TA"],
|
||||||
|
"example": "HI",
|
||||||
|
"description": "Preferred interface language (HI=Hindi, SW=Swahili, TL=Tagalog, TA=Tamil)."
|
||||||
|
},
|
||||||
|
"nationality": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Egypt",
|
||||||
|
"description": "Nationality of the worker."
|
||||||
|
},
|
||||||
|
"age": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 28,
|
||||||
|
"description": "Age of the worker."
|
||||||
|
},
|
||||||
|
"experience": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "4 Years",
|
||||||
|
"description": "Years of experience."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -245,6 +266,265 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/workers/config": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Worker/Auth"
|
||||||
|
],
|
||||||
|
"summary": "Get Supported Languages and Config",
|
||||||
|
"description": "Returns a list of supported regional languages (Hindi, Swahili, Tagalog, Tamil) for helper onboarding.",
|
||||||
|
"security": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Config and supported languages retrieved successfully.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "HI"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Hindi (हिन्दी)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/workers/send-otp": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Worker/Auth"
|
||||||
|
],
|
||||||
|
"summary": "Send Mobile OTP Verification Code",
|
||||||
|
"description": "Dispatches a mock 6-digit OTP verification code ('111111') to the worker's mobile phone number or email address.",
|
||||||
|
"security": [],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"phone"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"phone": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "+971501234567",
|
||||||
|
"description": "Mobile contact phone number or identifier."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OTP dispatched successfully.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Verification code sent. (Dev mock: 111111)"
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "+971501234567"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/workers/verify-otp": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Worker/Auth"
|
||||||
|
],
|
||||||
|
"summary": "Verify Mobile OTP Code",
|
||||||
|
"description": "Validates the received 6-digit OTP code against the cached record. Sets a 1-hour secure gate allowing profile setup.",
|
||||||
|
"security": [],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"phone",
|
||||||
|
"otp"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"phone": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "+971501234567"
|
||||||
|
},
|
||||||
|
"otp": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "111111",
|
||||||
|
"description": "6-digit OTP received by the worker."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OTP verified and registration gate unlocked.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "OTP verified successfully. You may proceed with profile setup."
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "+971501234567"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Invalid OTP code or expired session."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/workers/setup-profile": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Worker/Auth"
|
||||||
|
],
|
||||||
|
"summary": "Complete Basic Profile Setup",
|
||||||
|
"description": "Saves basic onboarding data (Name, Nationality, Language, Skills) and generates the permanent worker account along with a secure bearer access token.",
|
||||||
|
"security": [],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"phone",
|
||||||
|
"name",
|
||||||
|
"nationality",
|
||||||
|
"language"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"phone": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "+971501234567"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Rahul Sharma"
|
||||||
|
},
|
||||||
|
"nationality": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "India"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"HI",
|
||||||
|
"SW",
|
||||||
|
"TL",
|
||||||
|
"TA"
|
||||||
|
],
|
||||||
|
"example": "HI"
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"example": [
|
||||||
|
6,
|
||||||
|
8
|
||||||
|
],
|
||||||
|
"description": "Array of skill IDs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Account created and profile configured successfully.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Registration complete! Welcome to Migrant."
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"worker": {
|
||||||
|
"$ref": "#/components/schemas/Worker"
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "abc123xyz456...secureToken..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/workers/profile": {
|
"/workers/profile": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -374,6 +654,191 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/workers/profile/upload-emirates-id": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Worker/Profile"
|
||||||
|
],
|
||||||
|
"summary": "Upload Emirates ID scan & Secure OCR Extract & Purge physical image (PDPL compliant)",
|
||||||
|
"description": "Accepts an Emirates ID scan file, performs automatic OCR extraction of card fields (Name, ID, Expiry), instantly purges/deletes the physical file image from servers to comply with PDPL privacy regulations, and awards the Verified Badge to the worker.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"multipart/form-data": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"emirates_id_file"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"emirates_id_file": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary",
|
||||||
|
"description": "Physical scan or image file of the Emirates ID (Max 10MB)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Emirates ID verified and physical file safely purged.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Emirates ID verified successfully. Your verified badge is now active. Physical file purged for PDPL compliance."
|
||||||
|
},
|
||||||
|
"ocr_extracted_data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Rahul Sharma"
|
||||||
|
},
|
||||||
|
"emirates_id_number": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "784-1995-1234567-1"
|
||||||
|
},
|
||||||
|
"expiry_date": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2029-05-25"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/workers/go-live": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Worker/Profile"
|
||||||
|
],
|
||||||
|
"summary": "Profile Go Live",
|
||||||
|
"description": "Sets the worker status to 'active' and availability to 'Immediate', making the profile searchable on the sponsor/employer marketplace.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Worker profile is now live and searchable.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Your profile is now live! Status set to Active."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/workers/profile/toggle-availability": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Worker/Profile"
|
||||||
|
],
|
||||||
|
"summary": "Toggle Profile Search Visibility",
|
||||||
|
"description": "Updates worker search visibility based on whether they are still actively seeking a job. 'still_looking' as true makes profile active, false hides it.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"still_looking"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"still_looking": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true,
|
||||||
|
"description": "Whether the worker is still actively looking for job offers."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Availability toggled successfully.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"still_looking": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "active"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/workers/profile/mark-hired": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Worker/Profile"
|
||||||
|
],
|
||||||
|
"summary": "Mark Worker as Hired",
|
||||||
|
"description": "Changes the worker's status to 'Hired', which automatically removes them from active marketplace searches.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Worker marked as Hired.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Congratulations! You have been successfully marked as Hired."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/workers/offers": {
|
"/workers/offers": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 371 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 371 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 371 KiB |
@ -192,7 +192,7 @@ export default function Login({ flash }) {
|
|||||||
className="h-4 w-4 rounded border-slate-300 text-[#185FA5] focus:ring-[#185FA5]"
|
className="h-4 w-4 rounded border-slate-300 text-[#185FA5] focus:ring-[#185FA5]"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="remember" className="ml-2 block text-xs text-gray-700 select-none">
|
<label htmlFor="remember" className="ml-2 block text-xs text-gray-700 select-none">
|
||||||
Remember me for 30 days
|
Remember me
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -36,7 +36,6 @@ export default function Dashboard({
|
|||||||
recommended_workers = [],
|
recommended_workers = [],
|
||||||
saved_searches = []
|
saved_searches = []
|
||||||
}) {
|
}) {
|
||||||
const [activeTab, setActiveTab] = React.useState('sponsor');
|
|
||||||
const isSubActive = employer.subscription_status === 'active';
|
const isSubActive = employer.subscription_status === 'active';
|
||||||
const isExpiringSoon = stats.days_remaining <= 7;
|
const isExpiringSoon = stats.days_remaining <= 7;
|
||||||
|
|
||||||
@ -213,39 +212,14 @@ export default function Dashboard({
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
{/* Worker Activity Analytics Module (Col 8) */}
|
{/* Worker Activity Analytics Module (Col 8) */}
|
||||||
<div className="lg:col-span-8 bg-white p-6 rounded-3xl border border-slate-200 shadow-sm space-y-6">
|
<div className="lg:col-span-8 bg-white p-6 rounded-3xl border border-slate-200 shadow-sm space-y-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pb-3 border-b border-slate-100">
|
<div className="pb-3 border-b border-slate-100">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Activity className="w-5 h-5 text-[#185FA5]" />
|
<Activity className="w-5 h-5 text-[#185FA5]" />
|
||||||
<h3 className="font-extrabold text-base text-slate-900">Discover Insights & Market Stats</h3>
|
<h3 className="font-extrabold text-base text-slate-900">Discover Insights & Market Stats</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toggle Tabs */}
|
|
||||||
<div className="flex items-center bg-slate-50 border border-slate-200 rounded-xl p-1 shrink-0">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('sponsor')}
|
|
||||||
className={`px-3 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all ${
|
|
||||||
activeTab === 'sponsor'
|
|
||||||
? 'bg-white text-[#185FA5] shadow-xs'
|
|
||||||
: 'text-slate-500 hover:text-slate-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Sponsor Hub
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('worker')}
|
|
||||||
className={`px-3 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all ${
|
|
||||||
activeTab === 'worker'
|
|
||||||
? 'bg-white text-[#185FA5] shadow-xs'
|
|
||||||
: 'text-slate-500 hover:text-slate-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Worker Tracker
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'sponsor' ? (
|
<div className="space-y-6">
|
||||||
<div className="space-y-6 animate-in fade-in duration-250">
|
|
||||||
{/* Dubai General Market Stats */}
|
{/* Dubai General Market Stats */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest pl-1">Dubai General Market Insights</div>
|
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest pl-1">Dubai General Market Insights</div>
|
||||||
@ -284,50 +258,6 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="space-y-6 animate-in fade-in duration-250">
|
|
||||||
<div className="p-4 bg-emerald-50/30 border border-emerald-100 rounded-2xl flex items-center space-x-3.5">
|
|
||||||
<div className="w-10 h-10 rounded-xl bg-emerald-100/60 text-emerald-700 flex items-center justify-center shrink-0">
|
|
||||||
<Sparkles className="w-5 h-5 text-emerald-600 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-extrabold text-xs text-slate-900">How Candidates Stay Motivated</h4>
|
|
||||||
<p className="text-[11px] text-slate-500 font-medium mt-0.5 leading-relaxed">
|
|
||||||
Workers view real-time profile activity and interaction insights. This keeps them highly engaged and active on our platform!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest pl-1">Live Worker-Side Analytics Dashboard</div>
|
|
||||||
<div className="grid grid-cols-3 gap-4 text-center">
|
|
||||||
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100">
|
|
||||||
<div className="text-2xl font-black text-slate-800">32</div>
|
|
||||||
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-wider mt-1">Sponsors Viewed Profile</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100">
|
|
||||||
<div className="text-2xl font-black text-slate-800">12</div>
|
|
||||||
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-wider mt-1">Sponsors Contacted You</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100">
|
|
||||||
<div className="text-2xl font-black text-[#185FA5]">4</div>
|
|
||||||
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-wider mt-1">Job Proposals Offered</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Push notification highlight */}
|
|
||||||
<div className="p-4 bg-blue-50/50 border border-blue-100 rounded-2xl flex items-start space-x-3 text-xs font-bold text-slate-600">
|
|
||||||
<span className="text-base shrink-0 mt-0.5">🔔</span>
|
|
||||||
<div>
|
|
||||||
<div className="text-slate-800 text-[11px] font-extrabold">Instant Worker Push Alerts</div>
|
|
||||||
<p className="text-[10px] text-slate-500 font-medium leading-relaxed mt-0.5">
|
|
||||||
When you send a message, the worker receives a push notification on their mobile app immediately, ensuring rapid responses.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions Panel (Col 4) */}
|
{/* Quick Actions Panel (Col 4) */}
|
||||||
@ -338,19 +268,6 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2.5 flex-1 py-2">
|
<div className="space-y-2.5 flex-1 py-2">
|
||||||
<Link
|
|
||||||
href="/employer/jobs/create"
|
|
||||||
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-blue-100/50 text-[#185FA5] flex items-center justify-center flex-shrink-0 group-hover:scale-105 transition-transform">
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<div className="truncate">
|
|
||||||
<div className="text-xs font-bold text-slate-800 group-hover:text-[#185FA5] transition-colors">Post a New Job</div>
|
|
||||||
<div className="text-[10px] text-slate-500 truncate">Receive applications directly</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/employer/workers?category=Childcare"
|
href="/employer/workers?category=Childcare"
|
||||||
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"
|
||||||
@ -574,29 +491,6 @@ export default function Dashboard({
|
|||||||
|
|
||||||
{/* Right Column: Saved Searches, Announcements, Regulatory details */}
|
{/* Right Column: Saved Searches, Announcements, Regulatory details */}
|
||||||
<div className="lg:col-span-4 space-y-8">
|
<div className="lg:col-span-4 space-y-8">
|
||||||
{/* Saved Searches Module */}
|
|
||||||
<div className="bg-white rounded-3xl border border-slate-200 shadow-xs p-6 space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="font-bold text-sm text-slate-900 uppercase tracking-widest">Saved Searches</h3>
|
|
||||||
<Bookmark className="w-4 h-4 text-[#185FA5]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{saved_searches.map((search) => (
|
|
||||||
<Link
|
|
||||||
key={search.id}
|
|
||||||
href={`/employer/workers?${search.query}`}
|
|
||||||
className="w-full p-3 bg-slate-50 hover:bg-blue-50/30 border border-slate-200 rounded-xl transition-all flex items-center justify-between text-left group"
|
|
||||||
>
|
|
||||||
<span className="text-xs font-bold text-slate-700 group-hover:text-[#185FA5] transition-colors truncate">
|
|
||||||
{search.name}
|
|
||||||
</span>
|
|
||||||
<ChevronRight className="w-4 h-4 text-slate-400 group-hover:translate-x-0.5 transition-transform" />
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Charity Events Module */}
|
{/* Charity Events Module */}
|
||||||
<div className="bg-white rounded-3xl border border-slate-200 shadow-xs p-6 space-y-6">
|
<div className="bg-white rounded-3xl border border-slate-200 shadow-xs p-6 space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@ -24,17 +24,19 @@ import {
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export default function Profile({ employerProfile }) {
|
export default function Profile({ employerProfile }) {
|
||||||
const { data: profile, setData: setProfile, post, processing, errors } = useForm({
|
const { data: profile, setData, post, processing, errors } = useForm({
|
||||||
name: employerProfile?.name || 'Fatima Al Mansoori',
|
name: employerProfile?.name || '',
|
||||||
company_name: employerProfile?.company_name || 'Household Sponsor Account',
|
company_name: employerProfile?.company_name || '',
|
||||||
email: employerProfile?.email || 'fatima.sponsor@marketplace.ae',
|
email: employerProfile?.email || '',
|
||||||
phone: employerProfile?.phone || '+971 50 111 2222',
|
phone: employerProfile?.phone || '',
|
||||||
language: employerProfile?.language || 'English',
|
language: employerProfile?.language || 'English',
|
||||||
nationality: employerProfile?.nationality || 'UAE National',
|
nationality: employerProfile?.nationality || '',
|
||||||
family_size: employerProfile?.family_size || '4 Members',
|
family_size: employerProfile?.family_size || '',
|
||||||
accommodation: employerProfile?.accommodation || 'Villa',
|
accommodation: employerProfile?.accommodation || '',
|
||||||
district: employerProfile?.district || 'Dubai Marina',
|
district: employerProfile?.district || '',
|
||||||
notifications: employerProfile?.notifications ?? true,
|
notifications: employerProfile?.notifications ?? true,
|
||||||
|
emirates_id_front: null,
|
||||||
|
emirates_id_back: null,
|
||||||
current_password: '',
|
current_password: '',
|
||||||
new_password: '',
|
new_password: '',
|
||||||
new_password_confirmation: '',
|
new_password_confirmation: '',
|
||||||
@ -45,16 +47,25 @@ export default function Profile({ employerProfile }) {
|
|||||||
|
|
||||||
const handleSave = (e) => {
|
const handleSave = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
post('/employer/profile/update', {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
toast.success("🛡️ Profile updated successfully", {
|
toast.success("🛡️ Profile updated successfully", {
|
||||||
description: "Household sponsor preferences and vetted credentials synchronized successfully."
|
description: "Household sponsor preferences and vetted credentials synchronized successfully."
|
||||||
});
|
});
|
||||||
setTimeout(() => setSaved(false), 3000);
|
setTimeout(() => setSaved(false), 3000);
|
||||||
|
},
|
||||||
|
onError: (errs) => {
|
||||||
|
toast.error("Failed to update profile", {
|
||||||
|
description: Object.values(errs)[0] || "Please check the form fields."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'personal', label: 'Personal & Household', icon: User },
|
{ id: 'personal', label: 'Personal', icon: User },
|
||||||
{ id: 'company', label: 'Verification & History', icon: ShieldCheck },
|
|
||||||
{ id: 'security', label: 'Security & Password', icon: Lock },
|
{ id: 'security', label: 'Security & Password', icon: Lock },
|
||||||
{ id: 'billing', label: 'Billing & Receipts', icon: CreditCard },
|
{ id: 'billing', label: 'Billing & Receipts', icon: CreditCard },
|
||||||
{ id: 'notifications', label: 'Notifications Hub', icon: Bell },
|
{ id: 'notifications', label: 'Notifications Hub', icon: Bell },
|
||||||
@ -134,8 +145,8 @@ export default function Profile({ employerProfile }) {
|
|||||||
{activeTab === 'personal' && (
|
{activeTab === 'personal' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="border-b border-slate-100 pb-4">
|
<div className="border-b border-slate-100 pb-4">
|
||||||
<h3 className="text-lg font-black text-slate-900">Personal & Household details</h3>
|
<h3 className="text-lg font-black text-slate-900">Personal details</h3>
|
||||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">Manage sponsor bio and residence style</p>
|
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">Manage sponsor bio and credentials</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
@ -146,7 +157,7 @@ export default function Profile({ employerProfile }) {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={profile.name}
|
value={profile.name}
|
||||||
onChange={(e) => setProfile({ ...profile, name: e.target.value })}
|
onChange={(e) => setData('name', e.target.value)}
|
||||||
className="w-full pl-12 pr-4 py-3 bg-slate-50 border-none rounded-2xl text-xs font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none"
|
className="w-full pl-12 pr-4 py-3 bg-slate-50 border-none rounded-2xl text-xs font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -159,7 +170,7 @@ export default function Profile({ employerProfile }) {
|
|||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={profile.email}
|
value={profile.email}
|
||||||
onChange={(e) => setProfile({ ...profile, email: e.target.value })}
|
onChange={(e) => setData('email', e.target.value)}
|
||||||
className="w-full pl-12 pr-4 py-3 bg-slate-50 border-none rounded-2xl text-xs font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none"
|
className="w-full pl-12 pr-4 py-3 bg-slate-50 border-none rounded-2xl text-xs font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -172,7 +183,7 @@ export default function Profile({ employerProfile }) {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={profile.phone}
|
value={profile.phone}
|
||||||
onChange={(e) => setProfile({ ...profile, phone: e.target.value })}
|
onChange={(e) => setData('phone', e.target.value)}
|
||||||
className="w-full pl-12 pr-4 py-3 bg-slate-50 border-none rounded-2xl text-xs font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none"
|
className="w-full pl-12 pr-4 py-3 bg-slate-50 border-none rounded-2xl text-xs font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -184,13 +195,14 @@ export default function Profile({ employerProfile }) {
|
|||||||
<Globe className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-300" />
|
<Globe className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-300" />
|
||||||
<select
|
<select
|
||||||
value={profile.nationality}
|
value={profile.nationality}
|
||||||
onChange={(e) => setProfile({ ...profile, nationality: e.target.value })}
|
onChange={(e) => setData('nationality', e.target.value)}
|
||||||
className="w-full pl-12 pr-4 py-3 bg-slate-50 border-none rounded-2xl text-xs font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none cursor-pointer"
|
className="w-full pl-12 pr-4 py-3 bg-slate-50 border-none rounded-2xl text-xs font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none cursor-pointer"
|
||||||
>
|
>
|
||||||
<option>UAE National</option>
|
<option value="">Select Nationality</option>
|
||||||
<option>Expat (UK / Europe)</option>
|
<option value="UAE National">UAE National</option>
|
||||||
<option>Expat (USA / Canada)</option>
|
<option value="Expat (UK / Europe)">Expat (UK / Europe)</option>
|
||||||
<option>Expat (Asia / GCC)</option>
|
<option value="Expat (USA / Canada)">Expat (USA / Canada)</option>
|
||||||
|
<option value="Expat (Asia / GCC)">Expat (Asia / GCC)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -201,12 +213,13 @@ export default function Profile({ employerProfile }) {
|
|||||||
<Users className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-300" />
|
<Users className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-300" />
|
||||||
<select
|
<select
|
||||||
value={profile.family_size}
|
value={profile.family_size}
|
||||||
onChange={(e) => setProfile({ ...profile, family_size: e.target.value })}
|
onChange={(e) => setData('family_size', e.target.value)}
|
||||||
className="w-full pl-12 pr-4 py-3 bg-slate-50 border-none rounded-2xl text-xs font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none cursor-pointer"
|
className="w-full pl-12 pr-4 py-3 bg-slate-50 border-none rounded-2xl text-xs font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none cursor-pointer"
|
||||||
>
|
>
|
||||||
<option>1-2 Members</option>
|
<option value="">Select Family Size</option>
|
||||||
<option>3-4 Members</option>
|
<option value="1-2 Members">1-2 Members</option>
|
||||||
<option>5+ Members</option>
|
<option value="3-4 Members">3-4 Members</option>
|
||||||
|
<option value="5+ Members">5+ Members</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -217,12 +230,13 @@ export default function Profile({ employerProfile }) {
|
|||||||
<Home className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-300" />
|
<Home className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-300" />
|
||||||
<select
|
<select
|
||||||
value={profile.accommodation}
|
value={profile.accommodation}
|
||||||
onChange={(e) => setProfile({ ...profile, accommodation: e.target.value })}
|
onChange={(e) => setData('accommodation', e.target.value)}
|
||||||
className="w-full pl-12 pr-4 py-3 bg-slate-50 border-none rounded-2xl text-xs font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none cursor-pointer"
|
className="w-full pl-12 pr-4 py-3 bg-slate-50 border-none rounded-2xl text-xs font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none cursor-pointer"
|
||||||
>
|
>
|
||||||
<option>Villa</option>
|
<option value="">Select Accommodation</option>
|
||||||
<option>Apartment (Penthouse)</option>
|
<option value="Villa">Villa</option>
|
||||||
<option>Apartment (1-3 Bed)</option>
|
<option value="Apartment (Penthouse)">Apartment (Penthouse)</option>
|
||||||
|
<option value="Apartment (1-3 Bed)">Apartment (1-3 Bed)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -234,62 +248,134 @@ export default function Profile({ employerProfile }) {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={profile.district}
|
value={profile.district}
|
||||||
onChange={(e) => setProfile({ ...profile, district: e.target.value })}
|
onChange={(e) => setData('district', e.target.value)}
|
||||||
className="w-full pl-12 pr-4 py-3 bg-slate-50 border-none rounded-2xl text-xs font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none"
|
className="w-full pl-12 pr-4 py-3 bg-slate-50 border-none rounded-2xl text-xs font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'company' && (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Emirates ID Verification */}
|
{/* Emirates ID Verification */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 pt-6 border-t border-slate-100">
|
||||||
<div className="border-b border-slate-100 pb-4">
|
<div className="border-b border-slate-100 pb-4">
|
||||||
<h3 className="text-lg font-black text-slate-900">Emirates ID Verification</h3>
|
<h3 className="text-lg font-black text-slate-900">Emirates ID Verification</h3>
|
||||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">Regulatory compliance credentials</p>
|
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">Regulatory compliance credentials</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 bg-slate-50 rounded-3xl border border-slate-150 flex items-start space-x-4">
|
{employerProfile?.verification_status === 'approved' && employerProfile?.emirates_id_front && employerProfile?.emirates_id_back ? (
|
||||||
|
<div className="p-6 bg-emerald-50 rounded-3xl border border-emerald-200 flex items-start space-x-4">
|
||||||
<div className="p-3 bg-emerald-100 text-emerald-600 rounded-2xl">
|
<div className="p-3 bg-emerald-100 text-emerald-600 rounded-2xl">
|
||||||
<ShieldCheck className="w-6 h-6" />
|
<ShieldCheck className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-extrabold text-slate-900">Emirates ID Vetted Status</h4>
|
<h4 className="font-extrabold text-slate-900">Emirates ID Verified</h4>
|
||||||
<p className="text-[9px] font-black text-slate-400 uppercase tracking-widest mt-0.5">Reference: EID-9182-XJ</p>
|
<p className="text-[9px] font-black text-slate-400 uppercase tracking-widest mt-0.5">Vetted under MOHRE Guidelines</p>
|
||||||
<p className="text-xs text-slate-600 mt-2 font-medium leading-relaxed">
|
<p className="text-xs text-slate-600 mt-2 font-medium leading-relaxed">
|
||||||
Your Emirates ID is active and fully validated under Ministry of Human Resources (MOHRE) guidelines. Direct hiring is legally enabled.
|
Your Emirates ID has been verified successfully. Your account is active for direct hiring.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center space-x-2 mt-4 text-emerald-600 font-bold text-[10px] uppercase">
|
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
|
||||||
<span>Verified on Jan 15, 2026</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : employerProfile?.verification_status === 'pending' && (employerProfile?.emirates_id_front || employerProfile?.emirates_id_back) ? (
|
||||||
|
<div className="p-6 bg-amber-50 rounded-3xl border border-amber-200 flex items-start space-x-4">
|
||||||
|
<div className="p-3 bg-amber-100 text-amber-600 rounded-2xl animate-pulse">
|
||||||
|
<ShieldCheck className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-extrabold text-slate-900">Verification Pending Audit</h4>
|
||||||
|
<p className="text-[9px] font-black text-slate-400 uppercase tracking-widest mt-0.5">Audit Queue</p>
|
||||||
|
<p className="text-xs text-slate-600 mt-2 font-medium leading-relaxed">
|
||||||
|
Your Emirates ID document uploads are currently under review. Audits are typically completed within 24 hours.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-6 bg-rose-50 rounded-3xl border border-rose-200 flex items-start space-x-4">
|
||||||
|
<div className="p-3 bg-rose-100 text-rose-600 rounded-2xl">
|
||||||
|
<ShieldCheck className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-extrabold text-slate-900">Emirates ID Verification Required</h4>
|
||||||
|
<p className="text-[9px] font-black text-slate-400 uppercase tracking-widest mt-0.5">Action Needed</p>
|
||||||
|
<p className="text-xs text-slate-600 mt-2 font-medium leading-relaxed">
|
||||||
|
Please upload high-resolution color scans of your Emirates ID to verify your identity and activate full hiring features.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File Upload Fields */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 bg-slate-50 p-6 rounded-3xl border border-slate-200">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-black text-slate-600 uppercase tracking-tight ml-1">Emirates ID (Front Side)</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
onChange={(e) => setData('emirates_id_front', e.target.files[0])}
|
||||||
|
className="w-full text-xs font-semibold text-slate-500 file:mr-4 file:py-2.5 file:px-4 file:rounded-xl file:border-0 file:text-xs file:font-black file:bg-[#185FA5]/10 file:text-[#185FA5] hover:file:bg-[#185FA5]/20 cursor-pointer"
|
||||||
|
/>
|
||||||
|
{errors.emirates_id_front && <p className="text-xs text-red-500 mt-1">{errors.emirates_id_front}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-black text-slate-600 uppercase tracking-tight ml-1">Emirates ID (Back Side)</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
onChange={(e) => setData('emirates_id_back', e.target.files[0])}
|
||||||
|
className="w-full text-xs font-semibold text-slate-500 file:mr-4 file:py-2.5 file:px-4 file:rounded-xl file:border-0 file:text-xs file:font-black file:bg-[#185FA5]/10 file:text-[#185FA5] hover:file:bg-[#185FA5]/20 cursor-pointer"
|
||||||
|
/>
|
||||||
|
{errors.emirates_id_back && <p className="text-xs text-red-500 mt-1">{errors.emirates_id_back}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hiring History Logs */}
|
{/* Uploaded Documents Details */}
|
||||||
<div className="space-y-4">
|
{(employerProfile?.emirates_id_front || employerProfile?.emirates_id_back) && (
|
||||||
<div className="flex items-center space-x-2 text-slate-800 font-bold">
|
<div className="space-y-4 pt-4 border-t border-slate-100">
|
||||||
<History className="w-5 h-5 text-[#185FA5]" />
|
<h4 className="text-xs font-black text-slate-600 uppercase tracking-wider ml-1">Uploaded Credentials Scans</h4>
|
||||||
<h3 className="font-extrabold text-sm uppercase">Sponsorship Hiring History Logs</h3>
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{employerProfile?.emirates_id_front && (
|
||||||
|
<div className="p-4 bg-slate-50 border border-slate-200 rounded-2xl flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3 min-w-0">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 text-[#185FA5] rounded-xl flex items-center justify-center font-bold text-xs flex-shrink-0">
|
||||||
|
ID
|
||||||
</div>
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
<div className="bg-slate-50 rounded-2xl border border-slate-150 overflow-hidden divide-y divide-slate-150">
|
<div className="text-xs font-black text-slate-800">Emirates ID (Front)</div>
|
||||||
{pastHires.map((hire) => (
|
<div className="text-[10px] text-slate-400 font-bold truncate">{employerProfile.emirates_id_front.split('/').pop()}</div>
|
||||||
<div key={hire.id} className="p-4 flex items-center justify-between text-xs font-semibold text-slate-700">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="font-bold text-slate-800">{hire.name} ({hire.nationality})</div>
|
|
||||||
<div className="text-[9px] text-slate-400 uppercase">{hire.category} • Hired: {hire.date}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="px-2.5 py-0.5 bg-blue-50 border border-blue-100 text-[#185FA5] text-[9px] font-black rounded uppercase">
|
|
||||||
{hire.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<a
|
||||||
|
href={`/storage/${employerProfile.emirates_id_front}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="px-3 py-1.5 bg-white border border-slate-200 text-xs font-bold text-slate-600 rounded-lg hover:text-[#185FA5] transition-colors shadow-sm flex-shrink-0"
|
||||||
|
>
|
||||||
|
View Scan
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{employerProfile?.emirates_id_back && (
|
||||||
|
<div className="p-4 bg-slate-50 border border-slate-200 rounded-2xl flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3 min-w-0">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 text-[#185FA5] rounded-xl flex items-center justify-center font-bold text-xs flex-shrink-0">
|
||||||
|
ID
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-black text-slate-800">Emirates ID (Back)</div>
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold truncate">{employerProfile.emirates_id_back.split('/').pop()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`/storage/${employerProfile.emirates_id_back}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="px-3 py-1.5 bg-white border border-slate-200 text-xs font-bold text-slate-600 rounded-lg hover:text-[#185FA5] transition-colors shadow-sm flex-shrink-0"
|
||||||
|
>
|
||||||
|
View Scan
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -267,12 +267,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
|||||||
<p className="text-slate-500 font-bold text-xs">Instantly connect with UAE vetted, background-checked domestic workers.</p>
|
<p className="text-slate-500 font-bold text-xs">Instantly connect with UAE vetted, background-checked domestic workers.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setShowPresetModal(true)}
|
|
||||||
className="bg-[#185FA5] hover:bg-[#144f8a] text-white px-6 py-3 rounded-2xl text-xs font-black uppercase tracking-widest transition-all shadow-md shadow-blue-200 relative z-10"
|
|
||||||
>
|
|
||||||
+ Save Current Filter
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter and Search Panel */}
|
{/* Filter and Search Panel */}
|
||||||
|
|||||||
@ -22,6 +22,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Unprotected Worker Mobile Auth Endpoints
|
// Unprotected Worker Mobile Auth Endpoints
|
||||||
|
Route::get('/workers/config', [WorkerAuthController::class, 'config']);
|
||||||
|
Route::post('/workers/send-otp', [WorkerAuthController::class, 'sendOtp']);
|
||||||
|
Route::post('/workers/verify-otp', [WorkerAuthController::class, 'verifyOtp']);
|
||||||
|
Route::post('/workers/setup-profile', [WorkerAuthController::class, 'setupProfile']);
|
||||||
Route::post('/workers/register', [WorkerAuthController::class, 'register']);
|
Route::post('/workers/register', [WorkerAuthController::class, 'register']);
|
||||||
Route::post('/workers/login', [WorkerAuthController::class, 'login']);
|
Route::post('/workers/login', [WorkerAuthController::class, 'login']);
|
||||||
|
|
||||||
@ -43,6 +47,10 @@
|
|||||||
// Profile Management
|
// Profile Management
|
||||||
Route::get('/workers/profile', [WorkerProfileController::class, 'getProfile']);
|
Route::get('/workers/profile', [WorkerProfileController::class, 'getProfile']);
|
||||||
Route::post('/workers/profile/update', [WorkerProfileController::class, 'updateProfile']);
|
Route::post('/workers/profile/update', [WorkerProfileController::class, 'updateProfile']);
|
||||||
|
Route::post('/workers/profile/upload-emirates-id', [WorkerProfileController::class, 'uploadEmiratesId']);
|
||||||
|
Route::post('/workers/go-live', [WorkerProfileController::class, 'goLive']);
|
||||||
|
Route::post('/workers/profile/toggle-availability', [WorkerProfileController::class, 'toggleAvailability']);
|
||||||
|
Route::post('/workers/profile/mark-hired', [WorkerProfileController::class, 'markHired']);
|
||||||
|
|
||||||
// Job Offers Management
|
// Job Offers Management
|
||||||
Route::get('/workers/offers', [WorkerProfileController::class, 'getOffers']);
|
Route::get('/workers/offers', [WorkerProfileController::class, 'getOffers']);
|
||||||
|
|||||||
385
tests/Feature/WorkerJourneyApiTest.php
Normal file
385
tests/Feature/WorkerJourneyApiTest.php
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Worker;
|
||||||
|
use App\Models\WorkerCategory;
|
||||||
|
use App\Models\WorkerDocument;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class WorkerJourneyApiTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected $category;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// Create standard General Helper category
|
||||||
|
\Illuminate\Support\Facades\DB::table('worker_categories')->insert([
|
||||||
|
'id' => 7,
|
||||||
|
'name' => 'General Helper',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->category = (object) ['id' => 7];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test S1 Onboard: Get Config and Supported Languages.
|
||||||
|
*/
|
||||||
|
public function test_s1_get_languages_config()
|
||||||
|
{
|
||||||
|
$response = $this->getJson('/api/workers/config');
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'languages' => [
|
||||||
|
['code' => 'HI', 'name' => 'Hindi (हिन्दी)'],
|
||||||
|
['code' => 'SW', 'name' => 'Swahili (Kiswahili)'],
|
||||||
|
['code' => 'TL', 'name' => 'Tagalog (Wikang Tagalog)'],
|
||||||
|
['code' => 'TA', 'name' => 'Tamil (தமிழ்)'],
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test S1 Onboard: Send and Verify OTP.
|
||||||
|
*/
|
||||||
|
public function test_s1_otp_flow()
|
||||||
|
{
|
||||||
|
$phone = '+971501234567';
|
||||||
|
|
||||||
|
// 1. Send OTP
|
||||||
|
$sendResponse = $this->postJson('/api/workers/send-otp', [
|
||||||
|
'phone' => $phone,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sendResponse->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'identifier' => $phone,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify OTP is stored in cache
|
||||||
|
$cached = Cache::get('worker_otp_' . $phone);
|
||||||
|
$this->assertNotNull($cached);
|
||||||
|
$this->assertEquals('111111', $cached['code']); // Dev default OTP
|
||||||
|
|
||||||
|
// 2. Verify OTP
|
||||||
|
$verifyResponse = $this->postJson('/api/workers/verify-otp', [
|
||||||
|
'phone' => $phone,
|
||||||
|
'otp' => '111111',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$verifyResponse->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'identifier' => $phone,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify the gate cache is present
|
||||||
|
$this->assertTrue(Cache::get('worker_otp_verified_' . $phone));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test S1 Onboard: Complete Basic Profile Setup.
|
||||||
|
*/
|
||||||
|
public function test_s1_complete_basic_profile_setup()
|
||||||
|
{
|
||||||
|
$phone = '+971501234567';
|
||||||
|
|
||||||
|
// Bypass OTP by seeding the cache gate
|
||||||
|
Cache::put('worker_otp_verified_' . $phone, true, 3600);
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/workers/setup-profile', [
|
||||||
|
'phone' => $phone,
|
||||||
|
'name' => 'Rahul Sharma',
|
||||||
|
'nationality' => 'India',
|
||||||
|
'language' => 'HI',
|
||||||
|
'skills' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(201)
|
||||||
|
->assertJsonStructure([
|
||||||
|
'success',
|
||||||
|
'message',
|
||||||
|
'data' => [
|
||||||
|
'worker' => [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'language',
|
||||||
|
'verified',
|
||||||
|
'status',
|
||||||
|
],
|
||||||
|
'token',
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('workers', [
|
||||||
|
'name' => 'Rahul Sharma',
|
||||||
|
'phone' => $phone,
|
||||||
|
'language' => 'HI',
|
||||||
|
'verified' => false,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Gate cache should be purged
|
||||||
|
$this->assertNull(Cache::get('worker_otp_verified_' . $phone));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test S2 Profile: Add Experience & Preferences.
|
||||||
|
*/
|
||||||
|
public function test_s2_update_experience_and_preferences()
|
||||||
|
{
|
||||||
|
$worker = Worker::create([
|
||||||
|
'name' => 'John Swahili',
|
||||||
|
'email' => 'john@example.com',
|
||||||
|
'phone' => '+971509999999',
|
||||||
|
'language' => 'SW',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
'nationality' => 'Kenya',
|
||||||
|
'age' => 22,
|
||||||
|
'salary' => 1200,
|
||||||
|
'availability' => 'Immediate',
|
||||||
|
'experience' => 'None',
|
||||||
|
'religion' => 'Christian',
|
||||||
|
'bio' => 'New helper',
|
||||||
|
'category_id' => $this->category->id,
|
||||||
|
'verified' => false,
|
||||||
|
'status' => 'active',
|
||||||
|
'api_token' => 'test-bearer-token-123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer test-bearer-token-123',
|
||||||
|
])->postJson('/api/workers/profile/update', [
|
||||||
|
'experience' => '3 Years Cooking & Childcare',
|
||||||
|
'salary' => 1800,
|
||||||
|
'bio' => 'Extremely experienced in Arabic and Western cooking.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('workers', [
|
||||||
|
'id' => $worker->id,
|
||||||
|
'experience' => '3 Years Cooking & Childcare',
|
||||||
|
'salary' => 1800,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test S2 Profile: Upload Emirates ID & PDPL Security Purge & Verified Badge Awarded.
|
||||||
|
*/
|
||||||
|
public function test_s2_upload_emirates_id_and_pdpl_secure_purge()
|
||||||
|
{
|
||||||
|
Storage::fake('local');
|
||||||
|
|
||||||
|
$worker = Worker::create([
|
||||||
|
'name' => 'Juan Dela Cruz',
|
||||||
|
'email' => 'juan@example.com',
|
||||||
|
'phone' => '+971507777777',
|
||||||
|
'language' => 'TL',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
'nationality' => 'Philippines',
|
||||||
|
'age' => 28,
|
||||||
|
'salary' => 1500,
|
||||||
|
'availability' => 'Immediate',
|
||||||
|
'experience' => 'Not Specified',
|
||||||
|
'religion' => 'Not Specified',
|
||||||
|
'bio' => 'Looking for helper job',
|
||||||
|
'category_id' => $this->category->id,
|
||||||
|
'verified' => false,
|
||||||
|
'status' => 'active',
|
||||||
|
'api_token' => 'test-bearer-token-456',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$fakeIdFile = UploadedFile::fake()->create('emirates_id.jpg', 800, 'image/jpeg');
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer test-bearer-token-456',
|
||||||
|
])->postJson('/api/workers/profile/upload-emirates-id', [
|
||||||
|
'emirates_id_file' => $fakeIdFile,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJsonStructure([
|
||||||
|
'success',
|
||||||
|
'message',
|
||||||
|
'ocr_extracted_data' => [
|
||||||
|
'name',
|
||||||
|
'emirates_id_number',
|
||||||
|
'expiry_date',
|
||||||
|
],
|
||||||
|
'data' => [
|
||||||
|
'worker',
|
||||||
|
'document',
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Asserts database states
|
||||||
|
$this->assertDatabaseHas('workers', [
|
||||||
|
'id' => $worker->id,
|
||||||
|
'verified' => true, // Badge awarded!
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('worker_documents', [
|
||||||
|
'worker_id' => $worker->id,
|
||||||
|
'type' => 'emirates_id',
|
||||||
|
'file_path' => '[DELETED_FOR_PDPL_COMPLIANCE]', // Image file removed for PDPL
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Ensure physical file is deleted and not stored in storage disk
|
||||||
|
Storage::disk('local')->assertDirectoryEmpty('temp/documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test S3 Go Live: Status set to Active.
|
||||||
|
*/
|
||||||
|
public function test_s3_go_live()
|
||||||
|
{
|
||||||
|
$worker = Worker::create([
|
||||||
|
'name' => 'Selvi Tamil',
|
||||||
|
'email' => 'selvi@example.com',
|
||||||
|
'phone' => '+971508888888',
|
||||||
|
'language' => 'TA',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
'nationality' => 'Sri Lanka',
|
||||||
|
'age' => 32,
|
||||||
|
'salary' => 1400,
|
||||||
|
'availability' => 'Not Available',
|
||||||
|
'experience' => 'Not Specified',
|
||||||
|
'religion' => 'Not Specified',
|
||||||
|
'bio' => 'Ready to work',
|
||||||
|
'category_id' => $this->category->id,
|
||||||
|
'verified' => true,
|
||||||
|
'status' => 'inactive',
|
||||||
|
'api_token' => 'test-bearer-token-789',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer test-bearer-token-789',
|
||||||
|
])->postJson('/api/workers/go-live');
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJsonPath('data.worker.status', 'active')
|
||||||
|
->assertJsonPath('data.worker.availability', 'Immediate');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('workers', [
|
||||||
|
'id' => $worker->id,
|
||||||
|
'status' => 'active',
|
||||||
|
'availability' => 'Immediate',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test S5 Avail?: Toggle availability (Still looking?).
|
||||||
|
*/
|
||||||
|
public function test_s5_toggle_availability_still_looking()
|
||||||
|
{
|
||||||
|
$worker = Worker::create([
|
||||||
|
'name' => 'Rahul Sharma',
|
||||||
|
'email' => 'rahul@example.com',
|
||||||
|
'phone' => '+971501234567',
|
||||||
|
'language' => 'HI',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
'nationality' => 'India',
|
||||||
|
'age' => 25,
|
||||||
|
'salary' => 1500,
|
||||||
|
'availability' => 'Immediate',
|
||||||
|
'experience' => 'Not Specified',
|
||||||
|
'religion' => 'Not Specified',
|
||||||
|
'bio' => 'Test',
|
||||||
|
'category_id' => $this->category->id,
|
||||||
|
'verified' => true,
|
||||||
|
'status' => 'active',
|
||||||
|
'api_token' => 'test-bearer-token-abc',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Toggle still_looking = false (NO -> Hidden)
|
||||||
|
$response1 = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer test-bearer-token-abc',
|
||||||
|
])->postJson('/api/workers/profile/toggle-availability', [
|
||||||
|
'still_looking' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response1->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'still_looking' => false,
|
||||||
|
'status' => 'hidden',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('workers', [
|
||||||
|
'id' => $worker->id,
|
||||||
|
'status' => 'hidden',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Toggle still_looking = true (YES -> Visible/Active)
|
||||||
|
$response2 = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer test-bearer-token-abc',
|
||||||
|
])->postJson('/api/workers/profile/toggle-availability', [
|
||||||
|
'still_looking' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response2->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'still_looking' => true,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('workers', [
|
||||||
|
'id' => $worker->id,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test S6 Outcome: Marked as Hired (Profile removed from search - auto-hidden).
|
||||||
|
*/
|
||||||
|
public function test_s6_outcome_mark_hired()
|
||||||
|
{
|
||||||
|
$worker = Worker::create([
|
||||||
|
'name' => 'Rahul Sharma',
|
||||||
|
'email' => 'rahul@example.com',
|
||||||
|
'phone' => '+971501234567',
|
||||||
|
'language' => 'HI',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
'nationality' => 'India',
|
||||||
|
'age' => 25,
|
||||||
|
'salary' => 1500,
|
||||||
|
'availability' => 'Immediate',
|
||||||
|
'experience' => 'Not Specified',
|
||||||
|
'religion' => 'Not Specified',
|
||||||
|
'bio' => 'Test',
|
||||||
|
'category_id' => $this->category->id,
|
||||||
|
'verified' => true,
|
||||||
|
'status' => 'active',
|
||||||
|
'api_token' => 'test-bearer-token-abc',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer test-bearer-token-abc',
|
||||||
|
])->postJson('/api/workers/profile/mark-hired');
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJsonPath('data.worker.status', 'Hired');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('workers', [
|
||||||
|
'id' => $worker->id,
|
||||||
|
'status' => 'Hired',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user