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 ( - - + +
{/* Statistics Ribbons */}
- Total Dossiers + Total Sponsors

{sponsors.total || 0}

@@ -257,6 +268,15 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
+
+ +
{/* Dynamic Sponsors Table */} @@ -265,7 +285,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) { handleSort('full_name')} className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 pl-8 cursor-pointer select-none"> - Sponsor Vetting Dossier {sortField === 'full_name' && (sortOrder === 'desc' ? '↓' : '↑')} + Sponsor {sortField === 'full_name' && (sortOrder === 'desc' ? '↓' : '↑')} handleSort('city')} className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 cursor-pointer select-none"> Location {sortField === 'city' && (sortOrder === 'desc' ? '↓' : '↑')} @@ -366,14 +386,14 @@ export default function SponsorsIndex({ sponsors, filters = {} }) { >
- Edit Dossier + Edit Sponsor
{!sponsor.is_verified && ( handleApprove(sponsor.id)}>
- Verify Dossier Vetting + Verify Sponsor
)} @@ -384,7 +404,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) { >
- Suspend Dossier + Suspend Sponsor
) : ( @@ -394,7 +414,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) { >
- Activate Dossier + Activate Sponsor
)} @@ -405,7 +425,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) { >
- Delete Dossier + Delete Sponsor
@@ -417,7 +437,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) { ) : ( - No sponsor dossiers match the active search/filters criteria. + No sponsors match the active search/filters criteria. )} @@ -425,11 +445,15 @@ export default function SponsorsIndex({ sponsors, filters = {} }) { {/* Pagination */} - {sponsors.last_page > 1 && ( -
- - Showing page {sponsors.current_page} of {sponsors.last_page} - +
+ + {sponsors.total > 0 ? ( + `Showing ${sponsors.from || 0} to ${sponsors.to || 0} of ${sponsors.total} sponsors` + ) : ( + 'No sponsors found' + )} + + {sponsors.last_page > 1 && (
-
- )} + )} +
@@ -478,7 +502,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {

- UAE Sponsor Vetting Dossier + UAE Sponsor Details

Sponsor ID: SPN-{selectedSponsor?.id} @@ -548,13 +572,13 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
- Dossier Verification Action: + Verification Action: {!selectedSponsor?.is_verified ? ( ) : ( @@ -564,19 +588,19 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
- {selectedSponsor?.status !== 'suspended' ? ( + {selectedSponsor?.status !== 'suspended' ? ( ) : ( )} -
diff --git a/resources/js/Pages/Admin/MasterData/WorkerCategories.jsx b/resources/js/Pages/Admin/MasterData/WorkerCategories.jsx deleted file mode 100644 index 562aea7..0000000 --- a/resources/js/Pages/Admin/MasterData/WorkerCategories.jsx +++ /dev/null @@ -1,193 +0,0 @@ -import React, { useState } from 'react'; -import { Head } from '@inertiajs/react'; -import AdminLayout from '@/Layouts/AdminLayout'; -import { - Plus, - Search, - GripVertical, - Edit2, - Trash2, - Check, - X, - LayoutGrid, - Info, - ChevronRight, - ArrowUp, - ArrowDown -} from 'lucide-react'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/components/ui/dialog"; - -export default function WorkerCategories({ categories: initialCategories }) { - const [categories, setCategories] = useState(initialCategories || [ - { id: 1, name: 'Electrician', worker_count: 142, status: 'Active', order: 1 }, - { id: 2, name: 'Mason', worker_count: 89, status: 'Active', order: 2 }, - { id: 3, name: 'Plumber', worker_count: 67, status: 'Active', order: 3 }, - { id: 4, name: 'Site Supervisor', worker_count: 34, status: 'Active', order: 4 }, - { id: 5, name: 'Helper', worker_count: 256, status: 'Active', order: 5 }, - ]); - - const [isDialogOpen, setIsDialogOpen] = useState(false); - const [editingCategory, setEditingCategory] = useState(null); - - const handleDelete = (id) => { - if (confirm('Delete this category? This might affect job postings.')) { - setCategories(categories.filter(c => c.id !== id)); - } - }; - - return ( - - - -
- {/* Header Actions */} -
-
- - -
- -
- - {/* Categories Card */} -
-
-
-
- -
-
-

Worker Classifications

-

Manage job categories for the entire platform

-
-
-
- - - - - - Category Name - Worker Count - Status - Action - - - - {categories.map((cat) => ( - - - - - - {cat.name} - - -
- {cat.worker_count} - Workers -
-
- - - {cat.status} - - - -
- - -
-
-
- ))} -
-
- -
-
- -

- Tip: You can reorder categories by dragging the handle. The order will reflect in job posting screens and filter menus for both employers and workers. -

-
-
-
-
- - {/* Category Modal */} - - - - - {editingCategory ? 'Edit Category' : 'New Category'} - - - -
-
- - -
-
- -
- {['⚡', '🏗️', '🔧', '🧹', '🛡️'].map(emoji => ( - - ))} -
-
-
- - - - - -
-
-
- ); -} diff --git a/resources/js/Pages/Admin/MasterData/WorkerSkills.jsx b/resources/js/Pages/Admin/MasterData/WorkerSkills.jsx new file mode 100644 index 0000000..6aaf6d5 --- /dev/null +++ b/resources/js/Pages/Admin/MasterData/WorkerSkills.jsx @@ -0,0 +1,247 @@ +import React, { useState } from 'react'; +import { Head, router } from '@inertiajs/react'; +import AdminLayout from '@/Layouts/AdminLayout'; +import { + Plus, + Search, + GripVertical, + Edit2, + Trash2, + Info, + Wrench +} from 'lucide-react'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; + +export default function WorkerSkills({ skills }) { + const [searchTerm, setSearchTerm] = useState(''); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [editingSkill, setEditingSkill] = useState(null); + const [skillName, setSkillName] = useState(''); + const [error, setError] = useState(''); + + const handleOpenAdd = () => { + setEditingSkill(null); + setSkillName(''); + setError(''); + setIsDialogOpen(true); + }; + + const handleOpenEdit = (skill) => { + setEditingSkill(skill); + setSkillName(skill.name); + setError(''); + setIsDialogOpen(true); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + if (!skillName.trim()) { + setError('Skill name is required.'); + return; + } + + if (editingSkill) { + router.post(`/admin/master-data/skills/${editingSkill.id}/update`, { + name: skillName.trim() + }, { + onSuccess: () => { + setIsDialogOpen(false); + }, + onError: (err) => { + setError(err.name || 'Failed to update skill.'); + } + }); + } else { + router.post('/admin/master-data/skills', { + name: skillName.trim() + }, { + onSuccess: () => { + setIsDialogOpen(false); + }, + onError: (err) => { + setError(err.name || 'Failed to add skill.'); + } + }); + } + }; + + const handleDelete = (id) => { + if (confirm('Are you sure you want to delete this skill? It will be removed from all associated worker profiles.')) { + router.delete(`/admin/master-data/skills/${id}`); + } + }; + + const filteredSkills = skills.filter(skill => + skill.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return ( + + + +
+ {/* Header Actions */} +
+
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-teal-500/10 outline-none transition-all" + /> +
+ +
+ + {/* Skills Card */} +
+
+
+
+ +
+
+

Worker Skills

+

Manage skills workers can choose for their profiles

+
+
+
+ + + + + + Skill Name + Worker Count + Status + Action + + + + {filteredSkills.length > 0 ? ( + filteredSkills.map((skill) => ( + + + + + + {skill.name} + + +
+ {skill.worker_count} + Workers +
+
+ + + {skill.status} + + + +
+ + +
+
+
+ )) + ) : ( + + + No skills match your search. + + + )} +
+
+ +
+
+ +

+ Tip: These skills are populated in the mobile application configuration so workers can select their specific expertise when setting up or updating their profiles. +

+
+
+
+
+ + {/* Skill Modal */} + + + + + {editingSkill ? 'Edit Skill' : 'New Skill'} + + + +
+
+
+ + setSkillName(e.target.value)} + /> + {error && ( +

{error}

+ )} +
+
+ + + + + +
+
+
+
+ ); +} diff --git a/resources/js/Pages/Employer/Dashboard.jsx b/resources/js/Pages/Employer/Dashboard.jsx index 2d8d6ef..98cd6e0 100644 --- a/resources/js/Pages/Employer/Dashboard.jsx +++ b/resources/js/Pages/Employer/Dashboard.jsx @@ -15,7 +15,6 @@ import { Clock, ChevronRight, UserCircle2, - Sparkles, Plus, Activity, TrendingUp, @@ -315,71 +314,6 @@ export default function Dashboard({
{/* Left Column: Recommended Workers, Shortlist, Chats */}
- {/* Recommended Workers Module */} -
-
-
- -

{t('recommended_for_you', 'Recommended for You')}

-
- - {t('browse_all', 'Browse all')} - - -
- -
- {recommended_workers.length > 0 ? ( - recommended_workers.map((worker) => ( -
- {/* Top info */} -
-
-
- {worker.name.charAt(0)} -
- {worker.verified && ( - - VERIFIED - - )} -
-
-
- {worker.name} -
-
- {worker.nationality} • {worker.category} -
-
-
- - {worker.rating} - ({Math.floor(Math.random() * 15) + 3} reviews) -
-
- - {/* Action buttons */} -
-
- {worker.salary} AED/mo -
- - {t('view_profile', 'View Profile')} - -
-
- )) - ) : ( -
- {t('no_recommended', 'No workers currently recommended. Update profile details.')} -
- )} -
-
{/* My Shortlist Module */}
diff --git a/resources/js/Pages/Employer/Messages/Show.jsx b/resources/js/Pages/Employer/Messages/Show.jsx index 02b935d..3391ca6 100644 --- a/resources/js/Pages/Employer/Messages/Show.jsx +++ b/resources/js/Pages/Employer/Messages/Show.jsx @@ -25,7 +25,9 @@ import { MessageSquare, Eye, UploadCloud, - FileText + FileText, + Mic, + Square } from 'lucide-react'; import { toast } from 'sonner'; @@ -38,6 +40,9 @@ export default function Show({ conversation, initialMessages, conversations = [] const [showProfile, setShowProfile] = useState(true); const [showAttachmentModal, setShowAttachmentModal] = useState(false); const [uploadedFiles, setUploadedFiles] = useState([]); + const [isRecording, setIsRecording] = useState(false); + const mediaRecorderRef = useRef(null); + const audioChunksRef = useRef([]); const messagesEndRef = useRef(null); const scrollToBottom = () => { @@ -63,6 +68,40 @@ export default function Show({ conversation, initialMessages, conversations = [] c.worker_name.toLowerCase().includes(searchTerm.toLowerCase()) ); + const sendFile = (file) => { + if (!file) return; + + const isImage = file.type.startsWith('image/'); + const isAudio = file.type.startsWith('audio/'); + const type = isImage ? 'image' : (isAudio ? 'voice' : 'document'); + + // Optimistically update message feed + const newMsg = { + id: Date.now(), + sender: 'employer', + text: file.name, + time: t('just_now', 'Just Now'), + read: false, + attachment_url: URL.createObjectURL(file), + attachment_type: type + }; + setMessages(prev => [...prev, newMsg]); + + router.post(`/employer/messages/${conversation.id}/send`, { + text: file.name, + file: file + }, { + preserveScroll: true, + onSuccess: () => { + toast.success(t('file_sent_success', 'File and notification sent successfully!')); + setShowAttachmentModal(false); + }, + onError: (err) => { + toast.error(t('file_sent_error', 'Failed to upload file attachment.')); + } + }); + }; + const sendMsg = (text) => { if (!text.trim()) return; @@ -127,19 +166,63 @@ export default function Show({ conversation, initialMessages, conversations = [] const handleFileUpload = (e) => { const file = e.target.files[0]; if (!file) return; + sendFile(file); + }; - const newFile = { - name: file.name, - size: (file.size / 1024).toFixed(1) + ' KB', - date: t('today', 'Today') - }; + const startRecording = async () => { + try { + // Check browser permission status if query API is supported + if (navigator.permissions && navigator.permissions.query) { + try { + const permissionStatus = await navigator.permissions.query({ name: 'microphone' }); + if (permissionStatus.state === 'denied') { + toast.error(t('microphone_blocked', 'Microphone access is blocked. Please enable microphone permissions in your browser settings.')); + return; + } + } catch (pe) { + // Ignore query check failures if name is not supported in some browsers + } + } - setUploadedFiles([...uploadedFiles, newFile]); - toast.success(t('document_attached', 'Document attached successfully!')); - setShowAttachmentModal(false); - - // Optimistically insert attachment message - sendMsg(`[${t('document_attached_msg', 'Document Attached')}: ${file.name}]`); + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + audioChunksRef.current = []; + const recorder = new MediaRecorder(stream); + + recorder.ondataavailable = (e) => { + if (e.data.size > 0) { + audioChunksRef.current.push(e.data); + } + }; + + recorder.onstop = () => { + const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); + const file = new File([audioBlob], `voice_${Date.now()}.webm`, { type: 'audio/webm' }); + sendFile(file); + // Stop all tracks to release mic + stream.getTracks().forEach(track => track.stop()); + }; + + mediaRecorderRef.current = recorder; + recorder.start(); + setIsRecording(true); + toast.info(t('recording_started', 'Recording voice message... Click microphone again to stop.')); + } catch (err) { + console.error(err); + if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') { + toast.error(t('microphone_permission_denied', 'Permission to access the microphone was denied. Please allow microphone access in your browser settings.')); + } else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') { + toast.error(t('microphone_not_found', 'No microphone device was detected on your system.')); + } else { + toast.error(t('microphone_access_error', 'Could not access the microphone. Please check your system settings.')); + } + } + }; + + const stopRecording = () => { + if (mediaRecorderRef.current && isRecording) { + mediaRecorderRef.current.stop(); + setIsRecording(false); + } }; const chips = [ @@ -286,29 +369,80 @@ export default function Show({ conversation, initialMessages, conversations = [] {messages.map((msg, idx) => { const isEmp = msg.sender === 'employer'; + const hasImage = msg.attachment_url && msg.attachment_type === 'image'; + const hasVoice = msg.attachment_url && msg.attachment_type === 'voice'; + const hasDoc = msg.attachment_url && msg.attachment_type === 'document'; + const isImageFilename = msg.text && msg.text.match(/\.(jpg|jpeg|png|gif|webp)$/i); + return (
-
-
+ + {/* Main Message Bubble (WhatsApp Style) */} +
- {msg.text} -
-
- {msg.time} - {isEmp && ( - - - {t('read', 'Read')} - - + ? 'bg-[#185FA5] text-white border-transparent rounded-tr-none' + : 'bg-white border-slate-100 text-slate-800 rounded-tl-none' + } ${hasImage ? 'p-1.5 pb-2.5 max-w-[320px]' : 'px-5 py-3'}`}> + + {/* Image Attachment (WhatsApp Full Width style) */} + {hasImage && ( +
+ + Attachment + +
)} + + {/* Voice Message */} + {hasVoice && ( +
+
+ )} + + {/* Document Attachment */} + {hasDoc && ( + + )} + + {/* Text Content (if not just the filename of the image) */} + {msg.text && (!hasImage || !isImageFilename) && ( +
+ {msg.text} +
+ )} + + {/* Time & Read Status Indicator */} +
+ {msg.time} + {isEmp && ( + + + + + )} +
+
+
); @@ -344,28 +478,40 @@ export default function Show({ conversation, initialMessages, conversations = []
- - setInput(e.target.value)} - placeholder={t('type_message_placeholder', 'Type a compliant secure message...')} - className="flex-1 px-5 py-3.5 bg-slate-50 rounded-xl text-xs font-semibold outline-none border border-transparent focus:border-slate-200 transition-all" - /> - -
+ + setInput(e.target.value)} + placeholder={t('type_message_placeholder', 'Type a compliant secure message...')} + className="flex-1 px-5 py-3.5 bg-slate-50 rounded-xl text-xs font-semibold outline-none border border-transparent focus:border-slate-200 transition-all" + /> + + +
diff --git a/routes/web.php b/routes/web.php index bed4d5c..983a442 100644 --- a/routes/web.php +++ b/routes/web.php @@ -34,6 +34,7 @@ Route::post('/workers/{worker}/verify', [\App\Http\Controllers\Admin\WorkerVerificationController::class, 'verify'])->name('admin.workers.verify'); Route::get('/employers', [SponsorController::class, 'index'])->name('admin.employers'); + Route::get('/employers/export', [SponsorController::class, 'export'])->name('admin.employers.export'); Route::post('/employers/{id}/verify', [SponsorController::class, 'verify'])->name('admin.employers.verify'); Route::post('/employers/{id}/suspend', [SponsorController::class, 'suspend'])->name('admin.employers.suspend'); Route::post('/employers/{id}/activate', [SponsorController::class, 'activate'])->name('admin.employers.activate'); @@ -50,9 +51,47 @@ Route::post('/payments/{id}/refund', [\App\Http\Controllers\Admin\AdminExtraController::class, 'refundPayment'])->name('admin.payments.refund'); - Route::get('/master-data/categories', function () { - return Inertia::render('Admin/MasterData/WorkerCategories'); - })->name('admin.categories'); + Route::get('/master-data/skills', function () { + $skills = \App\Models\Skill::all()->map(function ($skill) { + return [ + 'id' => $skill->id, + 'name' => $skill->name, + 'worker_count' => \DB::table('worker_skills')->where('skill_id', $skill->id)->count(), + 'status' => 'Active', + ]; + }); + return Inertia::render('Admin/MasterData/WorkerSkills', [ + 'skills' => $skills + ]); + })->name('admin.skills'); + + Route::post('/master-data/skills', function (\Illuminate\Http\Request $request) { + $request->validate([ + 'name' => 'required|string|max:255|unique:skills,name', + ]); + \App\Models\Skill::create([ + 'name' => strtolower($request->name), + ]); + return redirect()->back(); + })->name('admin.skills.store'); + + Route::post('/master-data/skills/{id}/update', function (\Illuminate\Http\Request $request, $id) { + $request->validate([ + 'name' => 'required|string|max:255|unique:skills,name,' . $id, + ]); + $skill = \App\Models\Skill::findOrFail($id); + $skill->update([ + 'name' => strtolower($request->name), + ]); + return redirect()->back(); + })->name('admin.skills.update'); + + Route::delete('/master-data/skills/{id}', function ($id) { + $skill = \App\Models\Skill::findOrFail($id); + \DB::table('worker_skills')->where('skill_id', $id)->delete(); + $skill->delete(); + return redirect()->back(); + })->name('admin.skills.delete'); Route::get('/announcements', function () { return Inertia::render('Admin/Announcements/Index');