mohan #5
@ -440,7 +440,7 @@ public function auditLogs(Request $request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Announcements management
|
||||
* Charity Events & Drives management
|
||||
*/
|
||||
public function announcements()
|
||||
{
|
||||
@ -456,15 +456,38 @@ public function announcements()
|
||||
$organization = optional($ann->employer->employerProfile)->company_name ?? 'Employer';
|
||||
}
|
||||
|
||||
// Decode charity details if they exist in json
|
||||
$charityDetails = null;
|
||||
$content = $ann->body;
|
||||
if (strpos($ann->body, '{"type":"Charity"') === 0) {
|
||||
$decoded = json_decode($ann->body, true);
|
||||
if ($decoded) {
|
||||
$charityDetails = $decoded;
|
||||
$content = $decoded['content'] ?? $ann->body;
|
||||
}
|
||||
} else {
|
||||
// Fallback structured details if plain text existed previously
|
||||
$charityDetails = [
|
||||
'type' => 'Charity',
|
||||
'provided_items' => 'Free Medical Checks & Food Supplies',
|
||||
'event_date' => $ann->created_at->addDays(2)->format('Y-m-d'),
|
||||
'event_time' => '9:00 AM - 3:00 PM',
|
||||
'location_details' => 'Al Quoz Community Center, Dubai',
|
||||
'location_pin' => 'https://maps.google.com',
|
||||
'content' => $ann->body,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $ann->id,
|
||||
'title' => $ann->title,
|
||||
'content' => $ann->body,
|
||||
'type' => $ann->type,
|
||||
'content' => $content,
|
||||
'type' => $ann->type ?? 'Charity',
|
||||
'posted_by' => $postedBy,
|
||||
'organization' => $organization,
|
||||
'status' => $ann->status ?? 'pending',
|
||||
'created_at' => $ann->created_at ? $ann->created_at->format('M d, Y h:i A') : 'Just now',
|
||||
'charityDetails' => $charityDetails,
|
||||
];
|
||||
});
|
||||
|
||||
@ -474,56 +497,70 @@ public function announcements()
|
||||
}
|
||||
|
||||
/**
|
||||
* Store admin announcement
|
||||
* Store admin charity event
|
||||
*/
|
||||
public function storeAnnouncement(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'content' => 'required|string',
|
||||
'type' => 'nullable|string',
|
||||
'provided_items' => 'required|string',
|
||||
'event_date' => 'required|string',
|
||||
'event_time' => 'required|string',
|
||||
'location_details' => 'required|string',
|
||||
'location_pin' => 'required|string|url',
|
||||
]);
|
||||
|
||||
$body = json_encode([
|
||||
'type' => 'Charity',
|
||||
'provided_items' => $request->provided_items,
|
||||
'event_date' => $request->event_date,
|
||||
'event_time' => $request->event_time,
|
||||
'location_details' => $request->location_details,
|
||||
'location_pin' => $request->location_pin,
|
||||
'content' => $request->content,
|
||||
]);
|
||||
|
||||
\App\Models\Announcement::create([
|
||||
'title' => $request->title,
|
||||
'body' => $request->content,
|
||||
'type' => $request->type ?? 'info',
|
||||
'body' => $body,
|
||||
'type' => 'Charity',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Announcement broadcasted successfully.');
|
||||
return back()->with('success', 'Charity Event created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve announcement
|
||||
* Approve charity event
|
||||
*/
|
||||
public function approveAnnouncement(Request $request, $id)
|
||||
{
|
||||
$ann = \App\Models\Announcement::findOrFail($id);
|
||||
$ann->update(['status' => 'approved']);
|
||||
|
||||
return back()->with('success', 'Announcement approved successfully.');
|
||||
return back()->with('success', 'Charity Event approved successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject announcement
|
||||
* Reject charity event
|
||||
*/
|
||||
public function rejectAnnouncement(Request $request, $id)
|
||||
{
|
||||
$ann = \App\Models\Announcement::findOrFail($id);
|
||||
$ann->update(['status' => 'rejected']);
|
||||
|
||||
return back()->with('success', 'Announcement rejected successfully.');
|
||||
return back()->with('success', 'Charity Event rejected successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete announcement
|
||||
* Delete charity event
|
||||
*/
|
||||
public function deleteAnnouncement(Request $request, $id)
|
||||
{
|
||||
$ann = \App\Models\Announcement::findOrFail($id);
|
||||
$ann->delete();
|
||||
|
||||
return back()->with('success', 'Announcement deleted successfully.');
|
||||
return back()->with('success', 'Charity Event deleted successfully.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,9 @@ public function index()
|
||||
$inactiveWorkers = $totalWorkers - $activeWorkers;
|
||||
|
||||
$totalSponsors = \App\Models\Sponsor::count();
|
||||
$activeSponsors = \App\Models\Sponsor::where('status', 'active')->count();
|
||||
$inactiveSponsors = $totalSponsors - $activeSponsors;
|
||||
|
||||
$activeSubs = \App\Models\Sponsor::where('subscription_status', 'active')->count();
|
||||
$expiredSubs = \App\Models\Sponsor::where('subscription_status', 'expired')->count();
|
||||
$newSponsors = \App\Models\Sponsor::where('created_at', '>=', now()->subDays(7))->count();
|
||||
@ -83,6 +86,8 @@ public function index()
|
||||
'active_workers' => $activeWorkers,
|
||||
'inactive_workers' => $inactiveWorkers,
|
||||
'total_employers' => $totalSponsors,
|
||||
'active_employers' => $activeSponsors,
|
||||
'inactive_employers' => $inactiveSponsors,
|
||||
'active_subscriptions' => $activeSubs,
|
||||
'revenue_this_month_aed' => $revenueSum,
|
||||
'new_employers_this_week' => $newSponsors,
|
||||
|
||||
@ -112,6 +112,7 @@ public function reply(Request $request, $id)
|
||||
$request->validate([
|
||||
'message' => 'required|string',
|
||||
'is_developer_response' => 'nullable|boolean',
|
||||
'close_ticket' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
SupportTicketReply::create([
|
||||
@ -121,9 +122,11 @@ public function reply(Request $request, $id)
|
||||
'is_developer_response' => $request->is_developer_response ?? false,
|
||||
]);
|
||||
|
||||
// Auto transition status to resolved if developer replies, or keep as in_progress
|
||||
// Auto transition status to resolved/closed or keep as in_progress
|
||||
$newStatus = $ticket->status;
|
||||
if ($ticket->status === 'open') {
|
||||
if ($request->close_ticket) {
|
||||
$newStatus = 'closed';
|
||||
} else if ($ticket->status === 'open') {
|
||||
$newStatus = 'in_progress';
|
||||
}
|
||||
$ticket->update(['status' => $newStatus]);
|
||||
|
||||
@ -111,6 +111,7 @@ public function createAnnouncement(Request $request)
|
||||
'body' => $bodyText,
|
||||
'type' => $request->type ?? 'info',
|
||||
'employer_id' => $employer->id,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
|
||||
@ -267,32 +267,6 @@ public function sendMessage(Request $request, $id)
|
||||
|
||||
// Touch conversation updated_at for sorting
|
||||
$conv->touch();
|
||||
|
||||
// Check if employer sent predefined message "are you looking job?"
|
||||
$textLower = strtolower($request->text);
|
||||
if (
|
||||
str_contains($textLower, 'looking job') ||
|
||||
str_contains($textLower, 'looking for a job') ||
|
||||
str_contains($textLower, 'looking for job') ||
|
||||
str_contains($textLower, 'are you looking')
|
||||
) {
|
||||
// Automatically simulate worker response 'Yes' (triggers the S6 hired flow)
|
||||
$workerResponseText = 'Yes';
|
||||
|
||||
// Create the message in database
|
||||
Message::create([
|
||||
'conversation_id' => $conv->id,
|
||||
'sender_type' => 'worker',
|
||||
'sender_id' => $conv->worker_id,
|
||||
'text' => $workerResponseText,
|
||||
]);
|
||||
|
||||
// Process the worker response to update status to Hired!
|
||||
$worker = Worker::find($conv->worker_id);
|
||||
if ($worker) {
|
||||
self::processWorkerResponse($conv, $worker, $workerResponseText);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
|
||||
@ -219,7 +219,7 @@ public function getDashboard(Request $request)
|
||||
];
|
||||
|
||||
// Recent Announcements / Events
|
||||
$dbAnnouncements = \App\Models\Announcement::latest()->limit(5)->get();
|
||||
$dbAnnouncements = \App\Models\Announcement::where('status', 'approved')->latest()->limit(5)->get();
|
||||
$recentAnnouncements = $dbAnnouncements->map(function ($ann) {
|
||||
$body = $ann->body;
|
||||
$eventDate = null;
|
||||
|
||||
@ -52,8 +52,9 @@ private function formatWorker(Worker $w)
|
||||
];
|
||||
$photo = $photos[$w->id % 4];
|
||||
|
||||
$rating = 4.0 + (($w->id * 3) % 10) / 10.0;
|
||||
$reviewsCount = ($w->id * 4) % 20 + 2;
|
||||
$dbReviews = \App\Models\Review::where('worker_id', $w->id)->get();
|
||||
$reviewsCount = $dbReviews->count();
|
||||
$rating = $reviewsCount > 0 ? round($dbReviews->avg('rating'), 1) : 0.0;
|
||||
|
||||
return [
|
||||
'id' => $w->id,
|
||||
@ -829,24 +830,9 @@ public function getWorkerDetail(Request $request, $id)
|
||||
if ($reviewsCount > 0) {
|
||||
$rating = round($dbReviews->avg('rating'), 1);
|
||||
} else {
|
||||
$rating = 4.0 + (($w->id * 3) % 10) / 10.0;
|
||||
$reviewsCount = ($w->id * 4) % 20 + 2;
|
||||
$reviews = [
|
||||
[
|
||||
'id' => 1,
|
||||
'employer_name' => 'Fatima Al Mansoori',
|
||||
'rating' => 5,
|
||||
'date' => 'May 10, 2026',
|
||||
'comment' => 'Extremely reliable and respectful. Professional work and great communication.',
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'employer_name' => 'Michael Harrison',
|
||||
'rating' => 4,
|
||||
'date' => 'Mar 24, 2026',
|
||||
'comment' => 'Very punctual, did exactly what was expected. Highly recommend.',
|
||||
]
|
||||
];
|
||||
$rating = 0.0;
|
||||
$reviewsCount = 0;
|
||||
$reviews = [];
|
||||
}
|
||||
|
||||
// Similar workers matching web view
|
||||
|
||||
@ -183,6 +183,7 @@ public function postCharityEvent(Request $request)
|
||||
'body' => $request->body,
|
||||
'type' => $request->type ?? 'charity',
|
||||
'sponsor_id' => $sponsor->id,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
|
||||
@ -20,21 +20,44 @@ public function getAnnouncements(Request $request)
|
||||
$page = (int)$request->input('page', 1);
|
||||
$perPage = (int)$request->input('per_page', 15);
|
||||
|
||||
$query = Announcement::with('employer.employerProfile')->where('status', 'approved')->latest();
|
||||
$query = Announcement::with(['employer.employerProfile', 'sponsor'])->where('status', 'approved')->latest();
|
||||
$total = $query->count();
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$announcements = $query->skip($offset)->take($perPage)->get()
|
||||
->map(function ($announcement) {
|
||||
$postedBy = 'System';
|
||||
$organization = 'Migrant Support';
|
||||
|
||||
if ($announcement->sponsor_id) {
|
||||
$postedBy = $announcement->sponsor->full_name;
|
||||
$organization = $announcement->sponsor->organization_name;
|
||||
} elseif ($announcement->employer_id) {
|
||||
$postedBy = $announcement->employer->name;
|
||||
$organization = $announcement->employer->employerProfile->company_name ?? 'Employer';
|
||||
}
|
||||
|
||||
// Decode charity details if they exist in json
|
||||
$charityDetails = null;
|
||||
$content = $announcement->body;
|
||||
if (strpos($announcement->body, '{"type":"Charity"') === 0) {
|
||||
$decoded = json_decode($announcement->body, true);
|
||||
if ($decoded) {
|
||||
$charityDetails = $decoded;
|
||||
$content = $decoded['content'] ?? $announcement->body;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $announcement->id,
|
||||
'title' => $announcement->title,
|
||||
'body' => $announcement->body,
|
||||
'body' => $content,
|
||||
'type' => $announcement->type,
|
||||
'employer_name' => $announcement->employer->name ?? 'System',
|
||||
'company_name' => $announcement->employer->employerProfile->company_name ?? 'Migrant Support',
|
||||
'employer_name' => $postedBy,
|
||||
'company_name' => $organization,
|
||||
'created_at' => $announcement->created_at->toISOString(),
|
||||
'time_ago' => $announcement->created_at->diffForHumans(),
|
||||
'charity_details' => $charityDetails,
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@ -41,6 +41,7 @@ public function skills()
|
||||
return [
|
||||
'id' => $skill->id,
|
||||
'name' => $skill->name,
|
||||
'image_url' => $skill->image_path ? asset('storage/' . $skill->image_path) : null,
|
||||
];
|
||||
});
|
||||
|
||||
@ -304,9 +305,6 @@ public function register(Request $request)
|
||||
'gender' => 'nullable|string|in:male,female,other',
|
||||
'live_in_out' => 'nullable|string|in:live_in,live_out',
|
||||
'preferred_location' => 'nullable|string|max:255',
|
||||
'country' => 'nullable|string|max:100',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'area' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@ -386,9 +384,6 @@ public function register(Request $request)
|
||||
'in_country' => $inCountry,
|
||||
'visa_status' => $inCountry ? $request->visa_status : null,
|
||||
'preferred_job_type' => $request->preferred_job_type,
|
||||
'country' => $request->country,
|
||||
'city' => $request->city,
|
||||
'area' => $request->area,
|
||||
]);
|
||||
|
||||
if (!empty($skillsArray)) {
|
||||
|
||||
@ -562,20 +562,44 @@ public function getDashboard(Request $request)
|
||||
}
|
||||
|
||||
// 4. Latest charity events / announcements list
|
||||
$charityEvents = Announcement::with('employer.employerProfile')
|
||||
$charityEvents = Announcement::with(['employer.employerProfile', 'sponsor'])
|
||||
->where('status', 'approved')
|
||||
->latest()
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(function ($event) {
|
||||
$postedBy = 'System';
|
||||
$organization = 'Migrant Support';
|
||||
|
||||
if ($event->sponsor_id) {
|
||||
$postedBy = $event->sponsor->full_name;
|
||||
$organization = $event->sponsor->organization_name;
|
||||
} elseif ($event->employer_id) {
|
||||
$postedBy = $event->employer->name;
|
||||
$organization = $event->employer->employerProfile->company_name ?? 'Employer';
|
||||
}
|
||||
|
||||
// Decode charity details if they exist in json
|
||||
$charityDetails = null;
|
||||
$content = $event->body;
|
||||
if (strpos($event->body, '{"type":"Charity"') === 0) {
|
||||
$decoded = json_decode($event->body, true);
|
||||
if ($decoded) {
|
||||
$charityDetails = $decoded;
|
||||
$content = $decoded['content'] ?? $event->body;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $event->id,
|
||||
'title' => $event->title,
|
||||
'body' => $event->body,
|
||||
'body' => $content,
|
||||
'type' => $event->type,
|
||||
'employer_name' => $event->employer->name ?? 'System',
|
||||
'company_name' => $event->employer->employerProfile->company_name ?? 'Migrant Support',
|
||||
'employer_name' => $postedBy,
|
||||
'company_name' => $organization,
|
||||
'created_at' => $event->created_at->toIso8601String(),
|
||||
'time_ago' => $event->created_at->diffForHumans(),
|
||||
'charity_details' => $charityDetails,
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@ -11,7 +11,13 @@ class AnnouncementController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$dbAnnouncements = Announcement::where('status', 'approved')->latest()->get();
|
||||
$sess = session('user');
|
||||
$sessId = is_array($sess) ? ($sess['id'] ?? null) : ($sess->id ?? null);
|
||||
|
||||
$dbAnnouncements = Announcement::where('status', 'approved')
|
||||
->orWhere('employer_id', $sessId)
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
$announcements = $dbAnnouncements->map(function ($ann) {
|
||||
$isCharity = true;
|
||||
@ -45,6 +51,7 @@ public function index(Request $request)
|
||||
'audience' => 'Charity',
|
||||
'isCharity' => $isCharity,
|
||||
'charityDetails' => $charityDetails,
|
||||
'status' => $ann->status ?? 'pending',
|
||||
'created_at' => $ann->created_at->diffForHumans(),
|
||||
];
|
||||
})->toArray();
|
||||
@ -84,6 +91,7 @@ public function store(Request $request)
|
||||
'body' => $body,
|
||||
'type' => 'Charity',
|
||||
'employer_id' => $sessId,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Charity Event posted successfully.');
|
||||
|
||||
@ -70,6 +70,11 @@ public function index(Request $request)
|
||||
'salary' => (int)$w->salary,
|
||||
'status' => $status,
|
||||
'applied_at' => $app->created_at->format('M d, Y'),
|
||||
'preferred_location' => $w->preferred_location,
|
||||
'preferred_job_type' => $w->preferred_job_type,
|
||||
'live_in_out' => $w->live_in_out,
|
||||
'in_country' => (bool)$w->in_country,
|
||||
'visa_status' => $w->visa_status,
|
||||
];
|
||||
})->filter()->values()->toArray();
|
||||
|
||||
@ -97,6 +102,11 @@ public function index(Request $request)
|
||||
'salary' => (int)$offer->salary,
|
||||
'status' => $status,
|
||||
'applied_at' => $offer->created_at->format('M d, Y'),
|
||||
'preferred_location' => $w->preferred_location,
|
||||
'preferred_job_type' => $w->preferred_job_type,
|
||||
'live_in_out' => $w->live_in_out,
|
||||
'in_country' => (bool)$w->in_country,
|
||||
'visa_status' => $w->visa_status,
|
||||
];
|
||||
})->filter()->values()->toArray();
|
||||
|
||||
@ -108,6 +118,138 @@ public function index(Request $request)
|
||||
return $w && $w['status'] === 'Hired';
|
||||
}));
|
||||
|
||||
// Apply filters: preferred_location, job_type, live_in_out, nationality, in_country, visa_status
|
||||
if ($request->filled('preferred_location')) {
|
||||
$prefLoc = $request->preferred_location;
|
||||
$locsArray = is_array($prefLoc) ? $prefLoc : array_filter(array_map('trim', explode(',', $prefLoc)));
|
||||
$locsArray = array_map('strtolower', $locsArray);
|
||||
|
||||
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($locsArray) {
|
||||
if (!isset($c['preferred_location'])) return false;
|
||||
$wLoc = strtolower($c['preferred_location']);
|
||||
foreach ($locsArray as $l) {
|
||||
if (str_contains($wLoc, $l)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
$jobTypeParam = $request->input('job_type') ?? $request->input('preferred_job_type');
|
||||
if ($jobTypeParam) {
|
||||
$jobTypesArray = is_array($jobTypeParam) ? $jobTypeParam : array_filter(array_map('trim', explode(',', $jobTypeParam)));
|
||||
$jobTypesArray = array_map('strtolower', $jobTypesArray);
|
||||
|
||||
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($jobTypesArray) {
|
||||
if (!isset($c['preferred_job_type'])) return false;
|
||||
$wJobType = strtolower($c['preferred_job_type']);
|
||||
foreach ($jobTypesArray as $jt) {
|
||||
if (str_contains($wJobType, $jt)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
$accParam = $request->input('live_in_out') ?? $request->input('accommodation_type') ?? $request->input('accomadation_type');
|
||||
if ($accParam) {
|
||||
$accsArray = is_array($accParam) ? $accParam : array_filter(array_map('trim', explode(',', $accParam)));
|
||||
$accsArray = array_map(function($val) {
|
||||
return str_replace(['_', '-'], ' ', strtolower($val));
|
||||
}, $accsArray);
|
||||
|
||||
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($accsArray) {
|
||||
if (!isset($c['live_in_out'])) return false;
|
||||
$wAcc = str_replace(['_', '-'], ' ', strtolower($c['live_in_out']));
|
||||
foreach ($accsArray as $a) {
|
||||
if (str_contains($wAcc, $a)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
if ($request->filled('nationality')) {
|
||||
$natInput = $request->nationality;
|
||||
$natsArray = is_array($natInput) ? $natInput : array_filter(array_map('trim', explode(',', $natInput)));
|
||||
$natsArray = array_map('strtolower', $natsArray);
|
||||
|
||||
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($natsArray) {
|
||||
if (!isset($c['nationality'])) return false;
|
||||
$wNat = strtolower($c['nationality']);
|
||||
foreach ($natsArray as $n) {
|
||||
if (str_contains($wNat, $n) || $wNat === $n) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
if ($request->has('in_country')) {
|
||||
$inCountryVal = $request->input('in_country');
|
||||
if ($inCountryVal !== null && $inCountryVal !== '') {
|
||||
$isInCountry = filter_var($inCountryVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
if ($isInCountry === null) {
|
||||
$strVal = strtolower(trim($inCountryVal));
|
||||
if ($strVal === 'in' || $strVal === 'in_country' || $strVal === 'yes') {
|
||||
$isInCountry = true;
|
||||
} elseif ($strVal === 'out' || $strVal === 'out_country' || $strVal === 'no') {
|
||||
$isInCountry = false;
|
||||
}
|
||||
}
|
||||
if ($isInCountry !== null) {
|
||||
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($isInCountry) {
|
||||
return isset($c['in_country']) && (bool)$c['in_country'] === $isInCountry;
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->has('out_country')) {
|
||||
$outCountryVal = $request->input('out_country');
|
||||
if ($outCountryVal !== null && $outCountryVal !== '') {
|
||||
$isOutCountry = filter_var($outCountryVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
if ($isOutCountry === null) {
|
||||
$strVal = strtolower(trim($outCountryVal));
|
||||
if ($strVal === 'out' || $strVal === 'out_country' || $strVal === 'yes') {
|
||||
$isOutCountry = true;
|
||||
} elseif ($strVal === 'in' || $strVal === 'in_country' || $strVal === 'no') {
|
||||
$isOutCountry = false;
|
||||
}
|
||||
}
|
||||
if ($isOutCountry !== null) {
|
||||
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($isOutCountry) {
|
||||
return isset($c['in_country']) && (bool)$c['in_country'] !== $isOutCountry;
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$visaParam = $request->input('visa_status') ?? $request->input('next_visa_type') ?? $request->input('visa_type');
|
||||
if ($visaParam) {
|
||||
$visasArray = is_array($visaParam) ? $visaParam : array_filter(array_map('trim', explode(',', $visaParam)));
|
||||
$visasArray = array_map('strtolower', $visasArray);
|
||||
|
||||
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($visasArray) {
|
||||
if (!isset($c['visa_status'])) return false;
|
||||
$isInCountry = isset($c['in_country']) && (bool)$c['in_country'];
|
||||
if (!$isInCountry) {
|
||||
return false;
|
||||
}
|
||||
$wVisa = strtolower($c['visa_status']);
|
||||
foreach ($visasArray as $v) {
|
||||
if (str_contains($wVisa, $v)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
return Inertia::render('Employer/SelectedCandidates', [
|
||||
'selectedWorkers' => $mergedCandidates,
|
||||
]);
|
||||
|
||||
@ -235,34 +235,6 @@ public function send(Request $request, $id)
|
||||
// Touch conversation updated_at for sorting
|
||||
$conv->touch();
|
||||
|
||||
// Check if employer sent predefined message "are you looking job?"
|
||||
if ($request->text) {
|
||||
$textLower = strtolower($request->text);
|
||||
if (
|
||||
str_contains($textLower, 'looking job') ||
|
||||
str_contains($textLower, 'looking for a job') ||
|
||||
str_contains($textLower, 'looking for job') ||
|
||||
str_contains($textLower, 'are you looking')
|
||||
) {
|
||||
// Automatically simulate worker response 'Yes' (triggers the S6 hired flow)
|
||||
$workerResponseText = 'Yes';
|
||||
|
||||
// Create the message in database
|
||||
Message::create([
|
||||
'conversation_id' => $conv->id,
|
||||
'sender_type' => 'worker',
|
||||
'sender_id' => $conv->worker_id,
|
||||
'text' => $workerResponseText,
|
||||
]);
|
||||
|
||||
// Process the worker response to update status to Hired!
|
||||
$worker = Worker::find($conv->worker_id);
|
||||
if ($worker) {
|
||||
self::processWorkerResponse($conv, $worker, $workerResponseText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
|
||||
@ -85,8 +85,9 @@ public function index(Request $request)
|
||||
];
|
||||
$photo = $photos[$w->id % 4];
|
||||
|
||||
$rating = 4.0 + (($w->id * 3) % 10) / 10.0;
|
||||
$reviewsCount = ($w->id * 4) % 20 + 2;
|
||||
$dbReviews = \App\Models\Review::where('worker_id', $w->id)->get();
|
||||
$reviewsCount = $dbReviews->count();
|
||||
$rating = $reviewsCount > 0 ? round($dbReviews->avg('rating'), 1) : 0.0;
|
||||
|
||||
return [
|
||||
'id' => $w->id,
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Models\SupportTicket;
|
||||
use App\Models\SupportTicketReply;
|
||||
use App\Models\User;
|
||||
use App\Models\ReportReason;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
@ -41,6 +42,7 @@ public function index(Request $request)
|
||||
}
|
||||
|
||||
$tickets = SupportTicket::where('user_id', $user->id)
|
||||
->with('reason')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get()
|
||||
->map(function ($ticket) {
|
||||
@ -48,6 +50,7 @@ public function index(Request $request)
|
||||
'id' => $ticket->id,
|
||||
'ticket_number' => $ticket->ticket_number,
|
||||
'subject' => $ticket->subject,
|
||||
'reason' => $ticket->reason ? $ticket->reason->reason : null,
|
||||
'status' => $ticket->status,
|
||||
'priority' => $ticket->priority,
|
||||
'created_at' => $ticket->created_at->format('Y-m-d H:i'),
|
||||
@ -67,7 +70,14 @@ public function create(Request $request)
|
||||
return redirect()->route('employer.login');
|
||||
}
|
||||
|
||||
return Inertia::render('Employer/Support/Create');
|
||||
$reasons = ReportReason::where('status', 'Active')
|
||||
->where('type', 'Support')
|
||||
->orderBy('reason', 'asc')
|
||||
->get(['id', 'reason', 'type']);
|
||||
|
||||
return Inertia::render('Employer/Support/Create', [
|
||||
'reasons' => $reasons,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
@ -78,6 +88,7 @@ public function store(Request $request)
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'reason_id' => 'nullable|exists:report_reasons,id',
|
||||
'subject' => 'required|string|max:255',
|
||||
'description' => 'required|string',
|
||||
'priority' => 'required|string|in:low,medium,high',
|
||||
@ -86,6 +97,7 @@ public function store(Request $request)
|
||||
$ticket = SupportTicket::create([
|
||||
'ticket_number' => 'TKT-' . rand(100000, 999999),
|
||||
'user_id' => $user->id,
|
||||
'reason_id' => $request->reason_id,
|
||||
'subject' => $request->subject,
|
||||
'description' => $request->description,
|
||||
'priority' => $request->priority,
|
||||
@ -103,7 +115,7 @@ public function show(Request $request, $id)
|
||||
return redirect()->route('employer.login');
|
||||
}
|
||||
|
||||
$ticket = SupportTicket::where('user_id', $user->id)->findOrFail($id);
|
||||
$ticket = SupportTicket::where('user_id', $user->id)->with('reason')->findOrFail($id);
|
||||
|
||||
$replies = SupportTicketReply::where('support_ticket_id', $ticket->id)
|
||||
->with(['user', 'worker'])
|
||||
@ -125,6 +137,7 @@ public function show(Request $request, $id)
|
||||
'id' => $ticket->id,
|
||||
'ticket_number' => $ticket->ticket_number,
|
||||
'subject' => $ticket->subject,
|
||||
'reason' => $ticket->reason ? $ticket->reason->reason : null,
|
||||
'description' => $ticket->description,
|
||||
'status' => $ticket->status,
|
||||
'priority' => $ticket->priority,
|
||||
|
||||
@ -62,7 +62,7 @@ public function index(Request $request)
|
||||
|
||||
// Preferred job types: full-time / part-time / live-in / live-out
|
||||
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
|
||||
$preferredJobType = $jobTypes[$w->id % 4];
|
||||
$preferredJobType = $w->preferred_job_type ?? $jobTypes[$w->id % 4];
|
||||
|
||||
// Emirates ID verification status (now dynamic passport status)
|
||||
$emiratesIdStatus = $w->emirates_id_status;
|
||||
@ -85,8 +85,9 @@ public function index(Request $request)
|
||||
];
|
||||
$photo = $photos[$w->id % 4];
|
||||
|
||||
$rating = 4.0 + (($w->id * 3) % 10) / 10.0;
|
||||
$reviewsCount = ($w->id * 4) % 20 + 2;
|
||||
$dbReviews = \App\Models\Review::where('worker_id', $w->id)->get();
|
||||
$reviewsCount = $dbReviews->count();
|
||||
$rating = $reviewsCount > 0 ? round($dbReviews->avg('rating'), 1) : 0.0;
|
||||
|
||||
return [
|
||||
'id' => $w->id,
|
||||
@ -111,9 +112,144 @@ public function index(Request $request)
|
||||
'bio' => $w->bio,
|
||||
'rating' => $rating,
|
||||
'reviews_count' => $reviewsCount,
|
||||
'preferred_location' => $w->preferred_location,
|
||||
'in_country' => (bool) $w->in_country,
|
||||
];
|
||||
})->filter()->values()->toArray();
|
||||
|
||||
// Apply request filters: preferred_location, job_type, live_in_out, nationality, in_country, visa_status
|
||||
if ($request->filled('preferred_location')) {
|
||||
$prefLoc = $request->preferred_location;
|
||||
$locsArray = is_array($prefLoc) ? $prefLoc : array_filter(array_map('trim', explode(',', $prefLoc)));
|
||||
$locsArray = array_map('strtolower', $locsArray);
|
||||
|
||||
$workers = array_values(array_filter($workers, function ($c) use ($locsArray) {
|
||||
if (!isset($c['preferred_location'])) return false;
|
||||
$wLoc = strtolower($c['preferred_location']);
|
||||
foreach ($locsArray as $l) {
|
||||
if (str_contains($wLoc, $l)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
$jobTypeParam = $request->input('job_type') ?? $request->input('preferred_job_type');
|
||||
if ($jobTypeParam) {
|
||||
$jobTypesArray = is_array($jobTypeParam) ? $jobTypeParam : array_filter(array_map('trim', explode(',', $jobTypeParam)));
|
||||
$jobTypesArray = array_map('strtolower', $jobTypesArray);
|
||||
|
||||
$workers = array_values(array_filter($workers, function ($c) use ($jobTypesArray) {
|
||||
if (!isset($c['preferred_job_type'])) return false;
|
||||
$wJobType = strtolower($c['preferred_job_type']);
|
||||
foreach ($jobTypesArray as $jt) {
|
||||
if (str_contains($wJobType, $jt)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
$accParam = $request->input('live_in_out') ?? $request->input('accommodation_type') ?? $request->input('accomadation_type');
|
||||
if ($accParam) {
|
||||
$accsArray = is_array($accParam) ? $accParam : array_filter(array_map('trim', explode(',', $accParam)));
|
||||
$accsArray = array_map(function($val) {
|
||||
return str_replace(['_', '-'], ' ', strtolower($val));
|
||||
}, $accsArray);
|
||||
|
||||
$workers = array_values(array_filter($workers, function ($c) use ($accsArray) {
|
||||
if (!isset($c['live_in_out'])) return false;
|
||||
$wAcc = str_replace(['_', '-'], ' ', strtolower($c['live_in_out']));
|
||||
foreach ($accsArray as $a) {
|
||||
if (str_contains($wAcc, $a)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
if ($request->filled('nationality')) {
|
||||
$natInput = $request->nationality;
|
||||
$natsArray = is_array($natInput) ? $natInput : array_filter(array_map('trim', explode(',', $natInput)));
|
||||
$natsArray = array_map('strtolower', $natsArray);
|
||||
|
||||
$workers = array_values(array_filter($workers, function ($c) use ($natsArray) {
|
||||
if (!isset($c['nationality'])) return false;
|
||||
$wNat = strtolower($c['nationality']);
|
||||
foreach ($natsArray as $n) {
|
||||
if (str_contains($wNat, $n) || $wNat === $n) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
if ($request->has('in_country')) {
|
||||
$inCountryVal = $request->input('in_country');
|
||||
if ($inCountryVal !== null && $inCountryVal !== '') {
|
||||
$isInCountry = filter_var($inCountryVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
if ($isInCountry === null) {
|
||||
$strVal = strtolower(trim($inCountryVal));
|
||||
if ($strVal === 'in' || $strVal === 'in_country' || $strVal === 'yes') {
|
||||
$isInCountry = true;
|
||||
} elseif ($strVal === 'out' || $strVal === 'out_country' || $strVal === 'no') {
|
||||
$isInCountry = false;
|
||||
}
|
||||
}
|
||||
if ($isInCountry !== null) {
|
||||
$workers = array_values(array_filter($workers, function ($c) use ($isInCountry) {
|
||||
return isset($c['in_country']) && (bool)$c['in_country'] === $isInCountry;
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->has('out_country')) {
|
||||
$outCountryVal = $request->input('out_country');
|
||||
if ($outCountryVal !== null && $outCountryVal !== '') {
|
||||
$isOutCountry = filter_var($outCountryVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
if ($isOutCountry === null) {
|
||||
$strVal = strtolower(trim($outCountryVal));
|
||||
if ($strVal === 'out' || $strVal === 'out_country' || $strVal === 'yes') {
|
||||
$isOutCountry = true;
|
||||
} elseif ($strVal === 'in' || $strVal === 'in_country' || $strVal === 'no') {
|
||||
$isOutCountry = false;
|
||||
}
|
||||
}
|
||||
if ($isOutCountry !== null) {
|
||||
$workers = array_values(array_filter($workers, function ($c) use ($isOutCountry) {
|
||||
return isset($c['in_country']) && (bool)$c['in_country'] !== $isOutCountry;
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$visaParam = $request->input('visa_status') ?? $request->input('next_visa_type') ?? $request->input('visa_type');
|
||||
if ($visaParam) {
|
||||
$visasArray = is_array($visaParam) ? $visaParam : array_filter(array_map('trim', explode(',', $visaParam)));
|
||||
$visasArray = array_map('strtolower', $visasArray);
|
||||
|
||||
$workers = array_values(array_filter($workers, function ($c) use ($visasArray) {
|
||||
if (!isset($c['visa_status'])) return false;
|
||||
$isInCountry = isset($c['in_country']) && (bool)$c['in_country'];
|
||||
if (!$isInCountry) {
|
||||
return false;
|
||||
}
|
||||
$wVisa = strtolower($c['visa_status']);
|
||||
foreach ($visasArray as $v) {
|
||||
if (str_contains($wVisa, $v)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
$shortlistedIds = Shortlist::where('employer_id', $employerId)->pluck('worker_id')->toArray();
|
||||
|
||||
$dbCategories = WorkerCategory::pluck('name')->toArray();
|
||||
@ -190,28 +326,7 @@ public function show($id)
|
||||
})->toArray();
|
||||
|
||||
$reviewsCount = count($reviews);
|
||||
if ($reviewsCount > 0) {
|
||||
$rating = round($dbReviews->avg('rating'), 1);
|
||||
} else {
|
||||
$rating = 4.0 + (($w->id * 3) % 10) / 10.0;
|
||||
$reviewsCount = ($w->id * 4) % 20 + 2;
|
||||
$reviews = [
|
||||
[
|
||||
'id' => 1,
|
||||
'employer_name' => 'Fatima Al Mansoori',
|
||||
'rating' => 5,
|
||||
'date' => 'May 10, 2026',
|
||||
'comment' => 'Extremely reliable and respectful. Professional work and great communication.',
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'employer_name' => 'Michael Harrison',
|
||||
'rating' => 4,
|
||||
'date' => 'Mar 24, 2026',
|
||||
'comment' => 'Very punctual, did exactly what was expected. Highly recommend.',
|
||||
]
|
||||
];
|
||||
}
|
||||
$rating = $reviewsCount > 0 ? round($dbReviews->avg('rating'), 1) : 0;
|
||||
|
||||
$simDb = Worker::with('category')
|
||||
->where('id', '!=', $w->id)
|
||||
|
||||
@ -9,7 +9,7 @@ class Skill extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name'];
|
||||
protected $fillable = ['name', 'image_path'];
|
||||
|
||||
public function workers()
|
||||
{
|
||||
|
||||
@ -12,6 +12,7 @@ class SupportTicket extends Model
|
||||
protected $fillable = [
|
||||
'ticket_number',
|
||||
'user_id',
|
||||
'reason_id',
|
||||
'worker_id',
|
||||
'subject',
|
||||
'description',
|
||||
@ -24,6 +25,11 @@ public function user()
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function reason()
|
||||
{
|
||||
return $this->belongsTo(ReportReason::class, 'reason_id');
|
||||
}
|
||||
|
||||
public function worker()
|
||||
{
|
||||
return $this->belongsTo(Worker::class, 'worker_id');
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('support_tickets', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('reason_id')->nullable()->after('user_id');
|
||||
$table->foreign('reason_id')->references('id')->on('report_reasons')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('support_tickets', function (Blueprint $table) {
|
||||
$table->dropForeign(['reason_id']);
|
||||
$table->dropColumn('reason_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$reasons = [
|
||||
['reason' => 'Billing & Payment Issues', 'type' => 'Support', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
|
||||
['reason' => 'Subscription Plan Changes', 'type' => 'Support', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
|
||||
['reason' => 'Technical Bugs & Site Errors', 'type' => 'Support', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
|
||||
['reason' => 'Worker Profile Verification Help', 'type' => 'Support', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
|
||||
['reason' => 'Employer Account Settings', 'type' => 'Support', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
|
||||
['reason' => 'General Feedback & Suggestions', 'type' => 'Support', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
|
||||
['reason' => 'Other Queries', 'type' => 'Support', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
|
||||
];
|
||||
|
||||
DB::table('report_reasons')->insert($reasons);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('report_reasons')->where('type', 'Support')->delete();
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$skills = [
|
||||
['name' => 'Ironing', 'created_at' => now(), 'updated_at' => now()],
|
||||
['name' => 'Car Washing', 'created_at' => now(), 'updated_at' => now()],
|
||||
['name' => 'Pet Sitting', 'created_at' => now(), 'updated_at' => now()],
|
||||
];
|
||||
|
||||
foreach ($skills as $skill) {
|
||||
DB::table('skills')->updateOrInsert(['name' => $skill['name']], $skill);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('skills')->whereIn('name', ['Ironing', 'Car Washing', 'Pet Sitting'])->delete();
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('skills', function (Blueprint $table) {
|
||||
$table->string('image_path')->nullable()->after('name');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('skills', function (Blueprint $table) {
|
||||
$table->dropColumn('image_path');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -438,23 +438,20 @@
|
||||
},
|
||||
"live_in_out": {
|
||||
"type": "string",
|
||||
"example": "Live-out",
|
||||
"description": "Accommodation preference (e.g. 'Live-in', 'Live-out')."
|
||||
"enum": ["live_in", "live_out"],
|
||||
"example": "live_out",
|
||||
"description": "Accommodation preference (live_in, live_out)."
|
||||
},
|
||||
"country": {
|
||||
"gender": {
|
||||
"type": "string",
|
||||
"example": "UAE",
|
||||
"description": "Preferred location country."
|
||||
"enum": ["male", "female", "other"],
|
||||
"example": "male",
|
||||
"description": "Gender of the worker (male, female, other)."
|
||||
},
|
||||
"city": {
|
||||
"preferred_location": {
|
||||
"type": "string",
|
||||
"example": "Dubai",
|
||||
"description": "Preferred location city."
|
||||
},
|
||||
"area": {
|
||||
"type": "string",
|
||||
"example": "Marina",
|
||||
"description": "Preferred location area."
|
||||
"example": "Dubai Marina",
|
||||
"description": "Preferred work location/area."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -998,6 +995,23 @@
|
||||
8
|
||||
],
|
||||
"description": "Array of skill IDs"
|
||||
},
|
||||
"gender": {
|
||||
"type": "string",
|
||||
"enum": ["male", "female", "other"],
|
||||
"example": "male",
|
||||
"description": "Gender of the worker (male, female, other)."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2618,6 +2632,60 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "preferred_location",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Filter by preferred location (comma-separated list, e.g. Dubai,Abu Dhabi).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "job_type",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Filter by job type (comma-separated list, e.g. full-time,part-time).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "accommodation_type",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Filter by accommodation type (comma-separated list, e.g. live_in,live_out).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "in_country",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Filter by in country status (true/false or yes/no or in/out).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "out_country",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Filter by out country status (true/false or yes/no or out/in).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "visa_status",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Filter by next visa status (comma-separated list, e.g. Residence Visa,Tourist Visa). Only applicable if in country.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "availability",
|
||||
"in": "query",
|
||||
@ -2864,6 +2932,60 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "preferred_location",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Filter candidates by preferred location (comma-separated list, e.g. Dubai,Abu Dhabi).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "job_type",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Filter candidates by job type (comma-separated list, e.g. full-time,part-time).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "accommodation_type",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Filter candidates by accommodation type (comma-separated list, e.g. live_in,live_out).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "in_country",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Filter candidates by in country status (true/false or yes/no or in/out).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "out_country",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Filter candidates by out country status (true/false or yes/no or out/in).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "visa_status",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Filter candidates by next visa status (comma-separated list, e.g. Residence Visa,Tourist Visa). Only applicable if in country.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "availability",
|
||||
"in": "query",
|
||||
@ -3759,6 +3881,15 @@
|
||||
"type": "string",
|
||||
"example": "Live-out"
|
||||
},
|
||||
"gender": {
|
||||
"type": "string",
|
||||
"enum": ["male", "female", "other"],
|
||||
"example": "male"
|
||||
},
|
||||
"preferred_location": {
|
||||
"type": "string",
|
||||
"example": "Dubai Marina"
|
||||
},
|
||||
"country": {
|
||||
"type": "string",
|
||||
"example": "UAE"
|
||||
|
||||
BIN
public/uploads/documents/1780990412_passport_mds1.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
public/uploads/documents/1780996836_passport_1000100671.jpg
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
public/uploads/documents/1780996836_visa_1000100671.jpg
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
public/uploads/documents/1780999934_passport_1000418145.jpg
Normal file
|
After Width: | Height: | Size: 318 KiB |
BIN
public/uploads/documents/1780999934_visa_1000418145.jpg
Normal file
|
After Width: | Height: | Size: 318 KiB |
BIN
public/uploads/documents/1781002405_passport_1000100687.jpg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
public/uploads/documents/1781002405_visa_1000100687.jpg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 81 KiB |
@ -18,7 +18,8 @@ import {
|
||||
BarChart3,
|
||||
History,
|
||||
List,
|
||||
LifeBuoy
|
||||
LifeBuoy,
|
||||
Heart
|
||||
} from 'lucide-react';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
|
||||
@ -39,7 +40,7 @@ export default function AdminLayout({ children, title = 'Dashboard' }) {
|
||||
{ name: 'Campaigns & Alerts', href: '/admin/notifications', icon: BellRing },
|
||||
{ name: 'Reports & Analytics', href: '/admin/analytics', icon: BarChart3 },
|
||||
{ name: 'System Audit Logs', href: '/admin/audit-logs', icon: History },
|
||||
{ name: 'Announcements', href: '/admin/announcements', icon: Megaphone },
|
||||
{ name: 'Charity Events', href: '/admin/announcements', icon: Heart },
|
||||
{ name: 'Skills', href: '/admin/master-data/skills', icon: Settings },
|
||||
{ name: 'Reason Master', href: '/admin/master-data/reasons', icon: List },
|
||||
];
|
||||
|
||||
@ -1,12 +1,43 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import AdminLayout from '../../../Layouts/AdminLayout';
|
||||
import { Megaphone, Plus, Trash2, Send, CheckCircle2, Check, X } from 'lucide-react';
|
||||
import {
|
||||
Heart, Plus, Trash2, Send, CheckCircle2, Check, X,
|
||||
Calendar, MapPin, Clock, Gift, Sparkles, Search,
|
||||
ChevronDown, ChevronUp, Building, User, XCircle, Eye, BellRing
|
||||
} from 'lucide-react';
|
||||
|
||||
const hoursList = [
|
||||
'12:00', '12:30', '01:00', '01:30', '02:00', '02:30',
|
||||
'03:00', '03:30', '04:00', '04:30', '05:00', '05:30',
|
||||
'06:00', '06:30', '07:00', '07:30', '08:00', '08:30',
|
||||
'09:00', '09:30', '10:00', '10:30', '11:00', '11:30'
|
||||
];
|
||||
|
||||
export default function Announcements({ initialAnnouncements }) {
|
||||
const [announcements, setAnnouncements] = useState(initialAnnouncements || []);
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [newAnnouncement, setNewAnnouncement] = useState({ title: '', content: '', audience: 'Both' });
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('All');
|
||||
const [expandedCards, setExpandedCards] = useState({});
|
||||
|
||||
// Time picker states
|
||||
const [startTimeHour, setStartTimeHour] = useState('09:00');
|
||||
const [startTimeAmpm, setStartTimeAmpm] = useState('AM');
|
||||
const [endTimeHour, setEndTimeHour] = useState('04:00');
|
||||
const [endTimeAmpm, setEndTimeAmpm] = useState('PM');
|
||||
const [isStartPickerOpen, setIsStartPickerOpen] = useState(false);
|
||||
const [isEndPickerOpen, setIsEndPickerOpen] = useState(false);
|
||||
|
||||
const [newAnnouncement, setNewAnnouncement] = useState({
|
||||
title: '',
|
||||
content: '',
|
||||
provided_items: '',
|
||||
event_date: '',
|
||||
event_time: '09:00 AM - 04:00 PM',
|
||||
location_details: '',
|
||||
location_pin: ''
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [toastMessage, setToastMessage] = useState(null);
|
||||
|
||||
@ -19,7 +50,16 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
e.preventDefault();
|
||||
let newErrors = {};
|
||||
if (!newAnnouncement.title.trim()) newErrors.title = 'Title is required';
|
||||
if (!newAnnouncement.content.trim()) newErrors.content = 'Content is required';
|
||||
if (!newAnnouncement.content.trim()) newErrors.content = 'Description is required';
|
||||
if (!newAnnouncement.provided_items.trim()) newErrors.provided_items = 'Provided items is required';
|
||||
if (!newAnnouncement.event_date.trim()) newErrors.event_date = 'Event date is required';
|
||||
if (!newAnnouncement.event_time.trim()) newErrors.event_time = 'Event time is required';
|
||||
if (!newAnnouncement.location_details.trim()) newErrors.location_details = 'Location details is required';
|
||||
if (!newAnnouncement.location_pin.trim()) {
|
||||
newErrors.location_pin = 'Location pin URL is required';
|
||||
} else if (!newAnnouncement.location_pin.startsWith('http://') && !newAnnouncement.location_pin.startsWith('https://')) {
|
||||
newErrors.location_pin = 'Must be a valid URL';
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
@ -29,22 +69,45 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
router.post(route('admin.announcements.store'), {
|
||||
title: newAnnouncement.title,
|
||||
content: newAnnouncement.content,
|
||||
type: newAnnouncement.audience === 'Both' ? 'info' : (newAnnouncement.audience === 'Employers' ? 'success' : 'warning')
|
||||
provided_items: newAnnouncement.provided_items,
|
||||
event_date: newAnnouncement.event_date,
|
||||
event_time: newAnnouncement.event_time,
|
||||
location_details: newAnnouncement.location_details,
|
||||
location_pin: newAnnouncement.location_pin
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setNewAnnouncement({ title: '', content: '', audience: 'Both' });
|
||||
setNewAnnouncement({
|
||||
title: '',
|
||||
content: '',
|
||||
provided_items: '',
|
||||
event_date: '',
|
||||
event_time: '09:00 AM - 04:00 PM',
|
||||
location_details: '',
|
||||
location_pin: ''
|
||||
});
|
||||
setStartTimeHour('09:00');
|
||||
setStartTimeAmpm('AM');
|
||||
setEndTimeHour('04:00');
|
||||
setEndTimeAmpm('PM');
|
||||
setErrors({});
|
||||
setIsFormOpen(false);
|
||||
showToast('Announcement broadcasted successfully');
|
||||
showToast('Charity Event created successfully');
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateEventTime = (sh, sa, eh, ea) => {
|
||||
setNewAnnouncement(prev => ({
|
||||
...prev,
|
||||
event_time: `${sh} ${sa} - ${eh} ${ea}`
|
||||
}));
|
||||
};
|
||||
|
||||
const handleApprove = (id) => {
|
||||
router.post(route('admin.announcements.approve', id), {}, {
|
||||
onSuccess: () => {
|
||||
showToast('Announcement approved successfully');
|
||||
showToast('Charity Event approved successfully');
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}
|
||||
});
|
||||
@ -53,26 +116,53 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
const handleReject = (id) => {
|
||||
router.post(route('admin.announcements.reject', id), {}, {
|
||||
onSuccess: () => {
|
||||
showToast('Announcement rejected successfully');
|
||||
showToast('Charity Event rejected successfully');
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (id) => {
|
||||
if (confirm('Are you sure you want to delete this announcement?')) {
|
||||
if (confirm('Are you sure you want to delete this charity event?')) {
|
||||
router.delete(route('admin.announcements.delete', id), {
|
||||
onSuccess: () => {
|
||||
showToast('Announcement deleted');
|
||||
showToast('Charity Event deleted');
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExpand = (id) => {
|
||||
setExpandedCards(prev => ({
|
||||
...prev,
|
||||
[id]: !prev[id]
|
||||
}));
|
||||
};
|
||||
|
||||
// Calculate dynamic statistics
|
||||
const totalDrives = announcements.length;
|
||||
const pendingCount = announcements.filter(a => a.status === 'pending').length;
|
||||
const approvedCount = announcements.filter(a => a.status === 'approved').length;
|
||||
const rejectedCount = announcements.filter(a => a.status === 'rejected').length;
|
||||
|
||||
// Filter announcements
|
||||
const filteredAnnouncements = announcements.filter(ann => {
|
||||
const matchesSearch =
|
||||
ann.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
ann.content.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(ann.posted_by && ann.posted_by.toLowerCase().includes(searchQuery.toLowerCase())) ||
|
||||
(ann.organization && ann.organization.toLowerCase().includes(searchQuery.toLowerCase())) ||
|
||||
(ann.charityDetails?.provided_items && ann.charityDetails.provided_items.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
const matchesStatus = statusFilter === 'All' || ann.status === statusFilter.toLowerCase();
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
return (
|
||||
<AdminLayout title="Announcements">
|
||||
<Head title="Announcements - Admin Portal" />
|
||||
<AdminLayout title="Charity Events & Drives">
|
||||
<Head title="Charity Events - Admin Portal" />
|
||||
|
||||
{/* Toast Notification */}
|
||||
{toastMessage && (
|
||||
@ -82,64 +172,342 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6 select-none max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-6 select-none max-w-6xl mx-auto pb-12">
|
||||
|
||||
{/* Upper Header section */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Platform Announcements & Events</h1>
|
||||
<p className="text-sm text-slate-500">Approve user-submitted events or broadcast messages to all users.</p>
|
||||
<h1 className="text-2xl font-black text-slate-900 tracking-tight">Charity Events & Drives</h1>
|
||||
<p className="text-sm text-slate-500 font-medium">Manage, approve, or launch community charity initiatives.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsFormOpen(true)}
|
||||
className="bg-[#185FA5] hover:bg-[#144f8a] text-white px-5 py-2.5 rounded-xl font-bold text-sm flex items-center space-x-2 transition-colors shadow-sm cursor-pointer border-none"
|
||||
className="bg-[#0F6E56] hover:bg-[#0b523f] text-white px-5 py-2.5 rounded-xl font-bold text-sm flex items-center justify-center space-x-2 transition-all shadow-sm cursor-pointer border-none self-start sm:self-auto hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>New Announcement</span>
|
||||
<span>New Charity Event</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Stats Overview (Compact) */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex items-center space-x-3.5">
|
||||
<div className="w-10 h-10 rounded-lg bg-teal-50 flex items-center justify-center text-teal-600 shrink-0">
|
||||
<Heart className="w-5 h-5 fill-teal-500 text-teal-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-400 font-bold uppercase tracking-wider">Total Drives</div>
|
||||
<div className="text-xl font-black text-slate-800">{totalDrives}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex items-center space-x-3.5">
|
||||
<div className="w-10 h-10 rounded-lg bg-amber-50 flex items-center justify-center text-amber-600 shrink-0">
|
||||
<Clock className="w-5 h-5 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-400 font-bold uppercase tracking-wider">Pending</div>
|
||||
<div className="text-xl font-black text-slate-800">{pendingCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex items-center space-x-3.5">
|
||||
<div className="w-10 h-10 rounded-lg bg-emerald-50 flex items-center justify-center text-emerald-600 shrink-0">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-400 font-bold uppercase tracking-wider">Approved</div>
|
||||
<div className="text-xl font-black text-slate-800">{approvedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex items-center space-x-3.5">
|
||||
<div className="w-10 h-10 rounded-lg bg-rose-50 flex items-center justify-center text-rose-600 shrink-0">
|
||||
<XCircle className="w-5 h-5 text-rose-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-400 font-bold uppercase tracking-wider">Rejected</div>
|
||||
<div className="text-xl font-black text-slate-800">{rejectedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search & Filtering Control Bar */}
|
||||
<div className="bg-white p-3 rounded-xl border border-slate-200 shadow-sm flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
{/* Search Field */}
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search events by title, description, or sponsor..."
|
||||
className="w-full pl-10 pr-4 py-2 text-sm bg-slate-50 border border-slate-200 rounded-lg outline-none focus:bg-white focus:border-[#0F6E56] focus:ring-2 focus:ring-[#0F6E56]/10 transition-all"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 text-xs bg-slate-200 hover:bg-slate-300 w-4 h-4 rounded-full flex items-center justify-center border-none cursor-pointer"
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex items-center space-x-1.5 overflow-x-auto self-start md:self-auto">
|
||||
{['All', 'Pending', 'Approved', 'Rejected'].map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(tab)}
|
||||
className={`px-4 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer border border-solid border-transparent ${
|
||||
statusFilter === tab
|
||||
? 'bg-[#0F6E56] text-white shadow-sm'
|
||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event Creation Modal */}
|
||||
{isFormOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 animate-in fade-in">
|
||||
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-2xl w-full max-w-lg animate-in slide-in-from-bottom-4">
|
||||
<div className="flex items-center space-x-2 mb-4 text-[#185FA5] font-bold">
|
||||
<Megaphone className="w-5 h-5" />
|
||||
<h2>Compose Message</h2>
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 animate-in fade-in">
|
||||
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-2xl w-full max-w-lg animate-in slide-in-from-bottom-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4 pb-2 border-b border-slate-100">
|
||||
<div className="flex items-center space-x-2 text-[#0F6E56] font-bold">
|
||||
<Heart className="w-5 h-5 fill-[#0F6E56]" />
|
||||
<h2>Compose Charity Event</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setIsFormOpen(false); setErrors({}); }}
|
||||
className="p-1 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 border-none bg-transparent cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1">Title</label>
|
||||
<label className="block text-xs font-bold text-slate-600 mb-1">Event Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newAnnouncement.title}
|
||||
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, title: e.target.value })}
|
||||
className={`w-full px-4 py-2.5 rounded-xl border ${errors.title ? 'border-red-500' : 'border-slate-300'} text-sm focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5] outline-none transition-all`}
|
||||
placeholder="Enter announcement title..."
|
||||
className={`w-full px-4 py-2.5 rounded-xl border ${errors.title ? 'border-red-500' : 'border-slate-300'} text-sm focus:ring-2 focus:ring-[#0F6E56]/20 focus:border-[#0F6E56] outline-none transition-all`}
|
||||
placeholder="e.g. Free Dental checkup by Emirates Charity"
|
||||
/>
|
||||
{errors.title && <p className="text-red-500 text-xs mt-1">{errors.title}</p>}
|
||||
{errors.title && <p className="text-red-500 text-xs mt-1 font-semibold">{errors.title}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-600 mb-1">Event Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newAnnouncement.event_date}
|
||||
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, event_date: e.target.value })}
|
||||
className={`w-full h-11 px-3.5 rounded-xl border ${errors.event_date ? 'border-red-500' : 'border-slate-300'} text-xs focus:ring-2 focus:ring-[#0F6E56]/20 focus:border-[#0F6E56] outline-none transition-all`}
|
||||
/>
|
||||
{errors.event_date && <p className="text-red-500 text-[10px] mt-1 font-semibold">{errors.event_date}</p>}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{isStartPickerOpen && (
|
||||
<div className="fixed inset-0 z-[45] bg-transparent" onClick={() => setIsStartPickerOpen(false)} />
|
||||
)}
|
||||
<div className="relative z-50">
|
||||
<label className="block text-xs font-bold text-slate-600 mb-1">Start Time</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsStartPickerOpen(!isStartPickerOpen);
|
||||
setIsEndPickerOpen(false);
|
||||
}}
|
||||
className={`w-full h-11 flex items-center justify-between px-3.5 bg-white border ${
|
||||
isStartPickerOpen ? 'border-blue-500 ring-2 ring-blue-500/10' : 'border-slate-300'
|
||||
} rounded-xl hover:border-blue-500 transition-all cursor-pointer text-left`}
|
||||
>
|
||||
<div className="flex items-center space-x-2 min-w-0">
|
||||
<Clock className="w-4 h-4 text-slate-400 shrink-0" />
|
||||
<div className="flex flex-col justify-center min-w-0 leading-tight">
|
||||
<div className="text-[8px] text-blue-500 font-bold uppercase tracking-wider">Start with</div>
|
||||
<div className="text-slate-800 text-xs font-bold truncate">{startTimeHour} {startTimeAmpm}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
</button>
|
||||
|
||||
{isStartPickerOpen && (
|
||||
<div className="absolute top-full mt-1.5 left-0 bg-white border border-slate-200 shadow-xl rounded-xl p-2 z-50 flex space-x-2.5 w-48 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="flex-1 max-h-40 overflow-y-auto pr-1 space-y-1 scrollbar-thin">
|
||||
{hoursList.map(h => (
|
||||
<button
|
||||
key={h}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStartTimeHour(h);
|
||||
setIsStartPickerOpen(false);
|
||||
updateEventTime(h, startTimeAmpm, endTimeHour, endTimeAmpm);
|
||||
}}
|
||||
className={`w-full text-left px-2 py-1 rounded text-xs font-semibold transition-colors ${
|
||||
startTimeHour === h
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1 justify-center border-l border-slate-100 pl-2">
|
||||
{['AM', 'PM'].map(ampm => (
|
||||
<button
|
||||
key={ampm}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStartTimeAmpm(ampm);
|
||||
updateEventTime(startTimeHour, ampm, endTimeHour, endTimeAmpm);
|
||||
}}
|
||||
className={`px-2 py-1 rounded text-[10px] font-black transition-all border border-solid ${
|
||||
startTimeAmpm === ampm
|
||||
? 'bg-blue-600 border-blue-600 text-white shadow-sm'
|
||||
: 'border-slate-200 text-slate-400 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{ampm}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{isEndPickerOpen && (
|
||||
<div className="fixed inset-0 z-[45] bg-transparent" onClick={() => setIsEndPickerOpen(false)} />
|
||||
)}
|
||||
<div className="relative z-50">
|
||||
<label className="block text-xs font-bold text-slate-600 mb-1">End Time</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsEndPickerOpen(!isEndPickerOpen);
|
||||
setIsStartPickerOpen(false);
|
||||
}}
|
||||
className={`w-full h-11 flex items-center justify-between px-3.5 bg-white border ${
|
||||
isEndPickerOpen ? 'border-blue-500 ring-2 ring-blue-500/10' : 'border-slate-300'
|
||||
} rounded-xl hover:border-blue-500 transition-all cursor-pointer text-left`}
|
||||
>
|
||||
<div className="flex items-center space-x-2 min-w-0">
|
||||
<Clock className="w-4 h-4 text-slate-400 shrink-0" />
|
||||
<div className="flex flex-col justify-center min-w-0 leading-tight">
|
||||
<div className="text-[8px] text-slate-500 font-bold uppercase tracking-wider">End with</div>
|
||||
<div className="text-slate-800 text-xs font-bold truncate">{endTimeHour} {endTimeAmpm}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
</button>
|
||||
|
||||
{isEndPickerOpen && (
|
||||
<div className="absolute top-full mt-1.5 right-0 bg-white border border-slate-200 shadow-xl rounded-xl p-2 z-50 flex space-x-2.5 w-48 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="flex-1 max-h-40 overflow-y-auto pr-1 space-y-1 scrollbar-thin">
|
||||
{hoursList.map(h => (
|
||||
<button
|
||||
key={h}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEndTimeHour(h);
|
||||
setIsEndPickerOpen(false);
|
||||
updateEventTime(startTimeHour, startTimeAmpm, h, endTimeAmpm);
|
||||
}}
|
||||
className={`w-full text-left px-2 py-1 rounded text-xs font-semibold transition-colors ${
|
||||
endTimeHour === h
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1 justify-center border-l border-slate-100 pl-2">
|
||||
{['AM', 'PM'].map(ampm => (
|
||||
<button
|
||||
key={ampm}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEndTimeAmpm(ampm);
|
||||
updateEventTime(startTimeHour, startTimeAmpm, endTimeHour, ampm);
|
||||
}}
|
||||
className={`px-2 py-1 rounded text-[10px] font-black transition-all border border-solid ${
|
||||
endTimeAmpm === ampm
|
||||
? 'bg-blue-600 border-blue-600 text-white shadow-sm'
|
||||
: 'border-slate-200 text-slate-400 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{ampm}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-600 mb-1">What is Being Provided</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newAnnouncement.provided_items}
|
||||
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, provided_items: e.target.value })}
|
||||
className={`w-full px-4 py-2.5 rounded-xl border ${errors.provided_items ? 'border-red-500' : 'border-slate-300'} text-sm focus:ring-2 focus:ring-[#0F6E56]/20 focus:border-[#0F6E56] outline-none transition-all`}
|
||||
placeholder="e.g. Free Medical Checks & Food Supplies"
|
||||
/>
|
||||
{errors.provided_items && <p className="text-red-500 text-xs mt-1 font-semibold">{errors.provided_items}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-600 mb-1">Location Details</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newAnnouncement.location_details}
|
||||
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, location_details: e.target.value })}
|
||||
className={`w-full px-4 py-2.5 rounded-xl border ${errors.location_details ? 'border-red-500' : 'border-slate-300'} text-sm focus:ring-2 focus:ring-[#0F6E56]/20 focus:border-[#0F6E56] outline-none transition-all`}
|
||||
placeholder="e.g. Al Quoz Community Center, Dubai"
|
||||
/>
|
||||
{errors.location_details && <p className="text-red-500 text-xs mt-1 font-semibold">{errors.location_details}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1">Content</label>
|
||||
<label className="block text-xs font-bold text-slate-600 mb-1">Location Pin URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newAnnouncement.location_pin}
|
||||
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, location_pin: e.target.value })}
|
||||
className={`w-full px-4 py-2.5 rounded-xl border ${errors.location_pin ? 'border-red-500' : 'border-slate-300'} text-sm focus:ring-2 focus:ring-[#0F6E56]/20 focus:border-[#0F6E56] outline-none transition-all`}
|
||||
placeholder="e.g. https://maps.app.goo.gl/xyz"
|
||||
/>
|
||||
{errors.location_pin && <p className="text-red-500 text-xs mt-1 font-semibold">{errors.location_pin}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-600 mb-1">Description & Instructions</label>
|
||||
<textarea
|
||||
rows="4"
|
||||
rows="3"
|
||||
value={newAnnouncement.content}
|
||||
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, content: e.target.value })}
|
||||
className={`w-full px-4 py-3 rounded-xl border ${errors.content ? 'border-red-500' : 'border-slate-300'} text-sm focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5] outline-none transition-all resize-none`}
|
||||
placeholder="Type your message here..."
|
||||
className={`w-full px-4 py-3 rounded-xl border ${errors.content ? 'border-red-500' : 'border-slate-300'} text-sm focus:ring-2 focus:ring-[#0F6E56]/20 focus:border-[#0F6E56] outline-none transition-all resize-none`}
|
||||
placeholder="Type event details and instructions here..."
|
||||
></textarea>
|
||||
{errors.content && <p className="text-red-500 text-xs mt-1">{errors.content}</p>}
|
||||
{errors.content && <p className="text-red-500 text-xs mt-1 font-semibold">{errors.content}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1">Target Audience</label>
|
||||
<select
|
||||
value={newAnnouncement.audience}
|
||||
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, audience: e.target.value })}
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-slate-300 text-sm focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5] outline-none transition-all bg-white"
|
||||
>
|
||||
<option value="Both">Both Employers & Workers</option>
|
||||
<option value="Employers">Employers Only</option>
|
||||
<option value="Workers">Workers Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-3 pt-2">
|
||||
|
||||
<div className="flex items-center justify-end space-x-3 pt-2 border-t border-slate-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setIsFormOpen(false); setErrors({}); }}
|
||||
@ -152,7 +520,7 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center space-x-2 transition-colors shadow-sm cursor-pointer border-none"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
<span>Broadcast Message</span>
|
||||
<span>Publish Event</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@ -160,73 +528,157 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{announcements.length === 0 ? (
|
||||
{/* Compact Grid of Events */}
|
||||
{filteredAnnouncements.length === 0 ? (
|
||||
<div className="text-center py-16 bg-white rounded-2xl border border-slate-200 shadow-sm text-slate-500">
|
||||
<Megaphone className="w-12 h-12 text-slate-300 mx-auto mb-3" />
|
||||
<p className="font-medium text-sm">No announcements published yet.</p>
|
||||
<Heart className="w-12 h-12 text-slate-300 mx-auto mb-3 fill-slate-100" />
|
||||
<p className="font-bold text-sm">No charity events match your criteria.</p>
|
||||
</div>
|
||||
) : (
|
||||
announcements.map((ann) => (
|
||||
<div key={ann.id} className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm flex flex-col sm:flex-row sm:items-start justify-between gap-4 group">
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="flex items-center space-x-3 flex-wrap gap-y-2">
|
||||
<h3 className="font-bold text-lg text-slate-900 tracking-tight">{ann.title}</h3>
|
||||
<span className={`px-2.5 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider ${
|
||||
ann.type === 'info' ? 'bg-purple-100 text-purple-700' :
|
||||
ann.type === 'success' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-emerald-100 text-emerald-700'
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{filteredAnnouncements.map((ann) => {
|
||||
const details = ann.charityDetails;
|
||||
const isExpanded = !!expandedCards[ann.id];
|
||||
|
||||
// Color accents based on status
|
||||
const statusColorClass =
|
||||
ann.status === 'approved' ? 'border-l-emerald-500' :
|
||||
ann.status === 'rejected' ? 'border-l-rose-500' :
|
||||
'border-l-amber-500';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ann.id}
|
||||
className={`bg-white rounded-xl border border-slate-200 border-l-4 ${statusColorClass} shadow-sm hover:shadow-md transition-all flex flex-col justify-between`}
|
||||
>
|
||||
{/* Top Card Area */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1 flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 flex-wrap gap-y-1">
|
||||
<h3 className="font-bold text-sm text-slate-800 tracking-tight flex items-center gap-1 min-w-0">
|
||||
<Sparkles className="w-3.5 h-3.5 text-rose-500 fill-rose-50 shrink-0" />
|
||||
<span className="truncate block" title={ann.title}>{ann.title}</span>
|
||||
</h3>
|
||||
<span className={`px-2 py-0.5 rounded text-[8px] font-extrabold uppercase tracking-wider shrink-0 ${
|
||||
ann.status === 'approved' ? 'bg-emerald-50 text-emerald-700 border border-solid border-emerald-100' :
|
||||
ann.status === 'rejected' ? 'bg-rose-50 text-rose-700 border border-solid border-rose-100' :
|
||||
'bg-amber-50 text-amber-700 border border-solid border-amber-100'
|
||||
}`}>
|
||||
Type: {ann.type}
|
||||
{ann.status}
|
||||
</span>
|
||||
<span className={`px-2.5 py-1 rounded-md text-[10px] font-bold uppercase border ${
|
||||
ann.status === 'approved' ? 'bg-emerald-50 text-emerald-700 border-emerald-200' :
|
||||
ann.status === 'rejected' ? 'bg-rose-50 text-rose-700 border-rose-200' :
|
||||
'bg-yellow-50 text-yellow-700 border-yellow-200'
|
||||
}`}>
|
||||
Status: {ann.status}
|
||||
{ann.status === 'approved' && (
|
||||
<span className="inline-flex items-center space-x-1 px-1.5 py-0.5 bg-emerald-50 rounded text-[8px] font-bold text-emerald-800 border border-emerald-100 uppercase tracking-wider shrink-0">
|
||||
<BellRing className="w-2.5 h-2.5 text-emerald-600 animate-bounce" />
|
||||
<span>Push & Reminder Scheduled</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 leading-relaxed whitespace-pre-wrap">{ann.content}</p>
|
||||
<div className="flex items-center space-x-2 text-[11px] font-medium text-slate-400">
|
||||
<span>Posted by: <strong className="text-slate-600">{ann.posted_by}</strong> ({ann.organization})</span>
|
||||
<span>•</span>
|
||||
<span>Published: {ann.created_at}</span>
|
||||
<div className="flex items-center text-[10px] text-slate-400 font-bold whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
<Building className="w-3 h-3 mr-1 text-slate-400 shrink-0" />
|
||||
<span className="truncate max-w-[120px]" title={ann.organization}>{ann.organization}</span>
|
||||
<span className="mx-1">•</span>
|
||||
<User className="w-3 h-3 mr-1 text-slate-400 shrink-0" />
|
||||
<span className="truncate max-w-[100px]" title={ann.posted_by}>{ann.posted_by}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 self-start flex-wrap gap-2">
|
||||
|
||||
{/* Action Control Buttons */}
|
||||
<div className="flex items-center space-x-1 shrink-0">
|
||||
{ann.status === 'pending' && (
|
||||
<>
|
||||
<div className="flex items-center space-x-1 bg-slate-50 p-0.5 rounded-lg border border-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleApprove(ann.id)}
|
||||
className="px-3 py-1.5 bg-emerald-50 hover:bg-emerald-100 text-emerald-700 rounded-xl transition-colors cursor-pointer flex items-center space-x-1 font-bold text-xs border border-solid border-emerald-200"
|
||||
title="Approve Announcement"
|
||||
className="p-1 text-emerald-600 hover:bg-emerald-50 rounded transition-colors cursor-pointer border-none bg-transparent"
|
||||
title="Approve"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
<span>Approve</span>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReject(ann.id)}
|
||||
className="px-3 py-1.5 bg-rose-50 hover:bg-rose-100 text-rose-700 rounded-xl transition-colors cursor-pointer flex items-center space-x-1 font-bold text-xs border border-solid border-rose-200"
|
||||
title="Reject Announcement"
|
||||
className="p-1 text-rose-600 hover:bg-rose-50 rounded transition-colors cursor-pointer border-none bg-transparent"
|
||||
title="Reject"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
<span>Reject</span>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(ann.id)}
|
||||
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-colors cursor-pointer border-none bg-transparent"
|
||||
title="Delete Announcement"
|
||||
className="p-1 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors cursor-pointer border-none bg-transparent"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
{/* Collapsible Content */}
|
||||
<div className="space-y-2 text-xs">
|
||||
<p className={`text-slate-600 leading-relaxed ${isExpanded ? '' : 'line-clamp-2'}`}>
|
||||
{ann.content}
|
||||
</p>
|
||||
|
||||
{ann.content.length > 90 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(ann.id)}
|
||||
className="text-[#0F6E56] hover:underline font-bold flex items-center space-x-0.5 border-none bg-transparent cursor-pointer p-0 text-[10px]"
|
||||
>
|
||||
<span>{isExpanded ? 'Show Less' : 'Read Full Description'}</span>
|
||||
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details metadata row (compact icons) */}
|
||||
{details && (
|
||||
<div className="pt-2.5 border-t border-slate-100 grid grid-cols-3 gap-2 text-[10px] text-slate-500 font-bold">
|
||||
<div className="flex items-center space-x-1.5 min-w-0">
|
||||
<Gift className="w-3.5 h-3.5 text-rose-500 fill-rose-50 shrink-0" />
|
||||
<span className="truncate text-slate-700" title={details.provided_items}>
|
||||
{details.provided_items}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 min-w-0">
|
||||
<Calendar className="w-3.5 h-3.5 text-amber-500 shrink-0" />
|
||||
<span className="truncate text-slate-700" title={`${details.event_date} ${details.event_time}`}>
|
||||
{details.event_date}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 min-w-0">
|
||||
<MapPin className="w-3.5 h-3.5 text-blue-500 shrink-0" />
|
||||
<span className="truncate text-slate-700" title={details.location_details}>
|
||||
{details.location_details}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Card Footer */}
|
||||
<div className="px-4 py-2 bg-slate-50 rounded-b-xl border-t border-slate-100 flex items-center justify-between text-[9px] text-slate-400 font-bold">
|
||||
<span>Published: {ann.created_at}</span>
|
||||
{details?.location_pin && (
|
||||
<a
|
||||
href={details.location_pin}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[#0F6E56] hover:underline flex items-center space-x-0.5"
|
||||
>
|
||||
<span>View on Map</span>
|
||||
<MapPin className="w-2.5 h-2.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
|
||||
@ -114,22 +114,23 @@ export default function Dashboard({ stats, recent_verifications, recent_subscrip
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification Stats */}
|
||||
{/* Employers Registered & active/inactive */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-100 shadow-sm flex flex-col justify-between hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-black text-slate-400 uppercase tracking-wider">Verification & OCR Rate</span>
|
||||
<div className="w-10 h-10 bg-emerald-50 rounded-lg flex items-center justify-center shadow-inner">
|
||||
<ShieldCheck className="w-5 h-5 text-emerald-600" />
|
||||
<span className="text-xs font-black text-slate-400 uppercase tracking-wider">Employers Registered</span>
|
||||
<div className="w-10 h-10 bg-teal-50 rounded-xl flex items-center justify-center shadow-inner">
|
||||
<Users className="w-5 h-5 text-[#0F6E56]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h3 className="text-3xl font-black text-slate-900 tracking-tight">
|
||||
{stats?.verification_rate || 0}%
|
||||
{stats?.total_employers?.toLocaleString() || 0}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 font-medium mt-2 flex items-center">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-emerald-500 mr-1 inline" />
|
||||
<span>{stats?.verified_workers_count?.toLocaleString() || 0} Profiles Auto-OCR Verified</span>
|
||||
</p>
|
||||
<div className="flex items-center space-x-2 mt-2 text-xs font-bold">
|
||||
<span className="text-emerald-600">{stats?.active_employers || 0} Active</span>
|
||||
<span className="text-slate-300">•</span>
|
||||
<span className="text-slate-400">{stats?.inactive_employers || 0} Inactive</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -289,12 +289,6 @@ export default function DisputesHub({ tickets }) {
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => { setSelectedTicket(ticket); setIsWorkspaceOpen(true); }}
|
||||
className="px-3 py-1.5 bg-white border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors shadow-sm font-bold uppercase tracking-wider text-[10px]"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setSelectedTicket(ticket); setIsWorkspaceOpen(true); }}
|
||||
className="px-3.5 py-1.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-lg transition-colors font-black uppercase tracking-wider text-[10px]"
|
||||
|
||||
@ -105,6 +105,8 @@ export default function ReportReasons({ reasons }) {
|
||||
return 'bg-blue-50 text-blue-700 border border-blue-200';
|
||||
case 'Review':
|
||||
return 'bg-purple-50 text-purple-700 border border-purple-200';
|
||||
case 'Support':
|
||||
return 'bg-amber-50 text-amber-700 border border-amber-200';
|
||||
default:
|
||||
return 'bg-teal-50 text-teal-700 border border-teal-200';
|
||||
}
|
||||
@ -257,6 +259,7 @@ export default function ReportReasons({ reasons }) {
|
||||
<option value="Both">Both (Chat & Review)</option>
|
||||
<option value="Chat">Chat Only</option>
|
||||
<option value="Review">Review Only</option>
|
||||
<option value="Support">Support Ticket Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@ -31,11 +31,13 @@ export default function WorkerSkills({ skills }) {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingSkill, setEditingSkill] = useState(null);
|
||||
const [skillName, setSkillName] = useState('');
|
||||
const [imageFile, setImageFile] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleOpenAdd = () => {
|
||||
setEditingSkill(null);
|
||||
setSkillName('');
|
||||
setImageFile(null);
|
||||
setError('');
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
@ -43,6 +45,7 @@ export default function WorkerSkills({ skills }) {
|
||||
const handleOpenEdit = (skill) => {
|
||||
setEditingSkill(skill);
|
||||
setSkillName(skill.name);
|
||||
setImageFile(null);
|
||||
setError('');
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
@ -54,23 +57,28 @@ export default function WorkerSkills({ skills }) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingSkill) {
|
||||
router.post(`/admin/master-data/skills/${editingSkill.id}/update`, {
|
||||
const formData = {
|
||||
name: skillName.trim()
|
||||
}, {
|
||||
};
|
||||
if (imageFile) {
|
||||
formData.image = imageFile;
|
||||
}
|
||||
|
||||
if (editingSkill) {
|
||||
router.post(`/admin/master-data/skills/${editingSkill.id}/update`, formData, {
|
||||
onSuccess: () => {
|
||||
setIsDialogOpen(false);
|
||||
setImageFile(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.name || 'Failed to update skill.');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
router.post('/admin/master-data/skills', {
|
||||
name: skillName.trim()
|
||||
}, {
|
||||
router.post('/admin/master-data/skills', formData, {
|
||||
onSuccess: () => {
|
||||
setIsDialogOpen(false);
|
||||
setImageFile(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.name || 'Failed to add skill.');
|
||||
@ -147,7 +155,16 @@ export default function WorkerSkills({ skills }) {
|
||||
<GripVertical className="w-4 h-4 text-slate-300 cursor-grab active:cursor-grabbing" />
|
||||
</TableCell>
|
||||
<TableCell className="py-4 font-bold text-slate-900 text-sm capitalize">
|
||||
{skill.name}
|
||||
<div className="flex items-center space-x-3">
|
||||
{skill.image_url ? (
|
||||
<img src={skill.image_url} alt={skill.name} className="w-10 h-10 object-cover rounded-xl border border-slate-100 shadow-xs" />
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-slate-50 border border-slate-100 rounded-xl flex items-center justify-center text-slate-400 text-xs font-bold uppercase">
|
||||
{skill.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<span>{skill.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
@ -222,6 +239,22 @@ export default function WorkerSkills({ skills }) {
|
||||
<p className="text-xs text-rose-600 font-bold ml-1">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Skill Image</label>
|
||||
{editingSkill && editingSkill.image_url && !imageFile && (
|
||||
<div className="flex items-center space-x-3 mb-2 p-2 bg-slate-50 rounded-xl border border-slate-100">
|
||||
<img src={editingSkill.image_url} alt="Current" className="w-12 h-12 object-cover rounded-xl" />
|
||||
<span className="text-xs font-bold text-slate-500">Current Image</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={e => setImageFile(e.target.files[0])}
|
||||
className="w-full text-xs 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:uppercase file:bg-teal-50 file:text-[#0F6E56] hover:file:bg-teal-100 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:justify-end gap-3 pt-4 border-t border-slate-100">
|
||||
|
||||
@ -303,12 +303,6 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => { setSelectedReport(report); setIsDialogOpen(true); }}
|
||||
className="px-3 py-1.5 bg-white border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors shadow-sm font-bold uppercase tracking-wider text-[10px]"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setSelectedReport(report); setIsDialogOpen(true); }}
|
||||
className="px-3.5 py-1.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-lg transition-colors font-black uppercase tracking-wider text-[10px]"
|
||||
|
||||
@ -19,12 +19,10 @@ import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Show({ ticket, replies }) {
|
||||
const [generatingDev, setGeneratingDev] = useState(false);
|
||||
const [isDevChecked, setIsDevChecked] = useState(false);
|
||||
|
||||
const replyForm = useForm({
|
||||
message: '',
|
||||
is_developer_response: false,
|
||||
close_ticket: false,
|
||||
});
|
||||
|
||||
const statusForm = useForm({
|
||||
@ -35,8 +33,7 @@ export default function Show({ ticket, replies }) {
|
||||
e.preventDefault();
|
||||
replyForm.post(`/admin/tickets/${ticket.id}/reply`, {
|
||||
onSuccess: () => {
|
||||
replyForm.reset('message');
|
||||
setIsDevChecked(false);
|
||||
replyForm.reset('message', 'close_ticket');
|
||||
toast.success('Reply posted successfully.');
|
||||
}
|
||||
});
|
||||
@ -51,25 +48,6 @@ export default function Show({ ticket, replies }) {
|
||||
});
|
||||
};
|
||||
|
||||
const generateDeveloperDraft = async () => {
|
||||
setGeneratingDev(true);
|
||||
try {
|
||||
const res = await axios.post(`/admin/tickets/${ticket.id}/generate-dev-response`);
|
||||
if (res.data && res.data.response) {
|
||||
replyForm.setData({
|
||||
message: res.data.response,
|
||||
is_developer_response: true
|
||||
});
|
||||
setIsDevChecked(true);
|
||||
toast.success('Senior Software Developer draft generated!');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Failed to generate developer response. Please try again.');
|
||||
} finally {
|
||||
setGeneratingDev(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'open': return 'bg-blue-50 text-blue-700 border-blue-200';
|
||||
@ -208,28 +186,8 @@ export default function Show({ ticket, replies }) {
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="font-extrabold text-sm text-slate-900">Post Reply</h4>
|
||||
<p className="text-[10px] text-slate-400 font-semibold">Respond directly to the customer or draft as developer.</p>
|
||||
<p className="text-[10px] text-slate-400 font-semibold">Respond directly to the customer as admin or software developer.</p>
|
||||
</div>
|
||||
|
||||
{/* Senior developer draft button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateDeveloperDraft}
|
||||
disabled={generatingDev}
|
||||
className="inline-flex items-center space-x-1.5 px-3 py-2 bg-slate-900 hover:bg-slate-800 text-white rounded-xl text-xs font-black transition-all shadow-sm disabled:opacity-50"
|
||||
>
|
||||
{generatingDev ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-emerald-400" />
|
||||
<span className="text-emerald-400">Generating Dev Response...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-3.5 h-3.5 text-amber-300" />
|
||||
<span>Draft as Senior Developer</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleReplySubmit} className="space-y-4 text-xs font-bold text-slate-700">
|
||||
@ -247,22 +205,24 @@ export default function Show({ ticket, replies }) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-slate-100 pt-4 flex-wrap gap-3">
|
||||
{/* Dev response toggle */}
|
||||
{/* Toggles Group */}
|
||||
<div className="flex flex-col space-y-2">
|
||||
{/* Close ticket toggle */}
|
||||
<label className="flex items-center space-x-2.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isDevChecked}
|
||||
checked={replyForm.data.close_ticket}
|
||||
onChange={e => {
|
||||
setIsDevChecked(e.target.checked);
|
||||
replyForm.setData('is_developer_response', e.target.checked);
|
||||
replyForm.setData('close_ticket', e.target.checked);
|
||||
}}
|
||||
className="w-4.5 h-4.5 rounded border-slate-300 text-slate-900 focus:ring-slate-900 cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs text-slate-700 font-extrabold flex items-center space-x-1">
|
||||
<Terminal className="w-3.5 h-3.5 text-[#185FA5]" />
|
||||
<span>Send response as Senior Software Developer</span>
|
||||
<CheckCircle className="w-3.5 h-3.5 text-emerald-600" />
|
||||
<span>Close Ticket after Reply</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@ -266,7 +266,7 @@ export default function WorkerManagement({ workers }) {
|
||||
<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">Placement Status</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">Category, Exp & Languages</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">Location Details</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 text-right pr-8">Actions</TableHead>
|
||||
@ -290,7 +290,7 @@ export default function WorkerManagement({ workers }) {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 font-medium mt-0.5">{worker.phone} • {worker.email}</div>
|
||||
<div className="text-xs text-gray-400 font-medium mt-0.5">{worker.phone}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
@ -314,9 +314,6 @@ export default function WorkerManagement({ workers }) {
|
||||
<TableCell>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-xs font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<Briefcase className="w-3.5 h-3.5 text-slate-400" /> {worker.category}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 font-bold flex items-center gap-1.5">
|
||||
<Globe className="w-3.5 h-3.5 text-slate-400" /> {worker.nationality} • {worker.experience}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 font-semibold flex items-center gap-1 mt-0.5">
|
||||
@ -374,21 +371,6 @@ export default function WorkerManagement({ workers }) {
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator className="my-1" />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleAvailabilityOverride(worker.id, 'Available Now')}
|
||||
className="flex items-center gap-2 p-2 rounded-lg cursor-pointer font-bold text-xs text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
<CheckSquare className="w-3.5 h-3.5 text-emerald-500" /> Mark Available
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleAvailabilityOverride(worker.id, 'Engaged')}
|
||||
className="flex items-center gap-2 p-2 rounded-lg cursor-pointer font-bold text-xs text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
<CheckSquare className="w-3.5 h-3.5 text-slate-400" /> Mark Engaged
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@ -423,8 +405,15 @@ export default function WorkerManagement({ workers }) {
|
||||
{selectedWorker?.name ? selectedWorker.name.charAt(0) : ''}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-black tracking-tight">{selectedWorker?.name}</h2>
|
||||
<p className="text-teal-100 text-xs font-semibold">{selectedWorker?.nationality} • ID #{selectedWorker?.id}</p>
|
||||
<span className={`px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider ${
|
||||
selectedWorker?.verified ? 'bg-emerald-500/25 text-emerald-100 border border-emerald-500/30' : 'bg-amber-500/25 text-amber-100 border border-amber-500/30'
|
||||
}`}>
|
||||
{selectedWorker?.verified ? 'Verified' : 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-teal-100 text-xs font-semibold mt-0.5">{selectedWorker?.nationality} • ID #{selectedWorker?.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -483,16 +472,6 @@ export default function WorkerManagement({ workers }) {
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-wider">Classification & Experience</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-slate-500">Category</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.category}
|
||||
onChange={e => setEditForm({ ...editForm, category: e.target.value })}
|
||||
className="w-full bg-white border border-slate-200 rounded-xl p-2.5 text-xs font-bold text-slate-700 outline-none focus:ring-2 focus:ring-[#0F6E56]/20"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-slate-500">Experience</label>
|
||||
<input
|
||||
@ -605,53 +584,64 @@ export default function WorkerManagement({ workers }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-bold text-slate-500">Bio / Notes</label>
|
||||
<textarea
|
||||
rows="3"
|
||||
value={editForm.bio}
|
||||
onChange={e => setEditForm({ ...editForm, bio: e.target.value })}
|
||||
className="w-full bg-white border border-slate-200 rounded-xl p-3 text-xs font-bold text-slate-700 outline-none focus:ring-2 focus:ring-[#0F6E56]/20"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 bg-slate-50/50 p-4 rounded-2xl border border-slate-100">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-wider">Classification & Preference</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-xs font-bold text-slate-700">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left/Middle Column (2/3) */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Verification banner if pending */}
|
||||
{!selectedWorker?.verified && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-2xl p-4 flex items-center justify-between shadow-sm">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-amber-100 text-amber-800 rounded-xl">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block">Gender</label>
|
||||
<h4 className="text-xs font-bold text-amber-950">Profile Pending Verification</h4>
|
||||
<p className="text-[10px] text-amber-700 font-medium">Verify candidate's document credentials.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleVerifyAction(selectedWorker.id, 'approve')}
|
||||
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shadow-md shadow-emerald-600/10"
|
||||
>
|
||||
Approve Verification
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile Information Card */}
|
||||
<div className="bg-slate-50/50 p-6 rounded-2xl border border-slate-100">
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-wider mb-4">Worker Profile Details</h3>
|
||||
<div className="grid grid-cols-2 gap-y-4 gap-x-6 text-xs font-bold text-slate-700">
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block mb-0.5">Gender</label>
|
||||
<span className="text-slate-800 font-extrabold capitalize">{selectedWorker?.gender || 'N/A'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block">Salary Expectations</label>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block mb-0.5">Salary Expectations</label>
|
||||
<span className="text-slate-800 font-extrabold">{selectedWorker?.salary ? `${selectedWorker.salary} AED / mo` : 'N/A'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block">Experience</label>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block mb-0.5">Experience</label>
|
||||
<span className="text-slate-800 font-extrabold">{selectedWorker?.experience}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block">Category</label>
|
||||
<span className="text-slate-800 font-extrabold">{selectedWorker?.category}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block">Job Type</label>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block mb-0.5">Job Type</label>
|
||||
<span className="text-slate-800 font-extrabold capitalize">{selectedWorker?.preferred_job_type || 'N/A'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block">Accommodation Preference</label>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block mb-0.5">Accommodation Preference</label>
|
||||
<span className="text-slate-800 font-extrabold">{selectedWorker?.live_in_out || 'N/A'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block">Language</label>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block mb-0.5">Language</label>
|
||||
<span className="text-slate-800 font-extrabold">{selectedWorker?.language || 'English'}</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block">Skills</label>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
<div className="col-span-2 mt-2">
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block mb-1">Skills</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedWorker?.skills?.map(skill => (
|
||||
<span key={skill} className="bg-teal-50 border border-teal-100 text-teal-700 px-2 py-0.5 rounded text-[10px] font-extrabold uppercase">
|
||||
{skill}
|
||||
@ -662,145 +652,120 @@ export default function WorkerManagement({ workers }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-wider">Contact & Location</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-xs font-bold text-slate-700 bg-white p-3.5 rounded-xl border border-slate-100 shadow-sm">
|
||||
<div className="col-span-2 flex items-center space-x-2">
|
||||
<Mail className="w-4 h-4 text-teal-600 flex-shrink-0" />
|
||||
<span>{selectedWorker?.email}</span>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center space-x-2">
|
||||
<Phone className="w-4 h-4 text-teal-600 flex-shrink-0" />
|
||||
<span>{selectedWorker?.phone || '+971 52 489 1209'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block">Country</label>
|
||||
<span className="text-slate-800 font-extrabold">{selectedWorker?.country || 'N/A'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block">City</label>
|
||||
<span className="text-slate-800 font-extrabold">{selectedWorker?.city || 'N/A'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block">Area</label>
|
||||
<span className="text-slate-800 font-extrabold">{selectedWorker?.area || 'N/A'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block">Preferred Location</label>
|
||||
<span className="text-slate-800 font-extrabold">{selectedWorker?.preferred_location || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-wider">Bio / Profile Description</h3>
|
||||
<p className="text-xs text-slate-600 bg-slate-50 p-4 rounded-xl border border-slate-100 leading-relaxed whitespace-pre-line">
|
||||
{selectedWorker?.bio}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Management Controls: Verification, Availability, Fraud flags */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Verification override */}
|
||||
<div className="p-4 bg-white border border-slate-200 rounded-2xl flex flex-col justify-between shadow-sm">
|
||||
<div>
|
||||
<h4 className="text-xs font-black text-slate-900 uppercase tracking-wide">Verification status</h4>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-1">Status of system OCR document match</p>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => handleVerifyAction(selectedWorker.id, 'approve')}
|
||||
className={`flex-1 py-2 rounded-xl text-[9px] font-black uppercase tracking-wider transition-all border ${selectedWorker?.verified
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||
: 'bg-white text-slate-500 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
Verified
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleVerifyAction(selectedWorker.id, 'reject')}
|
||||
className={`flex-1 py-2 rounded-xl text-[9px] font-black uppercase tracking-wider transition-all border ${!selectedWorker?.verified
|
||||
? 'bg-amber-50 text-amber-700 border-amber-200'
|
||||
: 'bg-white text-slate-500 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
Pending / Unverified
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Availability Override */}
|
||||
<div className="p-4 bg-white border border-slate-200 rounded-2xl flex flex-col justify-between shadow-sm">
|
||||
<div>
|
||||
<h4 className="text-xs font-black text-slate-900 uppercase tracking-wide">Availability Override</h4>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-1">Bypass worker app setting</p>
|
||||
</div>
|
||||
<div className="flex bg-slate-100 p-1 rounded-xl mt-4">
|
||||
{['Available Now', 'In Interview', 'Engaged'].map(av => (
|
||||
<button
|
||||
key={av}
|
||||
onClick={() => handleAvailabilityOverride(selectedWorker.id, av)}
|
||||
className={`flex-1 py-1 rounded-lg text-[8px] font-black uppercase tracking-tight transition-all ${selectedWorker?.availability === av
|
||||
? 'bg-white text-slate-800 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{av.replace(' Now', '')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suspicious / Fraud flagging */}
|
||||
<div className={`p-4 border rounded-2xl flex flex-col justify-between shadow-sm ${selectedWorker?.is_fraud ? 'bg-red-50/50 border-red-200' : 'bg-white border-slate-200'
|
||||
}`}>
|
||||
<div>
|
||||
<h4 className="text-xs font-black text-slate-900 uppercase tracking-wide flex items-center gap-1">
|
||||
<ShieldAlert className="w-4 h-4 text-red-600" />
|
||||
<span>Fraud Flag</span>
|
||||
</h4>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-1">Flag profile as fake or duplicate</p>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => handleFlagFraud(selectedWorker.id, true, 'Low OCR passport scan confidence. Signature is blurred.')}
|
||||
className={`flex-1 py-2 rounded-xl text-[9px] font-black uppercase tracking-wider transition-all border ${selectedWorker?.is_fraud
|
||||
? 'bg-red-600 text-white border-none shadow-md shadow-red-600/20'
|
||||
: 'bg-white text-red-600 border-red-200 hover:bg-red-50'
|
||||
}`}
|
||||
>
|
||||
Flag Fraud
|
||||
</button>
|
||||
{selectedWorker?.is_fraud && (
|
||||
<button
|
||||
onClick={() => handleFlagFraud(selectedWorker.id, false, '')}
|
||||
className="px-3 py-2 bg-slate-900 text-white rounded-xl text-[9px] font-black uppercase tracking-wider"
|
||||
>
|
||||
Clear Flag
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Internal Sticky compliance notes */}
|
||||
{/* Compliance Notes */}
|
||||
<div className="space-y-2 bg-yellow-50/60 p-4 border border-yellow-200 rounded-2xl">
|
||||
<label className="text-[10px] font-black text-yellow-800 uppercase tracking-widest flex items-center gap-1">
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
<span>Internal Admin Compliance Notes Logs</span>
|
||||
</label>
|
||||
<textarea
|
||||
rows="2"
|
||||
rows="3"
|
||||
placeholder="Write admin logs regarding candidate verification reviews or user complaints..."
|
||||
className="w-full bg-white border border-yellow-200 rounded-xl p-3 text-xs font-bold text-slate-700 focus:ring-2 focus:ring-yellow-500/20 outline-none"
|
||||
value={adminNotes}
|
||||
onChange={e => setAdminNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column (1/3) */}
|
||||
<div className="space-y-6">
|
||||
{/* Contact & Location */}
|
||||
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm space-y-4">
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-wider">Contact & Location</h3>
|
||||
<div className="space-y-3.5 text-xs font-bold text-slate-700">
|
||||
<div className="flex items-center space-x-2 bg-slate-50 p-2.5 rounded-xl border border-slate-100">
|
||||
<Phone className="w-4 h-4 text-teal-600 flex-shrink-0" />
|
||||
<span className="text-slate-800 font-extrabold">{selectedWorker?.phone || '+971 52 489 1209'}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 pt-1">
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block mb-0.5">Country</label>
|
||||
<span className="text-slate-800 font-extrabold block">{selectedWorker?.country || 'N/A'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block mb-0.5">City</label>
|
||||
<span className="text-slate-800 font-extrabold block">{selectedWorker?.city || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="col-span-2 border-t border-slate-100 pt-2">
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block mb-0.5">Area</label>
|
||||
<span className="text-slate-800 font-extrabold block">{selectedWorker?.area || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="col-span-2 border-t border-slate-100 pt-2">
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block mb-0.5">Preferred Location</label>
|
||||
<span className="text-slate-800 font-extrabold block text-xs leading-normal">{selectedWorker?.preferred_location || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification Status & Fraud Flag Card */}
|
||||
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm space-y-4">
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-wider">Verification & Trust</h3>
|
||||
<div className="space-y-4">
|
||||
{/* Verification Action */}
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block mb-2">Verification Status</label>
|
||||
{selectedWorker?.verified ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-1.5 text-emerald-600 bg-emerald-50 border border-emerald-100 px-3 py-2 rounded-xl text-xs font-extrabold">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span>Verified Candidate</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleVerifyAction(selectedWorker.id, 'reject')}
|
||||
className="w-full py-2 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-xl text-[10px] font-black uppercase tracking-wider transition-all"
|
||||
>
|
||||
Revert to Pending
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-1.5 text-amber-600 bg-amber-50 border border-amber-100 px-3 py-2 rounded-xl text-xs font-extrabold">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span>Pending Verification</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleVerifyAction(selectedWorker.id, 'approve')}
|
||||
className="w-full py-2 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-xl text-[10px] font-black uppercase tracking-wider transition-all shadow-md shadow-[#0F6E56]/10"
|
||||
>
|
||||
Approve Profile
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fraud Flagging */}
|
||||
<div className="border-t border-slate-100 pt-4">
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block mb-2">Trustworthiness</label>
|
||||
{selectedWorker?.is_fraud ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-1.5 text-red-600 bg-red-50 border border-red-100 px-3 py-2 rounded-xl text-xs font-extrabold">
|
||||
<ShieldAlert className="w-4 h-4" />
|
||||
<span>Flagged as Fraud</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleFlagFraud(selectedWorker.id, false, '')}
|
||||
className="w-full py-2 bg-slate-900 hover:bg-slate-800 text-white rounded-xl text-[10px] font-black uppercase tracking-wider transition-all"
|
||||
>
|
||||
Clear Fraud Flag
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleFlagFraud(selectedWorker.id, true, 'Flagged by system admin review.')}
|
||||
className="w-full py-2 border border-red-200 hover:bg-red-50 text-red-600 rounded-xl text-[10px] font-black uppercase tracking-wider transition-all"
|
||||
>
|
||||
Flag as Fraud
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom controls */}
|
||||
|
||||
@ -18,7 +18,11 @@ import {
|
||||
MapPin,
|
||||
Clock,
|
||||
Sparkles,
|
||||
BellRing
|
||||
BellRing,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Gift,
|
||||
Plus
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
@ -30,6 +34,13 @@ import {
|
||||
DialogFooter
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
const hoursList = [
|
||||
'12:00', '12:30', '01:00', '01:30', '02:00', '02:30',
|
||||
'03:00', '03:30', '04:00', '04:30', '05:00', '05:30',
|
||||
'06:00', '06:30', '07:00', '07:30', '08:00', '08:30',
|
||||
'09:00', '09:30', '10:00', '10:30', '11:00', '11:30'
|
||||
];
|
||||
|
||||
export default function Announcements({ initialAnnouncements }) {
|
||||
const { t } = useTranslation();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
@ -38,6 +49,16 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
const [toastSub, setToastSub] = useState(null);
|
||||
const [announcements, setAnnouncements] = useState(initialAnnouncements || []);
|
||||
|
||||
// Time picker states
|
||||
const [startTimeHour, setStartTimeHour] = useState('09:00');
|
||||
const [startTimeAmpm, setStartTimeAmpm] = useState('AM');
|
||||
const [endTimeHour, setEndTimeHour] = useState('04:00');
|
||||
const [endTimeAmpm, setEndTimeAmpm] = useState('PM');
|
||||
const [isStartPickerOpen, setIsStartPickerOpen] = useState(false);
|
||||
const [isEndPickerOpen, setIsEndPickerOpen] = useState(false);
|
||||
const [expandedCards, setExpandedCards] = useState({});
|
||||
const [statusFilter, setStatusFilter] = useState('All');
|
||||
|
||||
useEffect(() => {
|
||||
setAnnouncements(initialAnnouncements || []);
|
||||
}, [initialAnnouncements]);
|
||||
@ -49,11 +70,25 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
type: 'Charity',
|
||||
provided_items: '',
|
||||
event_date: '',
|
||||
event_time: '',
|
||||
event_time: '09:00 AM - 04:00 PM',
|
||||
location_details: '',
|
||||
location_pin: ''
|
||||
});
|
||||
|
||||
const updateEventTime = (sh, sa, eh, ea) => {
|
||||
setNewAnnouncement({
|
||||
...newAnnouncement,
|
||||
event_time: `${sh} ${sa} - ${eh} ${ea}`
|
||||
});
|
||||
};
|
||||
|
||||
const toggleExpand = (id) => {
|
||||
setExpandedCards(prev => ({
|
||||
...prev,
|
||||
[id]: !prev[id]
|
||||
}));
|
||||
};
|
||||
|
||||
const showToast = (message, subMessage = null) => {
|
||||
setToastMessage(message);
|
||||
setToastSub(subMessage);
|
||||
@ -69,6 +104,10 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
reset();
|
||||
setStartTimeHour('09:00');
|
||||
setStartTimeAmpm('AM');
|
||||
setEndTimeHour('04:00');
|
||||
setEndTimeAmpm('PM');
|
||||
setIsDialogOpen(false);
|
||||
showToast(
|
||||
t('community_charity_event_posted', 'COMMUNITY CHARITY EVENT POSTED!'),
|
||||
@ -78,10 +117,15 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
});
|
||||
};
|
||||
|
||||
const filteredAnnouncements = announcements.filter(ann =>
|
||||
const filteredAnnouncements = announcements.filter(ann => {
|
||||
const matchesSearch =
|
||||
ann.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
ann.content.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
ann.content.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === 'All' || ann.status.toLowerCase() === statusFilter.toLowerCase();
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
return (
|
||||
<EmployerLayout title={t('charity_events_support_drives', 'Charity Events & Support Drives')}>
|
||||
@ -102,10 +146,10 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
<div className="space-y-6 select-none max-w-5xl mx-auto">
|
||||
|
||||
{/* Community Drive & Charity Banner */}
|
||||
<div className="bg-gradient-to-br from-rose-500/10 via-pink-500/5 to-transparent border border-rose-200 rounded-[32px] p-6 sm:p-8 flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="bg-gradient-to-br from-[#185FA5]/10 via-[#185FA5]/5 to-transparent border border-[#185FA5]/20 rounded-[32px] p-6 sm:p-8 flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="space-y-2 max-w-2xl">
|
||||
<div className="inline-flex items-center space-x-2 px-3 py-1 bg-rose-50 border border-rose-100 rounded-full text-[10px] font-black uppercase tracking-wider text-rose-700">
|
||||
<Heart className="w-3.5 h-3.5 fill-rose-500 text-rose-500 animate-pulse" />
|
||||
<div className="inline-flex items-center space-x-2 px-3 py-1 bg-[#185FA5]/5 border border-[#185FA5]/10 rounded-full text-[10px] font-black uppercase tracking-wider text-[#185FA5]">
|
||||
<Heart className="w-3.5 h-3.5 fill-[#185FA5] text-[#185FA5] animate-pulse" />
|
||||
<span>{t('dubai_community_support_drives', 'Dubai Community Support Drives')}</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-black text-slate-900 tracking-tight">{t('free_medical_checks_title', 'Free Medical Checks, Food Drives & Support Services')}</h2>
|
||||
@ -113,45 +157,32 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
{t('free_medical_checks_desc', 'Organizing a support program? Share community campaigns directly. The platform will automatically push an instant pop-up notification to workers, followed by a morning-of event reminder to keep attendance strong.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-white p-6 rounded-[32px] border border-slate-100 shadow-sm">
|
||||
<div className="relative flex-1 max-w-md group">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 group-focus-within:text-[#185FA5] transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('search_charity_events_placeholder', 'Search charity events & drives...')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3 bg-slate-50 border-none rounded-2xl text-sm font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button className="bg-rose-600 hover:bg-rose-700 text-white px-8 py-3.5 rounded-2xl font-black text-xs uppercase tracking-widest flex items-center justify-center space-x-2 transition-all shadow-lg shadow-rose-200/50 hover:-translate-y-1 active:translate-y-0">
|
||||
<Heart className="w-4 h-4 fill-white" />
|
||||
<button className="bg-[#185FA5] hover:bg-[#14508c] text-white px-5 py-2.5 rounded-xl font-bold text-xs uppercase tracking-wider flex items-center justify-center space-x-1.5 transition-all shadow-md shadow-[#185FA5]/20 hover:-translate-y-0.5 active:translate-y-0 shrink-0 border-none cursor-pointer self-start md:self-center">
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>{t('post_charity_event', 'Post Charity Event')}</span>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[550px] rounded-3xl p-0 overflow-hidden border-none shadow-2xl">
|
||||
<div className="bg-gradient-to-r from-rose-500 to-pink-600 p-8 text-white relative">
|
||||
<div className="bg-gradient-to-r from-[#185FA5] to-[#2573c2] p-8 text-white relative">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-white/10 rounded-full -mr-16 -mt-16 blur-2xl" />
|
||||
<DialogTitle className="text-2xl font-black relative z-10">{t('post_charity_event', 'Post Charity Event')}</DialogTitle>
|
||||
<DialogDescription className="text-pink-100 font-semibold mt-2 relative z-10">
|
||||
<DialogDescription className="text-white/80 font-semibold mt-2 relative z-10">
|
||||
{t('broadcast_support_program_desc', 'Broadcast a support program, medical camp, or distribution drive to the worker community.')}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreate} className="p-8 space-y-5 bg-white max-h-[80vh] overflow-y-auto">
|
||||
|
||||
<div className="space-y-4 p-4 bg-rose-50/50 rounded-2xl border border-rose-100 animate-in fade-in duration-200">
|
||||
<div className="text-[10px] font-black text-rose-800 uppercase tracking-widest flex items-center gap-1.5">
|
||||
<Heart className="w-3.5 h-3.5 fill-rose-500 text-rose-500" />
|
||||
<div className="space-y-4 p-4 bg-[#185FA5]/5 rounded-2xl border border-[#185FA5]/10 animate-in fade-in duration-200">
|
||||
<div className="text-[10px] font-black text-[#185FA5] uppercase tracking-widest flex items-center gap-1.5">
|
||||
<Heart className="w-3.5 h-3.5 fill-[#185FA5] text-[#185FA5]" />
|
||||
<span>{t('charity_drive_metadata', 'Charity Drive Metadata (Dubai Support)')}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase tracking-widest ml-1">{t('event_date', 'Event Date')}</label>
|
||||
<input
|
||||
@ -159,19 +190,152 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
required
|
||||
value={newAnnouncement.event_date}
|
||||
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, event_date: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white border border-slate-200 rounded-xl text-xs font-bold focus:ring-2 focus:ring-rose-100 outline-none"
|
||||
className="w-full h-11 px-3.5 bg-white border border-slate-200 rounded-xl text-xs font-bold focus:ring-2 focus:ring-[#185FA5]/20 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase tracking-widest ml-1">{t('event_time', 'Event Time')}</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder={t('event_time_placeholder', 'e.g. 9:00 AM - 4:00 PM')}
|
||||
value={newAnnouncement.event_time}
|
||||
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, event_time: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white border border-slate-200 rounded-xl text-xs font-bold focus:ring-2 focus:ring-rose-100 outline-none"
|
||||
/>
|
||||
|
||||
<div className="relative space-y-1">
|
||||
{isStartPickerOpen && (
|
||||
<div className="fixed inset-0 z-[45] bg-transparent" onClick={() => setIsStartPickerOpen(false)} />
|
||||
)}
|
||||
<div className="relative z-50">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase tracking-widest ml-1">{t('start_time', 'Start Time')}</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsStartPickerOpen(!isStartPickerOpen);
|
||||
setIsEndPickerOpen(false);
|
||||
}}
|
||||
className={`w-full h-11 flex items-center justify-between px-3.5 bg-white border ${
|
||||
isStartPickerOpen ? 'border-[#185FA5] ring-2 ring-[#185FA5]/20' : 'border-slate-200'
|
||||
} rounded-xl hover:border-[#185FA5] transition-all cursor-pointer text-left`}
|
||||
>
|
||||
<div className="flex items-center space-x-2 min-w-0">
|
||||
<Clock className="w-4 h-4 text-slate-400 shrink-0" />
|
||||
<div className="flex flex-col justify-center min-w-0 leading-tight">
|
||||
<div className="text-[8px] text-slate-500 font-bold uppercase tracking-wider">Start at</div>
|
||||
<div className="text-slate-800 text-xs font-bold truncate">{startTimeHour} {startTimeAmpm}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
</button>
|
||||
|
||||
{isStartPickerOpen && (
|
||||
<div className="absolute top-full mt-1.5 left-0 bg-white border border-slate-200 shadow-xl rounded-xl p-2 z-50 flex space-x-2.5 w-48 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="flex-1 max-h-40 overflow-y-auto pr-1 space-y-1 scrollbar-thin">
|
||||
{hoursList.map(h => (
|
||||
<button
|
||||
key={h}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStartTimeHour(h);
|
||||
setIsStartPickerOpen(false);
|
||||
updateEventTime(h, startTimeAmpm, endTimeHour, endTimeAmpm);
|
||||
}}
|
||||
className={`w-full text-left px-2 py-1 rounded text-xs font-semibold transition-colors ${
|
||||
startTimeHour === h
|
||||
? 'bg-[#185FA5]/10 text-[#185FA5]'
|
||||
: 'text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1 justify-center border-l border-slate-100 pl-2">
|
||||
{['AM', 'PM'].map(ampm => (
|
||||
<button
|
||||
key={ampm}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStartTimeAmpm(ampm);
|
||||
updateEventTime(startTimeHour, ampm, endTimeHour, endTimeAmpm);
|
||||
}}
|
||||
className={`px-2 py-1 rounded text-[10px] font-black transition-all border border-solid ${
|
||||
startTimeAmpm === ampm
|
||||
? 'bg-[#185FA5] border-[#185FA5] text-white shadow-sm'
|
||||
: 'border-slate-200 text-slate-400 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{ampm}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative space-y-1">
|
||||
{isEndPickerOpen && (
|
||||
<div className="fixed inset-0 z-[45] bg-transparent" onClick={() => setIsEndPickerOpen(false)} />
|
||||
)}
|
||||
<div className="relative z-50">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase tracking-widest ml-1">{t('end_time', 'End Time')}</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsEndPickerOpen(!isEndPickerOpen);
|
||||
setIsStartPickerOpen(false);
|
||||
}}
|
||||
className={`w-full h-11 flex items-center justify-between px-3.5 bg-white border ${
|
||||
isEndPickerOpen ? 'border-[#185FA5] ring-2 ring-[#185FA5]/20' : 'border-slate-200'
|
||||
} rounded-xl hover:border-[#185FA5] transition-all cursor-pointer text-left`}
|
||||
>
|
||||
<div className="flex items-center space-x-2 min-w-0">
|
||||
<Clock className="w-4 h-4 text-slate-400 shrink-0" />
|
||||
<div className="flex flex-col justify-center min-w-0 leading-tight">
|
||||
<div className="text-[8px] text-slate-500 font-bold uppercase tracking-wider">End with</div>
|
||||
<div className="text-slate-800 text-xs font-bold truncate">{endTimeHour} {endTimeAmpm}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
</button>
|
||||
|
||||
{isEndPickerOpen && (
|
||||
<div className="absolute top-full mt-1.5 right-0 bg-white border border-slate-200 shadow-xl rounded-xl p-2 z-50 flex space-x-2.5 w-48 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="flex-1 max-h-40 overflow-y-auto pr-1 space-y-1 scrollbar-thin">
|
||||
{hoursList.map(h => (
|
||||
<button
|
||||
key={h}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEndTimeHour(h);
|
||||
setIsEndPickerOpen(false);
|
||||
updateEventTime(startTimeHour, startTimeAmpm, h, endTimeAmpm);
|
||||
}}
|
||||
className={`w-full text-left px-2 py-1 rounded text-xs font-semibold transition-colors ${
|
||||
endTimeHour === h
|
||||
? 'bg-[#185FA5]/10 text-[#185FA5]'
|
||||
: 'text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1 justify-center border-l border-slate-100 pl-2">
|
||||
{['AM', 'PM'].map(ampm => (
|
||||
<button
|
||||
key={ampm}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEndTimeAmpm(ampm);
|
||||
updateEventTime(startTimeHour, startTimeAmpm, endTimeHour, ampm);
|
||||
}}
|
||||
className={`px-2 py-1 rounded text-[10px] font-black transition-all border border-solid ${
|
||||
endTimeAmpm === ampm
|
||||
? 'bg-[#185FA5] border-[#185FA5] text-white shadow-sm'
|
||||
: 'border-slate-200 text-slate-400 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{ampm}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -183,7 +347,7 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
placeholder={t('provided_items_placeholder', 'e.g. Free Medical Check, Food Boxes, Supplies')}
|
||||
value={newAnnouncement.provided_items}
|
||||
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, provided_items: e.target.value })}
|
||||
className="w-full px-3.5 py-2.5 bg-white border border-slate-200 rounded-xl text-xs font-bold focus:ring-2 focus:ring-rose-100 outline-none"
|
||||
className="w-full px-3.5 py-2.5 bg-white border border-slate-200 rounded-xl text-xs font-bold focus:ring-2 focus:ring-[#185FA5]/20 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -195,7 +359,7 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
placeholder={t('location_details_placeholder', 'e.g. Al Quoz Community Center, Dubai')}
|
||||
value={newAnnouncement.location_details}
|
||||
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, location_details: e.target.value })}
|
||||
className="w-full px-3.5 py-2.5 bg-white border border-slate-200 rounded-xl text-xs font-bold focus:ring-2 focus:ring-rose-100 outline-none mb-2"
|
||||
className="w-full px-3.5 py-2.5 bg-white border border-slate-200 rounded-xl text-xs font-bold focus:ring-2 focus:ring-[#185FA5]/20 outline-none mb-2"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
@ -203,7 +367,7 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
placeholder={t('maps_pin_link_placeholder', 'Google Maps Pin Link (e.g. https://maps.app.goo.gl/xyz)')}
|
||||
value={newAnnouncement.location_pin}
|
||||
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, location_pin: e.target.value })}
|
||||
className="w-full px-3.5 py-2.5 bg-white border border-slate-200 rounded-xl text-xs font-bold focus:ring-2 focus:ring-rose-100 outline-none"
|
||||
className="w-full px-3.5 py-2.5 bg-white border border-slate-200 rounded-xl text-xs font-bold focus:ring-2 focus:ring-[#185FA5]/20 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -215,7 +379,7 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
required
|
||||
value={newAnnouncement.title}
|
||||
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, title: e.target.value })}
|
||||
className="w-full px-5 py-3.5 bg-slate-50 border-none rounded-2xl text-sm font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none"
|
||||
className="w-full px-5 py-3.5 bg-slate-50 border-none rounded-2xl text-sm font-bold focus:ring-2 focus:ring-[#185FA5]/20 transition-all outline-none"
|
||||
placeholder={t('title_placeholder', 'e.g. Free Dental checkup by Emirates Charity')}
|
||||
/>
|
||||
</div>
|
||||
@ -227,7 +391,7 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
rows="3"
|
||||
value={newAnnouncement.content}
|
||||
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, content: e.target.value })}
|
||||
className="w-full px-5 py-4 bg-slate-50 border-none rounded-2xl text-sm font-bold focus:ring-2 focus:ring-blue-100 transition-all outline-none resize-none"
|
||||
className="w-full px-5 py-4 bg-slate-50 border-none rounded-2xl text-sm font-bold focus:ring-2 focus:ring-[#185FA5]/20 transition-all outline-none resize-none"
|
||||
placeholder={t('event_desc_placeholder', 'Enter details of the charity event here...')}
|
||||
></textarea>
|
||||
</div>
|
||||
@ -236,7 +400,7 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
className="w-full bg-rose-600 hover:bg-rose-700 text-white py-4 rounded-2xl text-xs font-black uppercase tracking-widest flex items-center justify-center space-x-2 transition-all shadow-lg shadow-rose-200/50"
|
||||
className="w-full bg-[#185FA5] hover:bg-[#14508c] text-white py-4 rounded-2xl text-xs font-black uppercase tracking-widest flex items-center justify-center space-x-2 transition-all shadow-lg shadow-[#185FA5]/25 cursor-pointer border-none"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
<span>{t('publish_charity_event', 'Publish Charity Event')}</span>
|
||||
@ -247,9 +411,43 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-white p-6 rounded-[32px] border border-slate-100 shadow-sm">
|
||||
<div className="relative flex-1 max-w-md group">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 group-focus-within:text-[#185FA5] transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('search_charity_events_placeholder', 'Search charity events & drives...')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3 bg-slate-50 border-none rounded-2xl text-sm font-bold focus:ring-2 focus:ring-[#185FA5]/20 transition-all outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Filters */}
|
||||
<div className="flex items-center space-x-1 bg-slate-50 p-1 rounded-xl border border-slate-200 shrink-0 self-start md:self-center">
|
||||
{['All', 'Pending', 'Approved', 'Rejected'].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(status)}
|
||||
className={`px-4 py-1.5 rounded-lg text-xs font-bold transition-all border-none cursor-pointer ${
|
||||
statusFilter === status
|
||||
? 'bg-[#185FA5] text-white shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-800 bg-transparent'
|
||||
}`}
|
||||
>
|
||||
{status === 'All' ? t('status_filter_all', 'All') :
|
||||
status === 'Pending' ? t('status_filter_pending', 'Pending') :
|
||||
status === 'Approved' ? t('status_filter_approved', 'Approved') :
|
||||
t('status_filter_rejected', 'Rejected')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{filteredAnnouncements.length === 0 ? (
|
||||
<div className="text-center py-24 bg-white rounded-[40px] border border-slate-200 shadow-sm text-slate-500 border-dashed">
|
||||
<div className="text-center py-24 bg-white rounded-[40px] border border-slate-200 shadow-sm text-slate-500 border-dashed md:col-span-2">
|
||||
<div className="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Megaphone className="w-10 h-10 text-slate-200" />
|
||||
</div>
|
||||
@ -259,96 +457,102 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
) : (
|
||||
filteredAnnouncements.map((ann) => {
|
||||
const details = ann.charityDetails;
|
||||
const isExpanded = !!expandedCards[ann.id];
|
||||
const statusColorClass =
|
||||
ann.status === 'approved' ? 'border-l-emerald-500' :
|
||||
ann.status === 'rejected' ? 'border-l-rose-500' :
|
||||
'border-l-amber-500';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ann.id}
|
||||
className="group bg-white rounded-[32px] border border-rose-100 bg-rose-50/10 p-6 shadow-sm hover:shadow-md transition-all duration-300 relative overflow-hidden flex flex-col md:flex-row md:items-start gap-6"
|
||||
className={`bg-white rounded-2xl border border-slate-200 border-l-4 ${statusColorClass} shadow-sm hover:shadow-md transition-all flex flex-col justify-between overflow-hidden`}
|
||||
>
|
||||
<div className="absolute right-0 top-0 w-24 h-24 bg-rose-500/5 rounded-full blur-2xl pointer-events-none" />
|
||||
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="px-3 py-1.5 rounded-xl text-[9px] font-black uppercase tracking-widest border bg-rose-50 border-rose-100 text-rose-700 flex items-center space-x-1">
|
||||
<Heart className="w-3 h-3 fill-rose-500 text-rose-500" />
|
||||
<span>{t('community_charity_drive', 'COMMUNITY CHARITY DRIVE')}</span>
|
||||
{/* Top Card Area */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1 flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 flex-wrap gap-y-1">
|
||||
<h3 className="font-bold text-sm text-slate-800 tracking-tight flex items-center gap-1 min-w-0">
|
||||
<Sparkles className="w-3.5 h-3.5 text-[#185FA5] fill-[#185FA5]/10 shrink-0" />
|
||||
<span className="truncate block font-extrabold" title={ann.title}>{ann.title}</span>
|
||||
</h3>
|
||||
<span className={`px-2 py-0.5 rounded text-[8px] font-extrabold uppercase tracking-wider shrink-0 ${
|
||||
ann.status === 'approved' ? 'bg-emerald-50 text-emerald-700 border border-solid border-emerald-100' :
|
||||
ann.status === 'rejected' ? 'bg-rose-50 text-rose-700 border border-solid border-rose-100' :
|
||||
'bg-amber-50 text-amber-700 border border-solid border-amber-100'
|
||||
}`}>
|
||||
{ann.status === 'approved' ? t('status_approved', 'APPROVED') : (ann.status === 'rejected' ? t('status_rejected', 'REJECTED') : t('status_pending', 'PENDING'))}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center text-slate-400 text-[10px] font-black uppercase tracking-widest">
|
||||
<Calendar className="w-3.5 h-3.5 mr-1.5" />
|
||||
{ann.created_at}
|
||||
</div>
|
||||
|
||||
<div className="inline-flex items-center space-x-1 px-2.5 py-1 bg-emerald-50 rounded-lg text-[9px] font-bold text-emerald-800 border border-emerald-100">
|
||||
<BellRing className="w-3 h-3 text-emerald-600 animate-bounce" />
|
||||
{ann.status === 'approved' && (
|
||||
<span className="inline-flex items-center space-x-1 px-1.5 py-0.5 bg-emerald-50 rounded text-[8px] font-bold text-emerald-800 border border-emerald-100 uppercase tracking-wider shrink-0">
|
||||
<BellRing className="w-2.5 h-2.5 text-emerald-600 animate-bounce" />
|
||||
<span>{t('push_reminder_scheduled', 'Push & Reminder Scheduled')}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-lg font-black text-slate-900 tracking-tight flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-rose-500" />
|
||||
{ann.title}
|
||||
</h4>
|
||||
<p className="text-xs text-slate-600 font-bold leading-relaxed">{ann.content}</p>
|
||||
{/* Collapsible Content */}
|
||||
<div className="space-y-2 text-xs">
|
||||
<p className={`text-slate-600 leading-relaxed font-semibold ${isExpanded ? '' : 'line-clamp-2'}`}>
|
||||
{ann.content}
|
||||
</p>
|
||||
|
||||
{ann.content.length > 90 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(ann.id)}
|
||||
className="text-[#185FA5] hover:underline font-extrabold flex items-center space-x-0.5 border-none bg-transparent cursor-pointer p-0 text-[10px]"
|
||||
>
|
||||
<span>{isExpanded ? t('show_less', 'Show Less') : t('read_full_description', 'Read Full Description')}</span>
|
||||
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details metadata row (compact icons) */}
|
||||
{details && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 p-4 bg-white/80 rounded-2xl border border-rose-100/60 text-xs font-bold text-slate-600">
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-rose-50 flex items-center justify-center text-rose-600 shrink-0">
|
||||
<Heart className="w-4 h-4 fill-rose-500 text-rose-500" />
|
||||
<div className="pt-2.5 border-t border-slate-100 grid grid-cols-3 gap-2 text-[10px] text-slate-500 font-black uppercase tracking-wider">
|
||||
<div className="flex items-center space-x-1.5 min-w-0">
|
||||
<Gift className="w-3.5 h-3.5 text-[#185FA5] fill-[#185FA5]/10 shrink-0" />
|
||||
<span className="truncate text-slate-700" title={details.provided_items}>
|
||||
{details.provided_items}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-slate-400 uppercase font-black tracking-widest">{t('provided_packages', 'Provided Packages')}</div>
|
||||
<div className="text-slate-800 text-[11px] font-extrabold mt-0.5">{details.provided_items}</div>
|
||||
<div className="flex items-center space-x-1.5 min-w-0">
|
||||
<Calendar className="w-3.5 h-3.5 text-amber-500 shrink-0" />
|
||||
<span className="truncate text-slate-700" title={`${details.event_date} ${details.event_time}`}>
|
||||
{details.event_date}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 min-w-0">
|
||||
<MapPin className="w-3.5 h-3.5 text-[#185FA5] shrink-0" />
|
||||
<span className="truncate text-slate-700" title={details.location_details}>
|
||||
{details.location_details}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-50 flex items-center justify-center text-amber-600 shrink-0">
|
||||
<Clock className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-slate-400 uppercase font-black tracking-widest">{t('event_timing', 'Event Timing')}</div>
|
||||
<div className="text-slate-800 text-[11px] font-extrabold mt-0.5">
|
||||
{details.event_date} • {details.event_time}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2.5 md:col-span-2 pt-2 border-t border-slate-100 mt-1">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center text-[#185FA5] shrink-0">
|
||||
<MapPin className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[9px] text-slate-400 uppercase font-black tracking-widest">{t('event_location_address', 'Event Location Address')}</div>
|
||||
<div className="text-slate-800 text-[11px] font-extrabold mt-0.5">{details.location_details}</div>
|
||||
</div>
|
||||
{details.location_pin && (
|
||||
{/* Bottom Card Footer */}
|
||||
<div className="px-4 py-2 bg-slate-50 rounded-b-xl border-t border-slate-100 flex items-center justify-between text-[9px] text-slate-400 font-extrabold uppercase tracking-widest">
|
||||
<span>{t('posted_label', 'Posted')}: {ann.created_at}</span>
|
||||
{details?.location_pin && (
|
||||
<a
|
||||
href={details.location_pin}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="px-3.5 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded-xl text-[10px] font-black uppercase tracking-wider flex items-center space-x-1 w-fit transition-colors shrink-0 shadow-sm"
|
||||
className="text-[#185FA5] hover:underline flex items-center space-x-0.5 normal-case"
|
||||
>
|
||||
<MapPin className="w-3.5 h-3.5" />
|
||||
<span>{t('view_map_location_pin', 'View Map Location Pin')}</span>
|
||||
<span>{t('view_on_map', 'View on Map')}</span>
|
||||
<MapPin className="w-2.5 h-2.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 md:border-l md:border-slate-50 md:pl-6 shrink-0 h-full self-center">
|
||||
<button className="p-3 rounded-2xl bg-slate-50 text-slate-400 hover:text-rose-600 transition-all">
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@ -123,37 +123,7 @@ export default function Show({ conversation, initialMessages, conversations = []
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
// Show immediate confirmation of the push alert popping up on worker mobile app
|
||||
toast.success(t('direct_push_sent', 'Direct Mobile Push Notification sent!'), {
|
||||
description: t('popped_up_instantly_desc', "Popped up instantly on {name}'s mobile device.").replace('{name}', conversation.worker_name),
|
||||
duration: 4500,
|
||||
});
|
||||
|
||||
// Simulate worker typing response shortly after
|
||||
setTimeout(() => {
|
||||
setIsTyping(true);
|
||||
setTimeout(() => {
|
||||
setIsTyping(false);
|
||||
|
||||
// Reload conversation to pull actual database messages (including automated worker 'Yes')
|
||||
router.reload({
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
if (isLookingJobQuery) {
|
||||
toast.success(t('candidate_hired_success', "Candidate response: YES! Automatically marked as HIRED!"), {
|
||||
description: t('candidate_hired_desc', "{name} has been successfully added to your Hired candidate pipeline.").replace('{name}', conversation.worker_name),
|
||||
duration: 6000,
|
||||
});
|
||||
} else {
|
||||
toast.info(t('new_message_from', 'New message from {name}!').replace('{name}', conversation.worker_name), {
|
||||
description: t('auto_reply_text', "Thank you for your response! I have reviewed your offer proposal and am looking forward to our video interview."),
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 2000);
|
||||
}, 1500);
|
||||
router.reload({ preserveScroll: true });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,26 +1,18 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import EmployerLayout from '../../Layouts/EmployerLayout';
|
||||
import { useTranslation } from '../../lib/LanguageContext';
|
||||
import {
|
||||
CheckCircle2,
|
||||
Globe2,
|
||||
Briefcase,
|
||||
DollarSign,
|
||||
MessageSquare,
|
||||
UserCheck,
|
||||
Search,
|
||||
FileText,
|
||||
Download,
|
||||
Printer,
|
||||
Copy,
|
||||
FileSpreadsheet,
|
||||
FileJson,
|
||||
Filter,
|
||||
ChevronRight,
|
||||
MoreHorizontal,
|
||||
Users,
|
||||
Send
|
||||
MapPin,
|
||||
Briefcase,
|
||||
Home,
|
||||
Globe2,
|
||||
Plane
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -30,31 +22,212 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import FilterDrawer, { FilterSection, FilterSelect, FilterInput, FilterChips } from '../../Components/Employer/FilterDrawer';
|
||||
|
||||
export default function SelectedCandidates({ selectedWorkers }) {
|
||||
const { t } = useTranslation();
|
||||
const [workers, setWorkers] = useState((selectedWorkers || []).filter(w => w.status !== 'Searching'));
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Drawer state
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
// Filter States
|
||||
const [filterLocation, setFilterLocation] = useState('');
|
||||
const [filterJobType, setFilterJobType] = useState('All');
|
||||
const [filterAccommodation, setFilterAccommodation] = useState('All');
|
||||
const [filterNationality, setFilterNationality] = useState('All');
|
||||
const [filterInCountry, setFilterInCountry] = useState('All');
|
||||
const [filterVisaType, setFilterVisaType] = useState('All');
|
||||
|
||||
useEffect(() => {
|
||||
setWorkers((selectedWorkers || []).filter(w => w.status !== 'Searching'));
|
||||
}, [selectedWorkers]);
|
||||
|
||||
const changeStatus = (id, newStatus) => {
|
||||
// Optimistic UI state update
|
||||
setWorkers(workers.map(w => w.id === id ? { ...w, status: newStatus } : w));
|
||||
|
||||
// Persist change to database
|
||||
router.post(`/employer/candidates/${id}/status`, { status: newStatus }, {
|
||||
preserveScroll: true
|
||||
});
|
||||
};
|
||||
|
||||
// Extract unique nationalities for filter dropdown
|
||||
const nationalities = useMemo(() => {
|
||||
const nats = (selectedWorkers || []).map(w => w.nationality).filter(Boolean);
|
||||
return [...new Set(nats)];
|
||||
}, [selectedWorkers]);
|
||||
|
||||
// Apply Client-Side Filters
|
||||
const filteredWorkers = useMemo(() => {
|
||||
let list = (selectedWorkers || []).filter(w => w.status !== 'Searching');
|
||||
|
||||
if (searchTerm.trim() !== '') {
|
||||
const query = searchTerm.toLowerCase();
|
||||
list = list.filter(w => w.name.toLowerCase().includes(query));
|
||||
}
|
||||
if (filterLocation.trim() !== '') {
|
||||
const query = filterLocation.toLowerCase();
|
||||
list = list.filter(w => w.preferred_location && w.preferred_location.toLowerCase().includes(query));
|
||||
}
|
||||
if (filterJobType !== 'All') {
|
||||
list = list.filter(w => w.preferred_job_type && w.preferred_job_type.toLowerCase() === filterJobType.toLowerCase());
|
||||
}
|
||||
if (filterAccommodation !== 'All') {
|
||||
list = list.filter(w => w.live_in_out && w.live_in_out.toLowerCase() === filterAccommodation.toLowerCase());
|
||||
}
|
||||
if (filterNationality !== 'All') {
|
||||
list = list.filter(w => w.nationality && w.nationality.toLowerCase() === filterNationality.toLowerCase());
|
||||
}
|
||||
if (filterInCountry !== 'All') {
|
||||
const wantInCountry = filterInCountry === 'In Country';
|
||||
list = list.filter(w => {
|
||||
const wVal = w.in_country;
|
||||
return (wVal === true || wVal === '1' || wVal === 1) === wantInCountry;
|
||||
});
|
||||
}
|
||||
if (filterInCountry === 'In Country' && filterVisaType !== 'All') {
|
||||
list = list.filter(w => w.visa_status && w.visa_status.toLowerCase().includes(filterVisaType.toLowerCase()));
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [selectedWorkers, searchTerm, filterLocation, filterJobType, filterAccommodation, filterNationality, filterInCountry, filterVisaType]);
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilterLocation('');
|
||||
setFilterJobType('All');
|
||||
setFilterAccommodation('All');
|
||||
setFilterNationality('All');
|
||||
setFilterInCountry('All');
|
||||
setFilterVisaType('All');
|
||||
};
|
||||
|
||||
const activeFilterCount = useMemo(() => {
|
||||
let count = 0;
|
||||
if (filterLocation.trim() !== '') count++;
|
||||
if (filterJobType !== 'All') count++;
|
||||
if (filterAccommodation !== 'All') count++;
|
||||
if (filterNationality !== 'All') count++;
|
||||
if (filterInCountry !== 'All') count++;
|
||||
if (filterInCountry === 'In Country' && filterVisaType !== 'All') count++;
|
||||
return count;
|
||||
}, [filterLocation, filterJobType, filterAccommodation, filterNationality, filterInCountry, filterVisaType]);
|
||||
|
||||
// Active filter tags for display
|
||||
const activeFilterTags = useMemo(() => {
|
||||
const tags = [];
|
||||
if (filterLocation.trim()) tags.push({ key: 'location', label: `📍 ${filterLocation}`, clear: () => setFilterLocation('') });
|
||||
if (filterJobType !== 'All') tags.push({ key: 'job', label: `💼 ${filterJobType}`, clear: () => setFilterJobType('All') });
|
||||
if (filterAccommodation !== 'All') tags.push({ key: 'acc', label: `🏠 ${filterAccommodation.replace('_', '-')}`, clear: () => setFilterAccommodation('All') });
|
||||
if (filterNationality !== 'All') tags.push({ key: 'nat', label: `🌍 ${filterNationality}`, clear: () => setFilterNationality('All') });
|
||||
if (filterInCountry !== 'All') tags.push({ key: 'country', label: `✈️ ${filterInCountry}`, clear: () => { setFilterInCountry('All'); setFilterVisaType('All'); } });
|
||||
if (filterInCountry === 'In Country' && filterVisaType !== 'All') tags.push({ key: 'visa', label: `🪪 ${filterVisaType}`, clear: () => setFilterVisaType('All') });
|
||||
return tags;
|
||||
}, [filterLocation, filterJobType, filterAccommodation, filterNationality, filterInCountry, filterVisaType]);
|
||||
|
||||
return (
|
||||
<EmployerLayout title={t('candidates_pipeline', 'Candidates')}>
|
||||
<Head title={`${t('candidates_pipeline', 'Candidates')} - ${t('employer_portal', 'Employer Portal')}`} />
|
||||
|
||||
<div className="space-y-8 select-none">
|
||||
{/* ── Filter Drawer ── */}
|
||||
<FilterDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onReset={resetFilters}
|
||||
activeCount={activeFilterCount}
|
||||
title="Filter Candidates"
|
||||
>
|
||||
{/* Preferred Location */}
|
||||
<FilterSection label="Preferred Location">
|
||||
<FilterInput
|
||||
id="filter_preferred_location"
|
||||
value={filterLocation}
|
||||
onChange={e => setFilterLocation(e.target.value)}
|
||||
placeholder="e.g. Dubai, Abu Dhabi…"
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
{/* Job Type */}
|
||||
<FilterSection label="Job Type">
|
||||
<FilterChips
|
||||
options={[
|
||||
{ value: 'All', label: 'All' },
|
||||
{ value: 'full-time', label: 'Full-time' },
|
||||
{ value: 'part-time', label: 'Part-time' },
|
||||
]}
|
||||
selected={filterJobType}
|
||||
onChange={setFilterJobType}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
{/* Accommodation */}
|
||||
<FilterSection label="Accommodation Type">
|
||||
<FilterChips
|
||||
options={[
|
||||
{ value: 'All', label: 'All' },
|
||||
{ value: 'live_in', label: 'Live-in' },
|
||||
{ value: 'live_out', label: 'Live-out' },
|
||||
]}
|
||||
selected={filterAccommodation}
|
||||
onChange={setFilterAccommodation}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
{/* Nationality */}
|
||||
<FilterSection label="Nationality">
|
||||
<FilterSelect
|
||||
id="filter_nationality"
|
||||
value={filterNationality}
|
||||
onChange={e => setFilterNationality(e.target.value)}
|
||||
>
|
||||
<option value="All">All Nationalities</option>
|
||||
{nationalities.map(nat => (
|
||||
<option key={nat} value={nat}>{nat}</option>
|
||||
))}
|
||||
</FilterSelect>
|
||||
</FilterSection>
|
||||
|
||||
{/* Country Status */}
|
||||
<FilterSection label="Country Status">
|
||||
<FilterChips
|
||||
options={[
|
||||
{ value: 'All', label: 'All' },
|
||||
{ value: 'In Country', label: 'In Country' },
|
||||
{ value: 'Out of Country', label: 'Out of Country' },
|
||||
]}
|
||||
selected={filterInCountry}
|
||||
onChange={val => {
|
||||
setFilterInCountry(val);
|
||||
if (val !== 'In Country') setFilterVisaType('All');
|
||||
}}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
{/* Visa Type */}
|
||||
<FilterSection label="Visa Type">
|
||||
<div className={filterInCountry !== 'In Country' ? 'opacity-40 pointer-events-none' : ''}>
|
||||
<FilterSelect
|
||||
id="filter_visa_status"
|
||||
value={filterVisaType}
|
||||
onChange={e => setFilterVisaType(e.target.value)}
|
||||
disabled={filterInCountry !== 'In Country'}
|
||||
>
|
||||
<option value="All">All Visa Types</option>
|
||||
<option value="Residence Visa">Residence Visa</option>
|
||||
<option value="Tourist Visa">Tourist Visa</option>
|
||||
<option value="Employment Visa">Employment Visa</option>
|
||||
<option value="Cancelled Visa">Cancelled Visa</option>
|
||||
<option value="Own Visa">Own Visa</option>
|
||||
</FilterSelect>
|
||||
{filterInCountry !== 'In Country' && (
|
||||
<p className="text-[11px] text-slate-400 mt-1.5 font-medium">
|
||||
Select "In Country" to enable this filter
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FilterSection>
|
||||
</FilterDrawer>
|
||||
|
||||
<div className="space-y-6 select-none">
|
||||
{/* Header Section */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@ -65,32 +238,76 @@ export default function SelectedCandidates({ selectedWorkers }) {
|
||||
|
||||
{/* Table Section */}
|
||||
<div className="bg-white rounded-3xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between">
|
||||
<div className="relative w-72">
|
||||
{/* Toolbar */}
|
||||
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('search_by_name', 'Search by name...')}
|
||||
className="w-full pl-10 pr-4 py-2 bg-white border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-blue-100 focus:border-[#185FA5] transition-all outline-none"
|
||||
placeholder={t('search_by_name', 'Search by name…')}
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-blue-100 focus:border-[#185FA5] transition-all outline-none"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center bg-white border border-slate-200 rounded-xl p-1">
|
||||
<div className="flex items-center gap-3 ml-auto">
|
||||
{/* Results count */}
|
||||
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap">
|
||||
{filteredWorkers.length} result{filteredWorkers.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
|
||||
{/* Export actions */}
|
||||
<div className="hidden sm:flex items-center bg-white border border-slate-200 rounded-xl p-1">
|
||||
{['COPY', 'CSV', 'PDF', 'PRINT'].map(action => (
|
||||
<button key={action} className="px-3 py-1.5 text-[10px] font-bold text-slate-500 hover:text-[#185FA5] hover:bg-blue-50 rounded-lg transition-all">
|
||||
{action}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="p-2.5 bg-white border border-slate-200 text-slate-400 hover:text-[#185FA5] rounded-xl transition-all shadow-sm">
|
||||
|
||||
{/* Filter button */}
|
||||
<button
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className={`relative flex items-center space-x-2 px-4 py-2.5 rounded-xl font-bold text-xs transition-all border ${
|
||||
activeFilterCount > 0
|
||||
? 'bg-[#185FA5] text-white border-[#185FA5] shadow-md shadow-blue-200'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:border-[#185FA5] hover:text-[#185FA5]'
|
||||
}`}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
<span>Filters</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="bg-white/25 text-white text-[10px] font-black px-1.5 py-0.5 rounded-full leading-none">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active filter tags */}
|
||||
{activeFilterTags.length > 0 && (
|
||||
<div className="px-5 py-3 border-b border-slate-100 bg-blue-50/30 flex flex-wrap gap-2 items-center">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mr-1">Active:</span>
|
||||
{activeFilterTags.map(tag => (
|
||||
<button
|
||||
key={tag.key}
|
||||
onClick={tag.clear}
|
||||
className="inline-flex items-center space-x-1.5 px-2.5 py-1 bg-[#185FA5]/10 text-[#185FA5] text-[11px] font-bold rounded-full border border-[#185FA5]/20 hover:bg-[#185FA5]/20 transition-all"
|
||||
>
|
||||
<span>{tag.label}</span>
|
||||
<span className="text-[#185FA5]/60 font-black">×</span>
|
||||
</button>
|
||||
))}
|
||||
<button onClick={resetFilters} className="text-[11px] font-bold text-rose-500 hover:text-rose-700 ml-1 transition-colors">
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
@ -104,21 +321,24 @@ export default function SelectedCandidates({ selectedWorkers }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{workers.length > 0 ? (
|
||||
workers.filter(w => w.name.toLowerCase().includes(searchTerm.toLowerCase())).map((worker) => (
|
||||
{filteredWorkers.length > 0 ? (
|
||||
filteredWorkers.map((worker) => (
|
||||
<tr key={worker.id} className="group hover:bg-slate-50/50 transition-colors">
|
||||
<td className="px-6 py-5">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-100 text-[#185FA5] flex items-center justify-center font-bold text-sm">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-100 to-blue-200 text-[#185FA5] flex items-center justify-center font-bold text-sm flex-shrink-0">
|
||||
{worker.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-slate-900">{worker.name}</div>
|
||||
<div className="text-[10px] text-slate-400 font-medium">{worker.nationality}</div>
|
||||
<div className="text-[10px] text-slate-400 font-medium flex items-center space-x-1 mt-0.5">
|
||||
<Globe2 className="w-3 h-3" />
|
||||
<span>{worker.nationality || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-5 text-[11px] font-bold text-slate-500">Nov 14, 2026</td>
|
||||
<td className="px-6 py-5 text-[11px] font-bold text-slate-500">{worker.applied_at || '—'}</td>
|
||||
<td className="px-6 py-5">
|
||||
<span className="px-2 py-1 bg-slate-100 text-slate-600 text-[10px] font-bold rounded-lg">{worker.category}</span>
|
||||
</td>
|
||||
@ -126,8 +346,8 @@ export default function SelectedCandidates({ selectedWorkers }) {
|
||||
<span className="text-sm font-bold text-slate-800">{worker.salary} <span className="text-[10px] text-slate-400">AED</span></span>
|
||||
</td>
|
||||
<td className="px-6 py-5">
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-black tracking-tight bg-emerald-50 text-emerald-700 border border-emerald-100 shadow-xs">
|
||||
<span className="w-1 h-1 rounded-full mr-1.5 bg-emerald-500" />
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-black tracking-tight bg-emerald-50 text-emerald-700 border border-emerald-100">
|
||||
<span className="w-1.5 h-1.5 rounded-full mr-1.5 bg-emerald-500" />
|
||||
{t('hired', 'Hired').toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
@ -151,10 +371,17 @@ export default function SelectedCandidates({ selectedWorkers }) {
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="7" className="p-20 text-center">
|
||||
<td colSpan="6" className="py-20 text-center">
|
||||
<div className="space-y-3">
|
||||
<UserCheck className="w-12 h-12 text-slate-200 mx-auto" />
|
||||
<div className="w-16 h-16 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto">
|
||||
<UserCheck className="w-8 h-8 text-slate-300" />
|
||||
</div>
|
||||
<div className="text-base font-bold text-slate-400">{t('no_candidates_found', 'No candidates found')}</div>
|
||||
{activeFilterCount > 0 && (
|
||||
<button onClick={resetFilters} className="text-sm font-bold text-[#185FA5] hover:underline">
|
||||
Clear filters to see all candidates
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -163,21 +390,15 @@ export default function SelectedCandidates({ selectedWorkers }) {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Footer */}
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 bg-slate-50/50 border-t border-slate-100 flex items-center justify-between">
|
||||
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">
|
||||
{t('showing_candidates', 'Showing {start} to {end} of {total} candidates')
|
||||
.replace('{start}', 1)
|
||||
.replace('{end}', workers.length)
|
||||
.replace('{total}', workers.length)}
|
||||
Showing <span className="text-slate-700">{filteredWorkers.length}</span> of <span className="text-slate-700">{(selectedWorkers || []).filter(w => w.status !== 'Searching').length}</span> candidates
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button className="px-3 py-1.5 bg-white border border-slate-200 text-slate-400 rounded-lg text-[10px] font-black cursor-not-allowed">PREV</button>
|
||||
<div className="flex items-center space-x-1">
|
||||
<button className="w-8 h-8 flex items-center justify-center bg-[#185FA5] text-white rounded-lg text-[10px] font-black">1</button>
|
||||
<button className="w-8 h-8 flex items-center justify-center bg-white border border-slate-200 text-slate-600 hover:border-[#185FA5] rounded-lg text-[10px] font-black transition-colors">2</button>
|
||||
</div>
|
||||
<button className="px-3 py-1.5 bg-white border border-slate-200 text-[#185FA5] hover:bg-blue-50 rounded-lg text-[10px] font-black transition-colors">NEXT</button>
|
||||
<button className="px-3 py-1.5 bg-white border border-slate-200 text-slate-400 rounded-lg text-[10px] font-black cursor-not-allowed">NEXT</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -9,10 +9,11 @@ import {
|
||||
CheckCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function Create() {
|
||||
export default function Create({ reasons = [] }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, setData, post, processing, errors } = useForm({
|
||||
reason_id: '',
|
||||
subject: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
@ -51,6 +52,28 @@ export default function Create() {
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5 text-xs font-bold text-slate-700">
|
||||
{/* Support Reason */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-slate-400 uppercase tracking-widest text-[9px]">{t('ticket_reason', 'Support Category / Reason')}</label>
|
||||
<select
|
||||
value={data.reason_id}
|
||||
onChange={e => setData('reason_id', e.target.value)}
|
||||
className={`w-full p-3 border rounded-xl bg-slate-50/50 text-slate-800 outline-none focus:bg-white focus:border-[#185FA5] transition-all ${
|
||||
errors.reason_id ? 'border-red-400 bg-red-50/20' : 'border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<option value="">{t('select_reason_placeholder', 'Select support reason (optional)')}</option>
|
||||
{reasons.map(r => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.reason}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.reason_id && (
|
||||
<p className="text-[10px] text-red-500 font-semibold mt-1">{errors.reason_id}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-slate-400 uppercase tracking-widest text-[9px]">{t('ticket_subject', 'Subject / Summary')}</label>
|
||||
|
||||
@ -162,6 +162,11 @@ export default function Index({ tickets }) {
|
||||
<span className="text-[10px] font-mono font-bold text-slate-400 bg-slate-50 px-2 py-0.5 rounded border border-slate-100">
|
||||
{ticket.ticket_number}
|
||||
</span>
|
||||
{ticket.reason && (
|
||||
<span className="text-[9px] font-bold text-indigo-700 bg-indigo-50 px-2 py-0.5 rounded border border-indigo-100">
|
||||
{ticket.reason}
|
||||
</span>
|
||||
)}
|
||||
{getPriorityBadge(ticket.priority)}
|
||||
</div>
|
||||
<h3 className="font-extrabold text-sm text-slate-900 group-hover:text-[#185FA5] transition-colors truncate">
|
||||
|
||||
@ -122,6 +122,12 @@ export default function Show({ ticket, replies }) {
|
||||
{t('raised_on', 'Raised on:')} {ticket.created_at}
|
||||
</span>
|
||||
</div>
|
||||
{ticket.reason && (
|
||||
<div className="inline-flex items-center space-x-1.5 px-3 py-1 bg-indigo-50/80 border border-indigo-100/50 rounded-xl text-indigo-700 font-bold text-[10px] select-none">
|
||||
<span>Support Category:</span>
|
||||
<span className="font-extrabold">{ticket.reason}</span>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-base md:text-lg font-black text-slate-900 tracking-tight leading-snug">
|
||||
{ticket.subject}
|
||||
</h2>
|
||||
|
||||
@ -28,6 +28,7 @@ import {
|
||||
ShieldCheck,
|
||||
User
|
||||
} from 'lucide-react';
|
||||
import FilterDrawer, { FilterSection, FilterSelect, FilterInput, FilterChips, FilterCheckboxList } from '../../../Components/Employer/FilterDrawer';
|
||||
|
||||
const getLanguageFlag = (lang) => {
|
||||
const flags = {
|
||||
@ -50,7 +51,7 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
const [selectedNationality, setSelectedNationality] = useState('All Nationalities');
|
||||
const [selectedExperience, setSelectedExperience] = useState('All Experience');
|
||||
const [selectedReligion, setSelectedReligion] = useState('All Religions');
|
||||
const [maxSalary, setMaxSalary] = useState(3000);
|
||||
const [maxSalary, setMaxSalary] = useState(5000);
|
||||
|
||||
// Advanced Multi-Select Filters & Sorting
|
||||
const [selectedLanguages, setSelectedLanguages] = useState([]);
|
||||
@ -59,6 +60,14 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
const [selectedWorkTypes, setSelectedWorkTypes] = useState([]);
|
||||
const [sortBy, setSortBy] = useState('default');
|
||||
|
||||
// Advanced Filters Toggle & Details
|
||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
|
||||
const [filterLocation, setFilterLocation] = useState('');
|
||||
const [filterJobType, setFilterJobType] = useState('All');
|
||||
const [filterAccommodation, setFilterAccommodation] = useState('All');
|
||||
const [filterInCountry, setFilterInCountry] = useState('All');
|
||||
const [filterVisaType, setFilterVisaType] = useState('All');
|
||||
|
||||
// UI Helpers
|
||||
const [isLangOpen, setIsLangOpen] = useState(false);
|
||||
const [isVisaOpen, setIsVisaOpen] = useState(false);
|
||||
@ -79,6 +88,9 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
const [presetNameInput, setPresetNameInput] = useState('');
|
||||
const [showPresetModal, setShowPresetModal] = useState(false);
|
||||
|
||||
// Drawer
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
// Worker Comparison & Quick Preview
|
||||
const [comparisonIds, setComparisonIds] = useState([]);
|
||||
const [previewWorker, setPreviewWorker] = useState(null);
|
||||
@ -89,10 +101,47 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
const cat = params.get('category');
|
||||
const nat = params.get('nationality');
|
||||
const sal = params.get('max_salary');
|
||||
const loc = params.get('preferred_location');
|
||||
const job = params.get('job_type') || params.get('preferred_job_type');
|
||||
const acc = params.get('live_in_out') || params.get('accommodation_type') || params.get('accomadation_type');
|
||||
const inCountryVal = params.get('in_country');
|
||||
const outCountryVal = params.get('out_country');
|
||||
const visa = params.get('visa_status') || params.get('next_visa_type') || params.get('visa_type');
|
||||
|
||||
let hasAdvanced = false;
|
||||
|
||||
if (cat) setSelectedCategory(cat);
|
||||
if (nat) setSelectedNationality(nat);
|
||||
if (sal) setMaxSalary(Number(sal));
|
||||
if (loc) {
|
||||
setFilterLocation(loc);
|
||||
hasAdvanced = true;
|
||||
}
|
||||
if (job) {
|
||||
setFilterJobType(job);
|
||||
hasAdvanced = true;
|
||||
}
|
||||
if (acc) {
|
||||
setFilterAccommodation(acc);
|
||||
hasAdvanced = true;
|
||||
}
|
||||
if (inCountryVal) {
|
||||
const isTrue = inCountryVal === 'true' || inCountryVal === '1' || inCountryVal === 'yes' || inCountryVal === 'in' || inCountryVal === 'In Country';
|
||||
setFilterInCountry(isTrue ? 'In Country' : 'Out of Country');
|
||||
hasAdvanced = true;
|
||||
} else if (outCountryVal) {
|
||||
const isTrue = outCountryVal === 'true' || outCountryVal === '1' || outCountryVal === 'yes' || outCountryVal === 'out' || outCountryVal === 'Out of Country';
|
||||
setFilterInCountry(isTrue ? 'Out of Country' : 'In Country');
|
||||
hasAdvanced = true;
|
||||
}
|
||||
if (visa) {
|
||||
setFilterVisaType(visa);
|
||||
hasAdvanced = true;
|
||||
}
|
||||
|
||||
if (hasAdvanced) {
|
||||
setShowAdvancedFilters(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleShortlist = (id) => {
|
||||
@ -150,15 +199,48 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
setSelectedNationality('All Nationalities');
|
||||
setSelectedExperience('All Experience');
|
||||
setSelectedReligion('All Religions');
|
||||
setMaxSalary(3000);
|
||||
setMaxSalary(5000);
|
||||
setSelectedLanguages([]);
|
||||
setSelectedVisaStatuses([]);
|
||||
setSelectedSkills([]);
|
||||
setSelectedWorkTypes([]);
|
||||
setSortBy('default');
|
||||
setVisibleCount(6);
|
||||
setFilterLocation('');
|
||||
setFilterJobType('All');
|
||||
setFilterAccommodation('All');
|
||||
setFilterInCountry('All');
|
||||
setFilterVisaType('All');
|
||||
};
|
||||
|
||||
const activeFilterCount = useMemo(() => {
|
||||
let count = 0;
|
||||
if (filterLocation.trim() !== '') count++;
|
||||
if (filterJobType !== 'All') count++;
|
||||
if (filterAccommodation !== 'All') count++;
|
||||
if (selectedNationality !== 'All Nationalities') count++;
|
||||
if (filterInCountry !== 'All') count++;
|
||||
if (filterInCountry === 'In Country' && filterVisaType !== 'All') count++;
|
||||
if (selectedSkills.length > 0) count++;
|
||||
if (selectedLanguages.length > 0) count++;
|
||||
if (maxSalary < 5000) count++;
|
||||
return count;
|
||||
}, [filterLocation, filterJobType, filterAccommodation, selectedNationality, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]);
|
||||
|
||||
const activeFilterTags = useMemo(() => {
|
||||
const tags = [];
|
||||
if (filterLocation.trim()) tags.push({ key: 'loc', label: `📍 ${filterLocation}`, clear: () => setFilterLocation('') });
|
||||
if (filterJobType !== 'All') tags.push({ key: 'job', label: `💼 ${filterJobType}`, clear: () => setFilterJobType('All') });
|
||||
if (filterAccommodation !== 'All') tags.push({ key: 'acc', label: `🏠 ${filterAccommodation.replace('_', '-')}`, clear: () => setFilterAccommodation('All') });
|
||||
if (selectedNationality !== 'All Nationalities') tags.push({ key: 'nat', label: `🌍 ${selectedNationality}`, clear: () => setSelectedNationality('All Nationalities') });
|
||||
if (filterInCountry !== 'All') tags.push({ key: 'country', label: `✈️ ${filterInCountry}`, clear: () => { setFilterInCountry('All'); setFilterVisaType('All'); } });
|
||||
if (filterInCountry === 'In Country' && filterVisaType !== 'All') tags.push({ key: 'visa', label: `🪪 ${filterVisaType}`, clear: () => setFilterVisaType('All') });
|
||||
if (selectedSkills.length > 0) tags.push({ key: 'skills', label: `🛠 ${selectedSkills.length} skill${selectedSkills.length > 1 ? 's' : ''}`, clear: () => setSelectedSkills([]) });
|
||||
if (selectedLanguages.length > 0) tags.push({ key: 'lang', label: `🗣 ${selectedLanguages.length} lang`, clear: () => setSelectedLanguages([]) });
|
||||
if (maxSalary < 5000) tags.push({ key: 'salary', label: `💰 ≤${maxSalary} AED`, clear: () => setMaxSalary(5000) });
|
||||
return tags;
|
||||
}, [filterLocation, filterJobType, filterAccommodation, selectedNationality, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]);
|
||||
|
||||
// Filter and Sort Worker List
|
||||
const filteredWorkers = useMemo(() => {
|
||||
let workers = initialWorkers.filter(worker => {
|
||||
@ -176,7 +258,7 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
if (selectedNationality !== 'All Nationalities' && worker.nationality !== selectedNationality) return false;
|
||||
if (selectedExperience !== 'All Experience' && worker.experience !== selectedExperience) return false;
|
||||
if (selectedReligion !== 'All Religions' && worker.religion !== selectedReligion) return false;
|
||||
if (worker.salary > maxSalary) return false;
|
||||
if (maxSalary < 5000 && worker.salary > maxSalary) return false;
|
||||
|
||||
// Multi-select filters
|
||||
if (selectedLanguages.length > 0) {
|
||||
@ -192,6 +274,34 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
}
|
||||
if (selectedWorkTypes.length > 0 && !selectedWorkTypes.includes(worker.preferred_job_type)) return false;
|
||||
|
||||
// Preferred Location
|
||||
if (filterLocation.trim() !== '') {
|
||||
const query = filterLocation.toLowerCase();
|
||||
if (!worker.preferred_location || !worker.preferred_location.toLowerCase().includes(query)) return false;
|
||||
}
|
||||
|
||||
// Job Type
|
||||
if (filterJobType !== 'All') {
|
||||
if (!worker.preferred_job_type || worker.preferred_job_type.toLowerCase() !== filterJobType.toLowerCase()) return false;
|
||||
}
|
||||
|
||||
// Accommodation
|
||||
if (filterAccommodation !== 'All') {
|
||||
if (!worker.live_in_out || worker.live_in_out.toLowerCase() !== filterAccommodation.toLowerCase()) return false;
|
||||
}
|
||||
|
||||
// In/Out Country
|
||||
if (filterInCountry !== 'All') {
|
||||
const wantInCountry = filterInCountry === 'In Country';
|
||||
const wVal = worker.in_country;
|
||||
if ((wVal === true || wVal === '1' || wVal === 1) !== wantInCountry) return false;
|
||||
}
|
||||
|
||||
// Visa Type (if in country)
|
||||
if (filterInCountry === 'In Country' && filterVisaType !== 'All') {
|
||||
if (!worker.visa_status || !worker.visa_status.toLowerCase().includes(filterVisaType.toLowerCase())) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
@ -219,7 +329,12 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
selectedVisaStatuses,
|
||||
selectedSkills,
|
||||
selectedWorkTypes,
|
||||
sortBy
|
||||
sortBy,
|
||||
filterLocation,
|
||||
filterJobType,
|
||||
filterAccommodation,
|
||||
filterInCountry,
|
||||
filterVisaType
|
||||
]);
|
||||
|
||||
const paginatedWorkers = useMemo(() => {
|
||||
@ -265,10 +380,145 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Filter Drawer ── */}
|
||||
<FilterDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onReset={resetFilters}
|
||||
activeCount={activeFilterCount}
|
||||
title="Filter Workers"
|
||||
>
|
||||
{/* Preferred Location */}
|
||||
<FilterSection label="Preferred Location">
|
||||
<FilterInput
|
||||
id="filter_preferred_location"
|
||||
value={filterLocation}
|
||||
onChange={e => setFilterLocation(e.target.value)}
|
||||
placeholder="e.g. Dubai, Abu Dhabi…"
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
{/* Job Type */}
|
||||
<FilterSection label="Job Type">
|
||||
<FilterChips
|
||||
options={[
|
||||
{ value: 'All', label: 'All' },
|
||||
{ value: 'full-time', label: 'Full-time' },
|
||||
{ value: 'part-time', label: 'Part-time' },
|
||||
]}
|
||||
selected={filterJobType}
|
||||
onChange={setFilterJobType}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
{/* Accommodation */}
|
||||
<FilterSection label="Accommodation Type">
|
||||
<FilterChips
|
||||
options={[
|
||||
{ value: 'All', label: 'All' },
|
||||
{ value: 'live_in', label: 'Live-in' },
|
||||
{ value: 'live_out', label: 'Live-out' },
|
||||
]}
|
||||
selected={filterAccommodation}
|
||||
onChange={setFilterAccommodation}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
{/* Nationality */}
|
||||
<FilterSection label="Nationality">
|
||||
<FilterSelect
|
||||
id="filter_nationality"
|
||||
value={selectedNationality}
|
||||
onChange={e => setSelectedNationality(e.target.value)}
|
||||
>
|
||||
{filtersMetadata.nationalities?.map(nat => (
|
||||
<option key={nat} value={nat}>{nat}</option>
|
||||
))}
|
||||
</FilterSelect>
|
||||
</FilterSection>
|
||||
|
||||
{/* Country Status */}
|
||||
<FilterSection label="Country Status">
|
||||
<FilterChips
|
||||
options={[
|
||||
{ value: 'All', label: 'All' },
|
||||
{ value: 'In Country', label: 'In Country' },
|
||||
{ value: 'Out of Country', label: 'Out of Country' },
|
||||
]}
|
||||
selected={filterInCountry}
|
||||
onChange={val => {
|
||||
setFilterInCountry(val);
|
||||
if (val !== 'In Country') setFilterVisaType('All');
|
||||
}}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
{/* Visa Type */}
|
||||
<FilterSection label="Visa Type">
|
||||
<div className={filterInCountry !== 'In Country' ? 'opacity-40 pointer-events-none' : ''}>
|
||||
<FilterSelect
|
||||
id="filter_visa_status"
|
||||
value={filterVisaType}
|
||||
onChange={e => setFilterVisaType(e.target.value)}
|
||||
disabled={filterInCountry !== 'In Country'}
|
||||
>
|
||||
<option value="All">All Visa Types</option>
|
||||
<option value="Residence Visa">Residence Visa</option>
|
||||
<option value="Tourist Visa">Tourist Visa</option>
|
||||
<option value="Employment Visa">Employment Visa</option>
|
||||
<option value="Cancelled Visa">Cancelled Visa</option>
|
||||
<option value="Own Visa">Own Visa</option>
|
||||
</FilterSelect>
|
||||
{filterInCountry !== 'In Country' && (
|
||||
<p className="text-[11px] text-slate-400 mt-1.5 font-medium">Select "In Country" to enable this filter</p>
|
||||
)}
|
||||
</div>
|
||||
</FilterSection>
|
||||
|
||||
{/* Skills */}
|
||||
{filtersMetadata.skills?.length > 1 && (
|
||||
<FilterSection label="Skills">
|
||||
<FilterCheckboxList
|
||||
items={filtersMetadata.skills.slice(1).map(s => s.toLowerCase())}
|
||||
selected={selectedSkills}
|
||||
onToggle={skill => toggleMultiSelect(skill, selectedSkills, setSelectedSkills)}
|
||||
/>
|
||||
</FilterSection>
|
||||
)}
|
||||
|
||||
{/* Languages */}
|
||||
{filtersMetadata.languages?.length > 1 && (
|
||||
<FilterSection label="Languages">
|
||||
<FilterCheckboxList
|
||||
items={filtersMetadata.languages.slice(1)}
|
||||
selected={selectedLanguages}
|
||||
onToggle={lang => toggleMultiSelect(lang, selectedLanguages, setSelectedLanguages)}
|
||||
/>
|
||||
</FilterSection>
|
||||
)}
|
||||
|
||||
{/* Salary Range */}
|
||||
<FilterSection label={maxSalary === 5000 ? "Max Salary — Any" : `Max Salary — ${maxSalary} AED`}>
|
||||
<input
|
||||
type="range"
|
||||
min="500"
|
||||
max="5000"
|
||||
step="100"
|
||||
value={maxSalary}
|
||||
onChange={e => setMaxSalary(Number(e.target.value))}
|
||||
className="w-full accent-[#185FA5]"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] font-bold text-slate-400 mt-1">
|
||||
<span>500 AED</span>
|
||||
<span>5,000 AED</span>
|
||||
</div>
|
||||
</FilterSection>
|
||||
</FilterDrawer>
|
||||
|
||||
{/* Filter and Search Panel */}
|
||||
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm space-y-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||
{/* Core Search bar */}
|
||||
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
@ -280,10 +530,9 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort & Reset Actions */}
|
||||
<div className="flex items-center space-x-3 w-full lg:w-auto justify-end">
|
||||
{/* Sort Selector */}
|
||||
<div className="relative flex items-center bg-slate-50 rounded-xl border border-slate-200 px-3 py-2 text-xs font-bold text-slate-700">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Sort */}
|
||||
<div className="flex items-center bg-slate-50 rounded-xl border border-slate-200 px-3 py-2 text-xs font-bold text-slate-700">
|
||||
<ArrowUpDown className="w-4 h-4 text-slate-400 mr-2" />
|
||||
<select
|
||||
value={sortBy}
|
||||
@ -298,143 +547,56 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Reset */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetFilters}
|
||||
className="flex items-center justify-center space-x-1.5 px-4 py-2.5 rounded-xl border border-slate-200 hover:bg-slate-50 text-xs font-semibold text-slate-600 transition-colors"
|
||||
className="flex items-center justify-center space-x-1.5 px-3 py-2.5 rounded-xl border border-slate-200 hover:bg-slate-50 text-xs font-semibold text-slate-600 transition-colors"
|
||||
title="Reset all filters"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 text-slate-400" />
|
||||
<span>{t('reset')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Primary filters grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 pt-4 border-t border-slate-100">
|
||||
{/* Nationality */}
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1.5">{t('nationality')}</label>
|
||||
<select
|
||||
value={selectedNationality}
|
||||
onChange={(e) => setSelectedNationality(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-xl border border-slate-200 text-xs bg-slate-50/50 focus:bg-white focus:outline-none focus:border-[#185FA5] font-semibold text-slate-700 cursor-pointer"
|
||||
>
|
||||
{filtersMetadata.nationalities?.map(nat => (
|
||||
<option key={nat} value={nat}>{nat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Skills */}
|
||||
<div className="relative">
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1.5">{t('skills')}</label>
|
||||
{/* Filter Drawer button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsSkillsOpen(!isSkillsOpen);
|
||||
setIsLangOpen(false);
|
||||
setIsVisaOpen(false);
|
||||
}}
|
||||
className="w-full px-3 py-2.5 rounded-xl border border-slate-200 text-xs bg-slate-50/50 flex justify-between items-center font-bold text-slate-600"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className={`relative flex items-center space-x-2 px-4 py-2.5 rounded-xl border transition-all text-xs font-bold ${
|
||||
activeFilterCount > 0
|
||||
? 'bg-[#185FA5] text-white border-[#185FA5] shadow-md shadow-blue-200'
|
||||
: 'border-slate-200 hover:bg-slate-50 text-slate-600 bg-white'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedSkills.length === 0
|
||||
? t('select_skills')
|
||||
: `${selectedSkills.length} ${t('selected')}`}
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
<span>{t('filters', 'Filters')}</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="bg-white/25 text-white text-[10px] font-black px-1.5 py-0.5 rounded-full leading-none">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 ml-1 flex-shrink-0" />
|
||||
</button>
|
||||
{isSkillsOpen && (
|
||||
<div className="absolute left-0 right-0 mt-1 bg-white border border-slate-200 shadow-lg rounded-xl p-3 z-30 space-y-1.5 max-h-60 overflow-y-auto">
|
||||
{filtersMetadata.skills?.slice(1).map(skill => (
|
||||
<label key={skill} className="flex items-center space-x-2 text-xs font-bold text-slate-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSkills.includes(skill.toLowerCase())}
|
||||
onChange={() => toggleMultiSelect(skill.toLowerCase(), selectedSkills, setSelectedSkills)}
|
||||
className="rounded text-[#185FA5] focus:ring-[#185FA5]"
|
||||
/>
|
||||
<span className="capitalize">{skill}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language */}
|
||||
<div className="relative">
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1.5">{t('languages_spoken', 'Language')}</label>
|
||||
{/* Active filter tags */}
|
||||
{activeFilterTags.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-slate-100 flex flex-wrap gap-2 items-center">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mr-1">Active:</span>
|
||||
{activeFilterTags.map(tag => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsLangOpen(!isLangOpen);
|
||||
setIsSkillsOpen(false);
|
||||
setIsVisaOpen(false);
|
||||
}}
|
||||
className="w-full px-3 py-2.5 rounded-xl border border-slate-200 text-xs bg-slate-50/50 flex justify-between items-center font-bold text-slate-600"
|
||||
key={tag.key}
|
||||
onClick={tag.clear}
|
||||
className="inline-flex items-center space-x-1.5 px-2.5 py-1 bg-[#185FA5]/10 text-[#185FA5] text-[11px] font-bold rounded-full border border-[#185FA5]/20 hover:bg-[#185FA5]/20 transition-all"
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedLanguages.length === 0
|
||||
? t('select_languages')
|
||||
: `${selectedLanguages.length} ${t('selected')}`}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 ml-1 flex-shrink-0" />
|
||||
<span>{tag.label}</span>
|
||||
<span className="text-[#185FA5]/60 font-black">×</span>
|
||||
</button>
|
||||
{isLangOpen && (
|
||||
<div className="absolute left-0 right-0 mt-1 bg-white border border-slate-200 shadow-lg rounded-xl p-3 z-30 space-y-1.5">
|
||||
{filtersMetadata.languages?.slice(1).map(lang => (
|
||||
<label key={lang} className="flex items-center space-x-2 text-xs font-bold text-slate-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedLanguages.includes(lang)}
|
||||
onChange={() => toggleMultiSelect(lang, selectedLanguages, setSelectedLanguages)}
|
||||
className="rounded text-[#185FA5] focus:ring-[#185FA5]"
|
||||
/>
|
||||
<span>{lang}</span>
|
||||
</label>
|
||||
))}
|
||||
<button onClick={resetFilters} className="text-[11px] font-bold text-rose-500 hover:text-rose-700 ml-1 transition-colors">Clear all</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Visa Status */}
|
||||
<div className="relative">
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1.5">{t('visa_status', 'Visa Status')}</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsVisaOpen(!isVisaOpen);
|
||||
setIsSkillsOpen(false);
|
||||
setIsLangOpen(false);
|
||||
}}
|
||||
className="w-full px-3 py-2.5 rounded-xl border border-slate-200 text-xs bg-slate-50/50 flex justify-between items-center font-bold text-slate-600"
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedVisaStatuses.length === 0
|
||||
? t('select_visa_status')
|
||||
: `${selectedVisaStatuses.length} ${t('selected')}`}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 ml-1 flex-shrink-0" />
|
||||
</button>
|
||||
{isVisaOpen && (
|
||||
<div className="absolute left-0 right-0 mt-1 bg-white border border-slate-200 shadow-lg rounded-xl p-3 z-30 space-y-1.5">
|
||||
{filtersMetadata.visaStatuses?.slice(1).map(status => (
|
||||
<label key={status} className="flex items-center space-x-2 text-xs font-bold text-slate-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedVisaStatuses.includes(status)}
|
||||
onChange={() => toggleMultiSelect(status, selectedVisaStatuses, setSelectedVisaStatuses)}
|
||||
className="rounded text-[#185FA5] focus:ring-[#185FA5]"
|
||||
/>
|
||||
<span>{status}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Listing metadata bar */}
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="text-xs font-semibold text-slate-500">
|
||||
@ -625,7 +787,8 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
href={`/employer/workers/${worker.id}`}
|
||||
className="w-full bg-[#185FA5] hover:bg-[#144f8a] text-white rounded-xl h-10 font-bold text-xs flex items-center justify-center transition-colors shadow-xs"
|
||||
>
|
||||
{t('open_full_profile')}
|
||||
View Profile
|
||||
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -232,10 +232,12 @@ export default function Show({ worker }) {
|
||||
<span>•</span>
|
||||
<span className="bg-blue-50 text-[#185FA5] border border-blue-100 px-2 py-0.5 rounded text-[10px] uppercase font-black tracking-widest">{worker.category}</span>
|
||||
</div>
|
||||
{workerReviews.length > 0 && (
|
||||
<div className="flex items-center space-x-1 text-amber-500 font-bold text-xs">
|
||||
<Star className="w-3.5 h-3.5 fill-amber-500 text-amber-500" />
|
||||
<span className="text-slate-700">{worker.rating} / 5.0 ({workerReviews.length} {t('reviews', 'reviews')})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -348,23 +350,13 @@ export default function Show({ worker }) {
|
||||
<span className={`font-bold text-xs uppercase ${worker.passport_status.toLowerCase().includes('pending') ? 'text-amber-700' : 'text-emerald-700'}`}>{worker.passport_status}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 text-xs text-slate-600 font-bold">
|
||||
<FileText className="w-4 h-4 text-blue-600" />
|
||||
<span>{t('visa_status', 'Visa Status')}</span>
|
||||
</div>
|
||||
<span className="font-bold text-blue-700 text-xs uppercase">{worker.visa_status}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 text-xs text-slate-600 font-bold">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
|
||||
<span>{t('medical_health_test', 'Medical Health Test')}</span>
|
||||
</div>
|
||||
<span className="font-black text-emerald-700 bg-emerald-50 px-2.5 py-0.5 rounded text-[10px] uppercase border border-emerald-200">
|
||||
{t('passed_certified', 'PASSED CERTIFIED')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Languages Spoken with visual flags */}
|
||||
@ -386,13 +378,7 @@ export default function Show({ worker }) {
|
||||
|
||||
{/* Right Column: Bio & Skills */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
{/* Professional Summary */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-extrabold text-base text-slate-900">{t('professional_summary', 'Professional Summary')}</h3>
|
||||
<p className="text-sm text-slate-600 leading-relaxed bg-slate-50 p-6 rounded-2xl border border-slate-200 italic">
|
||||
"{worker.bio}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Verified Skills & Competencies */}
|
||||
<div className="space-y-4">
|
||||
@ -562,40 +548,15 @@ export default function Show({ worker }) {
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{workerReviews.length === 0 && (
|
||||
<div className="p-8 text-center text-xs text-slate-400 font-bold bg-slate-50 rounded-2xl border border-slate-150">
|
||||
{t('no_reviews_yet', 'No reviews posted yet.')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Similar Recommendations Slider */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-extrabold text-base text-slate-900">{t('similar_recommended_workers', 'Similar Recommended Workers')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{worker.similar_workers?.map((sim) => (
|
||||
<div key={sim.id} className="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-extrabold text-sm text-slate-900">{sim.name}</h4>
|
||||
<p className="text-[10px] text-slate-500 font-bold">{sim.nationality} • {sim.category}</p>
|
||||
</div>
|
||||
{sim.verified && (
|
||||
<span className="bg-emerald-50 text-emerald-700 px-2 py-0.5 border border-emerald-100 rounded text-[9px] font-black uppercase">
|
||||
{t('verified', 'Verified')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
|
||||
<span className="font-black text-slate-800 text-xs">{sim.salary} {t('aed', 'AED')}/{t('mo', 'mo')}</span>
|
||||
<Link
|
||||
href={`/employer/workers/${sim.id}`}
|
||||
className="text-[#185FA5] font-black text-xs hover:underline flex items-center space-x-1"
|
||||
>
|
||||
<span>{t('view_profile', 'View Profile')}</span>
|
||||
<span>→</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Stage 4/5 Hired Flow Success Modal Overlay */}
|
||||
|
||||
213
resources/js/components/Employer/FilterDrawer.jsx
Normal file
@ -0,0 +1,213 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { X, RotateCcw, SlidersHorizontal } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* FilterDrawer — a right-side slide-in drawer for employer portal filter panels.
|
||||
*
|
||||
* Props:
|
||||
* open {boolean} – controls visibility
|
||||
* onClose {function} – called when backdrop or X is clicked
|
||||
* onReset {function} – called when "Clear All" is clicked
|
||||
* activeCount {number} – number of active filters (shown in badge)
|
||||
* title {string} – drawer heading
|
||||
* children {node} – filter form content
|
||||
*/
|
||||
export default function FilterDrawer({ open, onClose, onReset, activeCount = 0, title = 'Filters', children }) {
|
||||
// Lock body scroll when drawer is open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [open]);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Backdrop ── */}
|
||||
<div
|
||||
className={`fixed inset-0 z-40 bg-black/30 backdrop-blur-[2px] transition-opacity duration-300 ${
|
||||
open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* ── Drawer Panel ── */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className={`fixed top-0 right-0 z-50 h-full w-full sm:w-[420px] bg-white shadow-2xl flex flex-col transition-transform duration-300 ease-in-out ${
|
||||
open ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-slate-100 bg-gradient-to-r from-[#185FA5]/5 to-white flex-shrink-0">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-[#185FA5]/10 rounded-xl">
|
||||
<SlidersHorizontal className="w-4 h-4 text-[#185FA5]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-black text-slate-900 tracking-tight">{title}</h2>
|
||||
{activeCount > 0 && (
|
||||
<p className="text-[10px] font-bold text-[#185FA5] mt-0.5">
|
||||
{activeCount} filter{activeCount !== 1 ? 's' : ''} active
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{activeCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="flex items-center space-x-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold text-rose-600 hover:bg-rose-50 border border-rose-100 transition-all"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
<span>Clear All</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl hover:bg-slate-100 text-slate-400 hover:text-slate-700 transition-colors"
|
||||
aria-label="Close filters"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-slate-100 bg-slate-50/50 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-full py-3 bg-[#185FA5] hover:bg-[#1450861] text-white text-sm font-bold rounded-xl transition-all shadow-sm hover:shadow-md active:scale-[0.98]"
|
||||
style={{ background: 'linear-gradient(135deg, #185FA5 0%, #1d7dd6 100%)' }}
|
||||
>
|
||||
Apply Filters
|
||||
{activeCount > 0 && (
|
||||
<span className="ml-2 bg-white/20 text-white text-[10px] font-black px-2 py-0.5 rounded-full">
|
||||
{activeCount} active
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FilterSection — a labelled group inside FilterDrawer.
|
||||
*/
|
||||
export function FilterSection({ label, children }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{label}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FilterSelect — a full-width styled select inside FilterDrawer.
|
||||
*/
|
||||
export function FilterSelect({ id, value, onChange, disabled = false, children }) {
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className={`w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm font-semibold text-slate-700 focus:ring-2 focus:ring-blue-100 focus:border-[#185FA5] outline-none cursor-pointer transition-all ${
|
||||
disabled ? 'opacity-40 cursor-not-allowed bg-slate-100' : ''
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FilterInput — a full-width styled text input inside FilterDrawer.
|
||||
*/
|
||||
export function FilterInput({ id, value, onChange, placeholder }) {
|
||||
return (
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm font-semibold text-slate-700 focus:ring-2 focus:ring-blue-100 focus:border-[#185FA5] outline-none transition-all placeholder:text-slate-400 placeholder:font-normal"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FilterChips — a row of selectable chip buttons for single-select options.
|
||||
* options: [{ value, label }]
|
||||
*/
|
||||
export function FilterChips({ options, selected, onChange }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-bold transition-all border ${
|
||||
selected === opt.value
|
||||
? 'bg-[#185FA5] text-white border-[#185FA5] shadow-sm'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:border-[#185FA5] hover:text-[#185FA5]'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FilterCheckboxList — a list of checkboxes for multi-select.
|
||||
* items: string[]
|
||||
*/
|
||||
export function FilterCheckboxList({ items, selected, onToggle }) {
|
||||
return (
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
||||
{items.map(item => (
|
||||
<label key={item} className="flex items-center space-x-3 cursor-pointer group">
|
||||
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 transition-all ${
|
||||
selected.includes(item)
|
||||
? 'bg-[#185FA5] border-[#185FA5]'
|
||||
: 'border-slate-300 group-hover:border-[#185FA5]'
|
||||
}`}>
|
||||
{selected.includes(item) && (
|
||||
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-700 capitalize group-hover:text-slate-900 transition-colors">{item}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -89,6 +89,7 @@
|
||||
return [
|
||||
'id' => $skill->id,
|
||||
'name' => $skill->name,
|
||||
'image_url' => $skill->image_path ? asset('storage/' . $skill->image_path) : null,
|
||||
'worker_count' => \DB::table('worker_skills')->where('skill_id', $skill->id)->count(),
|
||||
'status' => 'Active',
|
||||
];
|
||||
@ -101,9 +102,15 @@
|
||||
Route::post('/master-data/skills', function (\Illuminate\Http\Request $request) {
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255|unique:skills,name',
|
||||
'image' => 'nullable|image|max:2048',
|
||||
]);
|
||||
$imagePath = null;
|
||||
if ($request->hasFile('image')) {
|
||||
$imagePath = $request->file('image')->store('skills', 'public');
|
||||
}
|
||||
\App\Models\Skill::create([
|
||||
'name' => strtolower($request->name),
|
||||
'image_path' => $imagePath,
|
||||
]);
|
||||
return redirect()->back();
|
||||
})->name('admin.skills.store');
|
||||
@ -111,16 +118,28 @@
|
||||
Route::post('/master-data/skills/{id}/update', function (\Illuminate\Http\Request $request, $id) {
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255|unique:skills,name,' . $id,
|
||||
'image' => 'nullable|image|max:2048',
|
||||
]);
|
||||
$skill = \App\Models\Skill::findOrFail($id);
|
||||
$imagePath = $skill->image_path;
|
||||
if ($request->hasFile('image')) {
|
||||
if ($imagePath) {
|
||||
\Storage::disk('public')->delete($imagePath);
|
||||
}
|
||||
$imagePath = $request->file('image')->store('skills', 'public');
|
||||
}
|
||||
$skill->update([
|
||||
'name' => strtolower($request->name),
|
||||
'image_path' => $imagePath,
|
||||
]);
|
||||
return redirect()->back();
|
||||
})->name('admin.skills.update');
|
||||
|
||||
Route::delete('/master-data/skills/{id}', function ($id) {
|
||||
$skill = \App\Models\Skill::findOrFail($id);
|
||||
if ($skill->image_path) {
|
||||
\Storage::disk('public')->delete($skill->image_path);
|
||||
}
|
||||
\DB::table('worker_skills')->where('skill_id', $id)->delete();
|
||||
$skill->delete();
|
||||
return redirect()->back();
|
||||
@ -143,7 +162,7 @@
|
||||
Route::post('/master-data/reasons', function (\Illuminate\Http\Request $request) {
|
||||
$request->validate([
|
||||
'reason' => 'required|string|max:255|unique:report_reasons,reason',
|
||||
'type' => 'required|string|in:Chat,Review,Both',
|
||||
'type' => 'required|string|in:Chat,Review,Both,Support',
|
||||
]);
|
||||
\App\Models\ReportReason::create([
|
||||
'reason' => $request->reason,
|
||||
@ -157,7 +176,7 @@
|
||||
$request->validate([
|
||||
'reason' => 'required|string|max:255|unique:report_reasons,reason,' . $id,
|
||||
'status' => 'required|string|in:Active,Inactive',
|
||||
'type' => 'required|string|in:Chat,Review,Both',
|
||||
'type' => 'required|string|in:Chat,Review,Both,Support',
|
||||
]);
|
||||
$reason = \App\Models\ReportReason::findOrFail($id);
|
||||
$reason->update([
|
||||
|
||||
318
tests/Feature/EmployerWorkerFilterApiTest.php
Normal file
@ -0,0 +1,318 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Worker;
|
||||
use App\Models\WorkerCategory;
|
||||
use App\Models\WorkerDocument;
|
||||
use App\Models\JobOffer;
|
||||
use App\Models\JobPost;
|
||||
use App\Models\JobApplication;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EmployerWorkerFilterApiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected $employer;
|
||||
protected $token;
|
||||
protected $category;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create employer
|
||||
$this->token = 'test-employer-token';
|
||||
$this->employer = User::create([
|
||||
'name' => 'Test Employer',
|
||||
'email' => 'employer@example.com',
|
||||
'password' => bcrypt('Password@123'),
|
||||
'role' => 'employer',
|
||||
'api_token' => $this->token,
|
||||
]);
|
||||
|
||||
// Create worker category
|
||||
$this->category = WorkerCategory::create([
|
||||
'name' => 'General Helper',
|
||||
]);
|
||||
|
||||
// Seed 3 workers with specific filter attributes
|
||||
// Worker 1: Dubai, full-time, live-in, India, in_country=true, Residence Visa
|
||||
$w1 = Worker::create([
|
||||
'name' => 'Worker India In Dubai',
|
||||
'email' => 'worker1@example.com',
|
||||
'phone' => '+971501111111',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'India',
|
||||
'category_id' => $this->category->id,
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'preferred_location' => 'Dubai',
|
||||
'preferred_job_type' => 'full-time',
|
||||
'live_in_out' => 'live_in',
|
||||
'in_country' => true,
|
||||
'visa_status' => 'Residence Visa',
|
||||
'age' => 25,
|
||||
'salary' => 1500,
|
||||
'experience' => '3 Years',
|
||||
'religion' => 'Christian',
|
||||
'language' => 'EN',
|
||||
'availability' => 'Immediate',
|
||||
'bio' => 'Experienced helper',
|
||||
]);
|
||||
WorkerDocument::create([
|
||||
'worker_id' => $w1->id,
|
||||
'type' => 'passport',
|
||||
'number' => 'P111111',
|
||||
'file_path' => 'passports/test1.jpg',
|
||||
]);
|
||||
|
||||
// Worker 2: Abu Dhabi, part-time, live-out, Philippines, in_country=true, Tourist Visa
|
||||
$w2 = Worker::create([
|
||||
'name' => 'Worker Philippines In Abu Dhabi',
|
||||
'email' => 'worker2@example.com',
|
||||
'phone' => '+971502222222',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'Philippines',
|
||||
'category_id' => $this->category->id,
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'preferred_location' => 'Abu Dhabi',
|
||||
'preferred_job_type' => 'part-time',
|
||||
'live_in_out' => 'live_out',
|
||||
'in_country' => true,
|
||||
'visa_status' => 'Tourist Visa',
|
||||
'age' => 28,
|
||||
'salary' => 1800,
|
||||
'experience' => '2 Years',
|
||||
'religion' => 'Christian',
|
||||
'language' => 'TL',
|
||||
'availability' => 'Immediate',
|
||||
'bio' => 'Ready to work',
|
||||
]);
|
||||
WorkerDocument::create([
|
||||
'worker_id' => $w2->id,
|
||||
'type' => 'passport',
|
||||
'number' => 'P222222',
|
||||
'file_path' => 'passports/test2.jpg',
|
||||
]);
|
||||
|
||||
// Worker 3: Dubai, full-time, live-out, Kenya, in_country=false, Cancelled Visa
|
||||
$w3 = Worker::create([
|
||||
'name' => 'Worker Kenya Out Country',
|
||||
'email' => 'worker3@example.com',
|
||||
'phone' => '+971503333333',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'Kenya',
|
||||
'category_id' => $this->category->id,
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'preferred_location' => 'Dubai',
|
||||
'preferred_job_type' => 'full-time',
|
||||
'live_in_out' => 'live_out',
|
||||
'in_country' => false,
|
||||
'visa_status' => 'Cancelled Visa',
|
||||
'age' => 30,
|
||||
'salary' => 1200,
|
||||
'experience' => 'None',
|
||||
'religion' => 'Christian',
|
||||
'language' => 'SW',
|
||||
'availability' => 'Immediate',
|
||||
'bio' => 'New helper',
|
||||
]);
|
||||
WorkerDocument::create([
|
||||
'worker_id' => $w3->id,
|
||||
'type' => 'passport',
|
||||
'number' => 'P333333',
|
||||
'file_path' => 'passports/test3.jpg',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test getWorkers filtering.
|
||||
*/
|
||||
public function test_get_workers_preferred_location_filter()
|
||||
{
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->getJson('/api/employers/workers?preferred_location=Abu Dhabi');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$workers = $response->json('data.workers');
|
||||
$this->assertCount(1, $workers);
|
||||
$this->assertEquals('Worker Philippines In Abu Dhabi', $workers[0]['name']);
|
||||
}
|
||||
|
||||
public function test_get_workers_job_type_filter()
|
||||
{
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->getJson('/api/employers/workers?job_type=part-time');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$workers = $response->json('data.workers');
|
||||
$this->assertCount(1, $workers);
|
||||
$this->assertEquals('Worker Philippines In Abu Dhabi', $workers[0]['name']);
|
||||
}
|
||||
|
||||
public function test_get_workers_live_in_out_filter()
|
||||
{
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->getJson('/api/employers/workers?live_in_out=live_out');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$workers = $response->json('data.workers');
|
||||
$this->assertCount(2, $workers);
|
||||
}
|
||||
|
||||
public function test_get_workers_nationality_multiple_filter()
|
||||
{
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->getJson('/api/employers/workers?nationality=India,Kenya');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$workers = $response->json('data.workers');
|
||||
$this->assertCount(2, $workers);
|
||||
}
|
||||
|
||||
public function test_get_workers_in_country_filter()
|
||||
{
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->getJson('/api/employers/workers?in_country=false');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$workers = $response->json('data.workers');
|
||||
$this->assertCount(1, $workers);
|
||||
$this->assertEquals('Worker Kenya Out Country', $workers[0]['name']);
|
||||
}
|
||||
|
||||
public function test_get_workers_visa_status_if_in_country_filter()
|
||||
{
|
||||
// Tourist Visa matches Worker 2 (in country)
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->getJson('/api/employers/workers?visa_status=Tourist Visa');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$workers = $response->json('data.workers');
|
||||
$this->assertCount(1, $workers);
|
||||
$this->assertEquals('Worker Philippines In Abu Dhabi', $workers[0]['name']);
|
||||
|
||||
// Cancelled Visa is for Worker 3, but Worker 3 is out country, so it should not match
|
||||
// because of the "if in country next visa type" constraint.
|
||||
$response2 = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->getJson('/api/employers/workers?visa_status=Cancelled Visa');
|
||||
|
||||
$response2->assertStatus(200);
|
||||
$workers2 = $response2->json('data.workers');
|
||||
$this->assertCount(0, $workers2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test getCandidates filtering.
|
||||
*/
|
||||
public function test_get_candidates_filters()
|
||||
{
|
||||
// To be in getCandidates, candidates need status 'Hired' per current logic:
|
||||
// Let's create three direct offers with status accepted (which maps to 'Hired')
|
||||
$worker1 = Worker::where('nationality', 'India')->first();
|
||||
$worker2 = Worker::where('nationality', 'Philippines')->first();
|
||||
$worker3 = Worker::where('nationality', 'Kenya')->first();
|
||||
|
||||
JobOffer::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'worker_id' => $worker1->id,
|
||||
'work_date' => now()->format('Y-m-d'),
|
||||
'location' => 'Dubai',
|
||||
'salary' => 1500,
|
||||
'notes' => 'Hired test',
|
||||
'status' => 'accepted', // maps to Hired
|
||||
]);
|
||||
|
||||
JobOffer::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'worker_id' => $worker2->id,
|
||||
'work_date' => now()->format('Y-m-d'),
|
||||
'location' => 'Abu Dhabi',
|
||||
'salary' => 1500,
|
||||
'notes' => 'Hired test 2',
|
||||
'status' => 'accepted', // maps to Hired
|
||||
]);
|
||||
|
||||
JobOffer::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'worker_id' => $worker3->id,
|
||||
'work_date' => now()->format('Y-m-d'),
|
||||
'location' => 'Dubai',
|
||||
'salary' => 1500,
|
||||
'notes' => 'Hired test 3',
|
||||
'status' => 'accepted', // maps to Hired
|
||||
]);
|
||||
|
||||
// Filter candidates by preferred location Dubai
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->getJson('/api/employers/candidates?preferred_location=Dubai');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$candidates = $response->json('data.candidates');
|
||||
$this->assertCount(2, $candidates);
|
||||
|
||||
// Filter candidates by job_type
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->getJson('/api/employers/candidates?job_type=part-time');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$candidates = $response->json('data.candidates');
|
||||
$this->assertCount(1, $candidates);
|
||||
$this->assertEquals('Worker Philippines In Abu Dhabi', $candidates[0]['name']);
|
||||
|
||||
// Filter candidates by live_in_out
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->getJson('/api/employers/candidates?live_in_out=live_in');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$candidates = $response->json('data.candidates');
|
||||
$this->assertCount(1, $candidates);
|
||||
$this->assertEquals('Worker India In Dubai', $candidates[0]['name']);
|
||||
|
||||
// Filter candidates by nationality
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->getJson('/api/employers/candidates?nationality=Kenya');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$candidates = $response->json('data.candidates');
|
||||
$this->assertCount(1, $candidates);
|
||||
$this->assertEquals('Worker Kenya Out Country', $candidates[0]['name']);
|
||||
|
||||
// Filter candidates by in_country
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->getJson('/api/employers/candidates?in_country=false');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$candidates = $response->json('data.candidates');
|
||||
$this->assertCount(1, $candidates);
|
||||
$this->assertEquals('Worker Kenya Out Country', $candidates[0]['name']);
|
||||
|
||||
// Filter candidates by visa_status (if in country)
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->getJson('/api/employers/candidates?visa_status=Tourist Visa');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$candidates = $response->json('data.candidates');
|
||||
$this->assertCount(1, $candidates);
|
||||
$this->assertEquals('Worker Philippines In Abu Dhabi', $candidates[0]['name']);
|
||||
}
|
||||
}
|
||||
208
tests/Feature/EmployerWorkerWebFilterTest.php
Normal file
@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Worker;
|
||||
use App\Models\WorkerCategory;
|
||||
use App\Models\JobOffer;
|
||||
use App\Models\WorkerDocument;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EmployerWorkerWebFilterTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected $employer;
|
||||
protected $category;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->employer = User::create([
|
||||
'name' => 'Test Employer',
|
||||
'email' => 'employer@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'employer',
|
||||
]);
|
||||
|
||||
$this->category = WorkerCategory::create([
|
||||
'name' => 'General Helper',
|
||||
]);
|
||||
|
||||
// Seed some workers
|
||||
$w1 = Worker::create([
|
||||
'name' => 'Worker India Dubai',
|
||||
'email' => 'worker1@example.com',
|
||||
'phone' => '+971501111111',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'India',
|
||||
'category_id' => $this->category->id,
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'preferred_location' => 'Dubai',
|
||||
'preferred_job_type' => 'full-time',
|
||||
'live_in_out' => 'live_in',
|
||||
'in_country' => true,
|
||||
'visa_status' => 'Residence Visa',
|
||||
'age' => 25,
|
||||
'salary' => 1500,
|
||||
'experience' => '3 Years',
|
||||
'religion' => 'Christian',
|
||||
'language' => 'EN',
|
||||
'availability' => 'Immediate',
|
||||
'bio' => 'Experienced helper',
|
||||
]);
|
||||
WorkerDocument::create([
|
||||
'worker_id' => $w1->id,
|
||||
'type' => 'passport',
|
||||
'number' => 'P111111',
|
||||
'file_path' => 'passports/test1.jpg',
|
||||
]);
|
||||
|
||||
$w2 = Worker::create([
|
||||
'name' => 'Worker Philippines Abu Dhabi',
|
||||
'email' => 'worker2@example.com',
|
||||
'phone' => '+971502222222',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'Philippines',
|
||||
'category_id' => $this->category->id,
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'preferred_location' => 'Abu Dhabi',
|
||||
'preferred_job_type' => 'part-time',
|
||||
'live_in_out' => 'live_out',
|
||||
'in_country' => true,
|
||||
'visa_status' => 'Tourist Visa',
|
||||
'age' => 28,
|
||||
'salary' => 1800,
|
||||
'experience' => '2 Years',
|
||||
'religion' => 'Christian',
|
||||
'language' => 'TL',
|
||||
'availability' => 'Immediate',
|
||||
'bio' => 'Ready to work',
|
||||
]);
|
||||
WorkerDocument::create([
|
||||
'worker_id' => $w2->id,
|
||||
'type' => 'passport',
|
||||
'number' => 'P222222',
|
||||
'file_path' => 'passports/test2.jpg',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test web workers index page loads correctly.
|
||||
*/
|
||||
public function test_employer_can_view_workers_index()
|
||||
{
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.workers'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('initialWorkers');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test web workers index filtering on server-side.
|
||||
*/
|
||||
public function test_employer_workers_web_filtering()
|
||||
{
|
||||
// 1. Filter by location
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.workers', ['preferred_location' => 'Abu Dhabi']));
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Worker Philippines Abu Dhabi');
|
||||
$response->assertDontSee('Worker India Dubai');
|
||||
|
||||
// 2. Filter by nationality
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.workers', ['nationality' => 'India']));
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Worker India Dubai');
|
||||
$response->assertDontSee('Worker Philippines Abu Dhabi');
|
||||
|
||||
// 3. Filter by job_type
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.workers', ['job_type' => 'part-time']));
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Worker Philippines Abu Dhabi');
|
||||
$response->assertDontSee('Worker India Dubai');
|
||||
|
||||
// 4. Filter by live_in_out
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.workers', ['live_in_out' => 'live_in']));
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Worker India Dubai');
|
||||
$response->assertDontSee('Worker Philippines Abu Dhabi');
|
||||
|
||||
// 5. Filter by visa_status
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.workers', ['visa_status' => 'Tourist Visa']));
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Worker Philippines Abu Dhabi');
|
||||
$response->assertDontSee('Worker India Dubai');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test web candidates index page loads correctly.
|
||||
*/
|
||||
public function test_employer_can_view_candidates_index_and_apply_filters()
|
||||
{
|
||||
// Add a hired worker (candidate)
|
||||
$w = Worker::create([
|
||||
'name' => 'Worker Kenya Hired',
|
||||
'email' => 'worker3@example.com',
|
||||
'phone' => '+971503333333',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'Kenya',
|
||||
'category_id' => $this->category->id,
|
||||
'verified' => true,
|
||||
'status' => 'Hired',
|
||||
'preferred_location' => 'Dubai',
|
||||
'preferred_job_type' => 'full-time',
|
||||
'live_in_out' => 'live_out',
|
||||
'in_country' => false,
|
||||
'visa_status' => 'Cancelled Visa',
|
||||
'age' => 30,
|
||||
'salary' => 1200,
|
||||
'experience' => 'None',
|
||||
'religion' => 'Christian',
|
||||
'language' => 'SW',
|
||||
'availability' => 'Immediate',
|
||||
'bio' => 'New helper',
|
||||
]);
|
||||
|
||||
JobOffer::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'worker_id' => $w->id,
|
||||
'work_date' => now()->format('Y-m-d'),
|
||||
'location' => 'Dubai',
|
||||
'salary' => 1200,
|
||||
'notes' => 'Hired',
|
||||
'status' => 'accepted', // Hired status
|
||||
]);
|
||||
|
||||
// Request with location Dubai
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.candidates', ['preferred_location' => 'Dubai']));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('selectedWorkers');
|
||||
|
||||
// Request with out_country=true
|
||||
$response2 = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.candidates', ['out_country' => 'true']));
|
||||
|
||||
$response2->assertStatus(200);
|
||||
}
|
||||
}
|
||||
@ -130,6 +130,7 @@ public function test_s1_complete_basic_profile_setup()
|
||||
'name' => 'Rahul Sharma',
|
||||
'nationality' => 'India',
|
||||
'language' => 'HI',
|
||||
'gender' => 'male',
|
||||
'skills' => [],
|
||||
]);
|
||||
|
||||
@ -155,6 +156,7 @@ public function test_s1_complete_basic_profile_setup()
|
||||
'name' => 'Rahul Sharma',
|
||||
'phone' => $phone,
|
||||
'language' => 'HI',
|
||||
'gender' => 'male',
|
||||
'verified' => false,
|
||||
'status' => 'active',
|
||||
]);
|
||||
@ -431,9 +433,6 @@ public function test_register_with_new_api_fields()
|
||||
'gender' => 'male',
|
||||
'live_in_out' => 'live_out',
|
||||
'preferred_location' => 'Dubai Marina',
|
||||
'country' => 'UAE',
|
||||
'city' => 'Dubai',
|
||||
'area' => 'Marina',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
@ -447,9 +446,6 @@ public function test_register_with_new_api_fields()
|
||||
'gender' => 'male',
|
||||
'live_in_out' => 'live_out',
|
||||
'preferred_location' => 'Dubai Marina',
|
||||
'country' => 'UAE',
|
||||
'city' => 'Dubai',
|
||||
'area' => 'Marina',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -23,7 +23,7 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
cors: true,
|
||||
hmr: {
|
||||
host: '192.168.29.131',
|
||||
host: '192.168.0.203',
|
||||
},
|
||||
watch: {
|
||||
ignored: ['**/storage/framework/views/**'],
|
||||
|
||||