mohan #26

Merged
mohanmd merged 12 commits from mohan into master 2026-07-09 10:16:34 +00:00
16 changed files with 795 additions and 130 deletions
Showing only changes of commit f3d40f7679 - Show all commits

View File

@ -734,6 +734,9 @@ public function storeAnnouncement(Request $request)
'event_time' => 'required|string', 'event_time' => 'required|string',
'location_details' => 'required|string', 'location_details' => 'required|string',
'location_pin' => 'required|string|url', '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([ $body = json_encode([
@ -744,6 +747,9 @@ public function storeAnnouncement(Request $request)
'location_details' => $request->location_details, 'location_details' => $request->location_details,
'location_pin' => $request->location_pin, 'location_pin' => $request->location_pin,
'content' => $request->content, '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([ $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) protected function notifyUsersOfEvent($ann)
{ {
@ -816,31 +822,29 @@ protected function notifyUsersOfEvent($ann)
} }
// Notify all workers // Notify all workers
$workers = \App\Models\Worker::whereNotNull('fcm_token')->get(); $workers = \App\Models\Worker::all();
foreach ($workers as $worker) { foreach ($workers as $worker) {
\App\Services\FCMService::sendPushNotification( $worker->notify(new \App\Notifications\GenericNotification(
$worker->fcm_token,
$title, $title,
$body, $body,
[ [
'type' => 'announcement', 'type' => 'announcement',
'announcement_id' => $ann->id, 'announcement_id' => $ann->id,
] ]
); ));
} }
// Notify all employers // 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) { foreach ($employers as $employer) {
\App\Services\FCMService::sendPushNotification( $employer->notify(new \App\Notifications\GenericNotification(
$employer->fcm_token,
$title, $title,
$body, $body,
[ [
'type' => 'announcement', 'type' => 'announcement',
'announcement_id' => $ann->id, 'announcement_id' => $ann->id,
] ]
); ));
} }
} }
} }

View File

