mohan #5

Merged
mohanmd merged 46 commits from mohan into master 2026-06-15 09:13:23 +00:00
23 changed files with 285 additions and 16 deletions
Showing only changes of commit 7d0698509a - Show all commits

View File

@ -486,6 +486,7 @@ public function announcements()
'posted_by' => $postedBy, 'posted_by' => $postedBy,
'organization' => $organization, 'organization' => $organization,
'status' => $ann->status ?? 'pending', 'status' => $ann->status ?? 'pending',
'remarks' => $ann->remarks,
'created_at' => $ann->created_at ? $ann->created_at->format('M d, Y h:i A') : 'Just now', 'created_at' => $ann->created_at ? $ann->created_at->format('M d, Y h:i A') : 'Just now',
'charityDetails' => $charityDetails, 'charityDetails' => $charityDetails,
]; ];
@ -547,8 +548,15 @@ public function approveAnnouncement(Request $request, $id)
*/ */
public function rejectAnnouncement(Request $request, $id) public function rejectAnnouncement(Request $request, $id)
{ {
$request->validate([
'remarks' => 'required|string|max:1000',
]);
$ann = \App\Models\Announcement::findOrFail($id); $ann = \App\Models\Announcement::findOrFail($id);
$ann->update(['status' => 'rejected']); $ann->update([
'status' => 'rejected',
'remarks' => $request->remarks,
]);
return back()->with('success', 'Charity Event rejected successfully.'); return back()->with('success', 'Charity Event rejected successfully.');
} }

View File

@ -36,6 +36,8 @@ public function getAnnouncements(Request $request)
'title' => $announcement->title, 'title' => $announcement->title,
'body' => $announcement->body, 'body' => $announcement->body,
'type' => $announcement->type, 'type' => $announcement->type,
'status' => $announcement->status ?? 'pending',
'remarks' => $announcement->remarks,
'created_at' => $announcement->created_at->toISOString(), 'created_at' => $announcement->created_at->toISOString(),
'time_ago' => $announcement->created_at->diffForHumans(), 'time_ago' => $announcement->created_at->diffForHumans(),
]; ];
@ -123,6 +125,8 @@ public function createAnnouncement(Request $request)
'title' => $announcement->title, 'title' => $announcement->title,
'body' => $announcement->body, 'body' => $announcement->body,
'type' => $announcement->type, 'type' => $announcement->type,
'status' => $announcement->status ?? 'pending',
'remarks' => $announcement->remarks,
'created_at' => $announcement->created_at->toISOString(), 'created_at' => $announcement->created_at->toISOString(),
'time_ago' => $announcement->created_at->diffForHumans(), 'time_ago' => $announcement->created_at->diffForHumans(),
] ]

View File

@ -7,6 +7,7 @@
use App\Models\Worker; use App\Models\Worker;
use App\Models\WorkerDocument; use App\Models\WorkerDocument;
use App\Models\ProfileView; use App\Models\ProfileView;
use App\Models\Conversation;
use App\Models\EmployerProfile; use App\Models\EmployerProfile;
use App\Models\Announcement; use App\Models\Announcement;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -538,6 +539,12 @@ public function getDashboard(Request $request)
// 2. Count of sponsors who viewed the profile // 2. Count of sponsors who viewed the profile
$viewsCount = ProfileView::where('worker_id', $worker->id)->count(); $viewsCount = ProfileView::where('worker_id', $worker->id)->count();
// Unique count of employers who viewed this profile
$profileViewed = ProfileView::where('worker_id', $worker->id)->distinct('employer_id')->count('employer_id');
// Unique count of employers who contacted this worker
$employerContacted = Conversation::where('worker_id', $worker->id)->distinct('employer_id')->count('employer_id');
// 3. Currently working sponsor/employer // 3. Currently working sponsor/employer
$currentSponsor = null; $currentSponsor = null;
$acceptedOffer = JobOffer::where('worker_id', $worker->id) $acceptedOffer = JobOffer::where('worker_id', $worker->id)
@ -608,6 +615,8 @@ public function getDashboard(Request $request)
'data' => [ 'data' => [
'active_status' => $activeStatus, 'active_status' => $activeStatus,
'count_sponsors_viewed' => $viewsCount, 'count_sponsors_viewed' => $viewsCount,
'employer_contacted' => $employerContacted,
'profile_viewed' => $profileViewed,
'currently_working_sponsor' => $currentSponsor, 'currently_working_sponsor' => $currentSponsor,
'latest_charity_events_list' => $charityEvents 'latest_charity_events_list' => $charityEvents
] ]

