notification issue fixed
This commit is contained in:
parent
00e031a009
commit
f3d40f7679
@ -734,6 +734,9 @@ public function storeAnnouncement(Request $request)
|
||||
'event_time' => 'required|string',
|
||||
'location_details' => 'required|string',
|
||||
'location_pin' => 'required|string|url',
|
||||
'contact_person_name' => 'required|string|max:255',
|
||||
'contact_number' => 'required|string|max:50',
|
||||
'country_code' => 'nullable|string|max:10',
|
||||
]);
|
||||
|
||||
$body = json_encode([
|
||||
@ -744,6 +747,9 @@ public function storeAnnouncement(Request $request)
|
||||
'location_details' => $request->location_details,
|
||||
'location_pin' => $request->location_pin,
|
||||
'content' => $request->content,
|
||||
'contact_person_name' => $request->contact_person_name,
|
||||
'contact_number' => $request->contact_number,
|
||||
'country_code' => $request->country_code ?? '+971',
|
||||
]);
|
||||
|
||||
$ann = \App\Models\Announcement::create([
|
||||
@ -801,7 +807,7 @@ public function deleteAnnouncement(Request $request, $id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast FCM Push Notification for new Event/Announcement.
|
||||
* Broadcast FCM Push Notification and save to Database for new Event/Announcement.
|
||||
*/
|
||||
protected function notifyUsersOfEvent($ann)
|
||||
{
|
||||
@ -816,31 +822,29 @@ protected function notifyUsersOfEvent($ann)
|
||||
}
|
||||
|
||||
// Notify all workers
|
||||
$workers = \App\Models\Worker::whereNotNull('fcm_token')->get();
|
||||
$workers = \App\Models\Worker::all();
|
||||
foreach ($workers as $worker) {
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$worker->fcm_token,
|
||||
$worker->notify(new \App\Notifications\GenericNotification(
|
||||
$title,
|
||||
$body,
|
||||
[
|
||||
'type' => 'announcement',
|
||||
'announcement_id' => $ann->id,
|
||||
]
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
// Notify all employers
|
||||
$employers = \App\Models\User::where('role', 'employer')->whereNotNull('fcm_token')->get();
|
||||
$employers = \App\Models\User::where('role', 'employer')->get();
|
||||
foreach ($employers as $employer) {
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$employer->fcm_token,
|
||||
$employer->notify(new \App\Notifications\GenericNotification(
|
||||
$title,
|
||||
$body,
|
||||
[
|
||||
'type' => 'announcement',
|
||||
'announcement_id' => $ann->id,
|
||||
]
|
||||
);
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -324,14 +324,6 @@ public function register(Request $request)
|
||||
'address' => $request->address,
|
||||
]);
|
||||
|
||||
if ($request->filled('fcm_token')) {
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$request->fcm_token,
|
||||
'Registration Initiated',
|
||||
'Your verification code has been sent to your email.'
|
||||
);
|
||||
}
|
||||
|
||||
// Try sending email
|
||||
try {
|
||||
\Illuminate\Support\Facades\Mail::to($request->email)->send(new \App\Mail\EmployerOtpMail(
|
||||
|
||||
@ -852,10 +852,10 @@ public function hireCandidate(Request $request, $id = null)
|
||||
));
|
||||
} else if ($updatedOffer) {
|
||||
$worker->notify(new \App\Notifications\GenericNotification(
|
||||
"New Job Offer",
|
||||
"Employer " . ($employer->name ?? "Employer") . " has sent you a direct hire offer.",
|
||||
"Direct Hiring Offer",
|
||||
($employer->name ?? "Employer") . " wants to hire you. Do you want to accept this job offer?",
|
||||
[
|
||||
'type' => 'hire_request',
|
||||
'type' => 'direct_hire_request',
|
||||
'offer_id' => $updatedOffer->id,
|
||||
'status' => 'pending',
|
||||
]
|
||||
|
||||
@ -1108,22 +1108,6 @@ private function triggerStatusNotification($application, $status)
|
||||
$job = $application->jobPost;
|
||||
$employer = $job ? $job->employer : null;
|
||||
|
||||
// When a new application is submitted: notify employer
|
||||
if ($status === 'applied') {
|
||||
if ($employer) {
|
||||
$employer->notify(new \App\Notifications\GenericNotification(
|
||||
"New Job Application",
|
||||
"A candidate has applied for your job: " . ($job->title ?? 'Job Post'),
|
||||
[
|
||||
'type' => 'job_application_submitted',
|
||||
'job_id' => $job->id,
|
||||
'application_id' => $application->id,
|
||||
]
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// When status is updated: notify worker
|
||||
if ($worker) {
|
||||
$title = "Job Application Update";
|
||||
@ -1134,6 +1118,7 @@ private function triggerStatusNotification($application, $status)
|
||||
$body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post');
|
||||
$type = 'shortlisted';
|
||||
break;
|
||||
case 'applied':
|
||||
case 'review':
|
||||
case 'reviewing':
|
||||
$body = "Your application is under review for the job: " . ($job->title ?? 'Job Post');
|
||||
|
||||
@ -72,6 +72,9 @@ public function store(Request $request)
|
||||
'event_time' => 'required|string',
|
||||
'location_details' => 'required|string',
|
||||
'location_pin' => 'required|string|url',
|
||||
'contact_person_name' => 'required|string|max:255',
|
||||
'contact_number' => 'required|string|max:50',
|
||||
'country_code' => 'nullable|string|max:10',
|
||||
]);
|
||||
|
||||
$body = json_encode([
|
||||
@ -82,6 +85,9 @@ public function store(Request $request)
|
||||
'location_details' => $request->location_details,
|
||||
'location_pin' => $request->location_pin,
|
||||
'content' => $request->content,
|
||||
'contact_person_name' => $request->contact_person_name,
|
||||
'contact_number' => $request->contact_number,
|
||||
'country_code' => $request->country_code ?? '+971',
|
||||
]);
|
||||
|
||||
$sess = session('user');
|
||||
|
||||
@ -341,10 +341,10 @@ public function updateStatus(Request $request, $id)
|
||||
// Notify worker about the hire offer if it is set to pending
|
||||
if ($dbStatus === 'pending') {
|
||||
$offer->worker->notify(new \App\Notifications\GenericNotification(
|
||||
"Action Required: Confirm Hiring",
|
||||
"Employer " . ($offer->employer->name ?? "Employer") . " has sent you a direct hire offer. Please confirm.",
|
||||
"Direct Hiring Offer",
|
||||
($offer->employer->name ?? "Employer") . " wants to hire you. Do you want to accept this job offer?",
|
||||
[
|
||||
'type' => 'hire_request',
|
||||
'type' => 'direct_hire_request',
|
||||
'offer_id' => $offer->id,
|
||||
'status' => 'pending',
|
||||
]
|
||||
@ -458,22 +458,6 @@ private function triggerStatusNotification($application, $status)
|
||||
$job = $application->jobPost;
|
||||
$employer = $job ? $job->employer : null;
|
||||
|
||||
// When a new application is submitted: notify employer
|
||||
if ($status === 'applied') {
|
||||
if ($employer) {
|
||||
$employer->notify(new \App\Notifications\GenericNotification(
|
||||
"New Job Application",
|
||||
"A candidate has applied for your job: " . ($job->title ?? 'Job Post'),
|
||||
[
|
||||
'type' => 'job_application_submitted',
|
||||
'job_id' => $job->id,
|
||||
'application_id' => $application->id,
|
||||
]
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// When status is updated: notify worker
|
||||
if ($worker) {
|
||||
$title = "Job Application Update";
|
||||
@ -484,6 +468,7 @@ private function triggerStatusNotification($application, $status)
|
||||
$body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post');
|
||||
$type = 'shortlisted';
|
||||
break;
|
||||
case 'applied':
|
||||
case 'review':
|
||||
case 'reviewing':
|
||||
$body = "Your application is under review for the job: " . ($job->title ?? 'Job Post');
|
||||
|
||||
@ -497,6 +497,8 @@ public function markHired(Request $request, $id)
|
||||
->where('worker_id', $worker->id)
|
||||
->first();
|
||||
|
||||
$application = null;
|
||||
|
||||
if ($offer) {
|
||||
$offer->update(['status' => 'pending']);
|
||||
} else {
|
||||
@ -531,16 +533,28 @@ public function markHired(Request $request, $id)
|
||||
}
|
||||
|
||||
// Notify the worker
|
||||
$worker->notify(new \App\Notifications\GenericNotification(
|
||||
"Action Required: Confirm Hiring",
|
||||
"Employer " . ($user->name ?? "Employer") . " has sent you a hire request. Please confirm.",
|
||||
[
|
||||
'type' => 'hire_request',
|
||||
'offer_id' => $offer->id ?? '',
|
||||
'application_id' => $application->id ?? '',
|
||||
'status' => $offer ? 'pending' : 'hire_requested',
|
||||
]
|
||||
));
|
||||
if ($offer) {
|
||||
$worker->notify(new \App\Notifications\GenericNotification(
|
||||
"Direct Hiring Offer",
|
||||
($user->name ?? "Employer") . " wants to hire you. Do you want to accept this job offer?",
|
||||
[
|
||||
'type' => 'direct_hire_request',
|
||||
'offer_id' => $offer->id,
|
||||
'status' => 'pending',
|
||||
]
|
||||
));
|
||||
} else {
|
||||
$worker->notify(new \App\Notifications\GenericNotification(
|
||||
"Action Required: Confirm Hiring",
|
||||
"Employer " . ($user->name ?? "Employer") . " wants to hire you for '" . ($application->jobPost->title ?? 'Job Post') . "'. Please confirm.",
|
||||
[
|
||||
'type' => 'hire_request',
|
||||
'job_id' => $application->jobPost->id ?? '',
|
||||
'application_id' => $application->id,
|
||||
'status' => 'hire_requested',
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
return back()->with('success', 'Hire request initiated successfully! Awaiting candidate confirmation.');
|
||||
}
|
||||
|
||||
@ -10,6 +10,34 @@ class JobPost extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::updated(function ($jobPost) {
|
||||
if ($jobPost->wasChanged('status')) {
|
||||
$oldStatus = $jobPost->getOriginal('status');
|
||||
$newStatus = $jobPost->status;
|
||||
|
||||
// Send push notification to all workers who applied to this job
|
||||
$applications = $jobPost->applications()->with('worker')->get();
|
||||
foreach ($applications as $app) {
|
||||
$worker = $app->worker;
|
||||
if ($worker) {
|
||||
$worker->notify(new \App\Notifications\GenericNotification(
|
||||
"Job Status Changed",
|
||||
"The status of the job '" . $jobPost->title . "' has been changed from '" . ucfirst($oldStatus) . "' to '" . ucfirst($newStatus) . "'.",
|
||||
[
|
||||
'type' => 'job_status_change',
|
||||
'job_id' => $jobPost->id,
|
||||
'old_status' => $oldStatus,
|
||||
'new_status' => $newStatus,
|
||||
]
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'employer_id',
|
||||
'title',
|
||||
|
||||
@ -11,6 +11,27 @@ class Worker extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, Notifiable;
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::updated(function ($worker) {
|
||||
if ($worker->wasChanged('status')) {
|
||||
$oldStatus = $worker->getOriginal('status');
|
||||
$newStatus = $worker->status;
|
||||
|
||||
$worker->notify(new \App\Notifications\GenericNotification(
|
||||
"Profile Status Update",
|
||||
"Your profile status has changed from '" . ucfirst($oldStatus) . "' to '" . ucfirst($newStatus) . "'.",
|
||||
[
|
||||
'type' => 'worker_status_change',
|
||||
'worker_id' => $worker->id,
|
||||
'old_status' => $oldStatus,
|
||||
'new_status' => $newStatus,
|
||||
]
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
|
||||
@ -59,7 +59,7 @@ public static function getAccessToken()
|
||||
$base64UrlSignature = self::base64UrlEncode($signature);
|
||||
$jwtToken = $signatureInput . '.' . $base64UrlSignature;
|
||||
|
||||
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
|
||||
$response = Http::withoutVerifying()->asForm()->post('https://oauth2.googleapis.com/token', [
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwtToken,
|
||||
]);
|
||||
@ -140,7 +140,7 @@ public static function sendPushNotification($token, $title, $body, array $data =
|
||||
$payload['message']['data'] = $formattedData;
|
||||
}
|
||||
|
||||
$response = Http::withToken($accessToken)
|
||||
$response = Http::withoutVerifying()->withToken($accessToken)
|
||||
->post("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send", $payload);
|
||||
|
||||
if ($response->failed()) {
|
||||
|
||||
@ -6,6 +6,15 @@ import {
|
||||
Calendar, MapPin, Clock, Gift, Sparkles, Search,
|
||||
ChevronDown, ChevronUp, Building, User, XCircle, Eye, BellRing
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
const hoursList = [
|
||||
'12:00', '12:30', '01:00', '01:30', '02:00', '02:30',
|
||||
@ -17,6 +26,7 @@ const hoursList = [
|
||||
export default function Announcements({ initialAnnouncements }) {
|
||||
const [announcements, setAnnouncements] = useState(initialAnnouncements || []);
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [selectedAnnouncement, setSelectedAnnouncement] = useState(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('All');
|
||||
const [expandedCards, setExpandedCards] = useState({});
|
||||
@ -36,7 +46,10 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
event_date: '',
|
||||
event_time: '09:00 AM - 04:00 PM',
|
||||
location_details: '',
|
||||
location_pin: ''
|
||||
location_pin: '',
|
||||
contact_person_name: '',
|
||||
contact_number: '',
|
||||
country_code: '+971'
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [toastMessage, setToastMessage] = useState(null);
|
||||
@ -62,6 +75,8 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
} else if (!newAnnouncement.location_pin.startsWith('http://') && !newAnnouncement.location_pin.startsWith('https://')) {
|
||||
newErrors.location_pin = 'Must be a valid URL';
|
||||
}
|
||||
if (!newAnnouncement.contact_person_name.trim()) newErrors.contact_person_name = 'Contact person name is required';
|
||||
if (!newAnnouncement.contact_number.trim()) newErrors.contact_number = 'Contact number is required';
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
@ -75,7 +90,10 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
event_date: newAnnouncement.event_date,
|
||||
event_time: newAnnouncement.event_time,
|
||||
location_details: newAnnouncement.location_details,
|
||||
location_pin: newAnnouncement.location_pin
|
||||
location_pin: newAnnouncement.location_pin,
|
||||
contact_person_name: newAnnouncement.contact_person_name,
|
||||
contact_number: newAnnouncement.contact_number,
|
||||
country_code: newAnnouncement.country_code
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setNewAnnouncement({
|
||||
@ -85,7 +103,10 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
event_date: '',
|
||||
event_time: '09:00 AM - 04:00 PM',
|
||||
location_details: '',
|
||||
location_pin: ''
|
||||
location_pin: '',
|
||||
contact_person_name: '',
|
||||
contact_number: '',
|
||||
country_code: '+971'
|
||||
});
|
||||
setStartTimeHour('09:00');
|
||||
setStartTimeAmpm('AM');
|
||||
@ -509,6 +530,39 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-600 mb-1">Contact Person Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newAnnouncement.contact_person_name}
|
||||
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, contact_person_name: e.target.value })}
|
||||
className={`w-full px-4 py-2.5 rounded-xl border ${errors.contact_person_name ? '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. Jane Doe"
|
||||
/>
|
||||
{errors.contact_person_name && <p className="text-red-500 text-xs mt-1 font-semibold">{errors.contact_person_name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-600 mb-1">Contact Mobile Number</label>
|
||||
<div className="flex space-x-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newAnnouncement.country_code}
|
||||
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, country_code: e.target.value })}
|
||||
className="w-16 px-2 py-2.5 bg-white border border-slate-300 rounded-xl text-xs font-bold focus:ring-2 focus:ring-[#0F6E56]/20 focus:border-[#0F6E56] outline-none text-center"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newAnnouncement.contact_number}
|
||||
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, contact_number: e.target.value })}
|
||||
className={`flex-1 px-4 py-2.5 rounded-xl border ${errors.contact_number ? 'border-red-500' : 'border-slate-300'} text-sm focus:ring-2 focus:ring-[#0F6E56]/20 focus:border-[#0F6E56] outline-none transition-all`}
|
||||
placeholder="551234567"
|
||||
/>
|
||||
</div>
|
||||
{errors.contact_number && <p className="text-red-500 text-xs mt-1 font-semibold">{errors.contact_number}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-600 mb-1">Description & Instructions</label>
|
||||
<textarea
|
||||
@ -613,10 +667,11 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
ann.status === 'rejected' ? 'border-l-rose-500' :
|
||||
'border-l-amber-500';
|
||||
|
||||
return (
|
||||
return (
|
||||
<div
|
||||
key={ann.id}
|
||||
className="group bg-white rounded-2xl border border-slate-200/80 hover:border-slate-300 shadow-sm hover:shadow-md transition-all duration-300 flex flex-col justify-between overflow-hidden relative"
|
||||
onClick={() => setSelectedAnnouncement(ann)}
|
||||
className="group bg-white rounded-2xl border border-slate-200/80 hover:border-slate-300 shadow-sm hover:shadow-md transition-all duration-300 flex flex-col justify-between overflow-hidden relative cursor-pointer"
|
||||
>
|
||||
{/* Top colored accent line */}
|
||||
<div className={`h-1.5 w-full ${
|
||||
@ -655,7 +710,7 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
</div>
|
||||
|
||||
{/* Action Control Buttons */}
|
||||
<div className="flex items-center space-x-1 shrink-0">
|
||||
<div className="flex items-center space-x-1 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{ann.status === 'pending' && (
|
||||
<div className="flex items-center space-x-0.5 bg-slate-50 p-0.5 rounded-lg border border-slate-200">
|
||||
<button
|
||||
@ -691,6 +746,7 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
{ann.posted_by_email ? (
|
||||
<Link
|
||||
href={`/admin/employers?search=${encodeURIComponent(ann.posted_by_email)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center text-[10px] text-slate-500 font-bold bg-slate-50/60 hover:bg-slate-100/80 hover:border-slate-300 py-1 px-2.5 rounded-lg border border-slate-100 transition-all w-fit cursor-pointer no-underline"
|
||||
title="Click to view Employer Profile"
|
||||
>
|
||||
@ -720,7 +776,7 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
{ann.content.length > 90 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(ann.id)}
|
||||
onClick={(e) => { e.stopPropagation(); toggleExpand(ann.id); }}
|
||||
className="text-[#0F6E56] hover:text-[#0b523f] hover:underline font-bold flex items-center space-x-0.5 border-none bg-transparent cursor-pointer p-0 text-[10px] transition-colors"
|
||||
>
|
||||
<span>{isExpanded ? 'Show Less' : 'Read Full Description'}</span>
|
||||
@ -729,7 +785,7 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
)}
|
||||
|
||||
{ann.status === 'rejected' && ann.remarks && (
|
||||
<div className="bg-rose-50/80 border border-rose-100 rounded-xl p-2.5 text-rose-800 text-[10px] font-semibold mt-1.5 leading-relaxed">
|
||||
<div className="bg-rose-50/80 border border-rose-100 rounded-xl p-2.5 text-rose-800 text-[10px] font-semibold mt-1.5 leading-relaxed" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="font-extrabold text-rose-900 block mb-0.5">Rejection Remarks:</span>
|
||||
{ann.remarks}
|
||||
</div>
|
||||
@ -777,6 +833,21 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Details */}
|
||||
{details.contact_person_name && (
|
||||
<div className="flex items-center space-x-2.5 bg-emerald-50/40 border border-emerald-100/50 p-2 rounded-xl">
|
||||
<div className="w-7 h-7 rounded-lg bg-emerald-50 flex items-center justify-center text-emerald-500 shrink-0 border border-emerald-100">
|
||||
<User className="w-3.5 h-3.5 text-emerald-600" />
|
||||
</div>
|
||||
<div className="min-w-0 leading-tight">
|
||||
<div className="text-[8px] text-emerald-500 font-extrabold uppercase tracking-wider">CONTACT PERSON</div>
|
||||
<div className="truncate text-slate-700 font-bold text-xs" title={`${details.contact_person_name} (${details.country_code || '+971'} ${details.contact_number})`}>
|
||||
{details.contact_person_name} ({details.country_code || '+971'} {details.contact_number})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -789,6 +860,7 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
href={details.location_pin}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-[#0F6E56] hover:text-[#0b523f] hover:underline flex items-center space-x-0.5 bg-white border border-slate-200/80 hover:border-slate-300 py-1 px-2.5 rounded-lg shadow-sm font-extrabold transition-all hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
<span>View on Map</span>
|
||||
@ -802,6 +874,122 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Event Detail Popup Modal */}
|
||||
<Dialog open={!!selectedAnnouncement} onOpenChange={(open) => !open && setSelectedAnnouncement(null)}>
|
||||
<DialogContent className="sm:max-w-[650px] rounded-3xl p-0 overflow-hidden border-none shadow-2xl">
|
||||
{selectedAnnouncement && (
|
||||
<>
|
||||
<div className="bg-gradient-to-r from-[#0F6E56] to-[#128a6c] 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" />
|
||||
<div className="flex items-center space-x-2 mb-2 relative z-10">
|
||||
<span className="px-2 py-0.5 rounded text-[8px] font-extrabold uppercase tracking-wider shrink-0 bg-white/20 text-white">
|
||||
{selectedAnnouncement.status}
|
||||
</span>
|
||||
<span className="bg-white/20 text-white px-2 py-0.5 rounded text-[8px] font-extrabold uppercase tracking-wider flex items-center gap-0.5">
|
||||
<Heart className="w-2.5 h-2.5 fill-white text-white" />
|
||||
<span>Charity</span>
|
||||
</span>
|
||||
</div>
|
||||
<DialogTitle className="text-2xl font-black relative z-10 leading-tight">
|
||||
{selectedAnnouncement.title}
|
||||
</DialogTitle>
|
||||
<div className="flex flex-col space-y-0.5 text-white/80 font-bold text-xs mt-2 relative z-10">
|
||||
<span>Sponsor: {selectedAnnouncement.organization} ({selectedAnnouncement.posted_by})</span>
|
||||
<span>Published: {selectedAnnouncement.created_at}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-6 bg-white max-h-[70vh] overflow-y-auto font-sans">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Description & Instructions</h4>
|
||||
<p className="text-slate-600 text-xs font-bold leading-relaxed whitespace-pre-wrap">
|
||||
{selectedAnnouncement.content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedAnnouncement.charityDetails && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t border-slate-100">
|
||||
<div className="space-y-1 bg-rose-50/40 border border-rose-100/50 p-3 rounded-2xl">
|
||||
<div className="text-[9px] text-rose-500 font-extrabold uppercase tracking-wider flex items-center gap-1">
|
||||
<Gift className="w-3.5 h-3.5 fill-rose-500 text-rose-500" />
|
||||
<span>Items Provided</span>
|
||||
</div>
|
||||
<div className="text-slate-700 font-extrabold text-xs">
|
||||
{selectedAnnouncement.charityDetails.provided_items}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 bg-amber-50/40 border border-amber-100/50 p-3 rounded-2xl">
|
||||
<div className="text-[9px] text-amber-600 font-extrabold uppercase tracking-wider flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5 text-amber-600" />
|
||||
<span>Event Date</span>
|
||||
</div>
|
||||
<div className="text-slate-700 font-extrabold text-xs">
|
||||
{selectedAnnouncement.charityDetails.event_date}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 bg-indigo-50/40 border border-indigo-100/50 p-3 rounded-2xl">
|
||||
<div className="text-[9px] text-indigo-600 font-extrabold uppercase tracking-wider flex items-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5 text-indigo-600" />
|
||||
<span>Event Time</span>
|
||||
</div>
|
||||
<div className="text-slate-700 font-extrabold text-xs">
|
||||
{selectedAnnouncement.charityDetails.event_time}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 bg-blue-50/40 border border-blue-100/50 p-3 rounded-2xl">
|
||||
<div className="text-[9px] text-blue-600 font-extrabold uppercase tracking-wider flex items-center gap-1">
|
||||
<MapPin className="w-3.5 h-3.5 text-blue-600" />
|
||||
<span>Location</span>
|
||||
</div>
|
||||
<div className="text-slate-700 font-extrabold text-xs">
|
||||
{selectedAnnouncement.charityDetails.location_details}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedAnnouncement.charityDetails.contact_person_name && (
|
||||
<div className="space-y-1 bg-emerald-50/40 border border-emerald-100/50 p-3 rounded-2xl md:col-span-2">
|
||||
<div className="text-[9px] text-emerald-600 font-extrabold uppercase tracking-wider flex items-center gap-1">
|
||||
<User className="w-3.5 h-3.5 text-emerald-600" />
|
||||
<span>Contact Person Details</span>
|
||||
</div>
|
||||
<div className="text-slate-700 font-extrabold text-xs flex flex-col sm:flex-row sm:justify-between gap-1 mt-1">
|
||||
<span>Name: <strong className="text-slate-900">{selectedAnnouncement.charityDetails.contact_person_name}</strong></span>
|
||||
<span>Mobile: <strong className="text-slate-900">{selectedAnnouncement.charityDetails.country_code || '+971'} {selectedAnnouncement.charityDetails.contact_number}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-3 pt-4 border-t border-slate-100">
|
||||
{selectedAnnouncement.charityDetails?.location_pin && (
|
||||
<a
|
||||
href={selectedAnnouncement.charityDetails.location_pin}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex-1 bg-slate-100 hover:bg-slate-200 text-slate-700 py-3 rounded-2xl text-xs font-black uppercase tracking-widest text-center no-underline flex items-center justify-center space-x-1.5 transition-all"
|
||||
>
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>View on Map</span>
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedAnnouncement(null)}
|
||||
className="flex-1 bg-[#0F6E56] hover:bg-[#0b523f] text-white py-3 rounded-2xl text-xs font-black uppercase tracking-widest cursor-pointer border-none shadow-md shadow-[#0F6E56]/25"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -22,7 +22,8 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Gift,
|
||||
Plus
|
||||
Plus,
|
||||
User
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
@ -44,6 +45,7 @@ const hoursList = [
|
||||
export default function Announcements({ initialAnnouncements }) {
|
||||
const { t } = useTranslation();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [selectedAnnouncement, setSelectedAnnouncement] = useState(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [toastMessage, setToastMessage] = useState(null);
|
||||
const [toastSub, setToastSub] = useState(null);
|
||||
@ -72,7 +74,10 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
event_date: '',
|
||||
event_time: '09:00 AM - 04:00 PM',
|
||||
location_details: '',
|
||||
location_pin: ''
|
||||
location_pin: '',
|
||||
contact_person_name: '',
|
||||
contact_number: '',
|
||||
country_code: '+971'
|
||||
});
|
||||
|
||||
const updateEventTime = (sh, sa, eh, ea) => {
|
||||
@ -165,7 +170,7 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
<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">
|
||||
<DialogContent className="sm:max-w-[650px] rounded-3xl p-0 overflow-hidden border-none shadow-2xl">
|
||||
<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>
|
||||
@ -366,6 +371,41 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
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 className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase tracking-widest ml-1">{t('contact_person_name', 'Contact Person Name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder={t('contact_person_placeholder', 'e.g. Jane Doe')}
|
||||
value={newAnnouncement.contact_person_name}
|
||||
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, contact_person_name: 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-[#185FA5]/20 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 min-w-0">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase tracking-widest ml-1">{t('contact_mobile_number', 'Contact Mobile Number')}</label>
|
||||
<div className="flex space-x-1.5 w-full min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="+971"
|
||||
value={newAnnouncement.country_code}
|
||||
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, country_code: e.target.value })}
|
||||
className="w-16 px-2 py-2.5 bg-white border border-slate-200 rounded-xl text-xs font-bold focus:ring-2 focus:ring-[#185FA5]/20 outline-none text-center shrink-0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="551234567"
|
||||
value={newAnnouncement.contact_number}
|
||||
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, contact_number: e.target.value })}
|
||||
className="flex-1 min-w-0 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@ -462,7 +502,8 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
return (
|
||||
<div
|
||||
key={ann.id}
|
||||
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`}
|
||||
onClick={() => setSelectedAnnouncement(ann)}
|
||||
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 cursor-pointer`}
|
||||
>
|
||||
{/* Top Card Area */}
|
||||
<div className="p-4 space-y-3">
|
||||
@ -494,7 +535,7 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
{ann.content.length > 90 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(ann.id)}
|
||||
onClick={(e) => { e.stopPropagation(); 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>
|
||||
@ -502,7 +543,7 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
</button>
|
||||
)}
|
||||
{ann.status === 'rejected' && ann.remarks && (
|
||||
<div className="bg-rose-50 border border-rose-100 rounded-xl p-2.5 text-rose-800 text-[11px] font-semibold mt-1.5 leading-relaxed">
|
||||
<div className="bg-rose-50 border border-rose-100 rounded-xl p-2.5 text-rose-800 text-[11px] font-semibold mt-1.5 leading-relaxed" onClick={(e) => e.stopPropagation()}>
|
||||
<strong>{t('rejection_remarks_label', 'Remarks')}:</strong> {ann.remarks}
|
||||
</div>
|
||||
)}
|
||||
@ -531,6 +572,16 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact details row */}
|
||||
{details && details.contact_person_name && (
|
||||
<div className="pt-2 border-t border-slate-100 flex items-center space-x-1.5 text-[10px] text-slate-500 font-black uppercase tracking-wider">
|
||||
<User className="w-3.5 h-3.5 text-indigo-500 shrink-0" />
|
||||
<span className="truncate text-slate-700 normal-case" title={`${details.contact_person_name} (${details.country_code || '+971'} ${details.contact_number})`}>
|
||||
<strong>{details.contact_person_name}</strong>: {details.country_code || '+971'} {details.contact_number}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Card Footer */}
|
||||
@ -541,7 +592,8 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
href={details.location_pin}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[#185FA5] hover:underline flex items-center space-x-0.5 normal-case"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-[#185FA5] hover:underline flex items-center space-x-0.5 normal-case animate-none"
|
||||
>
|
||||
<span>{t('view_on_map', 'View on Map')}</span>
|
||||
<MapPin className="w-2.5 h-2.5" />
|
||||
@ -553,6 +605,121 @@ export default function Announcements({ initialAnnouncements }) {
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Event Detail Popup Modal */}
|
||||
<Dialog open={!!selectedAnnouncement} onOpenChange={(open) => !open && setSelectedAnnouncement(null)}>
|
||||
<DialogContent className="sm:max-w-[650px] rounded-3xl p-0 overflow-hidden border-none shadow-2xl">
|
||||
{selectedAnnouncement && (
|
||||
<>
|
||||
<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" />
|
||||
<div className="flex items-center space-x-2 mb-2 relative z-10">
|
||||
<span className="px-2 py-0.5 rounded text-[8px] font-extrabold uppercase tracking-wider shrink-0 bg-white/20 text-white">
|
||||
{selectedAnnouncement.status}
|
||||
</span>
|
||||
<span className="bg-white/20 text-white px-2 py-0.5 rounded text-[8px] font-extrabold uppercase tracking-wider flex items-center gap-0.5">
|
||||
<Heart className="w-2.5 h-2.5 fill-white text-white" />
|
||||
<span>Charity</span>
|
||||
</span>
|
||||
</div>
|
||||
<DialogTitle className="text-2xl font-black relative z-10 leading-tight">
|
||||
{selectedAnnouncement.title}
|
||||
</DialogTitle>
|
||||
<p className="text-white/80 font-bold text-xs mt-2 relative z-10">
|
||||
{t('posted_label', 'Posted')}: {selectedAnnouncement.created_at}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-6 bg-white max-h-[70vh] overflow-y-auto font-sans">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('description', 'Description')}</h4>
|
||||
<p className="text-slate-600 text-xs font-bold leading-relaxed whitespace-pre-wrap">
|
||||
{selectedAnnouncement.content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedAnnouncement.charityDetails && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t border-slate-100">
|
||||
<div className="space-y-1 bg-rose-50/40 border border-rose-100/50 p-3 rounded-2xl">
|
||||
<div className="text-[9px] text-rose-500 font-extrabold uppercase tracking-wider flex items-center gap-1">
|
||||
<Gift className="w-3.5 h-3.5 fill-rose-500 text-rose-500" />
|
||||
<span>{t('items_provided', 'Items Provided')}</span>
|
||||
</div>
|
||||
<div className="text-slate-700 font-extrabold text-xs">
|
||||
{selectedAnnouncement.charityDetails.provided_items}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 bg-amber-50/40 border border-amber-100/50 p-3 rounded-2xl">
|
||||
<div className="text-[9px] text-amber-600 font-extrabold uppercase tracking-wider flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5 text-amber-600" />
|
||||
<span>{t('event_date', 'Event Date')}</span>
|
||||
</div>
|
||||
<div className="text-slate-700 font-extrabold text-xs">
|
||||
{selectedAnnouncement.charityDetails.event_date}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 bg-indigo-50/40 border border-indigo-100/50 p-3 rounded-2xl">
|
||||
<div className="text-[9px] text-indigo-600 font-extrabold uppercase tracking-wider flex items-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5 text-indigo-600" />
|
||||
<span>{t('event_time', 'Event Time')}</span>
|
||||
</div>
|
||||
<div className="text-slate-700 font-extrabold text-xs">
|
||||
{selectedAnnouncement.charityDetails.event_time}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 bg-blue-50/40 border border-blue-100/50 p-3 rounded-2xl">
|
||||
<div className="text-[9px] text-blue-600 font-extrabold uppercase tracking-wider flex items-center gap-1">
|
||||
<MapPin className="w-3.5 h-3.5 text-blue-600" />
|
||||
<span>{t('location', 'Location')}</span>
|
||||
</div>
|
||||
<div className="text-slate-700 font-extrabold text-xs">
|
||||
{selectedAnnouncement.charityDetails.location_details}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedAnnouncement.charityDetails.contact_person_name && (
|
||||
<div className="space-y-1 bg-emerald-50/40 border border-emerald-100/50 p-3 rounded-2xl md:col-span-2">
|
||||
<div className="text-[9px] text-emerald-600 font-extrabold uppercase tracking-wider flex items-center gap-1">
|
||||
<User className="w-3.5 h-3.5 text-emerald-600" />
|
||||
<span>{t('contact_person_details', 'Contact Person Details')}</span>
|
||||
</div>
|
||||
<div className="text-slate-700 font-extrabold text-xs flex flex-col sm:flex-row sm:justify-between gap-1 mt-1">
|
||||
<span>Name: <strong className="text-slate-900">{selectedAnnouncement.charityDetails.contact_person_name}</strong></span>
|
||||
<span>Mobile: <strong className="text-slate-900">{selectedAnnouncement.charityDetails.country_code || '+971'} {selectedAnnouncement.charityDetails.contact_number}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-3 pt-4 border-t border-slate-100">
|
||||
{selectedAnnouncement.charityDetails?.location_pin && (
|
||||
<a
|
||||
href={selectedAnnouncement.charityDetails.location_pin}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex-1 bg-slate-100 hover:bg-slate-200 text-slate-700 py-3 rounded-2xl text-xs font-black uppercase tracking-widest text-center no-underline flex items-center justify-center space-x-1.5 transition-all"
|
||||
>
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>{t('view_on_map', 'View on Map')}</span>
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedAnnouncement(null)}
|
||||
className="flex-1 bg-[#185FA5] hover:bg-[#14508c] text-white py-3 rounded-2xl text-xs font-black uppercase tracking-widest cursor-pointer border-none shadow-md shadow-[#185FA5]/25"
|
||||
>
|
||||
{t('close', 'Close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</EmployerLayout>
|
||||
);
|
||||
|
||||
@ -290,21 +290,12 @@ export default function Show({ worker }) {
|
||||
<span>{t('start_direct_chat', 'Start Direct Chat')}</span>
|
||||
</Link>
|
||||
|
||||
{/* Stage 4 Hire: Mark Hired (Finalize Hiring Direct Flow) */}
|
||||
{availabilityStatus === 'Pending Confirmation' ? (
|
||||
{/* Stage 4 Hire: Pending Confirmation Indicator */}
|
||||
{availabilityStatus === 'Pending Confirmation' && (
|
||||
<div className="bg-amber-50 text-amber-700 border border-amber-200 px-6 py-3 rounded-xl font-bold text-sm flex items-center justify-center space-x-2">
|
||||
<Clock className="w-4 h-4 text-amber-600 animate-pulse" />
|
||||
<span>{t('pending_confirmation', 'Pending Confirmation')}</span>
|
||||
</div>
|
||||
) : availabilityStatus !== 'Hired' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHireConfirmModal(true)}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white border border-emerald-700 px-8 py-3 rounded-xl font-black text-sm shadow-md transition-all flex items-center justify-center space-x-2 flex-1 sm:flex-initial scale-105 active:scale-100"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 text-white" />
|
||||
<span>{t('mark_hired_action', 'Mark Hired')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -1093,43 +1084,7 @@ export default function Show({ worker }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hired Confirmation Modal */}
|
||||
{showHireConfirmModal && (
|
||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-xs flex items-center justify-center p-4 z-50 animate-fade-in">
|
||||
<div className="bg-white rounded-3xl w-full max-w-md p-6 relative animate-zoom-in text-center space-y-4 border border-slate-200 shadow-2xl">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-blue-50 border border-blue-100 flex items-center justify-center text-blue-600">
|
||||
<CheckCircle2 className="w-8 h-8" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-extrabold text-lg text-slate-900">{t('confirm_hire_title', 'Confirm Hiring')}</h4>
|
||||
<p className="text-xs text-slate-600 leading-normal px-2">
|
||||
{t('confirm_hire_desc', 'Verify this worker with your needs and make sure to hire this worker. If hired, you can view this worker in the Hired Workers page.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-2 text-xs font-bold">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHireConfirmModal(false)}
|
||||
className="flex-1 py-3 border border-slate-200 hover:bg-slate-50 rounded-xl transition-colors"
|
||||
>
|
||||
{t('cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowHireConfirmModal(false);
|
||||
handleMarkHired();
|
||||
}}
|
||||
className="flex-1 bg-emerald-600 hover:bg-emerald-700 text-white py-3 rounded-xl shadow-sm transition-colors"
|
||||
>
|
||||
{t('confirm_hire_action', 'Confirm Hire')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</EmployerLayout>
|
||||
);
|
||||
|
||||
@ -432,5 +432,119 @@ public function test_employer_can_post_and_get_charity_announcement_with_contact
|
||||
$this->assertEquals('551234567', $myPostedAnn['charity_details']['contact_number']);
|
||||
$this->assertEquals('+971', $myPostedAnn['charity_details']['country_code']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test web employer can create charity event with contact details.
|
||||
*/
|
||||
public function test_web_employer_can_create_charity_event()
|
||||
{
|
||||
$response = $this->actingAs($this->employer)
|
||||
->post(route('employer.events.store'), [
|
||||
'title' => 'Web Charity Camp',
|
||||
'content' => 'Giving away free wellness items and tools.',
|
||||
'provided_items' => 'Wellness Kits',
|
||||
'event_date' => '2026-08-20',
|
||||
'event_time' => '10:00 AM - 3:00 PM',
|
||||
'location_details' => 'Safa Park, Dubai',
|
||||
'location_pin' => 'https://maps.app.goo.gl/xyz',
|
||||
'contact_person_name' => 'John Doe',
|
||||
'contact_number' => '559876543',
|
||||
'country_code' => '+971',
|
||||
]);
|
||||
|
||||
$response->assertStatus(302); // Redirect on success
|
||||
|
||||
$this->assertDatabaseHas('announcements', [
|
||||
'title' => 'Web Charity Camp',
|
||||
'employer_id' => $this->employer->id,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
$announcement = Announcement::where('title', 'Web Charity Camp')->first();
|
||||
$this->assertNotNull($announcement);
|
||||
$body = json_decode($announcement->body, true);
|
||||
$this->assertEquals('John Doe', $body['contact_person_name']);
|
||||
$this->assertEquals('559876543', $body['contact_number']);
|
||||
$this->assertEquals('+971', $body['country_code']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test web admin can create charity event with contact details.
|
||||
*/
|
||||
public function test_web_admin_can_create_charity_event()
|
||||
{
|
||||
$admin = User::create([
|
||||
'name' => 'Admin User',
|
||||
'email' => 'admin@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'admin',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->withSession(['user' => $admin])
|
||||
->post(route('admin.events.store'), [
|
||||
'title' => 'Admin Charity Camp',
|
||||
'content' => 'Giving away free medical checkups.',
|
||||
'provided_items' => 'Medical Checkups',
|
||||
'event_date' => '2026-09-10',
|
||||
'event_time' => '08:00 AM - 05:00 PM',
|
||||
'location_details' => 'Community Center',
|
||||
'location_pin' => 'https://maps.app.goo.gl/abc',
|
||||
'contact_person_name' => 'Admin Coordinator',
|
||||
'contact_number' => '501234567',
|
||||
'country_code' => '+971',
|
||||
]);
|
||||
|
||||
$response->assertStatus(302); // Redirect on success
|
||||
|
||||
$this->assertDatabaseHas('announcements', [
|
||||
'title' => 'Admin Charity Camp',
|
||||
'status' => 'approved', // Admin events are auto-approved
|
||||
]);
|
||||
|
||||
$announcement = Announcement::where('title', 'Admin Charity Camp')->first();
|
||||
$this->assertNotNull($announcement);
|
||||
$body = json_decode($announcement->body, true);
|
||||
$this->assertEquals('Admin Coordinator', $body['contact_person_name']);
|
||||
$this->assertEquals('501234567', $body['contact_number']);
|
||||
$this->assertEquals('+971', $body['country_code']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test admin approving announcement creates database notification for worker.
|
||||
*/
|
||||
public function test_admin_approving_announcement_creates_database_notification()
|
||||
{
|
||||
$admin = User::create([
|
||||
'name' => 'Admin User',
|
||||
'email' => 'admin@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'admin',
|
||||
]);
|
||||
|
||||
$announcement = Announcement::create([
|
||||
'title' => 'Important Community Event',
|
||||
'body' => 'Event description.',
|
||||
'type' => 'Charity',
|
||||
'employer_id' => $this->employer->id,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
$this->assertEquals(0, $this->worker->notifications()->count());
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->withSession(['user' => $admin])
|
||||
->post(route('admin.events.approve', $announcement->id));
|
||||
|
||||
$response->assertStatus(302);
|
||||
|
||||
// Verify database notification exists for worker
|
||||
$this->assertEquals(1, $this->worker->notifications()->count());
|
||||
$notification = $this->worker->notifications()->first();
|
||||
$this->assertNotNull($notification);
|
||||
$this->assertEquals('New Event: Important Community Event', $notification->data['title']);
|
||||
$this->assertEquals('announcement', $notification->data['type']);
|
||||
$this->assertEquals($announcement->id, $notification->data['announcement_id']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -245,4 +245,64 @@ public function test_employer_can_delete_single_and_all_notifications()
|
||||
$response->assertJsonPath('success', true);
|
||||
$this->assertEquals(0, $employer->fresh()->notifications()->count());
|
||||
}
|
||||
|
||||
public function test_push_notification_sent_when_job_status_changes()
|
||||
{
|
||||
$worker = $this->createTestWorker();
|
||||
$employer = $this->createTestEmployer();
|
||||
|
||||
$jobPost = \App\Models\JobPost::create([
|
||||
'employer_id' => $employer->id,
|
||||
'title' => 'Test Maid Job',
|
||||
'main_profession' => 'Housekeeper',
|
||||
'workers_needed' => 1,
|
||||
'job_type' => 'Full Time',
|
||||
'location' => 'Dubai',
|
||||
'salary' => 2000,
|
||||
'start_date' => now()->addDays(7),
|
||||
'description' => 'Test description',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
\App\Models\JobApplication::create([
|
||||
'worker_id' => $worker->id,
|
||||
'job_id' => $jobPost->id,
|
||||
'status' => 'applied'
|
||||
]);
|
||||
|
||||
$this->assertEquals(0, $worker->notifications()->count());
|
||||
|
||||
// Update JobPost status
|
||||
$jobPost->update(['status' => 'closed']);
|
||||
|
||||
$notifications = $worker->fresh()->notifications;
|
||||
$this->assertEquals(1, $notifications->count());
|
||||
$notification = $notifications->first();
|
||||
$this->assertEquals('Job Status Changed', $notification->data['title']);
|
||||
$this->assertEquals('job_status_change', $notification->data['type']);
|
||||
$this->assertEquals($jobPost->id, $notification->data['job_id']);
|
||||
$this->assertEquals('active', $notification->data['old_status']);
|
||||
$this->assertEquals('closed', $notification->data['new_status']);
|
||||
}
|
||||
|
||||
public function test_push_notification_sent_when_worker_status_changes()
|
||||
{
|
||||
$worker = $this->createTestWorker([
|
||||
'status' => 'active'
|
||||
]);
|
||||
|
||||
$this->assertEquals(0, $worker->notifications()->count());
|
||||
|
||||
// Update Worker status
|
||||
$worker->update(['status' => 'Hired']);
|
||||
|
||||
$notifications = $worker->fresh()->notifications;
|
||||
$this->assertEquals(1, $notifications->count());
|
||||
$notification = $notifications->first();
|
||||
$this->assertEquals('Profile Status Update', $notification->data['title']);
|
||||
$this->assertEquals('worker_status_change', $notification->data['type']);
|
||||
$this->assertEquals($worker->id, $notification->data['worker_id']);
|
||||
$this->assertEquals('active', $notification->data['old_status']);
|
||||
$this->assertEquals('Hired', $notification->data['new_status']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -821,6 +821,152 @@ public function test_worker_can_filter_and_sort_jobs()
|
||||
$response->assertJsonFragment(['title' => 'Full Time Plumber Job']);
|
||||
$response->assertJsonMissingExact(['title' => 'Contract Plumber Job']);
|
||||
}
|
||||
|
||||
public function test_candidate_status_update_sends_correct_notifications_to_worker()
|
||||
{
|
||||
// Fake Google OAuth and FCM endpoints
|
||||
\Illuminate\Support\Facades\Http::fake([
|
||||
'https://oauth2.googleapis.com/token' => \Illuminate\Support\Facades\Http::response(['access_token' => 'fake_token'], 200),
|
||||
'https://fcm.googleapis.com/v1/projects/*' => \Illuminate\Support\Facades\Http::response(['name' => 'projects/migrant-fe5a7/messages/1234'], 200),
|
||||
]);
|
||||
|
||||
// Create standard credentials file dummy for FCMService if not exists
|
||||
$credentialsPath = base_path('migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json');
|
||||
$createdFile = false;
|
||||
if (!file_exists($credentialsPath)) {
|
||||
file_put_contents($credentialsPath, json_encode([
|
||||
'private_key' => "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3\n-----END PRIVATE KEY-----",
|
||||
'client_email' => 'fake-fcm@migrant.iam.gserviceaccount.com',
|
||||
'project_id' => 'migrant-fe5a7'
|
||||
]));
|
||||
$createdFile = true;
|
||||
}
|
||||
|
||||
// Give the worker an FCM token so FcmChannel is used
|
||||
$this->worker->update(['fcm_token' => 'worker_test_fcm_token']);
|
||||
|
||||
// Create a job post
|
||||
$job = JobPost::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'title' => 'Test Notification Job',
|
||||
'location' => 'Dubai Marina',
|
||||
'salary' => 3000,
|
||||
'workers_needed' => 1,
|
||||
'job_type' => 'Full Time',
|
||||
'start_date' => now()->addDays(2),
|
||||
'description' => 'Description here',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// Apply for the job
|
||||
$application = JobApplication::create([
|
||||
'worker_id' => $this->worker->id,
|
||||
'job_id' => $job->id,
|
||||
'status' => 'applied'
|
||||
]);
|
||||
|
||||
// 1. Update status to 'shortlisted' via Web endpoint
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->post("/employer/candidates/{$application->id}/status", [
|
||||
'status' => 'Shortlisted',
|
||||
'notes' => 'Shortlist note'
|
||||
]);
|
||||
|
||||
$response->assertSessionHasNoErrors();
|
||||
|
||||
// Assert that the database notification was created for worker
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $this->worker->id,
|
||||
'notifiable_type' => get_class($this->worker),
|
||||
]);
|
||||
|
||||
// Verify FCM request was triggered
|
||||
\Illuminate\Support\Facades\Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), 'messages:send') &&
|
||||
str_contains($request['message']['token'], 'worker_test_fcm_token') &&
|
||||
str_contains($request['message']['notification']['title'], 'Job Application Update') &&
|
||||
str_contains($request['message']['data']['type'], 'shortlisted');
|
||||
});
|
||||
|
||||
// 2. Update status to 'applied' (Review) and assert notification type is 'review'
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->post("/employer/candidates/{$application->id}/status", [
|
||||
'status' => 'Applied',
|
||||
'notes' => 'Reviewing again'
|
||||
]);
|
||||
|
||||
$response->assertSessionHasNoErrors();
|
||||
|
||||
// Verify FCM request for 'review' was triggered
|
||||
\Illuminate\Support\Facades\Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), 'messages:send') &&
|
||||
str_contains($request['message']['token'], 'worker_test_fcm_token') &&
|
||||
str_contains($request['message']['data']['type'], 'review');
|
||||
});
|
||||
|
||||
// Clean up temporary credentials file if we created it
|
||||
if ($createdFile && file_exists($credentialsPath)) {
|
||||
unlink($credentialsPath);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_direct_hiring_sends_correct_notifications_to_worker()
|
||||
{
|
||||
// Fake Google OAuth and FCM endpoints
|
||||
\Illuminate\Support\Facades\Http::fake([
|
||||
'https://oauth2.googleapis.com/token' => \Illuminate\Support\Facades\Http::response(['access_token' => 'fake_token'], 200),
|
||||
'https://fcm.googleapis.com/v1/projects/*' => \Illuminate\Support\Facades\Http::response(['name' => 'projects/migrant-fe5a7/messages/1234'], 200),
|
||||
]);
|
||||
|
||||
$credentialsPath = base_path('migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json');
|
||||
$createdFile = false;
|
||||
if (!file_exists($credentialsPath)) {
|
||||
file_put_contents($credentialsPath, json_encode([
|
||||
'private_key' => "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3\n-----END PRIVATE KEY-----",
|
||||
'client_email' => 'fake-fcm@migrant.iam.gserviceaccount.com',
|
||||
'project_id' => 'migrant-fe5a7'
|
||||
]));
|
||||
$createdFile = true;
|
||||
}
|
||||
|
||||
$this->worker->update(['fcm_token' => 'worker_test_fcm_token']);
|
||||
|
||||
// 1. Create a direct Job Offer for the worker
|
||||
$offer = \App\Models\JobOffer::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'worker_id' => $this->worker->id,
|
||||
'work_date' => now()->format('Y-m-d'),
|
||||
'location' => 'Dubai',
|
||||
'salary' => 3000,
|
||||
'notes' => 'Direct sponsoring',
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
// Send a status update to 'Hired' via CandidateController (maps to pending for direct offers)
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->post("/employer/candidates/offer_{$offer->id}/status", [
|
||||
'status' => 'Hired',
|
||||
'notes' => 'Re-initiating direct hire offer'
|
||||
]);
|
||||
|
||||
$response->assertSessionHasNoErrors();
|
||||
|
||||
// Verify FCM request for 'direct_hire_request' was triggered with correct text
|
||||
\Illuminate\Support\Facades\Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), 'messages:send') &&
|
||||
str_contains($request['message']['token'], 'worker_test_fcm_token') &&
|
||||
str_contains($request['message']['notification']['title'], 'Direct Hiring Offer') &&
|
||||
str_contains($request['message']['notification']['body'], 'wants to hire you. Do you want to accept this job offer?') &&
|
||||
str_contains($request['message']['data']['type'], 'direct_hire_request');
|
||||
});
|
||||
|
||||
if ($createdFile && file_exists($credentialsPath)) {
|
||||
unlink($credentialsPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user