mohan #5
@ -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.
|
||||
*/
|
||||
|
||||
@ -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
|
||||
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);
|
||||
|
||||
@ -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,22 +200,43 @@ 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?"
|
||||
if ($request->text) {
|
||||
$textLower = strtolower($request->text);
|
||||
if (
|
||||
str_contains($textLower, 'looking job') ||
|
||||
@ -238,6 +261,7 @@ public function send(Request $request, $id)
|
||||
self::processWorkerResponse($conv, $worker, $workerResponseText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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 (
|
||||
<AdminLayout title="UAE Sponsor Vetting Dossier">
|
||||
<Head title="UAE Sponsor Vetting Dossiers" />
|
||||
<AdminLayout title="Sponsors">
|
||||
<Head title="Sponsors" />
|
||||
|
||||
<div className="space-y-6 font-sans">
|
||||
{/* Statistics Ribbons */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
|
||||
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1">Total Dossiers</span>
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1">Total Sponsors</span>
|
||||
<h3 className="text-2xl font-black text-slate-900">{sponsors.total || 0}</h3>
|
||||
</div>
|
||||
<div className="p-3 bg-teal-50 rounded-xl">
|
||||
@ -257,6 +268,15 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="px-4 py-2.5 bg-[#0F6E56] text-white rounded-xl text-xs font-black uppercase tracking-wider flex items-center gap-2 hover:bg-[#0c5945] transition-all shadow-md shadow-teal-900/10 cursor-pointer"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Export Sponsors</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Sponsors Table */}
|
||||
@ -265,7 +285,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50/50 hover:bg-slate-50/50">
|
||||
<TableHead onClick={() => 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' ? '↓' : '↑')}
|
||||
</TableHead>
|
||||
<TableHead onClick={() => 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 = {} }) {
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
<span className="text-xs font-bold">Edit Dossier</span>
|
||||
<span className="text-xs font-bold">Edit Sponsor</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
{!sponsor.is_verified && (
|
||||
<DropdownMenuItem className="p-3 rounded-lg focus:bg-emerald-50 cursor-pointer text-emerald-600 font-bold" onClick={() => handleApprove(sponsor.id)}>
|
||||
<div className="flex items-center space-x-3">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span className="text-xs font-bold">Verify Dossier Vetting</span>
|
||||
<span className="text-xs font-bold">Verify Sponsor</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
@ -384,7 +404,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<XCircle className="w-4 h-4" />
|
||||
<span className="text-xs font-bold">Suspend Dossier</span>
|
||||
<span className="text-xs font-bold">Suspend Sponsor</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
@ -394,7 +414,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span className="text-xs font-bold">Activate Dossier</span>
|
||||
<span className="text-xs font-bold">Activate Sponsor</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
@ -405,7 +425,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span className="text-xs font-bold">Delete Dossier</span>
|
||||
<span className="text-xs font-bold">Delete Sponsor</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@ -417,7 +437,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-12 text-center text-slate-400 font-semibold text-sm">
|
||||
No sponsor dossiers match the active search/filters criteria.
|
||||
No sponsors match the active search/filters criteria.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@ -425,11 +445,15 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
{sponsors.last_page > 1 && (
|
||||
<div className="p-5 border-t border-slate-100 flex items-center justify-between bg-slate-50/50">
|
||||
<span className="text-xs text-slate-500 font-bold">
|
||||
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'
|
||||
)}
|
||||
</span>
|
||||
{sponsors.last_page > 1 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link
|
||||
href={sponsors.prev_page_url || '#'}
|
||||
@ -452,10 +476,10 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
||||
Next
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Sponsor Detail Modal */}
|
||||
<Dialog open={isDetailsOpen} onOpenChange={setIsDetailsOpen}>
|
||||
@ -478,7 +502,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-2">
|
||||
<h3 className="text-xs font-black text-slate-700 uppercase tracking-widest flex items-center gap-1.5">
|
||||
<Award className="w-4.5 h-4.5 text-[#0F6E56]" />
|
||||
<span>UAE Sponsor Vetting Dossier</span>
|
||||
<span>UAE Sponsor Details</span>
|
||||
</h3>
|
||||
<Badge className="bg-teal-50 text-[#0F6E56] border-none font-black text-[9px] uppercase px-3 py-1 rounded-lg">
|
||||
Sponsor ID: SPN-{selectedSponsor?.id}
|
||||
@ -548,13 +572,13 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
||||
|
||||
<div className="p-6 border-t border-slate-100 bg-slate-50 flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Dossier Verification Action:</span>
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Verification Action:</span>
|
||||
{!selectedSponsor?.is_verified ? (
|
||||
<button
|
||||
onClick={() => handleApprove(selectedSponsor.id)}
|
||||
className="px-4 py-2.5 bg-emerald-600 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md shadow-emerald-600/10 hover:bg-emerald-500 transition-colors"
|
||||
>
|
||||
Verify Vetting OTP
|
||||
Verify Sponsor OTP
|
||||
</button>
|
||||
) : (
|
||||
<Badge className="bg-emerald-100 text-emerald-800 border-none font-black uppercase text-[9px] px-3 py-1.5 rounded-lg shadow-sm">
|
||||
@ -569,14 +593,14 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
||||
onClick={() => handleSuspend(selectedSponsor.id)}
|
||||
className="px-4 py-2.5 bg-red-600 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md shadow-red-600/10 hover:bg-red-700 transition-all"
|
||||
>
|
||||
Suspend Dossier
|
||||
Suspend Sponsor
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleActivate(selectedSponsor.id)}
|
||||
className="px-4 py-2.5 bg-slate-900 text-white rounded-xl text-[10px] font-black uppercase tracking-widest hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Activate Dossier
|
||||
Activate Sponsor
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@ -594,7 +618,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
||||
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||
<DialogContent className="sm:max-w-xl w-full bg-white p-6 rounded-[24px] font-sans">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-black text-slate-800 uppercase tracking-wide">Edit Sponsor Dossier Details</DialogTitle>
|
||||
<DialogTitle className="text-lg font-black text-slate-800 uppercase tracking-wide">Edit Sponsor Details</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleEditSubmit} className="space-y-4 mt-4">
|
||||
<div className="space-y-1">
|
||||
@ -701,7 +725,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
||||
type="submit"
|
||||
className="px-5 py-2.5 bg-[#0F6E56] text-white rounded-xl text-xs font-black uppercase tracking-wider"
|
||||
>
|
||||
Save Dossier
|
||||
Save Sponsor
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -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 (
|
||||
<AdminLayout title="Worker Categories">
|
||||
<Head title="Master Data - Categories" />
|
||||
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header Actions */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Find category..."
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setEditingCategory(null); setIsDialogOpen(true); }}
|
||||
className="bg-[#0F6E56] text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center justify-center space-x-2 hover:bg-[#085041] transition-all shadow-lg shadow-teal-500/10"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add Category</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Categories Card */}
|
||||
<div className="bg-white rounded-[24px] border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-50 bg-slate-50/30">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-teal-50 rounded-lg">
|
||||
<LayoutGrid className="w-5 h-5 text-[#0F6E56]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-black text-slate-900 uppercase tracking-tight">Worker Classifications</h2>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-0.5">Manage job categories for the entire platform</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-white hover:bg-white">
|
||||
<TableHead className="w-12"></TableHead>
|
||||
<TableHead className="font-bold text-slate-400 text-[10px] uppercase tracking-widest h-12">Category Name</TableHead>
|
||||
<TableHead className="font-bold text-slate-400 text-[10px] uppercase tracking-widest h-12">Worker Count</TableHead>
|
||||
<TableHead className="font-bold text-slate-400 text-[10px] uppercase tracking-widest h-12">Status</TableHead>
|
||||
<TableHead className="font-bold text-slate-400 text-[10px] uppercase tracking-widest h-12 text-right pr-6">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{categories.map((cat) => (
|
||||
<TableRow key={cat.id} className="hover:bg-slate-50/50 transition-colors group">
|
||||
<TableCell className="pl-4">
|
||||
<GripVertical className="w-4 h-4 text-slate-300 cursor-grab active:cursor-grabbing" />
|
||||
</TableCell>
|
||||
<TableCell className="py-4 font-bold text-slate-900 text-sm">
|
||||
{cat.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-black text-slate-600">{cat.worker_count}</span>
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase">Workers</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="inline-flex items-center px-2 py-0.5 bg-emerald-50 text-emerald-600 rounded text-[10px] font-black uppercase tracking-tight">
|
||||
{cat.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right pr-6">
|
||||
<div className="flex items-center justify-end space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => { setEditingCategory(cat); setIsDialogOpen(true); }}
|
||||
className="p-2 text-slate-400 hover:text-[#0F6E56] hover:bg-teal-50 rounded-lg transition-all"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(cat.id)}
|
||||
className="p-2 text-slate-400 hover:text-rose-500 hover:bg-rose-50 rounded-lg transition-all"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="p-6 bg-slate-50/50 border-t border-slate-100">
|
||||
<div className="flex items-start space-x-3 text-slate-400">
|
||||
<Info className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<p className="text-[10px] font-bold uppercase tracking-wide leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Modal */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[400px] rounded-[24px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-black text-slate-900 tracking-tight">
|
||||
{editingCategory ? 'Edit Category' : 'New Category'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Category Name</label>
|
||||
<input className="w-full px-4 py-3 bg-slate-50 border border-slate-100 rounded-xl text-sm font-bold focus:ring-4 focus:ring-teal-500/10 outline-none" placeholder="e.g. Electrician" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Icon Style (Optional)</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{['⚡', '🏗️', '🔧', '🧹', '🛡️'].map(emoji => (
|
||||
<button key={emoji} className="h-10 bg-slate-50 rounded-lg flex items-center justify-center border border-slate-100 hover:bg-white transition-colors">{emoji}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:justify-end gap-3 pt-4">
|
||||
<button
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
className="px-6 py-2.5 rounded-xl text-sm font-bold text-slate-400 uppercase tracking-widest hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="bg-[#0F6E56] text-white px-8 py-2.5 rounded-xl text-sm font-bold uppercase tracking-widest shadow-lg shadow-teal-500/20 active:scale-95 transition-all"
|
||||
>
|
||||
{editingCategory ? 'Update' : 'Add Category'}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
247
resources/js/Pages/Admin/MasterData/WorkerSkills.jsx
Normal file
247
resources/js/Pages/Admin/MasterData/WorkerSkills.jsx
Normal file
@ -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 (
|
||||
<AdminLayout title="Worker Skills">
|
||||
<Head title="Master Data - Skills" />
|
||||
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header Actions */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Find skill..."
|
||||
value={searchTerm}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenAdd}
|
||||
className="bg-[#0F6E56] text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center justify-center space-x-2 hover:bg-[#085041] transition-all shadow-lg shadow-teal-500/10 cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add Skill</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Skills Card */}
|
||||
<div className="bg-white rounded-[24px] border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-50 bg-slate-50/30">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-teal-50 rounded-lg">
|
||||
<Wrench className="w-5 h-5 text-[#0F6E56]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-black text-slate-900 uppercase tracking-tight">Worker Skills</h2>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-0.5">Manage skills workers can choose for their profiles</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-white hover:bg-white">
|
||||
<TableHead className="w-12"></TableHead>
|
||||
<TableHead className="font-bold text-slate-400 text-[10px] uppercase tracking-widest h-12">Skill Name</TableHead>
|
||||
<TableHead className="font-bold text-slate-400 text-[10px] uppercase tracking-widest h-12">Worker Count</TableHead>
|
||||
<TableHead className="font-bold text-slate-400 text-[10px] uppercase tracking-widest h-12">Status</TableHead>
|
||||
<TableHead className="font-bold text-slate-400 text-[10px] uppercase tracking-widest h-12 text-right pr-6">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredSkills.length > 0 ? (
|
||||
filteredSkills.map((skill) => (
|
||||
<TableRow key={skill.id} className="hover:bg-slate-50/50 transition-colors group">
|
||||
<TableCell className="pl-4">
|
||||
<GripVertical className="w-4 h-4 text-slate-300 cursor-grab active:cursor-grabbing" />
|
||||
</TableCell>
|
||||
<TableCell className="py-4 font-bold text-slate-900 text-sm capitalize">
|
||||
{skill.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-black text-slate-600">{skill.worker_count}</span>
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase">Workers</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="inline-flex items-center px-2 py-0.5 bg-emerald-50 text-emerald-600 rounded text-[10px] font-black uppercase tracking-tight">
|
||||
{skill.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right pr-6">
|
||||
<div className="flex items-center justify-end space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleOpenEdit(skill)}
|
||||
className="p-2 text-slate-400 hover:text-[#0F6E56] hover:bg-teal-50 rounded-lg transition-all cursor-pointer"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(skill.id)}
|
||||
className="p-2 text-slate-400 hover:text-rose-500 hover:bg-rose-50 rounded-lg transition-all cursor-pointer"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-8 text-center text-slate-400 font-bold text-xs">
|
||||
No skills match your search.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="p-6 bg-slate-50/50 border-t border-slate-100">
|
||||
<div className="flex items-start space-x-3 text-slate-400">
|
||||
<Info className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<p className="text-[10px] font-bold uppercase tracking-wide leading-relaxed">
|
||||
Tip: These skills are populated in the mobile application configuration so workers can select their specific expertise when setting up or updating their profiles.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skill Modal */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[400px] rounded-[24px] bg-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-black text-slate-900 tracking-tight">
|
||||
{editingSkill ? 'Edit Skill' : 'New Skill'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="py-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Skill Name</label>
|
||||
<input
|
||||
className="w-full px-4 py-3 bg-slate-50 border border-slate-100 rounded-xl text-sm font-bold focus:ring-4 focus:ring-teal-500/10 outline-none"
|
||||
placeholder="e.g. cooking"
|
||||
value={skillName}
|
||||
onChange={e => setSkillName(e.target.value)}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs text-rose-600 font-bold ml-1">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:justify-end gap-3 pt-4 border-t border-slate-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
className="px-6 py-2.5 rounded-xl text-sm font-bold text-slate-400 uppercase tracking-widest hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-[#0F6E56] text-white px-8 py-2.5 rounded-xl text-sm font-bold uppercase tracking-widest shadow-lg shadow-teal-500/20 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
{editingSkill ? 'Update' : 'Add Skill'}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@ -15,7 +15,6 @@ import {
|
||||
Clock,
|
||||
ChevronRight,
|
||||
UserCircle2,
|
||||
Sparkles,
|
||||
Plus,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
@ -315,71 +314,6 @@ export default function Dashboard({
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
{/* Left Column: Recommended Workers, Shortlist, Chats */}
|
||||
<div className="lg:col-span-8 space-y-8">
|
||||
{/* Recommended Workers Module */}
|
||||
<div className="bg-white p-6 rounded-3xl border border-slate-200 shadow-xs space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Sparkles className="w-5 h-5 text-amber-500 animate-pulse" />
|
||||
<h3 className="font-bold text-base text-slate-900">{t('recommended_for_you', 'Recommended for You')}</h3>
|
||||
</div>
|
||||
<Link href="/employer/workers" className="text-xs font-bold text-[#185FA5] hover:underline flex items-center space-x-0.5">
|
||||
<span>{t('browse_all', 'Browse all')}</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{recommended_workers.length > 0 ? (
|
||||
recommended_workers.map((worker) => (
|
||||
<div key={worker.id} className="bg-slate-50 border border-slate-200/80 rounded-2xl p-4 flex flex-col justify-between space-y-4 hover:border-[#185FA5] hover:bg-white transition-all group relative">
|
||||
{/* Top info */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-100 text-[#185FA5] font-black text-sm flex items-center justify-center border border-blue-200">
|
||||
{worker.name.charAt(0)}
|
||||
</div>
|
||||
{worker.verified && (
|
||||
<span className="px-2 py-0.5 bg-emerald-100 text-emerald-800 text-[8px] font-black uppercase rounded border border-emerald-200">
|
||||
VERIFIED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-sm text-slate-900 group-hover:text-[#185FA5] transition-colors truncate">
|
||||
{worker.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 font-bold truncate">
|
||||
{worker.nationality} • {worker.category}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1 text-xs text-amber-500 font-bold">
|
||||
<Star className="w-3.5 h-3.5 fill-amber-500" />
|
||||
<span>{worker.rating}</span>
|
||||
<span className="text-[10px] text-slate-400 font-medium">({Math.floor(Math.random() * 15) + 3} reviews)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="pt-3 border-t border-slate-200/60 flex items-center justify-between gap-2">
|
||||
<div className="text-[11px] font-black text-slate-700">
|
||||
{worker.salary} AED<span className="text-[9px] font-bold text-slate-400">/mo</span>
|
||||
</div>
|
||||
<Link
|
||||
href={`/employer/workers/${worker.id}`}
|
||||
className="px-3 py-1.5 bg-[#185FA5] hover:bg-[#144f8a] text-white text-[10px] font-black rounded-lg transition-colors shadow-xs"
|
||||
>
|
||||
{t('view_profile', 'View Profile')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-3 text-center py-8 bg-slate-50 rounded-2xl border border-dashed border-slate-200 text-slate-400 text-xs">
|
||||
{t('no_recommended', 'No workers currently recommended. Update profile details.')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* My Shortlist Module */}
|
||||
<div className="bg-white rounded-3xl border border-slate-200 shadow-xs p-6 space-y-6">
|
||||
|
||||
@ -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;
|
||||
|
||||
const newFile = {
|
||||
name: file.name,
|
||||
size: (file.size / 1024).toFixed(1) + ' KB',
|
||||
date: t('today', 'Today')
|
||||
sendFile(file);
|
||||
};
|
||||
|
||||
setUploadedFiles([...uploadedFiles, newFile]);
|
||||
toast.success(t('document_attached', 'Document attached successfully!'));
|
||||
setShowAttachmentModal(false);
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex w-full ${isEmp ? 'justify-end' : 'justify-start'} animate-in fade-in slide-in-from-bottom-4 duration-300`}
|
||||
>
|
||||
<div className={`flex flex-col ${isEmp ? 'items-end' : 'items-start'} max-w-[80%] sm:max-w-[65%]`}>
|
||||
<div className={`px-5 py-3 rounded-2xl text-xs font-semibold leading-relaxed shadow-xs relative ${
|
||||
<div className={`flex flex-col ${isEmp ? 'items-end' : 'items-start'} max-w-[85%] sm:max-w-[70%]`}>
|
||||
|
||||
{/* Main Message Bubble (WhatsApp Style) */}
|
||||
<div className={`rounded-2xl shadow-xs overflow-hidden border relative ${
|
||||
isEmp
|
||||
? 'bg-[#185FA5] text-white rounded-tr-none'
|
||||
: 'bg-white border border-slate-100 text-slate-800 rounded-tl-none'
|
||||
}`}>
|
||||
? '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 && (
|
||||
<div className="relative mb-2 rounded-xl overflow-hidden bg-black/5 aspect-video flex items-center justify-center">
|
||||
<a href={msg.attachment_url} target="_blank" rel="noopener noreferrer" className="w-full h-full block">
|
||||
<img
|
||||
src={msg.attachment_url}
|
||||
alt="Attachment"
|
||||
className="w-full h-full object-cover hover:scale-[1.02] transition-transform duration-200"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voice Message */}
|
||||
{hasVoice && (
|
||||
<div className="mb-2 flex items-center bg-slate-100 p-2 rounded-xl border border-slate-200 min-w-[240px]">
|
||||
<audio src={msg.attachment_url} controls className="w-full h-8 text-[#185FA5]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document Attachment */}
|
||||
{hasDoc && (
|
||||
<div className="mb-2">
|
||||
<a
|
||||
href={msg.attachment_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-2 bg-slate-50 hover:bg-slate-100 text-[#185FA5] px-3.5 py-2.5 rounded-xl border border-slate-200 transition-colors font-bold text-xs shadow-sm"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className="truncate max-w-[150px]">{msg.text || t('download_document', 'Download Document')}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Content (if not just the filename of the image) */}
|
||||
{msg.text && (!hasImage || !isImageFilename) && (
|
||||
<div className={`text-xs font-semibold leading-relaxed ${hasImage ? 'px-2' : ''}`}>
|
||||
{msg.text}
|
||||
</div>
|
||||
<div className="flex items-center space-x-1 mt-1 px-1 text-[9px] font-bold text-slate-400 uppercase">
|
||||
)}
|
||||
|
||||
{/* Time & Read Status Indicator */}
|
||||
<div className={`flex items-center justify-end space-x-1 mt-1.5 text-[8px] font-black uppercase opacity-60 ${hasImage ? 'px-2' : ''}`}>
|
||||
<span>{msg.time}</span>
|
||||
{isEmp && (
|
||||
<span className="flex items-center space-x-0.5 text-emerald-600">
|
||||
<span className="flex items-center space-x-0.5 text-emerald-400">
|
||||
<span>•</span>
|
||||
<span>{t('read', 'Read')}</span>
|
||||
<CheckCircle2 className="w-2.5 h-2.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -347,7 +481,7 @@ export default function Show({ conversation, initialMessages, conversations = []
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAttachmentModal(true)}
|
||||
className="p-3 bg-slate-50 text-slate-400 hover:text-slate-600 rounded-xl transition-all border border-slate-100"
|
||||
className="p-3 bg-slate-50 text-slate-400 hover:text-slate-600 rounded-xl transition-all border border-slate-100 cursor-pointer"
|
||||
title="Attach Document Details"
|
||||
>
|
||||
<Paperclip className="w-5 h-5" />
|
||||
@ -358,10 +492,22 @@ export default function Show({ conversation, initialMessages, conversations = []
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
className={`p-3.5 rounded-xl transition-all border cursor-pointer ${
|
||||
isRecording
|
||||
? 'bg-rose-50 border-rose-200 text-rose-600 animate-pulse'
|
||||
: 'bg-slate-50 border-slate-100 text-slate-400 hover:text-[#185FA5]'
|
||||
}`}
|
||||
title={isRecording ? "Stop Recording" : "Record Voice Message"}
|
||||
>
|
||||
{isRecording ? <Square className="w-5 h-5" /> : <Mic className="w-5 h-5" />}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim()}
|
||||
className="bg-[#185FA5] hover:bg-[#144f8a] disabled:bg-slate-200 text-white p-3.5 rounded-xl transition-all shadow-xs flex items-center justify-center flex-shrink-0"
|
||||
className="bg-[#185FA5] hover:bg-[#144f8a] disabled:bg-slate-200 text-white p-3.5 rounded-xl transition-all shadow-xs flex items-center justify-center flex-shrink-0 cursor-pointer"
|
||||
>
|
||||
<Send className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user