View File

@ -52,6 +52,7 @@ public function index(Request $request)
'isCharity' => $isCharity, 'isCharity' => $isCharity,
'charityDetails' => $charityDetails, 'charityDetails' => $charityDetails,
'status' => $ann->status ?? 'pending', 'status' => $ann->status ?? 'pending',
'remarks' => $ann->remarks,
'created_at' => $ann->created_at->diffForHumans(), 'created_at' => $ann->created_at->diffForHumans(),
]; ];
})->toArray(); })->toArray();

View File

@ -379,6 +379,8 @@ public function show($id)
'reviews_count' => $reviewsCount, 'reviews_count' => $reviewsCount,
'reviews' => $reviews, 'reviews' => $reviews,
'similar_workers' => $similarWorkers, 'similar_workers' => $similarWorkers,
'status' => $w->status,
'availability_status' => $w->status,
]; ];
return Inertia::render('Employer/Workers/Show', [ return Inertia::render('Employer/Workers/Show', [

View File

@ -16,6 +16,7 @@ class Announcement extends Model
'employer_id', 'employer_id',
'sponsor_id', 'sponsor_id',
'status', 'status',
'remarks',
]; ];
/** /**

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('announcements', function (Blueprint $table) {
$table->text('remarks')->nullable()->after('status');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('announcements', function (Blueprint $table) {
$table->dropColumn('remarks');
});
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -164,7 +164,18 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
<nav className="p-4 space-y-1"> <nav className="p-4 space-y-1">
{navItems.map((item) => { {navItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = url.startsWith(item.href); let isActive = url.startsWith(item.href);
if (url.startsWith('/employer/workers/')) {
const isHired = props.worker?.status === 'Hired' || props.worker?.availability_status === 'Hired';
if (isHired) {
if (item.href === '/employer/candidates') {
isActive = true;
} else if (item.href === '/employer/workers') {
isActive = false;
}
}
}
return ( return (
<Link <Link
@ -309,7 +320,18 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
<nav className="lg:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 h-16 px-4 flex items-center justify-around z-40"> <nav className="lg:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 h-16 px-4 flex items-center justify-around z-40">
{mobileTabs.map((tab) => { {mobileTabs.map((tab) => {
const Icon = tab.icon; const Icon = tab.icon;
const isActive = url.startsWith(tab.href); let isActive = url.startsWith(tab.href);
if (url.startsWith('/employer/workers/')) {
const isHired = props.worker?.status === 'Hired' || props.worker?.availability_status === 'Hired';
if (isHired) {
if (tab.href === '/employer/candidates') {
isActive = true;
} else if (tab.href === '/employer/workers') {
isActive = false;
}
}
}
return ( return (
<Link <Link

View File

@ -40,6 +40,8 @@ export default function Announcements({ initialAnnouncements }) {
}); });
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const [toastMessage, setToastMessage] = useState(null); const [toastMessage, setToastMessage] = useState(null);
const [rejectingAnnouncementId, setRejectingAnnouncementId] = useState(null);
const [rejectionRemarks, setRejectionRemarks] = useState('');
const showToast = (message) => { const showToast = (message) => {
setToastMessage(message); setToastMessage(message);
@ -114,8 +116,20 @@ export default function Announcements({ initialAnnouncements }) {
}; };
const handleReject = (id) => { const handleReject = (id) => {
router.post(route('admin.announcements.reject', id), {}, { setRejectingAnnouncementId(id);
setRejectionRemarks('');
};
const handleRejectSubmit = (e) => {
e.preventDefault();
if (!rejectionRemarks.trim()) return;
router.post(route('admin.announcements.reject', rejectingAnnouncementId), {
remarks: rejectionRemarks
}, {
onSuccess: () => { onSuccess: () => {
setRejectingAnnouncementId(null);
setRejectionRemarks('');
showToast('Charity Event rejected successfully'); showToast('Charity Event rejected successfully');
setTimeout(() => window.location.reload(), 500); setTimeout(() => window.location.reload(), 500);
} }
@ -528,6 +542,59 @@ export default function Announcements({ initialAnnouncements }) {
</div> </div>
)} )}
{/* Rejection remarks modal */}
{rejectingAnnouncementId && (
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4 z-50 animate-fade-in">
<div className="bg-white rounded-3xl max-w-md w-full p-6 space-y-4 shadow-2xl border border-slate-100 transform scale-100 transition-all font-sans">
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
<h3 className="text-base font-black text-slate-900 uppercase tracking-tight flex items-center gap-2">
<XCircle className="w-5 h-5 text-rose-500" />
<span>Reject Charity Event</span>
</h3>
<button
onClick={() => setRejectingAnnouncementId(null)}
className="text-slate-400 hover:text-slate-600 bg-transparent border-none cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleRejectSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1">
Reason for Rejection / Remarks
</label>
<textarea
required
rows="4"
value={rejectionRemarks}
onChange={(e) => setRejectionRemarks(e.target.value)}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-xs font-bold focus:ring-2 focus:ring-rose-500/20 focus:border-rose-500 outline-none resize-none transition-all"
placeholder="Please provide a reason for rejecting this event..."
></textarea>
</div>
<div className="flex space-x-2 pt-2">
<button
type="button"
onClick={() => setRejectingAnnouncementId(null)}
className="flex-1 bg-slate-100 hover:bg-slate-200 text-slate-700 py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-colors cursor-pointer border-none"
>
Cancel
</button>
<button
type="submit"
disabled={!rejectionRemarks.trim()}
className="flex-1 bg-rose-600 hover:bg-rose-700 text-white py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-colors cursor-pointer border-none disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-rose-200"
>
Reject Event
</button>
</div>
</form>
</div>
</div>
)}
{/* Compact Grid of Events */} {/* Compact Grid of Events */}
{filteredAnnouncements.length === 0 ? ( {filteredAnnouncements.length === 0 ? (
<div className="text-center py-16 bg-white rounded-2xl border border-slate-200 shadow-sm text-slate-500"> <div className="text-center py-16 bg-white rounded-2xl border border-slate-200 shadow-sm text-slate-500">
@ -567,12 +634,7 @@ export default function Announcements({ initialAnnouncements }) {
}`}> }`}>
{ann.status} {ann.status}
</span> </span>
{ann.status === 'approved' && (
<span className="inline-flex items-center space-x-1 px-1.5 py-0.5 bg-emerald-50 rounded text-[8px] font-bold text-emerald-800 border border-emerald-100 uppercase tracking-wider shrink-0">
<BellRing className="w-2.5 h-2.5 text-emerald-600 animate-bounce" />
<span>Push & Reminder Scheduled</span>
</span>
)}
</div> </div>
<div className="flex items-center text-[10px] text-slate-400 font-bold whitespace-nowrap overflow-hidden text-ellipsis"> <div className="flex items-center text-[10px] text-slate-400 font-bold whitespace-nowrap overflow-hidden text-ellipsis">
<Building className="w-3 h-3 mr-1 text-slate-400 shrink-0" /> <Building className="w-3 h-3 mr-1 text-slate-400 shrink-0" />
@ -632,6 +694,11 @@ export default function Announcements({ initialAnnouncements }) {
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />} {isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button> </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">
<strong>Remarks:</strong> {ann.remarks}
</div>
)}
</div> </div>
{/* Details metadata row (compact icons) */} {/* Details metadata row (compact icons) */}

View File

@ -484,12 +484,7 @@ export default function Announcements({ initialAnnouncements }) {
}`}> }`}>
{ann.status === 'approved' ? t('status_approved', 'APPROVED') : (ann.status === 'rejected' ? t('status_rejected', 'REJECTED') : t('status_pending', 'PENDING'))} {ann.status === 'approved' ? t('status_approved', 'APPROVED') : (ann.status === 'rejected' ? t('status_rejected', 'REJECTED') : t('status_pending', 'PENDING'))}
</span> </span>
{ann.status === 'approved' && (
<span className="inline-flex items-center space-x-1 px-1.5 py-0.5 bg-emerald-50 rounded text-[8px] font-bold text-emerald-800 border border-emerald-100 uppercase tracking-wider shrink-0">
<BellRing className="w-2.5 h-2.5 text-emerald-600 animate-bounce" />
<span>{t('push_reminder_scheduled', 'Push & Reminder Scheduled')}</span>
</span>
)}
</div> </div>
</div> </div>
</div> </div>
@ -510,6 +505,11 @@ export default function Announcements({ initialAnnouncements }) {
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />} {isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button> </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">
<strong>{t('rejection_remarks_label', 'Remarks')}:</strong> {ann.remarks}
</div>
)}
</div> </div>
{/* Details metadata row (compact icons) */} {/* Details metadata row (compact icons) */}

View File

@ -265,4 +265,39 @@ public function test_worker_cannot_see_pending_announcements_until_approved()
$this->assertCount(1, $response->json('data.announcements')); $this->assertCount(1, $response->json('data.announcements'));
$this->assertEquals('Pending Ann', $response->json('data.announcements.0.title')); $this->assertEquals('Pending Ann', $response->json('data.announcements.0.title'));
} }
/**
* Test admin can reject announcement with remarks.
*/
public function test_admin_can_reject_announcement_with_remarks()
{
$admin = User::create([
'name' => 'Admin User',
'email' => 'admin@example.com',
'password' => bcrypt('password'),
'role' => 'admin',
]);
$announcement = Announcement::create([
'title' => 'Pending Ann',
'body' => 'Event description.',
'type' => 'Charity',
'employer_id' => $this->employer->id,
'status' => 'pending',
]);
$response = $this->actingAs($admin)
->withSession(['user' => $admin])
->post('/admin/announcements/' . $announcement->id . '/reject', [
'remarks' => 'Inappropriate content or location details.',
]);
$response->assertStatus(302); // redirects back
$this->assertDatabaseHas('announcements', [
'id' => $announcement->id,
'status' => 'rejected',
'remarks' => 'Inappropriate content or location details.',
]);
}
} }

