worker page profile update

This commit is contained in:
mohanmd 2026-06-20 20:05:54 +05:30
parent 0a497e56ed
commit d2c3564c26
53 changed files with 6021 additions and 5645 deletions

View File

@ -30,6 +30,8 @@ public function getProfile(Request $request)
$worker->load(['skills', 'documents']); $worker->load(['skills', 'documents']);
$worker->makeHidden(['email', 'religion', 'availability', 'bio', 'country', 'city', 'area']);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'data' => [ 'data' => [
@ -49,16 +51,29 @@ public function updateProfile(Request $request)
/** @var Worker $worker */ /** @var Worker $worker */
$worker = $request->attributes->get('worker'); $worker = $request->attributes->get('worker');
$data = $request->all();
if (isset($data['passport']) && is_string($data['passport'])) {
$decoded = json_decode($data['passport'], true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$data['passport'] = $decoded;
}
}
if (isset($data['visa']) && is_string($data['visa'])) {
$decoded = json_decode($data['visa'], true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$data['visa'] = $decoded;
}
}
$request->merge($data);
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'name' => 'nullable|string|max:255', 'name' => 'nullable|string|max:255',
'phone' => 'nullable|string|max:50|unique:workers,phone,' . $worker->id,
'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', 'language' => 'nullable|string',
'salary' => 'nullable|numeric|min:0', 'salary' => 'nullable|numeric|min:0',
'availability' => 'nullable|string|max:100',
'experience' => 'nullable|string|max:100', 'experience' => 'nullable|string|max:100',
'religion' => 'nullable|string|max:100',
'bio' => 'nullable|string|max:1000',
'skills' => 'nullable|array', 'skills' => 'nullable|array',
'skills.*' => 'exists:skills,id', 'skills.*' => 'exists:skills,id',
'in_country' => 'nullable', 'in_country' => 'nullable',
@ -67,10 +82,19 @@ public function updateProfile(Request $request)
'gender' => 'nullable|string|in:male,female,other', 'gender' => 'nullable|string|in:male,female,other',
'live_in_out' => 'nullable|string|in:live_in,live_out', 'live_in_out' => 'nullable|string|in:live_in,live_out',
'preferred_location' => 'nullable|string|max:255', 'preferred_location' => 'nullable|string|max:255',
'country' => 'nullable|string|max:100',
'city' => 'nullable|string|max:100',
'area' => 'nullable|string|max:100',
'fcm_token' => 'nullable|string|max:255', 'fcm_token' => 'nullable|string|max:255',
'passport' => 'nullable|array',
'passport.passport_number' => [
'nullable',
'string',
\Illuminate\Validation\Rule::unique('worker_documents', 'number')->where(function ($query) use ($worker) {
return $query->where('type', 'passport')->where('worker_id', '!=', $worker->id);
}),
],
'visa' => 'nullable|array',
], [
'phone.unique' => 'This mobile number is already registered.',
'passport.passport_number.unique' => 'This passport number is already registered.',
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
@ -81,27 +105,40 @@ public function updateProfile(Request $request)
], 422); ], 422);
} }
// Document Update Check: Verify passport number matches existing passport number if already verified/saved
$existingPassport = $worker->documents()->where('type', 'passport')->first();
if ($existingPassport && $request->has('passport')) {
$passportData = $request->input('passport');
if ($passportData) {
$newPassportNumber = $passportData['passport_number'] ?? $passportData['number'] ?? null;
if ($newPassportNumber && strcasecmp(trim($existingPassport->number), trim($newPassportNumber)) !== 0) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => [
'passport.passport_number' => ['The passport number cannot be changed. It must match the previously verified passport number.']
]
], 422);
}
}
}
try { try {
DB::transaction(function () use ($request, $worker) { DB::transaction(function () use ($request, $worker, $existingPassport) {
// Update worker attributes // Update worker attributes
$updateData = $request->only([ $updateData = $request->only([
'name', 'name',
'phone',
'age', 'age',
'nationality', 'nationality',
'language', 'language',
'salary', 'salary',
'availability',
'experience', 'experience',
'religion',
'bio',
'visa_status', 'visa_status',
'preferred_job_type', 'preferred_job_type',
'gender', 'gender',
'live_in_out', 'live_in_out',
'preferred_location', 'preferred_location',
'country',
'city',
'area',
'fcm_token' 'fcm_token'
]); ]);
@ -120,10 +157,89 @@ public function updateProfile(Request $request)
if ($request->has('skills')) { if ($request->has('skills')) {
$worker->skills()->sync($request->skills ?: []); $worker->skills()->sync($request->skills ?: []);
} }
// Process passport document creation/update
if ($request->has('passport')) {
$passportDataInput = $request->input('passport');
if ($passportDataInput) {
$expiryDate = $passportDataInput['date_of_expiry'] ?? null;
$issueDate = $passportDataInput['date_of_issue'] ?? null;
$expiryDateFormatted = $expiryDate ? $this->normaliseDateForController($expiryDate) : null;
$issueDateFormatted = $issueDate ? $this->normaliseDateForController($issueDate) : null;
if ($existingPassport) {
$updateFields = [
'ocr_data' => $passportDataInput,
];
if ($expiryDateFormatted) {
$updateFields['expiry_date'] = $expiryDateFormatted;
}
if ($issueDateFormatted) {
$updateFields['issue_date'] = $issueDateFormatted;
}
$existingPassport->update($updateFields);
} else {
$passportNum = $passportDataInput['passport_number'] ?? $passportDataInput['number'] ?? ('P' . rand(1000000, 9999999));
$expiryDateFormatted = $expiryDateFormatted ?: now()->addYears(8)->toDateString();
$issueDateFormatted = $issueDateFormatted ?: now()->subYears(3)->toDateString();
$worker->documents()->create([
'type' => 'passport',
'number' => $passportNum,
'issue_date' => $issueDateFormatted,
'expiry_date' => $expiryDateFormatted,
'ocr_accuracy' => 99.0,
'file_path' => null,
'ocr_data' => $passportDataInput,
]);
}
}
}
// Process visa document creation/update
if ($request->has('visa')) {
$visaDataInput = $request->input('visa');
if ($visaDataInput) {
$existingVisa = $worker->documents()->where('type', 'visa')->first();
$expiryDate = $visaDataInput['expiry_date'] ?? null;
$issueDate = $visaDataInput['issue_date'] ?? null;
$expiryDateFormatted = $expiryDate ? $this->normaliseDateForController($expiryDate) : null;
$issueDateFormatted = $issueDate ? $this->normaliseDateForController($issueDate) : null;
if ($existingVisa) {
$updateFields = [
'ocr_data' => $visaDataInput,
];
if ($expiryDateFormatted) {
$updateFields['expiry_date'] = $expiryDateFormatted;
}
if ($issueDateFormatted) {
$updateFields['issue_date'] = $issueDateFormatted;
}
$existingVisa->update($updateFields);
} else {
$visaNum = $visaDataInput['file_number'] ?? $visaDataInput['id_number'] ?? ('V' . rand(1000000, 9999999));
$expiryDateFormatted = $expiryDateFormatted ?: now()->addYears(2)->toDateString();
$issueDateFormatted = $issueDateFormatted ?: now()->subYears(2)->toDateString();
$worker->documents()->create([
'type' => 'visa',
'number' => $visaNum,
'issue_date' => $issueDateFormatted,
'expiry_date' => $expiryDateFormatted,
'ocr_accuracy' => 99.0,
'file_path' => null,
'ocr_data' => $visaDataInput,
]);
}
}
}
}); });
$worker->load(['skills', 'documents']); $worker->load(['skills', 'documents']);
$worker->makeHidden(['email', 'religion', 'availability', 'bio', 'country', 'city', 'area']);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'message' => 'Profile updated successfully.', 'message' => 'Profile updated successfully.',
@ -552,4 +668,35 @@ public function getDashboard(Request $request)
], 500); ], 500);
} }
} }
/**
* E.g. "14/05/1990" -> "1990-05-14"
*/
private function normaliseDateForController(?string $raw): ?string
{
if (empty($raw)) {
return null;
}
$raw = trim($raw);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) {
return $raw;
}
try {
if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) {
$day = str_pad($m[1], 2, '0', STR_PAD_LEFT);
$month = $m[2];
$year = $m[3];
if (is_numeric($month)) {
$month = str_pad($month, 2, '0', STR_PAD_LEFT);
return "{$year}-{$month}-{$day}";
}
$ts = strtotime("{$day} {$month} {$year}");
return $ts ? date('Y-m-d', $ts) : $raw;
}
$dt = new \DateTime($raw);
return $dt->format('Y-m-d');
} catch (\Exception $e) {
return $raw;
}
}
} }