@ -324,14 +324,6 @@ public function register(Request $request)
'address' => $request->address, '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 sending email
try { try {
\Illuminate\Support\Facades\Mail::to($request->email)->send(new \App\Mail\EmployerOtpMail( \Illuminate\Support\Facades\Mail::to($request->email)->send(new \App\Mail\EmployerOtpMail(

View File

@ -852,10 +852,10 @@ public function hireCandidate(Request $request, $id = null)
)); ));
} else if ($updatedOffer) { } else if ($updatedOffer) {
$worker->notify(new \App\Notifications\GenericNotification( $worker->notify(new \App\Notifications\GenericNotification(
"New Job Offer", "Direct Hiring Offer",
"Employer " . ($employer->name ?? "Employer") . " has sent you a direct hire 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, 'offer_id' => $updatedOffer->id,
'status' => 'pending', 'status' => 'pending',
] ]

View File

@ -1108,22 +1108,6 @@ private function triggerStatusNotification($application, $status)
$job = $application->jobPost; $job = $application->jobPost;
$employer = $job ? $job->employer : null; $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 // When status is updated: notify worker
if ($worker) { if ($worker) {
$title = "Job Application Update"; $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'); $body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post');
$type = 'shortlisted'; $type = 'shortlisted';
break; break;
case 'applied':
case 'review': case 'review':
case 'reviewing': case 'reviewing':
$body = "Your application is under review for the job: " . ($job->title ?? 'Job Post'); $body = "Your application is under review for the job: " . ($job->title ?? 'Job Post');

View File

@ -72,6 +72,9 @@ public function store(Request $request)
'event_time' => 'required|string', 'event_time' => 'required|string',
'location_details' => 'required|string', 'location_details' => 'required|string',
'location_pin' => 'required|string|url', '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([ $body = json_encode([
@ -82,6 +85,9 @@ public function store(Request $request)
'location_details' => $request->location_details, 'location_details' => $request->location_details,
'location_pin' => $request->location_pin, 'location_pin' => $request->location_pin,
'content' => $request->content, 'content' => $request->content,
'contact_person_name' => $request->contact_person_name,
'contact_number' => $request->contact_number,
'country_code' => $request->country_code ?? '+971',
]); ]);
$sess = session('user'); $sess = session('user');

View File

@ -341,10 +341,10 @@ public function updateStatus(Request $request, $id)
// Notify worker about the hire offer if it is set to pending // Notify worker about the hire offer if it is set to pending
if ($dbStatus === 'pending') { if ($dbStatus === 'pending') {
$offer->worker->notify(new \App\Notifications\GenericNotification( $offer->worker->notify(new \App\Notifications\GenericNotification(
"Action Required: Confirm Hiring", "Direct Hiring Offer",
"Employer " . ($offer->employer->name ?? "Employer") . " has sent you a direct hire offer. Please confirm.", ($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, 'offer_id' => $offer->id,
'status' => 'pending', 'status' => 'pending',
] ]
@ -458,22 +458,6 @@ private function triggerStatusNotification($application, $status)
$job = $application->jobPost; $job = $application->jobPost;
$employer = $job ? $job->employer : null; $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 // When status is updated: notify worker
if ($worker) { if ($worker) {
$title = "Job Application Update"; $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'); $body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post');
$type = 'shortlisted'; $type = 'shortlisted';
break; break;
case 'applied':
case 'review': case 'review':
case 'reviewing': case 'reviewing':
$body = "Your application is under review for the job: " . ($job->title ?? 'Job Post'); $body = "Your application is under review for the job: " . ($job->title ?? 'Job Post');

View File

@ -497,6 +497,8 @@ public function markHired(Request $request, $id)
->where('worker_id', $worker->id) ->where('worker_id', $worker->id)
->first(); ->first();
$application = null;
if ($offer) { if ($offer) {
$offer->update(['status' => 'pending']); $offer->update(['status' => 'pending']);
} else { } else {
@ -531,16 +533,28 @@ public function markHired(Request $request, $id)
} }
// Notify the worker // Notify the worker
$worker->notify(new \App\Notifications\GenericNotification( if ($offer) {
"Action Required: Confirm Hiring", $worker->notify(new \App\Notifications\GenericNotification(
"Employer " . ($user->name ?? "Employer") . " has sent you a hire request. Please confirm.", "Direct Hiring Offer",
[ ($user->name ?? "Employer") . " wants to hire you. Do you want to accept this job offer?",
'type' => 'hire_request', [
'offer_id' => $offer->id ?? '', 'type' => 'direct_hire_request',
'application_id' => $application->id ?? '', 'offer_id' => $offer->id,
'status' => $offer ? 'pending' : 'hire_requested', '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.'); return back()->with('success', 'Hire request initiated successfully! Awaiting candidate confirmation.');
} }

View File

@ -10,6 +10,34 @@ class JobPost extends Model
{ {
use HasFactory, SoftDeletes; 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 = [ protected $fillable = [
'employer_id', 'employer_id',
'title', 'title',

View File

@ -11,6 +11,27 @@ class Worker extends Model
{ {
use HasFactory, SoftDeletes, Notifiable; 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 = [ protected $fillable = [
'name', 'name',
'email', 'email',

View File

@ -59,7 +59,7 @@ public static function getAccessToken()
$base64UrlSignature = self::base64UrlEncode($signature); $base64UrlSignature = self::base64UrlEncode($signature);
$jwtToken = $signatureInput . '.' . $base64UrlSignature; $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', 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwtToken, 'assertion' => $jwtToken,
]); ]);
@ -140,7 +140,7 @@ public static function sendPushNotification($token, $title, $body, array $data =
$payload['message']['data'] = $formattedData; $payload['message']['data'] = $formattedData;
} }
$response = Http::withToken($accessToken) $response = Http::withoutVerifying()->withToken($accessToken)
->post("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send", $payload); ->post("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send", $payload);
if ($response->failed()) { if ($response->failed()) {

View File

@ -6,6 +6,15 @@ import {
Calendar, MapPin, Clock, Gift, Sparkles, Search, Calendar, MapPin, Clock, Gift, Sparkles, Search,
ChevronDown, ChevronUp, Building, User, XCircle, Eye, BellRing ChevronDown, ChevronUp, Building, User, XCircle, Eye, BellRing
} from 'lucide-react'; } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter
} from '@/components/ui/dialog';
const hoursList = [ const hoursList = [
'12:00', '12:30', '01:00', '01:30', '02:00', '02:30', '12:00', '12:30', '01:00', '01:30', '02:00', '02:30',
@ -17,6 +26,7 @@ const hoursList = [
export default function Announcements({ initialAnnouncements }) { export default function Announcements({ initialAnnouncements }) {
const [announcements, setAnnouncements] = useState(initialAnnouncements || []); const [announcements, setAnnouncements] = useState(initialAnnouncements || []);
const [isFormOpen, setIsFormOpen] = useState(false); const [isFormOpen, setIsFormOpen] = useState(false);
const [selectedAnnouncement, setSelectedAnnouncement] = useState(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('All'); const [statusFilter, setStatusFilter] = useState('All');
const [expandedCards, setExpandedCards] = useState({}); const [expandedCards, setExpandedCards] = useState({});
@ -36,7 +46,10 @@ export default function Announcements({ initialAnnouncements }) {
event_date: '', event_date: '',
event_time: '09:00 AM - 04:00 PM', event_time: '09:00 AM - 04:00 PM',
location_details: '', location_details: '',
location_pin: '' location_pin: '',
contact_person_name: '',
contact_number: '',
country_code: '+971'
}); });
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const [toastMessage, setToastMessage] = useState(null); 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://')) { } else if (!newAnnouncement.location_pin.startsWith('http://') && !newAnnouncement.location_pin.startsWith('https://')) {
newErrors.location_pin = 'Must be a valid URL'; 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) { if (Object.keys(newErrors).length > 0) {
setErrors(newErrors); setErrors(newErrors);
@ -75,7 +90,10 @@ export default function Announcements({ initialAnnouncements }) {
event_date: newAnnouncement.event_date, event_date: newAnnouncement.event_date,
event_time: newAnnouncement.event_time, event_time: newAnnouncement.event_time,
location_details: newAnnouncement.location_details, 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: () => { onSuccess: () => {
setNewAnnouncement({ setNewAnnouncement({
@ -85,7 +103,10 @@ export default function Announcements({ initialAnnouncements }) {
event_date: '', event_date: '',
event_time: '09:00 AM - 04:00 PM', event_time: '09:00 AM - 04:00 PM',
location_details: '', location_details: '',
location_pin: '' location_pin: '',
contact_person_name: '',
contact_number: '',
country_code: '+971'
}); });
setStartTimeHour('09:00'); setStartTimeHour('09:00');
setStartTimeAmpm('AM'); setStartTimeAmpm('AM');
@ -509,6 +530,39 @@ export default function Announcements({ initialAnnouncements }) {
</div> </div>
</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> <div>
<label className="block text-xs font-bold text-slate-600 mb-1">Description & Instructions</label> <label className="block text-xs font-bold text-slate-600 mb-1">Description & Instructions</label>
<textarea <textarea
@ -613,10 +667,11 @@ export default function Announcements({ initialAnnouncements }) {
ann.status === 'rejected' ? 'border-l-rose-500' : ann.status === 'rejected' ? 'border-l-rose-500' :
'border-l-amber-500'; 'border-l-amber-500';
return ( return (
<div <div
key={ann.id} 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 */} {/* Top colored accent line */}
<div className={`h-1.5 w-full ${ <div className={`h-1.5 w-full ${
@ -655,7 +710,7 @@ export default function Announcements({ initialAnnouncements }) {
</div> </div>
{/* Action Control Buttons */} {/* 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' && ( {ann.status === 'pending' && (
<div className="flex items-center space-x-0.5 bg-slate-50 p-0.5 rounded-lg border border-slate-200"> <div className="flex items-center space-x-0.5 bg-slate-50 p-0.5 rounded-lg border border-slate-200">
<button <button
@ -691,6 +746,7 @@ export default function Announcements({ initialAnnouncements }) {
{ann.posted_by_email ? ( {ann.posted_by_email ? (
<Link <Link
href={`/admin/employers?search=${encodeURIComponent(ann.posted_by_email)}`} 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" 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" title="Click to view Employer Profile"
> >
@ -720,7 +776,7 @@ export default function Announcements({ initialAnnouncements }) {
{ann.content.length > 90 && ( {ann.content.length > 90 && (
<button <button
type="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" 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> <span>{isExpanded ? 'Show Less' : 'Read Full Description'}</span>
@ -729,7 +785,7 @@ export default function Announcements({ initialAnnouncements }) {
)} )}
{ann.status === 'rejected' && ann.remarks && ( {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> <span className="font-extrabold text-rose-900 block mb-0.5">Rejection Remarks:</span>
{ann.remarks} {ann.remarks}
</div> </div>
@ -777,6 +833,21 @@ export default function Announcements({ initialAnnouncements }) {
</div> </div>
</div> </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>
)} )}
</div> </div>
@ -789,6 +860,7 @@ export default function Announcements({ initialAnnouncements }) {
href={details.location_pin} href={details.location_pin}
target="_blank" target="_blank"
rel="noreferrer" 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]" 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> <span>View on Map</span>
@ -802,6 +874,122 @@ export default function Announcements({ initialAnnouncements }) {
</div> </div>
)} )}
</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> </AdminLayout>
); );
} }

View File

@ -22,7 +22,8 @@ import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Gift, Gift,
Plus Plus,
User
} from 'lucide-react'; } from 'lucide-react';
import { import {
Dialog, Dialog,
@ -44,6 +45,7 @@ const hoursList = [
export default function Announcements({ initialAnnouncements }) { export default function Announcements({ initialAnnouncements }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedAnnouncement, setSelectedAnnouncement] = useState(null);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [toastMessage, setToastMessage] = useState(null); const [toastMessage, setToastMessage] = useState(null);
const [toastSub, setToastSub] = useState(null); const [toastSub, setToastSub] = useState(null);
@ -72,7 +74,10 @@ export default function Announcements({ initialAnnouncements }) {
event_date: '', event_date: '',
event_time: '09:00 AM - 04:00 PM', event_time: '09:00 AM - 04:00 PM',
location_details: '', location_details: '',
location_pin: '' location_pin: '',
contact_person_name: '',
contact_number: '',
country_code: '+971'
}); });
const updateEventTime = (sh, sa, eh, ea) => { const updateEventTime = (sh, sa, eh, ea) => {
@ -165,7 +170,7 @@ export default function Announcements({ initialAnnouncements }) {
<span>{t('post_charity_event', 'Post Charity Event')}</span> <span>{t('post_charity_event', 'Post Charity Event')}</span>
</button> </button>
</DialogTrigger> </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="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="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> <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" 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>
<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>
<div className="space-y-2"> <div className="space-y-2">
@ -462,7 +502,8 @@ export default function Announcements({ initialAnnouncements }) {
return ( return (
<div <div
key={ann.id} 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 */} {/* Top Card Area */}
<div className="p-4 space-y-3"> <div className="p-4 space-y-3">
@ -494,7 +535,7 @@ export default function Announcements({ initialAnnouncements }) {
{ann.content.length > 90 && ( {ann.content.length > 90 && (
<button <button
type="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]" 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> <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> </button>
)} )}
{ann.status === 'rejected' && ann.remarks && ( {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} <strong>{t('rejection_remarks_label', 'Remarks')}:</strong> {ann.remarks}
</div> </div>
)} )}
@ -531,6 +572,16 @@ export default function Announcements({ initialAnnouncements }) {
</div> </div>
</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> </div>
{/* Bottom Card Footer */} {/* Bottom Card Footer */}
@ -541,7 +592,8 @@ export default function Announcements({ initialAnnouncements }) {
href={details.location_pin} href={details.location_pin}
target="_blank" target="_blank"
rel="noreferrer" 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> <span>{t('view_on_map', 'View on Map')}</span>
<MapPin className="w-2.5 h-2.5" /> <MapPin className="w-2.5 h-2.5" />
@ -553,6 +605,121 @@ 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-[#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> </div>
</EmployerLayout> </EmployerLayout>
); );

View File

@ -290,21 +290,12 @@ export default function Show({ worker }) {
<span>{t('start_direct_chat', 'Start Direct Chat')}</span> <span>{t('start_direct_chat', 'Start Direct Chat')}</span>
</Link> </Link>
{/* Stage 4 Hire: Mark Hired (Finalize Hiring Direct Flow) */} {/* Stage 4 Hire: Pending Confirmation Indicator */}
{availabilityStatus === 'Pending Confirmation' ? ( {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"> <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" /> <Clock className="w-4 h-4 text-amber-600 animate-pulse" />
<span>{t('pending_confirmation', 'Pending Confirmation')}</span> <span>{t('pending_confirmation', 'Pending Confirmation')}</span>
</div> </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>
</div> </div>
@ -1093,43 +1084,7 @@ export default function Show({ worker }) {
</div> </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> </EmployerLayout>
); );

View File

@ -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('551234567', $myPostedAnn['charity_details']['contact_number']);
$this->assertEquals('+971', $myPostedAnn['charity_details']['country_code']); $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']);
}
} }

View File

@ -245,4 +245,64 @@ public function test_employer_can_delete_single_and_all_notifications()
$response->assertJsonPath('success', true); $response->assertJsonPath('success', true);
$this->assertEquals(0, $employer->fresh()->notifications()->count()); $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']);
}
} }

View File

@ -821,6 +821,152 @@ public function test_worker_can_filter_and_sort_jobs()
$response->assertJsonFragment(['title' => 'Full Time Plumber Job']); $response->assertJsonFragment(['title' => 'Full Time Plumber Job']);
$response->assertJsonMissingExact(['title' => 'Contract 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);
}
}
} }