View File

@ -587,4 +587,96 @@ public function test_nationalities_list_api()
'name' => 'Indian' 'name' => 'Indian'
]); ]);
} }
/**
* Test worker dashboard stats including employer_contacted and profile_viewed unique counts.
*/
public function test_worker_dashboard_stats()
{
// 1. Create a worker
$worker = Worker::create([
'name' => 'Rahul Sharma',
'email' => 'rahul@example.com',
'phone' => '+971501234567',
'language' => 'HI',
'password' => bcrypt('password'),
'nationality' => 'India',
'age' => 25,
'salary' => 1500,
'availability' => 'Immediate',
'experience' => 'Not Specified',
'religion' => 'Not Specified',
'bio' => 'Test',
'category_id' => $this->category->id,
'verified' => true,
'status' => 'active',
'api_token' => 'worker-test-token-dashboard',
]);
// Create passport document to prevent "Passport Pending" 404
WorkerDocument::create([
'worker_id' => $worker->id,
'type' => 'passport',
'number' => '123456',
'file_path' => 'passports/test.pdf',
]);
// 2. Create two employers
$employer1 = \App\Models\User::create([
'name' => 'Employer One',
'email' => 'emp1@example.com',
'password' => bcrypt('password'),
'role' => 'employer',
'api_token' => 'emp1-token',
]);
$employer2 = \App\Models\User::create([
'name' => 'Employer Two',
'email' => 'emp2@example.com',
'password' => bcrypt('password'),
'role' => 'employer',
'api_token' => 'emp2-token',
]);
// 3. Employer 1 views worker\'s profile twice (should count as 1)
$response = $this->withHeaders([
'Authorization' => 'Bearer emp1-token',
])->getJson('/api/employers/workers/' . $worker->id);
$response->assertStatus(200);
$response = $this->withHeaders([
'Authorization' => 'Bearer emp1-token',
])->getJson('/api/employers/workers/' . $worker->id);
$response->assertStatus(200);
// 4. Employer 2 views worker\'s profile once
$response = $this->withHeaders([
'Authorization' => 'Bearer emp2-token',
])->getJson('/api/employers/workers/' . $worker->id);
$response->assertStatus(200);
// Assert profile_views database has 2 unique views
$this->assertEquals(2, \App\Models\ProfileView::where('worker_id', $worker->id)->count());
// 5. Create a conversation (contact) from Employer 1
\App\Models\Conversation::create([
'employer_id' => $employer1->id,
'worker_id' => $worker->id,
]);
// 6. Hit the worker dashboard API
$dashboardResponse = $this->withHeaders([
'Authorization' => 'Bearer worker-test-token-dashboard',
])->getJson('/api/workers/dashboard');
$dashboardResponse->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'active_status' => 'active',
'profile_viewed' => 2,
'employer_contacted' => 1,
]
]);
}
} }