View File

@ -13,9 +13,7 @@
], ],
"security": [ "security": [
{ {
"bearerAuth": [ "bearerAuth": []
]
} }
], ],
"paths": { "paths": {
@ -26,9 +24,7 @@
], ],
"summary": "Register Sponsor Account", "summary": "Register Sponsor Account",
"description": "Registers a new sponsor with basic information and uploads their organization/trade license. Sponsors have restricted access — dashboard and charity events only. No payment required.", "description": "Registers a new sponsor with basic information and uploads their organization/trade license. Sponsors have restricted access — dashboard and charity events only. No payment required.",
"security": [ "security": [],
],
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@ -216,9 +212,7 @@
], ],
"summary": "Upload Sponsor Trade License", "summary": "Upload Sponsor Trade License",
"description": "Uploads and processes the trade license document of a Sponsor using OCR.", "description": "Uploads and processes the trade license document of a Sponsor using OCR.",
"security": [ "security": [],
],
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@ -307,10 +301,8 @@
"Sponsor/Auth" "Sponsor/Auth"
], ],
"summary": "Authenticate Sponsor or Employer", "summary": "Authenticate Sponsor or Employer",
"description": "Unified authentication endpoint. Validates email/mobile and password, then returns a stateless Bearer token and the user\u0027s role (employer or sponsor). Same behavior as /employers/login.", "description": "Unified authentication endpoint. Validates email/mobile and password, then returns a stateless Bearer token and the user's role (employer or sponsor). Same behavior as /employers/login.",
"security": [ "security": [],
],
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@ -392,7 +384,7 @@
"Sponsor" "Sponsor"
], ],
"summary": "Sponsor Dashboard", "summary": "Sponsor Dashboard",
"description": "Returns the sponsor profile summary, latest charity events, and total \u0026 active counts for employers and workers.", "description": "Returns the sponsor profile summary, latest charity events, and total & active counts for employers and workers.",
"responses": { "responses": {
"200": { "200": {
"description": "Dashboard data retrieved successfully.", "description": "Dashboard data retrieved successfully.",
@ -488,7 +480,7 @@
"schema": { "schema": {
"type": "string" "type": "string"
}, },
"description": "Optional event type filter (e.g. \u0027charity\u0027, \u0027update\u0027)." "description": "Optional event type filter (e.g. 'charity', 'update')."
} }
], ],
"responses": { "responses": {
@ -609,9 +601,7 @@
], ],
"summary": "Register Worker Account (Step 1)", "summary": "Register Worker Account (Step 1)",
"description": "Creates the worker account and returns a secure Bearer token.\n\nDocument upload is a **separate step** after registration:\n- `POST /workers/register/passport` — upload passport (OCR-processed)\n- `POST /workers/register/visa` — upload visa (OCR-processed)\n\nBoth document endpoints require the Bearer token returned here.", "description": "Creates the worker account and returns a secure Bearer token.\n\nDocument upload is a **separate step** after registration:\n- `POST /workers/register/passport` — upload passport (OCR-processed)\n- `POST /workers/register/visa` — upload visa (OCR-processed)\n\nBoth document endpoints require the Bearer token returned here.",
"security": [ "security": [],
],
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@ -656,12 +646,12 @@
1, 1,
3 3
], ],
"description": "Optional array of Skill IDs (can be sent as comma-separated string e.g. \u00271,3\u0027 or json \u0027[1,3]\u0027 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)."
}, },
"language": { "language": {
"type": "string", "type": "string",
"example": "English, Arabic", "example": "English, Arabic",
"description": "Languages spoken by the worker (e.g. \u0027English, Arabic, Hindi\u0027)." "description": "Languages spoken by the worker (e.g. 'English, Arabic, Hindi')."
}, },
"nationality": { "nationality": {
"type": "string", "type": "string",
@ -923,9 +913,7 @@
], ],
"summary": "Authenticate Worker", "summary": "Authenticate Worker",
"description": "Validates worker credentials (mobile phone number and password) and generates a secure, stateless Bearer token for api access.", "description": "Validates worker credentials (mobile phone number and password) and generates a secure, stateless Bearer token for api access.",
"security": [ "security": [],
],
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@ -1021,9 +1009,7 @@
], ],
"summary": "Extract Passport Data using Google Cloud Vision API", "summary": "Extract Passport Data using Google Cloud Vision API",
"description": "Upload a passport image file to extract OCR data using the Google Cloud Vision API.", "description": "Upload a passport image file to extract OCR data using the Google Cloud Vision API.",
"security": [ "security": [],
],
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@ -1114,9 +1100,7 @@
], ],
"summary": "Get Supported Languages and Config", "summary": "Get Supported Languages and Config",
"description": "Returns a list of supported regional languages (Hindi, Swahili, Tagalog, Tamil) for helper onboarding.", "description": "Returns a list of supported regional languages (Hindi, Swahili, Tagalog, Tamil) for helper onboarding.",
"security": [ "security": [],
],
"responses": { "responses": {
"200": { "200": {
"description": "Config and supported languages retrieved successfully.", "description": "Config and supported languages retrieved successfully.",
@ -1160,9 +1144,7 @@
], ],
"summary": "Get Master Skills List", "summary": "Get Master Skills List",
"description": "Returns a list of all available master skills for helper onboarding profile setup.", "description": "Returns a list of all available master skills for helper onboarding profile setup.",
"security": [ "security": [],
],
"responses": { "responses": {
"200": { "200": {
"description": "Master skills list retrieved successfully.", "description": "Master skills list retrieved successfully.",
@ -1206,9 +1188,7 @@
], ],
"summary": "Get Nationalities List", "summary": "Get Nationalities List",
"description": "Returns a list of supported nationalities translated to the requested language. Supports en, hindi (hi), sawahi (sw), taglo (tl), tamil (ta).", "description": "Returns a list of supported nationalities translated to the requested language. Supports en, hindi (hi), sawahi (sw), taglo (tl), tamil (ta).",
"security": [ "security": [],
],
"parameters": [ "parameters": [
{ {
"name": "lang", "name": "lang",
@ -1217,7 +1197,7 @@
"type": "string", "type": "string",
"default": "en" "default": "en"
}, },
"description": "Language locale code (e.g. \u0027en\u0027, \u0027hindi\u0027, \u0027sawahi\u0027, \u0027taglo\u0027, \u0027tamil\u0027)" "description": "Language locale code (e.g. 'en', 'hindi', 'sawahi', 'taglo', 'tamil')"
}, },
{ {
"name": "Accept-Language", "name": "Accept-Language",
@ -1225,7 +1205,7 @@
"schema": { "schema": {
"type": "string" "type": "string"
}, },
"description": "Fallback header locale (e.g. \u0027en\u0027, \u0027hi\u0027, \u0027sw\u0027, \u0027tl\u0027, \u0027ta\u0027)" "description": "Fallback header locale (e.g. 'en', 'hi', 'sw', 'tl', 'ta')"
}, },
{ {
"name": "search", "name": "search",
@ -1323,9 +1303,7 @@
], ],
"summary": "Get Languages List", "summary": "Get Languages List",
"description": "Returns a list of supported languages translated to the requested language. Supports en, hindi (hi), sawahi (sw), taglo (tl), tamil (ta).", "description": "Returns a list of supported languages translated to the requested language. Supports en, hindi (hi), sawahi (sw), taglo (tl), tamil (ta).",
"security": [ "security": [],
],
"parameters": [ "parameters": [
{ {
"name": "lang", "name": "lang",
@ -1334,7 +1312,7 @@
"type": "string", "type": "string",
"default": "en" "default": "en"
}, },
"description": "Language locale code (e.g. \u0027en\u0027, \u0027hindi\u0027, \u0027sawahi\u0027, \u0027taglo\u0027, \u0027tamil\u0027)" "description": "Language locale code (e.g. 'en', 'hindi', 'sawahi', 'taglo', 'tamil')"
}, },
{ {
"name": "Accept-Language", "name": "Accept-Language",
@ -1342,7 +1320,7 @@
"schema": { "schema": {
"type": "string" "type": "string"
}, },
"description": "Fallback header locale (e.g. \u0027en\u0027, \u0027hi\u0027, \u0027sw\u0027, \u0027tl\u0027, \u0027ta\u0027)" "description": "Fallback header locale (e.g. 'en', 'hi', 'sw', 'tl', 'ta')"
}, },
{ {
"name": "search", "name": "search",
@ -1440,9 +1418,7 @@
], ],
"summary": "Get Localized Preferred Locations List", "summary": "Get Localized Preferred Locations List",
"description": "Returns a list of preferred locations (Dubai, Abu Dhabi, Oman) translated to the requested language. Supports en, hindi (hi), sawahi (sw), taglo (tl), tamil (ta).", "description": "Returns a list of preferred locations (Dubai, Abu Dhabi, Oman) translated to the requested language. Supports en, hindi (hi), sawahi (sw), taglo (tl), tamil (ta).",
"security": [ "security": [],
],
"parameters": [ "parameters": [
{ {
"name": "lang", "name": "lang",
@ -1451,7 +1427,7 @@
"type": "string", "type": "string",
"default": "en" "default": "en"
}, },
"description": "Language locale code (e.g. \u0027en\u0027, \u0027hindi\u0027, \u0027sawahi\u0027, \u0027taglo\u0027, \u0027tamil\u0027)" "description": "Language locale code (e.g. 'en', 'hindi', 'sawahi', 'taglo', 'tamil')"
}, },
{ {
"name": "Accept-Language", "name": "Accept-Language",
@ -1459,7 +1435,7 @@
"schema": { "schema": {
"type": "string" "type": "string"
}, },
"description": "Fallback header locale (e.g. \u0027en\u0027, \u0027hi\u0027, \u0027sw\u0027, \u0027tl\u0027, \u0027ta\u0027)" "description": "Fallback header locale (e.g. 'en', 'hi', 'sw', 'tl', 'ta')"
}, },
{ {
"name": "search", "name": "search",
@ -1557,9 +1533,7 @@
], ],
"summary": "Get Localized Areas for a Preferred Location (Path Parameter)", "summary": "Get Localized Areas for a Preferred Location (Path Parameter)",
"description": "Returns a list of localized neighborhoods or sub-locations mapped under a specific preferred location (Dubai, Abu Dhabi, Oman) using path routing.", "description": "Returns a list of localized neighborhoods or sub-locations mapped under a specific preferred location (Dubai, Abu Dhabi, Oman) using path routing.",
"security": [ "security": [],
],
"parameters": [ "parameters": [
{ {
"name": "location", "name": "location",
@ -1577,7 +1551,7 @@
"type": "string", "type": "string",
"default": "en" "default": "en"
}, },
"description": "Language locale code (e.g. \u0027en\u0027, \u0027hindi\u0027, \u0027sawahi\u0027, \u0027taglo\u0027, \u0027tamil\u0027)" "description": "Language locale code (e.g. 'en', 'hindi', 'sawahi', 'taglo', 'tamil')"
}, },
{ {
"name": "Accept-Language", "name": "Accept-Language",
@ -1585,7 +1559,7 @@
"schema": { "schema": {
"type": "string" "type": "string"
}, },
"description": "Fallback header locale (e.g. \u0027en\u0027, \u0027hi\u0027, \u0027sw\u0027, \u0027tl\u0027, \u0027ta\u0027)" "description": "Fallback header locale (e.g. 'en', 'hi', 'sw', 'tl', 'ta')"
}, },
{ {
"name": "search", "name": "search",
@ -1687,9 +1661,7 @@
], ],
"summary": "Get Localized Areas for a Preferred Location (Query Parameter)", "summary": "Get Localized Areas for a Preferred Location (Query Parameter)",
"description": "Returns a list of localized neighborhoods or sub-locations mapped under a specific preferred location (Dubai, Abu Dhabi, Oman) using query parameter routing.", "description": "Returns a list of localized neighborhoods or sub-locations mapped under a specific preferred location (Dubai, Abu Dhabi, Oman) using query parameter routing.",
"security": [ "security": [],
],
"parameters": [ "parameters": [
{ {
"name": "city", "name": "city",
@ -1716,7 +1688,7 @@
"type": "string", "type": "string",
"default": "en" "default": "en"
}, },
"description": "Language locale code (e.g. \u0027en\u0027, \u0027hindi\u0027, \u0027sawahi\u0027, \u0027taglo\u0027, \u0027tamil\u0027)" "description": "Language locale code (e.g. 'en', 'hindi', 'sawahi', 'taglo', 'tamil')"
}, },
{ {
"name": "Accept-Language", "name": "Accept-Language",
@ -1724,7 +1696,7 @@
"schema": { "schema": {
"type": "string" "type": "string"
}, },
"description": "Fallback header locale (e.g. \u0027en\u0027, \u0027hi\u0027, \u0027sw\u0027, \u0027tl\u0027, \u0027ta\u0027)" "description": "Fallback header locale (e.g. 'en', 'hi', 'sw', 'tl', 'ta')"
}, },
{ {
"name": "search", "name": "search",
@ -1825,10 +1797,8 @@
"Worker/Auth" "Worker/Auth"
], ],
"summary": "Send Mobile OTP Verification Code", "summary": "Send Mobile OTP Verification Code",
"description": "Dispatches a mock 6-digit OTP verification code (\u0027111111\u0027) to the worker\u0027s mobile phone number or email address.", "description": "Dispatches a mock 6-digit OTP verification code ('111111') to the worker's mobile phone number or email address.",
"security": [ "security": [],
],
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@ -1884,9 +1854,7 @@
], ],
"summary": "Verify Mobile OTP Code", "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.", "description": "Validates the received 6-digit OTP code against the cached record. Sets a 1-hour secure gate allowing profile setup.",
"security": [ "security": [],
],
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@ -1950,9 +1918,7 @@
], ],
"summary": "Complete Basic Profile Setup", "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.", "description": "Saves basic onboarding data (Name, Nationality, Language, Skills) and generates the permanent worker account along with a secure bearer access token.",
"security": [ "security": [],
],
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@ -2075,7 +2041,7 @@
"Worker/Profile" "Worker/Profile"
], ],
"summary": "Get Profile details", "summary": "Get Profile details",
"description": "Retrieves the authenticated worker\u0027s profile details including their selected job category, connected skills, and uploaded documents.", "description": "Retrieves the authenticated worker's profile details including their selected job category, connected skills, and uploaded documents.",
"responses": { "responses": {
"200": { "200": {
"description": "Worker profile details retrieved.", "description": "Worker profile details retrieved.",
@ -2110,7 +2076,7 @@
"Worker/Profile" "Worker/Profile"
], ],
"summary": "Update Profile details", "summary": "Update Profile details",
"description": "Allows workers to customize and complete their profiles by updating age, nationality, desired salary, bio, availability, experience, religion, categories, and master skills list.", "description": "Allows workers to customize and complete their profiles by updating name, phone, age, nationality, desired salary, language, preferred job type, live in/out accommodation preference, preferred location, experience, skills list, fcm_token, passport details, and visa details.",
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@ -2122,6 +2088,10 @@
"type": "string", "type": "string",
"example": "Amina Al-Masry" "example": "Amina Al-Masry"
}, },
"phone": {
"type": "string",
"example": "+971509998888"
},
"age": { "age": {
"type": "integer", "type": "integer",
"example": 28 "example": 28
@ -2130,26 +2100,43 @@
"type": "string", "type": "string",
"example": "Egypt" "example": "Egypt"
}, },
"language": {
"type": "string",
"enum": [
"HI",
"SW",
"TL",
"TA"
],
"example": "HI"
},
"salary": { "salary": {
"type": "number", "type": "number",
"example": 1800 "example": 1800
}, },
"availability": { "preferred_job_type": {
"type": "string", "type": "string",
"example": "Immediate" "example": "full-time",
"description": "Preferred job type (e.g., full-time, part-time)."
},
"live_in_out": {
"type": "string",
"enum": [
"live_in",
"live_out"
],
"example": "live_out",
"description": "Accommodation preference (live_in, live_out)."
},
"preferred_location": {
"type": "string",
"example": "Dubai Marina",
"description": "Preferred work location/area."
}, },
"experience": { "experience": {
"type": "string", "type": "string",
"example": "4 Years" "example": "4 Years"
}, },
"religion": {
"type": "string",
"example": "Muslim"
},
"bio": {
"type": "string",
"example": "Highly certified housekeeper and babysitter with cooking experience."
},
"skills": { "skills": {
"type": "array", "type": "array",
"items": { "items": {
@ -2164,6 +2151,36 @@
"type": "string", "type": "string",
"example": "fcm-device-token-xyz-123", "example": "fcm-device-token-xyz-123",
"description": "Firebase Cloud Messaging token for push notifications." "description": "Firebase Cloud Messaging token for push notifications."
},
"passport": {
"type": "object",
"properties": {
"passport_number": {
"type": "string",
"example": "ABC123456"
},
"date_of_expiry": {
"type": "string",
"example": "2030-01-01"
},
"date_of_issue": {
"type": "string",
"example": "2020-01-01"
}
}
},
"visa": {
"type": "object",
"properties": {
"file_number": {
"type": "string",
"example": "202/2022/2840234"
},
"expiry_date": {
"type": "string",
"example": "2028-12-31"
}
}
} }
} }
} }
@ -2208,7 +2225,7 @@
"Worker/Profile" "Worker/Profile"
], ],
"summary": "Profile Go Live", "summary": "Profile Go Live",
"description": "Sets the worker status to \u0027active\u0027 and availability to \u0027Immediate\u0027, making the profile searchable on the sponsor/employer marketplace.", "description": "Sets the worker status to 'active' and availability to 'Immediate', making the profile searchable on the sponsor/employer marketplace.",
"responses": { "responses": {
"200": { "200": {
"description": "Worker profile is now live and searchable.", "description": "Worker profile is now live and searchable.",
@ -2239,7 +2256,7 @@
"Worker/Profile" "Worker/Profile"
], ],
"summary": "Toggle Profile Search Visibility", "summary": "Toggle Profile Search Visibility",
"description": "Updates worker search visibility based on whether they are still actively seeking a job. \u0027still_looking\u0027 as true makes profile active, false hides it.", "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": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@ -2294,7 +2311,7 @@
"Worker/Profile" "Worker/Profile"
], ],
"summary": "Mark Worker as Hired", "summary": "Mark Worker as Hired",
"description": "Changes the worker\u0027s status to \u0027Hired\u0027, which automatically removes them from active marketplace searches.", "description": "Changes the worker's status to 'Hired', which automatically removes them from active marketplace searches.",
"responses": { "responses": {
"200": { "200": {
"description": "Worker marked as Hired.", "description": "Worker marked as Hired.",
@ -2363,7 +2380,7 @@
"Worker/Offers" "Worker/Offers"
], ],
"summary": "Accept or Reject Job Offer", "summary": "Accept or Reject Job Offer",
"description": "Responds to a pending job offer. If accepted, the job offer status becomes \u0027accepted\u0027 and the worker\u0027s status is automatically changed to \u0027Hired\u0027, which reflects immediately in the Employer panel.", "description": "Responds to a pending job offer. If accepted, the job offer status becomes 'accepted' and the worker's status is automatically changed to 'Hired', which reflects immediately in the Employer panel.",
"parameters": [ "parameters": [
{ {
"name": "id", "name": "id",
@ -2563,7 +2580,7 @@
"others": { "others": {
"type": "string", "type": "string",
"example": "Spam and malicious links", "example": "Spam and malicious links",
"description": "Custom reason string. Used only if reason is \u0027Others\u0027." "description": "Custom reason string. Used only if reason is 'Others'."
}, },
"description": { "description": {
"type": "string", "type": "string",
@ -2745,7 +2762,7 @@
"tags": [ "tags": [
"Worker/Support" "Worker/Support"
], ],
"summary": "Get Support Ticket Details \u0026 Replies (Worker)", "summary": "Get Support Ticket Details & Replies (Worker)",
"description": "Retrieves the details of a support ticket and its chronological reply history.", "description": "Retrieves the details of a support ticket and its chronological reply history.",
"parameters": [ "parameters": [
{ {
@ -2816,9 +2833,7 @@
], ],
"summary": "Register Sponsor (Alias)", "summary": "Register Sponsor (Alias)",
"description": "Legacy route matching the simplified sponsor registration (Name, Email, Phone) (Step 1).", "description": "Legacy route matching the simplified sponsor registration (Name, Email, Phone) (Step 1).",
"security": [ "security": [],
],
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@ -2834,17 +2849,17 @@
"name": { "name": {
"type": "string", "type": "string",
"example": "Ahmad Bin Ahmed", "example": "Ahmad Bin Ahmed",
"description": "Sponsor\u0027s full name" "description": "Sponsor's full name"
}, },
"email": { "email": {
"type": "string", "type": "string",
"example": "ahmad@example.com", "example": "ahmad@example.com",
"description": "Sponsor\u0027s contact email address" "description": "Sponsor's contact email address"
}, },
"phone": { "phone": {
"type": "string", "type": "string",
"example": "+971509990001", "example": "+971509990001",
"description": "Sponsor\u0027s mobile phone number" "description": "Sponsor's mobile phone number"
}, },
"emirates_id": { "emirates_id": {
"type": "object", "type": "object",
@ -2917,9 +2932,7 @@
], ],
"summary": "Verify Sponsor Email OTP", "summary": "Verify Sponsor Email OTP",
"description": "Verifies the email address with the OTP verification code (Step 2).", "description": "Verifies the email address with the OTP verification code (Step 2).",
"security": [ "security": [],
],
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@ -2964,9 +2977,7 @@
], ],
"summary": "Sponsor Subscription Payment", "summary": "Sponsor Subscription Payment",
"description": "Submits a successful plan selection and PayTabs payment confirmation (Step 4).", "description": "Submits a successful plan selection and PayTabs payment confirmation (Step 4).",
"security": [ "security": [],
],
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@ -3015,9 +3026,7 @@
], ],
"summary": "Create Sponsor Account Password", "summary": "Create Sponsor Account Password",
"description": "Configures and finalizes the portal login password to complete registration (Step 5). Returns bearer token.", "description": "Configures and finalizes the portal login password to complete registration (Step 5). Returns bearer token.",
"security": [ "security": [],
],
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@ -3068,9 +3077,7 @@
], ],
"summary": "Get Available Subscription Plans", "summary": "Get Available Subscription Plans",
"description": "Returns details (price, features) of subscription packages available to sponsors.", "description": "Returns details (price, features) of subscription packages available to sponsors.",
"security": [ "security": [],
],
"responses": { "responses": {
"200": { "200": {
"description": "Subscription plans list retrieved successfully." "description": "Subscription plans list retrieved successfully."
@ -3084,10 +3091,8 @@
"Employer/Auth" "Employer/Auth"
], ],
"summary": "Authenticate Employer or Sponsor", "summary": "Authenticate Employer or Sponsor",
"description": "Unified authentication endpoint. Validates email/mobile and password, then returns a stateless Bearer token and the user\u0027s role (employer or sponsor).", "description": "Unified authentication endpoint. Validates email/mobile and password, then returns a stateless Bearer token and the user's role (employer or sponsor).",
"security": [ "security": [],
],
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@ -3561,7 +3566,7 @@
"tags": [ "tags": [
"Employer/Profile" "Employer/Profile"
], ],
"summary": "Get Employer Dashboard stats \u0026 recent events", "summary": "Get Employer Dashboard stats & recent events",
"description": "Retrieves stats including contacted workers count, total hired workers, saved candidates, current plan details, and recent announcements or events.", "description": "Retrieves stats including contacted workers count, total hired workers, saved candidates, current plan details, and recent announcements or events.",
"responses": { "responses": {
"200": { "200": {
@ -3576,7 +3581,7 @@
"Employer/Profile" "Employer/Profile"
], ],
"summary": "Get Profile details (Employer)", "summary": "Get Profile details (Employer)",
"description": "Retrieves the authenticated employer\u0027s profile details including their selected company name, phone, language preference, and notification settings.", "description": "Retrieves the authenticated employer's profile details including their selected company name, phone, language preference, and notification settings.",
"responses": { "responses": {
"200": { "200": {
"description": "Employer profile details retrieved successfully." "description": "Employer profile details retrieved successfully."
@ -3868,7 +3873,7 @@
}, },
"photo": { "photo": {
"type": "string", "type": "string",
"example": "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format\u0026fit=crop\u0026q=80\u0026w=200" "example": "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=200"
}, },
"emirates_id_status": { "emirates_id_status": {
"type": "string", "type": "string",
@ -3989,7 +3994,7 @@
"Employer/Pipeline" "Employer/Pipeline"
], ],
"summary": "Get Candidate Applications and Offers", "summary": "Get Candidate Applications and Offers",
"description": "Returns all job applications and direct hiring offers connected to the authenticated sponsor account that have achieved \u0027Hired\u0027 status.", "description": "Returns all job applications and direct hiring offers connected to the authenticated sponsor account that have achieved 'Hired' status.",
"parameters": [ "parameters": [
{ {
"name": "search", "name": "search",
@ -4133,7 +4138,7 @@
"Employer/Pipeline" "Employer/Pipeline"
], ],
"summary": "Mark Candidate as Hired", "summary": "Mark Candidate as Hired",
"description": "Transitions the target candidate application, job offer, or helper status to \u0027Hired\u0027.", "description": "Transitions the target candidate application, job offer, or helper status to 'Hired'.",
"parameters": [ "parameters": [
{ {
"name": "id", "name": "id",
@ -4192,7 +4197,7 @@
"Employer/Pipeline" "Employer/Pipeline"
], ],
"summary": "Toggle Worker Shortlist Status", "summary": "Toggle Worker Shortlist Status",
"description": "Toggles (adds or removes) a worker to/from the authenticated sponsor\u0027s shortlist.", "description": "Toggles (adds or removes) a worker to/from the authenticated sponsor's shortlist.",
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@ -4371,7 +4376,7 @@
"profile_viewed": { "profile_viewed": {
"type": "integer", "type": "integer",
"example": 3, "example": 3,
"description": "Unique count of employers who viewed this worker\u0027s profile." "description": "Unique count of employers who viewed this worker's profile."
}, },
"currently_working_sponsor": { "currently_working_sponsor": {
"type": "object", "type": "object",
@ -4658,7 +4663,7 @@
"others": { "others": {
"type": "string", "type": "string",
"example": "Spam and malicious links", "example": "Spam and malicious links",
"description": "Custom reason string. Used only if reason is \u0027Others\u0027." "description": "Custom reason string. Used only if reason is 'Others'."
}, },
"description": { "description": {
"type": "string", "type": "string",
@ -4840,7 +4845,7 @@
"tags": [ "tags": [
"Employer/Support" "Employer/Support"
], ],
"summary": "Get Support Ticket Details \u0026 Replies (Employer)", "summary": "Get Support Ticket Details & Replies (Employer)",
"description": "Retrieves the details of a support ticket and its chronological reply history.", "description": "Retrieves the details of a support ticket and its chronological reply history.",
"parameters": [ "parameters": [
{ {
@ -4910,7 +4915,7 @@
"Workers - Auth" "Workers - Auth"
], ],
"summary": "Forgot Password (Send OTP)", "summary": "Forgot Password (Send OTP)",
"description": "Sends a 6-digit OTP to verify the worker\u0027s identity using their registered mobile number. Workers do not use email — mobile/phone is the primary identifier. OTP is valid for 10 minutes.", "description": "Sends a 6-digit OTP to verify the worker's identity using their registered mobile number. Workers do not use email — mobile/phone is the primary identifier. OTP is valid for 10 minutes.",
"operationId": "workerForgotPassword", "operationId": "workerForgotPassword",
"requestBody": { "requestBody": {
"required": true, "required": true,
@ -4925,7 +4930,7 @@
"phone": { "phone": {
"type": "string", "type": "string",
"example": "+971501234567", "example": "+971501234567",
"description": "Worker\u0027s registered mobile number" "description": "Worker's registered mobile number"
} }
} }
} }
@ -4965,7 +4970,7 @@
"Workers - Auth" "Workers - Auth"
], ],
"summary": "Reset Password (Verify OTP)", "summary": "Reset Password (Verify OTP)",
"description": "Verifies the OTP sent to the worker\u0027s mobile number and sets a new password. The phone number must match the one used in the forgot-password step.", "description": "Verifies the OTP sent to the worker's mobile number and sets a new password. The phone number must match the one used in the forgot-password step.",
"operationId": "workerResetPassword", "operationId": "workerResetPassword",
"requestBody": { "requestBody": {
"required": true, "required": true,
@ -4983,7 +4988,7 @@
"phone": { "phone": {
"type": "string", "type": "string",
"example": "+971501234567", "example": "+971501234567",
"description": "Worker\u0027s registered mobile number" "description": "Worker's registered mobile number"
}, },
"otp": { "otp": {
"type": "string", "type": "string",
@ -5024,7 +5029,7 @@
"Employers - Auth" "Employers - Auth"
], ],
"summary": "Forgot Password (Send OTP)", "summary": "Forgot Password (Send OTP)",
"description": "Sends a 6-digit OTP to the employer\u0027s registered email address. OTP expires in 10 minutes.", "description": "Sends a 6-digit OTP to the employer's registered email address. OTP expires in 10 minutes.",
"operationId": "employerForgotPassword", "operationId": "employerForgotPassword",
"requestBody": { "requestBody": {
"required": true, "required": true,
@ -5062,7 +5067,7 @@
"Employers - Auth" "Employers - Auth"
], ],
"summary": "Reset Password (Verify OTP)", "summary": "Reset Password (Verify OTP)",
"description": "Verifies the OTP sent to the employer\u0027s email and sets a new password.", "description": "Verifies the OTP sent to the employer's email and sets a new password.",
"operationId": "employerResetPassword", "operationId": "employerResetPassword",
"requestBody": { "requestBody": {
"required": true, "required": true,
@ -5115,7 +5120,7 @@
"Sponsors - Auth" "Sponsors - Auth"
], ],
"summary": "Forgot Password (Send OTP)", "summary": "Forgot Password (Send OTP)",
"description": "Sends a 6-digit OTP to the sponsor\u0027s registered email. Accepts email or mobile number as identifier. OTP expires in 10 minutes.", "description": "Sends a 6-digit OTP to the sponsor's registered email. Accepts email or mobile number as identifier. OTP expires in 10 minutes.",
"operationId": "sponsorForgotPassword", "operationId": "sponsorForgotPassword",
"requestBody": { "requestBody": {
"required": true, "required": true,
@ -5156,7 +5161,7 @@
"Sponsors - Auth" "Sponsors - Auth"
], ],
"summary": "Reset Password (Verify OTP)", "summary": "Reset Password (Verify OTP)",
"description": "Verifies the OTP sent to the sponsor\u0027s email and sets a new password. Use the email field to identify the account.", "description": "Verifies the OTP sent to the sponsor's email and sets a new password. Use the email field to identify the account.",
"operationId": "sponsorResetPassword", "operationId": "sponsorResetPassword",
"requestBody": { "requestBody": {
"required": true, "required": true,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

View File

@ -22,7 +22,8 @@ import {
Clock, Clock,
Ban, Ban,
FileEdit, FileEdit,
CheckSquare CheckSquare,
MapPin
} from 'lucide-react'; } from 'lucide-react';
import { import {
Table, Table,
@ -278,18 +279,23 @@ export default function WorkerManagement({ workers }) {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-gray-50/50 hover:bg-gray-50/50"> <TableRow className="bg-gray-50/50 hover:bg-gray-50/50">
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest py-4 pl-8">Worker Information</TableHead> <TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest py-4 pl-8">Worker</TableHead>
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest py-4">Placement Status</TableHead> <TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest py-4">Languages</TableHead>
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest py-4">Verification Status</TableHead> <TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest py-4">Nationality</TableHead>
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest py-4">Exp & Languages</TableHead> <TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest py-4">Preferred Location</TableHead>
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest py-4">Location Details</TableHead> <TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest py-4">Accommodation</TableHead>
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest py-4">Lifecycle</TableHead> <TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest py-4">Salary</TableHead>
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest py-4">Status</TableHead>
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest py-4 text-right pr-8">Actions</TableHead> <TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest py-4 text-right pr-8">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredWorkers.length > 0 ? ( {filteredWorkers.length > 0 ? (
filteredWorkers.map((worker) => ( filteredWorkers.map((worker) => {
const languages = worker.languages || (worker.language ? worker.language.split(',').map(l => l.trim()) : []);
const isLiveIn = worker.live_in_out?.toLowerCase().includes('in') || worker.live_in_out?.toLowerCase().includes('stay');
return (
<TableRow key={worker.id} className="group hover:bg-slate-50/50 transition-colors"> <TableRow key={worker.id} className="group hover:bg-slate-50/50 transition-colors">
<TableCell className="py-4 pl-8"> <TableCell className="py-4 pl-8">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -305,53 +311,73 @@ export default function WorkerManagement({ workers }) {
</Badge> </Badge>
)} )}
</div> </div>
<div className="text-xs text-gray-400 font-medium mt-0.5">{worker.phone}</div> <div className="text-[11px] text-gray-500 font-semibold flex items-center gap-1.5 mt-0.5">
<span className={`inline-block w-1.5 h-1.5 rounded-full ${worker.gender === 'Female' ? 'bg-pink-400' : 'bg-sky-400'}`}></span>
<span>{worker.gender || 'Female'}</span>
{worker.age && <span> {worker.age} Yrs</span>}
</div>
<div className="text-[11px] text-gray-400 font-medium flex items-center gap-1 mt-0.5">
<Phone className="w-3 h-3 text-slate-400" />
<span>{worker.phone}</span>
</div>
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell className="py-4">
<span className={`px-2.5 py-1 rounded-lg text-[10px] font-black uppercase tracking-wider ${worker.availability === 'Available Now' ? 'bg-emerald-50 text-emerald-600' : <div className="flex flex-wrap gap-1 max-w-[150px]">
worker.availability === 'In Interview' ? 'bg-blue-50 text-blue-600' : 'bg-slate-100 text-slate-500' {languages.length > 0 ? languages.map((lang, idx) => (
}`}> <span key={idx} className="bg-slate-100 text-slate-700 px-1.5 py-0.5 rounded text-[10px] font-semibold border border-slate-200/60">
{worker.availability} {lang}
</span>
</TableCell>
<TableCell>
<div className="flex items-center space-x-1.5">
<div className={`p-1 bg-${worker.verified ? 'emerald' : 'amber'}-50 rounded-lg`}>
<FileText className={`w-3.5 h-3.5 text-${worker.verified ? 'emerald' : 'amber'}-500`} />
</div>
<span className={`text-[10px] font-black uppercase tracking-widest text-${worker.verified ? 'emerald' : 'amber'}-600`}>
{worker.verified ? 'OCR Verified' : 'Pending Verification'}
</span> </span>
)) : (
<span className="text-slate-400 italic text-[11px]">English</span>
)}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell className="py-4">
<div className="space-y-0.5"> <div className="flex items-center gap-1.5 text-xs text-slate-700 font-bold">
<div className="text-xs font-bold text-slate-800 flex items-center gap-1.5"> <Globe className="w-3.5 h-3.5 text-slate-400" />
<Globe className="w-3.5 h-3.5 text-slate-400" /> {worker.nationality} {worker.experience} <span>{worker.nationality}</span>
</div>
<div className="text-[10px] text-slate-500 font-semibold flex items-center gap-1 mt-0.5">
<span className="text-slate-400 uppercase tracking-widest text-[8px] font-black">Lang:</span> {worker.language}
</div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell className="py-4">
<div className="space-y-0.5 text-xs"> <div className="text-xs space-y-0.5">
{worker.country ? ( {worker.preferred_location ? (
<> <div className="font-bold text-slate-800 flex items-center gap-1">
<div className="font-bold text-slate-800">{worker.city}, {worker.country}</div> <MapPin className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
<span>{worker.preferred_location}</span>
</div>
) : worker.city ? (
<div className="flex items-start gap-1">
<MapPin className="w-3.5 h-3.5 text-slate-400 flex-shrink-0 mt-0.5" />
<div>
<div className="font-bold text-slate-800">{worker.city}</div>
<div className="text-[10px] text-slate-400 font-bold">{worker.area}</div> <div className="text-[10px] text-slate-400 font-bold">{worker.area}</div>
</> </div>
</div>
) : ( ) : (
<span className="text-slate-400 italic">Not set</span> <span className="text-slate-400 italic">Not set</span>
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell className="py-4">
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold ${
isLiveIn ? 'bg-[#0F6E56]/10 text-[#0F6E56]' : 'bg-amber-100 text-amber-800'
}`}>
{isLiveIn ? 'Live-in' : 'Live-out'}
</span>
</TableCell>
<TableCell className="py-4">
<div className="text-xs font-bold text-slate-900">
{worker.salary ? `${worker.salary.toLocaleString()} AED` : 'N/A'}
<span className="text-[10px] text-gray-400 font-medium block">/ month</span>
</div>
</TableCell>
<TableCell className="py-4">
<Badge <Badge
variant="outline" variant="outline"
className={`px-3 py-0.5 rounded-full font-black text-[9px] uppercase tracking-wider ${worker.status === 'active' className={`px-2 py-0.5 rounded-full font-black text-[9px] uppercase tracking-wider block w-fit ${
worker.status === 'active'
? 'bg-emerald-50 text-emerald-700 border-emerald-200' ? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: worker.status === 'suspended' ? 'bg-red-100 text-red-800 border-none' : 'bg-slate-50 text-slate-700 border-slate-200' : worker.status === 'suspended' ? 'bg-red-100 text-red-800 border-none' : 'bg-slate-50 text-slate-700 border-slate-200'
}`} }`}
@ -359,7 +385,7 @@ export default function WorkerManagement({ workers }) {
{worker.status} {worker.status}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right pr-8"> <TableCell className="text-right pr-8 py-4">
<div className="flex items-center justify-end space-x-1"> <div className="flex items-center justify-end space-x-1">
<button <button
onClick={() => openWorkerDetails(worker)} onClick={() => openWorkerDetails(worker)}
@ -391,10 +417,11 @@ export default function WorkerManagement({ workers }) {
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) );
})
) : ( ) : (
<TableRow> <TableRow>
<TableCell colSpan={7} className="py-20 text-center"> <TableCell colSpan={8} className="py-20 text-center">
<Users className="w-12 h-12 text-slate-200 mx-auto mb-3" /> <Users className="w-12 h-12 text-slate-200 mx-auto mb-3" />
<div className="font-bold text-slate-400">No workers found matching your criteria.</div> <div className="font-bold text-slate-400">No workers found matching your criteria.</div>
</TableCell> </TableCell>

View File

@ -216,7 +216,6 @@ public function test_s2_update_experience_and_preferences()
])->postJson('/api/workers/profile/update', [ ])->postJson('/api/workers/profile/update', [
'experience' => '3 Years Cooking & Childcare', 'experience' => '3 Years Cooking & Childcare',
'salary' => 1800, 'salary' => 1800,
'bio' => 'Extremely experienced in Arabic and Western cooking.',
]); ]);
$response->assertStatus(200); $response->assertStatus(200);
@ -633,9 +632,6 @@ public function test_update_profile_with_new_api_fields()
'gender' => 'female', 'gender' => 'female',
'live_in_out' => 'live_in', 'live_in_out' => 'live_in',
'preferred_location' => 'Abu Dhabi', 'preferred_location' => 'Abu Dhabi',
'country' => 'UAE',
'city' => 'Abu Dhabi',
'area' => 'Khalifa City',
]); ]);
$response->assertStatus(200); $response->assertStatus(200);
@ -648,9 +644,6 @@ public function test_update_profile_with_new_api_fields()
'gender' => 'female', 'gender' => 'female',
'live_in_out' => 'live_in', 'live_in_out' => 'live_in',
'preferred_location' => 'Abu Dhabi', 'preferred_location' => 'Abu Dhabi',
'country' => 'UAE',
'city' => 'Abu Dhabi',
'area' => 'Khalifa City',
]); ]);
} }
@ -1234,4 +1227,208 @@ public function test_register_validation_fails_if_passport_number_already_exists
$response->json('errors')['passport.passport_number'][0] $response->json('errors')['passport.passport_number'][0]
); );
} }
/**
* Test profile update fails if updating a passport document with a different passport number.
*/
public function test_update_profile_fails_if_passport_number_does_not_match_existing()
{
$worker = Worker::create([
'name' => 'Test Worker',
'email' => 'test@example.com',
'phone' => '+971503333333',
'language' => 'HI',
'password' => bcrypt('password'),
'nationality' => 'Indian',
'age' => 25,
'salary' => 1500,
'availability' => 'Immediate',
'experience' => 'None',
'religion' => 'Not Specified',
'bio' => 'New helper',
'verified' => false,
'status' => 'active',
'api_token' => 'bearer-test-token-passport-mismatch',
]);
$worker->documents()->create([
'type' => 'passport',
'number' => 'ABC123456',
'issue_date' => '2020-01-01',
'expiry_date' => '2030-01-01',
'ocr_accuracy' => 95.0,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer bearer-test-token-passport-mismatch',
])->postJson('/api/workers/profile/update', [
'passport' => [
'passport_number' => 'XYZ987654', // Different passport number
]
]);
$response->assertStatus(422)
->assertJsonStructure([
'success',
'message',
'errors' => [
'passport.passport_number'
]
]);
$this->assertEquals(
'The passport number cannot be changed. It must match the previously verified passport number.',
$response->json('errors')['passport.passport_number'][0]
);
}
/**
* Test profile update succeeds if passport number matches existing.
*/
public function test_update_profile_succeeds_if_passport_number_matches_existing()
{
$worker = Worker::create([
'name' => 'Test Worker',
'email' => 'test2@example.com',
'phone' => '+971503333334',
'language' => 'HI',
'password' => bcrypt('password'),
'nationality' => 'Indian',
'age' => 25,
'salary' => 1500,
'availability' => 'Immediate',
'experience' => 'None',
'religion' => 'Not Specified',
'bio' => 'New helper',
'verified' => false,
'status' => 'active',
'api_token' => 'bearer-test-token-passport-match',
]);
$worker->documents()->create([
'type' => 'passport',
'number' => 'ABC123456',
'issue_date' => '2020-01-01',
'expiry_date' => '2030-01-01',
'ocr_accuracy' => 95.0,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer bearer-test-token-passport-match',
])->postJson('/api/workers/profile/update', [
'passport' => [
'passport_number' => 'ABC123456', // Same passport number
'date_of_expiry' => '2032-01-01', // Updating expiry date
]
]);
$response->assertStatus(200);
$this->assertDatabaseHas('worker_documents', [
'worker_id' => $worker->id,
'type' => 'passport',
'number' => 'ABC123456',
'expiry_date' => '2032-01-01',
]);
}
/**
* Test unique mobile number validation on profile update.
*/
public function test_update_profile_fails_if_phone_already_exists()
{
// 1. Create original worker with phone +971504444444
Worker::create([
'name' => 'Other Worker',
'email' => 'other@example.com',
'phone' => '+971504444444',
'language' => 'HI',
'password' => bcrypt('password'),
'nationality' => 'Indian',
'age' => 25,
'salary' => 1500,
'availability' => 'Immediate',
'experience' => 'None',
'religion' => 'Not Specified',
'bio' => 'New helper',
'verified' => false,
'status' => 'active',
]);
// 2. Create current worker with phone +971505555555
$worker = Worker::create([
'name' => 'Current Worker',
'email' => 'current@example.com',
'phone' => '+971505555555',
'language' => 'HI',
'password' => bcrypt('password'),
'nationality' => 'Indian',
'age' => 25,
'salary' => 1500,
'availability' => 'Immediate',
'experience' => 'None',
'religion' => 'Not Specified',
'bio' => 'New helper',
'verified' => false,
'status' => 'active',
'api_token' => 'bearer-test-token-phone-unique',
]);
// 3. Attempt to update current worker's phone to the other worker's phone
$response = $this->withHeaders([
'Authorization' => 'Bearer bearer-test-token-phone-unique',
])->postJson('/api/workers/profile/update', [
'phone' => '+971504444444',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['phone']);
$this->assertEquals(
'This mobile number is already registered.',
$response->json('errors')['phone'][0]
);
}
/**
* Test deprecated fields are hidden from JSON response.
*/
public function test_profile_response_does_not_contain_deprecated_fields()
{
$worker = Worker::create([
'name' => 'Test Worker',
'email' => 'test_dep@example.com',
'phone' => '+971503333335',
'language' => 'HI',
'password' => bcrypt('password'),
'nationality' => 'Indian',
'age' => 25,
'salary' => 1500,
'availability' => 'Immediate',
'experience' => 'None',
'religion' => 'Christian',
'bio' => 'Some Bio',
'country' => 'UAE',
'city' => 'Dubai',
'area' => 'JLT',
'verified' => false,
'status' => 'active',
'api_token' => 'bearer-test-token-deprecated-fields',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer bearer-test-token-deprecated-fields',
])->getJson('/api/workers/profile');
$response->assertStatus(200);
$json = $response->json('data.worker');
$this->assertArrayNotHasKey('email', $json);
$this->assertArrayNotHasKey('religion', $json);
$this->assertArrayNotHasKey('availability', $json);
$this->assertArrayNotHasKey('bio', $json);
$this->assertArrayNotHasKey('country', $json);
$this->assertArrayNotHasKey('city', $json);
$this->assertArrayNotHasKey('area', $json);
}
} }

View File

@ -23,7 +23,7 @@ export default defineConfig({
port: 5173, port: 5173,
cors: true, cors: true,
hmr: { hmr: {
host: '192.168.29.192', host: '192.168.29.131',
}, },
watch: { watch: {
ignored: ['**/storage/framework/views/**'], ignored: ['**/storage/framework/views/**'],