diff --git a/app/Http/Controllers/Admin/SponsorController.php b/app/Http/Controllers/Admin/SponsorController.php
index b43b919..1546358 100644
--- a/app/Http/Controllers/Admin/SponsorController.php
+++ b/app/Http/Controllers/Admin/SponsorController.php
@@ -55,6 +55,83 @@ public function index(Request $request)
]);
}
+ /**
+ * Export sponsors as CSV with filters, search, and sorting.
+ */
+ public function export(Request $request)
+ {
+ $query = Sponsor::query();
+
+ // Server-side Search
+ if ($request->filled('search')) {
+ $search = $request->input('search');
+ $query->where(function($q) use ($search) {
+ $q->where('full_name', 'like', "%{$search}%")
+ ->orWhere('email', 'like', "%{$search}%")
+ ->orWhere('mobile', 'like', "%{$search}%");
+ });
+ }
+
+ // Server-side Status filter
+ if ($request->filled('status') && $request->input('status') !== 'All') {
+ $query->where('status', strtolower($request->input('status')));
+ }
+
+ // Server-side Location/City filter
+ if ($request->filled('location') && $request->input('location') !== 'All') {
+ $query->where('city', $request->input('location'));
+ }
+
+ // Server-side Sorting
+ $sortField = $request->input('sort_field', 'created_at');
+ $sortOrder = $request->input('sort_order', 'desc');
+ if (in_array($sortField, ['full_name', 'email', 'city', 'status', 'created_at'])) {
+ $query->orderBy($sortField, $sortOrder);
+ } else {
+ $query->orderBy('created_at', 'desc');
+ }
+
+ $sponsors = $query->get();
+
+ $headers = [
+ "Content-type" => "text/csv",
+ "Content-Disposition" => "attachment; filename=sponsors_export_" . date('Ymd_His') . ".csv",
+ "Pragma" => "no-cache",
+ "Cache-Control" => "must-revalidate, post-check=0, pre-check=0",
+ "Expires" => "0"
+ ];
+
+ $columns = ['ID', 'Full Name', 'Email', 'Mobile', 'City', 'Nationality', 'Address', 'Status', 'OTP Verified', 'Verified At', 'Subscription Plan', 'Subscription Status', 'Subscription Expiry', 'Created At'];
+
+ $callback = function() use ($sponsors, $columns) {
+ $file = fopen('php://output', 'w');
+ fputcsv($file, $columns);
+
+ foreach ($sponsors as $sponsor) {
+ fputcsv($file, [
+ $sponsor->id,
+ $sponsor->full_name,
+ $sponsor->email,
+ $sponsor->mobile,
+ $sponsor->city ?? 'Dubai',
+ $sponsor->nationality ?? 'N/A',
+ $sponsor->address ?? 'N/A',
+ $sponsor->status,
+ $sponsor->is_verified ? 'Yes' : 'No',
+ $sponsor->otp_verified_at ? $sponsor->otp_verified_at->toDateTimeString() : 'N/A',
+ $sponsor->subscription_plan ?? 'None',
+ $sponsor->subscription_status ?? 'None',
+ $sponsor->subscription_end_date ? $sponsor->subscription_end_date->toDateString() : 'N/A',
+ $sponsor->created_at ? $sponsor->created_at->toDateTimeString() : 'N/A',
+ ]);
+ }
+
+ fclose($file);
+ };
+
+ return response()->stream($callback, 200, $headers);
+ }
+
/**
* Approve and verify a sponsor.
*/
diff --git a/app/Http/Controllers/Api/WorkerMessageController.php b/app/Http/Controllers/Api/WorkerMessageController.php
index 597fd23..df3e37a 100644
--- a/app/Http/Controllers/Api/WorkerMessageController.php
+++ b/app/Http/Controllers/Api/WorkerMessageController.php
@@ -163,6 +163,8 @@ public function getMessages(Request $request, $id)
'time' => $msg->created_at->format('g:i A') . ($msg->created_at->isToday() ? ' Today' : ' ' . $msg->created_at->format('M d')),
'read_at' => $msg->read_at ? $msg->read_at->toISOString() : null,
'created_at' => $msg->created_at->toISOString(),
+ 'attachment_url' => $msg->attachment_path ? asset('storage/' . $msg->attachment_path) : null,
+ 'attachment_type' => $msg->attachment_type,
];
});
@@ -204,7 +206,8 @@ public function sendMessage(Request $request, $id)
$worker = $request->attributes->get('worker');
$validator = Validator::make($request->all(), [
- 'text' => 'required|string|max:1000',
+ 'text' => 'required_without:file|string|max:1000|nullable',
+ 'file' => 'nullable|file|mimes:jpg,jpeg,png,pdf,mp3,wav,m4a,ogg,webm,mp4,aac,3gp,amr|max:10240', // 10MB limit
]);
if ($validator->fails()) {
@@ -227,20 +230,41 @@ public function sendMessage(Request $request, $id)
], 404);
}
+ $attachmentPath = null;
+ $attachmentType = null;
+
+ if ($request->hasFile('file')) {
+ $file = $request->file('file');
+ $attachmentPath = $file->store('chat_attachments', 'public');
+
+ $mime = $file->getMimeType();
+ if (str_starts_with($mime, 'image/')) {
+ $attachmentType = 'image';
+ } elseif (str_starts_with($mime, 'audio/')) {
+ $attachmentType = 'voice';
+ } else {
+ $attachmentType = 'document';
+ }
+ }
+
$message = null;
- DB::transaction(function () use ($conv, $worker, $request, &$message) {
+ DB::transaction(function () use ($conv, $worker, $request, $attachmentPath, $attachmentType, &$message) {
$message = Message::create([
'conversation_id' => $conv->id,
'sender_type' => 'worker',
'sender_id' => $worker->id,
'text' => $request->text,
+ 'attachment_path' => $attachmentPath,
+ 'attachment_type' => $attachmentType,
]);
// Touch conversation updated_at for sorting
$conv->touch();
// Process the worker response to update status to Hired or Hidden
- self::processWorkerResponse($conv, $worker, $request->text);
+ if ($request->text) {
+ self::processWorkerResponse($conv, $worker, $request->text);
+ }
});
return response()->json([
@@ -253,6 +277,8 @@ public function sendMessage(Request $request, $id)
'text' => $message->text,
'time' => $message->created_at->format('g:i A') . ' Today',
'created_at' => $message->created_at->toISOString(),
+ 'attachment_url' => $message->attachment_path ? asset('storage/' . $message->attachment_path) : null,
+ 'attachment_type' => $message->attachment_type,
]
]
], 201);
diff --git a/app/Http/Controllers/Employer/MessageController.php b/app/Http/Controllers/Employer/MessageController.php
index b5f55ed..ff691ad 100644
--- a/app/Http/Controllers/Employer/MessageController.php
+++ b/app/Http/Controllers/Employer/MessageController.php
@@ -180,6 +180,8 @@ public function show($id)
'sender' => $msg->sender_type, // employer or worker
'text' => $msg->text,
'time' => $msg->created_at->format('g:i A') . ($msg->created_at->isToday() ? ' Today' : ' ' . $msg->created_at->format('M d')),
+ 'attachment_url' => $msg->attachment_path ? asset('storage/' . $msg->attachment_path) : null,
+ 'attachment_type' => $msg->attachment_type,
];
})->toArray();
@@ -198,44 +200,66 @@ public function send(Request $request, $id)
}
$request->validate([
- 'text' => 'required|string|max:1000',
+ 'text' => 'required_without:file|string|max:1000|nullable',
+ 'file' => 'nullable|file|mimes:jpg,jpeg,png,pdf,mp3,wav,m4a,ogg,webm,mp4,aac,3gp,amr|max:10240', // 10MB limit
]);
$conv = Conversation::where('employer_id', $user->id)->where('id', $id)->firstOrFail();
+ $attachmentPath = null;
+ $attachmentType = null;
+
+ if ($request->hasFile('file')) {
+ $file = $request->file('file');
+ $attachmentPath = $file->store('chat_attachments', 'public');
+
+ $mime = $file->getMimeType();
+ if (str_starts_with($mime, 'image/')) {
+ $attachmentType = 'image';
+ } elseif (str_starts_with($mime, 'audio/')) {
+ $attachmentType = 'voice';
+ } else {
+ $attachmentType = 'document';
+ }
+ }
+
$message = Message::create([
'conversation_id' => $conv->id,
'sender_type' => 'employer',
'sender_id' => $user->id,
'text' => $request->text,
+ 'attachment_path' => $attachmentPath,
+ 'attachment_type' => $attachmentType,
]);
// Touch conversation updated_at for sorting
$conv->touch();
// Check if employer sent predefined message "are you looking job?"
- $textLower = strtolower($request->text);
- if (
- str_contains($textLower, 'looking job') ||
- str_contains($textLower, 'looking for a job') ||
- str_contains($textLower, 'looking for job') ||
- str_contains($textLower, 'are you looking')
- ) {
- // Automatically simulate worker response 'Yes' (triggers the S6 hired flow)
- $workerResponseText = 'Yes';
-
- // Create the message in database
- Message::create([
- 'conversation_id' => $conv->id,
- 'sender_type' => 'worker',
- 'sender_id' => $conv->worker_id,
- 'text' => $workerResponseText,
- ]);
-
- // Process the worker response to update status to Hired!
- $worker = Worker::find($conv->worker_id);
- if ($worker) {
- self::processWorkerResponse($conv, $worker, $workerResponseText);
+ if ($request->text) {
+ $textLower = strtolower($request->text);
+ if (
+ str_contains($textLower, 'looking job') ||
+ str_contains($textLower, 'looking for a job') ||
+ str_contains($textLower, 'looking for job') ||
+ str_contains($textLower, 'are you looking')
+ ) {
+ // Automatically simulate worker response 'Yes' (triggers the S6 hired flow)
+ $workerResponseText = 'Yes';
+
+ // Create the message in database
+ Message::create([
+ 'conversation_id' => $conv->id,
+ 'sender_type' => 'worker',
+ 'sender_id' => $conv->worker_id,
+ 'text' => $workerResponseText,
+ ]);
+
+ // Process the worker response to update status to Hired!
+ $worker = Worker::find($conv->worker_id);
+ if ($worker) {
+ self::processWorkerResponse($conv, $worker, $workerResponseText);
+ }
}
}
diff --git a/public/swagger.json b/public/swagger.json
index a5aaac9..a558cca 100644
--- a/public/swagger.json
+++ b/public/swagger.json
@@ -1022,16 +1022,19 @@
"requestBody": {
"required": true,
"content": {
- "application/json": {
+ "multipart/form-data": {
"schema": {
"type": "object",
- "required": [
- "text"
- ],
"properties": {
"text": {
"type": "string",
- "example": "Yes, I am available."
+ "example": "Yes, I am available.",
+ "description": "Optional text message content (required if no file is uploaded)."
+ },
+ "file": {
+ "type": "string",
+ "format": "binary",
+ "description": "Optional file or voice note attachment (Max 10MB). Supports images, documents, and common audio formats."
}
}
}
@@ -1623,16 +1626,19 @@
"requestBody": {
"required": true,
"content": {
- "application/json": {
+ "multipart/form-data": {
"schema": {
"type": "object",
- "required": [
- "text"
- ],
"properties": {
"text": {
"type": "string",
- "example": "When can you start?"
+ "example": "When can you start?",
+ "description": "Optional text message content (required if no file is uploaded)."
+ },
+ "file": {
+ "type": "string",
+ "format": "binary",
+ "description": "Optional file or voice note attachment (Max 10MB). Supports images, documents, and common audio formats."
}
}
}
diff --git a/resources/js/Layouts/AdminLayout.jsx b/resources/js/Layouts/AdminLayout.jsx
index 447deef..04d180d 100644
--- a/resources/js/Layouts/AdminLayout.jsx
+++ b/resources/js/Layouts/AdminLayout.jsx
@@ -30,7 +30,7 @@ export default function AdminLayout({ children, title = 'Dashboard' }) {
const navItems = [
{ name: 'Dashboard Overview', href: '/admin/dashboard', icon: LayoutDashboard },
{ name: 'Worker Profiles', href: '/admin/workers', icon: Users },
- { name: 'Sponsor Vetting Dossiers', href: '/admin/employers', icon: Briefcase },
+ { name: 'Sponsors', href: '/admin/employers', icon: Briefcase },
{ name: 'OCR Verification', href: '/admin/workers/verifications', icon: ShieldCheck },
{ name: 'Safety & Moderation', href: '/admin/safety', icon: ShieldAlert },
{ name: 'Disputes System', href: '/admin/disputes', icon: Scale },
@@ -39,7 +39,7 @@ export default function AdminLayout({ children, title = 'Dashboard' }) {
{ name: 'Reports & Analytics', href: '/admin/analytics', icon: BarChart3 },
{ name: 'System Audit Logs', href: '/admin/audit-logs', icon: History },
{ name: 'Announcements', href: '/admin/announcements', icon: Megaphone },
- { name: 'Master Categories', href: '/admin/master-data/categories', icon: Settings },
+ { name: 'Skills', href: '/admin/master-data/skills', icon: Settings },
];
const getInitials = (name) => {
diff --git a/resources/js/Pages/Admin/Employers/Index.jsx b/resources/js/Pages/Admin/Employers/Index.jsx
index e022744..6b540a4 100644
--- a/resources/js/Pages/Admin/Employers/Index.jsx
+++ b/resources/js/Pages/Admin/Employers/Index.jsx
@@ -134,7 +134,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
};
const handleDelete = (id) => {
- if (confirm('Are you absolutely sure you want to permanently delete this sponsor vetting dossier?')) {
+ if (confirm('Are you absolutely sure you want to permanently delete this sponsor?')) {
router.delete(`/admin/employers/${id}`);
}
};
@@ -163,21 +163,32 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
});
};
+ const handleExport = () => {
+ const params = new URLSearchParams({
+ search: searchTerm,
+ status: statusFilter,
+ location: locationFilter,
+ sort_field: sortField,
+ sort_order: sortOrder
+ });
+ window.location.href = `/admin/employers/export?${params.toString()}`;
+ };
+
const viewDetails = (sponsor) => {
setSelectedSponsor(sponsor);
setIsDetailsOpen(true);
};
return (
-