charity event admin aprove, reject

This commit is contained in:
mohanmd 2026-06-10 15:58:09 +05:30
parent ce111ae35c
commit e2ff95be28
98 changed files with 3244 additions and 997 deletions

View File

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

View File

@ -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,

View File

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

View File

@ -111,6 +111,7 @@ public function createAnnouncement(Request $request)
'body' => $bodyText,
'type' => $request->type ?? 'info',
'employer_id' => $employer->id,
'status' => 'pending',
]);
return response()->json([

View File

@ -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([

View File

@ -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;

View File

@ -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

View File

@ -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([

View File

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

View File

@ -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)) {

View File

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

View File

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

View File

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

View File

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

View File

@ -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,

View File

@ -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,

View File

@ -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)

View File

@ -9,7 +9,7 @@ class Skill extends Model
{
use HasFactory;
protected $fillable = ['name'];
protected $fillable = ['name', 'image_path'];
public function workers()
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

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

View File

@ -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-semibold text-slate-600 mb-1">Content</label>
<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-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 ? (
<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>
</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'
}`}>
Type: {ann.type}
</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}
</span>
{/* 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">
<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>
) : (
<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'
}`}>
{ann.status}
</span>
{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>
<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>
{/* 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="p-1 text-emerald-600 hover:bg-emerald-50 rounded transition-colors cursor-pointer border-none bg-transparent"
title="Approve"
>
<Check className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleReject(ann.id)}
className="p-1 text-rose-600 hover:bg-rose-50 rounded transition-colors cursor-pointer border-none bg-transparent"
title="Reject"
>
<X className="w-4 h-4" />
</button>
</div>
)}
<button
type="button"
onClick={() => handleDelete(ann.id)}
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-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>
<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>
{/* 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 className="flex items-center space-x-2 self-start flex-wrap gap-2">
{ann.status === 'pending' && (
<>
<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"
>
<Check className="w-3.5 h-3.5" />
<span>Approve</span>
</button>
<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"
>
<X className="w-3.5 h-3.5" />
<span>Reject</span>
</button>
</>
)}
<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"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</div>
))
)}
</div>
);
})}
</div>
)}
</div>
</AdminLayout>
);

View File

@ -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>

View File

@ -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]"

View File

@ -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>

View File

@ -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;
}
const formData = {
name: skillName.trim()
};
if (imageFile) {
formData.image = imageFile;
}
if (editingSkill) {
router.post(`/admin/master-data/skills/${editingSkill.id}/update`, {
name: skillName.trim()
}, {
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">

View File

@ -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]"

View File

@ -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 */}
<label className="flex items-center space-x-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={isDevChecked}
onChange={e => {
setIsDevChecked(e.target.checked);
replyForm.setData('is_developer_response', 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>
</span>
</label>
{/* 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={replyForm.data.close_ticket}
onChange={e => {
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">
<CheckCircle className="w-3.5 h-3.5 text-emerald-600" />
<span>Close Ticket after Reply</span>
</span>
</label>
</div>
<button
type="submit"

View File

@ -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>
<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>
<div className="flex items-center gap-2">
<h2 className="text-xl font-black tracking-tight">{selectedWorker?.name}</h2>
<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,202 +584,188 @@ 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>
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block">Gender</label>
<span className="text-slate-800 font-extrabold capitalize">{selectedWorker?.gender || 'N/A'}</span>
<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>
<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>
<div>
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block">Salary Expectations</label>
<span className="text-slate-800 font-extrabold">{selectedWorker?.salary ? `${selectedWorker.salary} AED / mo` : 'N/A'}</span>
)}
{/* 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 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 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 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 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 mb-0.5">Language</label>
<span className="text-slate-800 font-extrabold">{selectedWorker?.language || 'English'}</span>
</div>
<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}
</span>
)) || 'N/A'}
</div>
</div>
</div>
<div>
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest block">Experience</label>
<span className="text-slate-800 font-extrabold">{selectedWorker?.experience}</span>
</div>
{/* 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="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>
<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>
<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>
<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>
<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">
{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}
</span>
)) || 'N/A'}
</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 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 */}
<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"
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>
{/* Bottom controls */}

View File

@ -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 =>
ann.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
ann.content.toLowerCase().includes(searchTerm.toLowerCase())
);
const filteredAnnouncements = announcements.filter(ann => {
const matchesSearch =
ann.title.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,94 +457,100 @@ 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>
</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" />
<span>{t('push_reminder_scheduled', 'Push & Reminder Scheduled')}</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>
{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>
<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>
<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 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 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-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 && (
<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"
>
<MapPin className="w-3.5 h-3.5" />
<span>{t('view_map_location_pin', 'View Map Location Pin')}</span>
</a>
)}
</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-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>
{/* 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="text-[#185FA5] hover:underline flex items-center space-x-0.5 normal-case"
>
<span>{t('view_on_map', 'View on Map')}</span>
<MapPin className="w-2.5 h-2.5" />
</a>
)}
</div>
</div>
);

View File

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

View File

@ -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,57 +321,67 @@ 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) => (
<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">
{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>
</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">
<span className="px-2 py-1 bg-slate-100 text-slate-600 text-[10px] font-bold rounded-lg">{worker.category}</span>
</td>
<td className="px-6 py-5">
<span className="text-sm font-bold text-slate-800">{worker.salary} <span className="text-[10px] text-slate-400">AED</span></span>
</td>
<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" />
{t('hired', 'Hired').toUpperCase()}
</span>
</td>
<td className="px-6 py-5 text-right">
<div className="flex items-center justify-end space-x-1">
<Link
href={`/employer/messages/start/${worker.worker_id}`}
className="p-2 bg-blue-50 text-[#185FA5] hover:bg-[#185FA5] hover:text-white rounded-lg transition-all"
>
<MessageSquare className="w-3.5 h-3.5" />
</Link>
<Link
href={`/employer/workers/${worker.worker_id}`}
className="p-2 bg-slate-50 text-slate-400 hover:text-slate-900 rounded-lg transition-all"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</Link>
</div>
</td>
</tr>
))
{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-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 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">{worker.applied_at || '—'}</td>
<td className="px-6 py-5">
<span className="px-2 py-1 bg-slate-100 text-slate-600 text-[10px] font-bold rounded-lg">{worker.category}</span>
</td>
<td className="px-6 py-5">
<span className="text-sm font-bold text-slate-800">{worker.salary} <span className="text-[10px] text-slate-400">AED</span></span>
</td>
<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">
<span className="w-1.5 h-1.5 rounded-full mr-1.5 bg-emerald-500" />
{t('hired', 'Hired').toUpperCase()}
</span>
</td>
<td className="px-6 py-5 text-right">
<div className="flex items-center justify-end space-x-1">
<Link
href={`/employer/messages/start/${worker.worker_id}`}
className="p-2 bg-blue-50 text-[#185FA5] hover:bg-[#185FA5] hover:text-white rounded-lg transition-all"
>
<MessageSquare className="w-3.5 h-3.5" />
</Link>
<Link
href={`/employer/workers/${worker.worker_id}`}
className="p-2 bg-slate-50 text-slate-400 hover:text-slate-900 rounded-lg transition-all"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</Link>
</div>
</td>
</tr>
))
) : (
<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="w-8 h-8 flex items-center justify-center bg-[#185FA5] text-white rounded-lg text-[10px] font-black">1</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>

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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([]);
@ -58,6 +59,14 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
const [selectedSkills, setSelectedSkills] = useState([]);
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);
@ -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,141 +547,54 @@ 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>
{/* Filter Drawer button */}
<button
type="button"
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'
}`}
>
<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>
)}
</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>
{/* 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
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>
{/* Skills */}
<div className="relative">
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1.5">{t('skills')}</label>
<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"
>
<span className="truncate">
{selectedSkills.length === 0
? t('select_skills')
: `${selectedSkills.length} ${t('selected')}`}
</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>
)}
</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>
<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"
>
<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" />
</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>
))}
</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 */}
@ -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>

View File

@ -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>
<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>
{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 */}

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

View File

@ -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([

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

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

View File

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

View File

@ -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/**'],