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.
|
* 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')),
|
'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,
|
'read_at' => $msg->read_at ? $msg->read_at->toISOString() : null,
|
||||||
'created_at' => $msg->created_at->toISOString(),
|
'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');
|
$worker = $request->attributes->get('worker');
|
||||||
|
|
||||||
$validator = Validator::make($request->all(), [
|
$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()) {
|
if ($validator->fails()) {
|
||||||
@ -227,20 +230,41 @@ public function sendMessage(Request $request, $id)
|
|||||||
], 404);
|
], 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;
|
$message = null;
|
||||||
DB::transaction(function () use ($conv, $worker, $request, &$message) {
|
DB::transaction(function () use ($conv, $worker, $request, $attachmentPath, $attachmentType, &$message) {
|
||||||
$message = Message::create([
|
$message = Message::create([
|
||||||
'conversation_id' => $conv->id,
|
'conversation_id' => $conv->id,
|
||||||
'sender_type' => 'worker',
|
'sender_type' => 'worker',
|
||||||
'sender_id' => $worker->id,
|
'sender_id' => $worker->id,
|
||||||
'text' => $request->text,
|
'text' => $request->text,
|
||||||
|
'attachment_path' => $attachmentPath,
|
||||||
|
'attachment_type' => $attachmentType,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Touch conversation updated_at for sorting
|
// Touch conversation updated_at for sorting
|
||||||
$conv->touch();
|
$conv->touch();
|
||||||
|
|
||||||
// Process the worker response to update status to Hired or Hidden
|
// 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([
|
return response()->json([
|
||||||
@ -253,6 +277,8 @@ public function sendMessage(Request $request, $id)
|
|||||||
'text' => $message->text,
|
'text' => $message->text,
|
||||||
'time' => $message->created_at->format('g:i A') . ' Today',
|
'time' => $message->created_at->format('g:i A') . ' Today',
|
||||||
'created_at' => $message->created_at->toISOString(),
|
'created_at' => $message->created_at->toISOString(),
|
||||||
|
'attachment_url' => $message->attachment_path ? asset('storage/' . $message->attachment_path) : null,
|
||||||
|
'attachment_type' => $message->attachment_type,
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
], 201);
|
], 201);
|
||||||
|
|||||||
@ -180,6 +180,8 @@ public function show($id)
|
|||||||
'sender' => $msg->sender_type, // employer or worker
|
'sender' => $msg->sender_type, // employer or worker
|
||||||
'text' => $msg->text,
|
'text' => $msg->text,
|
||||||
'time' => $msg->created_at->format('g:i A') . ($msg->created_at->isToday() ? ' Today' : ' ' . $msg->created_at->format('M d')),
|
'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();
|
})->toArray();
|
||||||
|
|
||||||
@ -198,44 +200,66 @@ public function send(Request $request, $id)
|
|||||||
}
|
}
|
||||||
|
|
||||||
$request->validate([
|
$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();
|
$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([
|
$message = Message::create([
|
||||||
'conversation_id' => $conv->id,
|
'conversation_id' => $conv->id,
|
||||||
'sender_type' => 'employer',
|
'sender_type' => 'employer',
|
||||||
'sender_id' => $user->id,
|
'sender_id' => $user->id,
|
||||||
'text' => $request->text,
|
'text' => $request->text,
|
||||||
|
'attachment_path' => $attachmentPath,
|
||||||
|
'attachment_type' => $attachmentType,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Touch conversation updated_at for sorting
|
// Touch conversation updated_at for sorting
|
||||||
$conv->touch();
|
$conv->touch();
|
||||||
|
|
||||||
// Check if employer sent predefined message "are you looking job?"
|
// Check if employer sent predefined message "are you looking job?"
|
||||||
$textLower = strtolower($request->text);
|
if ($request->text) {
|
||||||
if (
|
$textLower = strtolower($request->text);
|
||||||
str_contains($textLower, 'looking job') ||
|
if (
|
||||||
str_contains($textLower, 'looking for a job') ||
|
str_contains($textLower, 'looking job') ||
|
||||||
str_contains($textLower, 'looking for job') ||
|
str_contains($textLower, 'looking for a job') ||
|
||||||
str_contains($textLower, 'are you looking')
|
str_contains($textLower, 'looking for job') ||
|
||||||
) {
|
str_contains($textLower, 'are you looking')
|
||||||
// Automatically simulate worker response 'Yes' (triggers the S6 hired flow)
|
) {
|
||||||
$workerResponseText = 'Yes';
|
// Automatically simulate worker response 'Yes' (triggers the S6 hired flow)
|
||||||
|
$workerResponseText = 'Yes';
|
||||||
// Create the message in database
|
|
||||||
Message::create([
|
// Create the message in database
|
||||||
'conversation_id' => $conv->id,
|
Message::create([
|
||||||
'sender_type' => 'worker',
|
'conversation_id' => $conv->id,
|
||||||
'sender_id' => $conv->worker_id,
|
'sender_type' => 'worker',
|
||||||
'text' => $workerResponseText,
|
'sender_id' => $conv->worker_id,
|
||||||
]);
|
'text' => $workerResponseText,
|
||||||
|
]);
|
||||||
// Process the worker response to update status to Hired!
|
|
||||||
$worker = Worker::find($conv->worker_id);
|
// Process the worker response to update status to Hired!
|
||||||
if ($worker) {
|
$worker = Worker::find($conv->worker_id);
|
||||||
self::processWorkerResponse($conv, $worker, $workerResponseText);
|
if ($worker) {
|
||||||
|
self::processWorkerResponse($conv, $worker, $workerResponseText);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1022,16 +1022,19 @@
|
|||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"multipart/form-data": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
|
||||||
"text"
|
|
||||||
],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"text": {
|
"text": {
|
||||||
"type": "string",
|
"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": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"multipart/form-data": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
|
||||||
"text"
|
|
||||||
],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"text": {
|
"text": {
|
||||||
"type": "string",
|
"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 = [
|
const navItems = [
|
||||||
{ name: 'Dashboard Overview', href: '/admin/dashboard', icon: LayoutDashboard },
|
{ name: 'Dashboard Overview', href: '/admin/dashboard', icon: LayoutDashboard },
|
||||||
{ name: 'Worker Profiles', href: '/admin/workers', icon: Users },
|
{ 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: 'OCR Verification', href: '/admin/workers/verifications', icon: ShieldCheck },
|
||||||
{ name: 'Safety & Moderation', href: '/admin/safety', icon: ShieldAlert },
|
{ name: 'Safety & Moderation', href: '/admin/safety', icon: ShieldAlert },
|
||||||
{ name: 'Disputes System', href: '/admin/disputes', icon: Scale },
|
{ 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: 'Reports & Analytics', href: '/admin/analytics', icon: BarChart3 },
|
||||||
{ name: 'System Audit Logs', href: '/admin/audit-logs', icon: History },
|
{ name: 'System Audit Logs', href: '/admin/audit-logs', icon: History },
|
||||||
{ name: 'Announcements', href: '/admin/announcements', icon: Megaphone },
|
{ 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) => {
|
const getInitials = (name) => {
|
||||||
|
|||||||
@ -134,7 +134,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id) => {
|
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}`);
|
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) => {
|
const viewDetails = (sponsor) => {
|
||||||
setSelectedSponsor(sponsor);
|
setSelectedSponsor(sponsor);
|
||||||
setIsDetailsOpen(true);
|
setIsDetailsOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminLayout title="UAE Sponsor Vetting Dossier">
|
<AdminLayout title="Sponsors">
|
||||||
<Head title="UAE Sponsor Vetting Dossiers" />
|
<Head title="Sponsors" />
|
||||||
|
|
||||||
<div className="space-y-6 font-sans">
|
<div className="space-y-6 font-sans">
|
||||||
{/* Statistics Ribbons */}
|
{/* Statistics Ribbons */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
|
<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 className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm flex items-center justify-between">
|
||||||
<div>
|
<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>
|
<h3 className="text-2xl font-black text-slate-900">{sponsors.total || 0}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-teal-50 rounded-xl">
|
<div className="p-3 bg-teal-50 rounded-xl">
|
||||||
@ -257,6 +268,15 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Dynamic Sponsors Table */}
|
{/* Dynamic Sponsors Table */}
|
||||||
@ -265,7 +285,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-slate-50/50 hover:bg-slate-50/50">
|
<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">
|
<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>
|
||||||
<TableHead onClick={() => handleSort('city')} className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 cursor-pointer select-none">
|
<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' ? '↓' : '↑')}
|
Location {sortField === 'city' && (sortOrder === 'desc' ? '↓' : '↑')}
|
||||||
@ -366,14 +386,14 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Edit2 className="w-4 h-4" />
|
<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>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{!sponsor.is_verified && (
|
{!sponsor.is_verified && (
|
||||||
<DropdownMenuItem className="p-3 rounded-lg focus:bg-emerald-50 cursor-pointer text-emerald-600 font-bold" onClick={() => handleApprove(sponsor.id)}>
|
<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">
|
<div className="flex items-center space-x-3">
|
||||||
<CheckCircle className="w-4 h-4" />
|
<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>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
@ -384,7 +404,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<XCircle className="w-4 h-4" />
|
<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>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : (
|
) : (
|
||||||
@ -394,7 +414,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<CheckCircle className="w-4 h-4" />
|
<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>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
@ -405,7 +425,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Trash2 className="w-4 h-4" />
|
<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>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@ -417,7 +437,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
|||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="py-12 text-center text-slate-400 font-semibold text-sm">
|
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
@ -425,11 +445,15 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
|||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{sponsors.last_page > 1 && (
|
<div className="p-5 border-t border-slate-100 flex items-center justify-between bg-slate-50/50">
|
||||||
<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">
|
||||||
<span className="text-xs text-slate-500 font-bold">
|
{sponsors.total > 0 ? (
|
||||||
Showing page {sponsors.current_page} of {sponsors.last_page}
|
`Showing ${sponsors.from || 0} to ${sponsors.to || 0} of ${sponsors.total} sponsors`
|
||||||
</span>
|
) : (
|
||||||
|
'No sponsors found'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{sponsors.last_page > 1 && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Link
|
<Link
|
||||||
href={sponsors.prev_page_url || '#'}
|
href={sponsors.prev_page_url || '#'}
|
||||||
@ -452,8 +476,8 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
|||||||
Next
|
Next
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -478,7 +502,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
|||||||
<div className="flex items-center justify-between border-b border-slate-100 pb-2">
|
<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">
|
<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]" />
|
<Award className="w-4.5 h-4.5 text-[#0F6E56]" />
|
||||||
<span>UAE Sponsor Vetting Dossier</span>
|
<span>UAE Sponsor Details</span>
|
||||||
</h3>
|
</h3>
|
||||||
<Badge className="bg-teal-50 text-[#0F6E56] border-none font-black text-[9px] uppercase px-3 py-1 rounded-lg">
|
<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}
|
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="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">
|
<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 ? (
|
{!selectedSponsor?.is_verified ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleApprove(selectedSponsor.id)}
|
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"
|
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>
|
</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">
|
<Badge className="bg-emerald-100 text-emerald-800 border-none font-black uppercase text-[9px] px-3 py-1.5 rounded-lg shadow-sm">
|
||||||
@ -564,19 +588,19 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
{selectedSponsor?.status !== 'suspended' ? (
|
{selectedSponsor?.status !== 'suspended' ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSuspend(selectedSponsor.id)}
|
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"
|
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>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleActivate(selectedSponsor.id)}
|
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"
|
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>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@ -594,7 +618,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
|||||||
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||||
<DialogContent className="sm:max-w-xl w-full bg-white p-6 rounded-[24px] font-sans">
|
<DialogContent className="sm:max-w-xl w-full bg-white p-6 rounded-[24px] font-sans">
|
||||||
<DialogHeader>
|
<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>
|
</DialogHeader>
|
||||||
<form onSubmit={handleEditSubmit} className="space-y-4 mt-4">
|
<form onSubmit={handleEditSubmit} className="space-y-4 mt-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@ -697,11 +721,11 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-5 py-2.5 bg-[#0F6E56] text-white rounded-xl text-xs font-black uppercase tracking-wider"
|
className="px-5 py-2.5 bg-[#0F6E56] text-white rounded-xl text-xs font-black uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Save Dossier
|
Save Sponsor
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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,
|
Clock,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
UserCircle2,
|
UserCircle2,
|
||||||
Sparkles,
|
|
||||||
Plus,
|
Plus,
|
||||||
Activity,
|
Activity,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@ -315,71 +314,6 @@ export default function Dashboard({
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
{/* Left Column: Recommended Workers, Shortlist, Chats */}
|
{/* Left Column: Recommended Workers, Shortlist, Chats */}
|
||||||
<div className="lg:col-span-8 space-y-8">
|
<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 */}
|
{/* My Shortlist Module */}
|
||||||
<div className="bg-white rounded-3xl border border-slate-200 shadow-xs p-6 space-y-6">
|
<div className="bg-white rounded-3xl border border-slate-200 shadow-xs p-6 space-y-6">
|
||||||
|
|||||||
@ -25,7 +25,9 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Eye,
|
Eye,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
FileText
|
FileText,
|
||||||
|
Mic,
|
||||||
|
Square
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@ -38,6 +40,9 @@ export default function Show({ conversation, initialMessages, conversations = []
|
|||||||
const [showProfile, setShowProfile] = useState(true);
|
const [showProfile, setShowProfile] = useState(true);
|
||||||
const [showAttachmentModal, setShowAttachmentModal] = useState(false);
|
const [showAttachmentModal, setShowAttachmentModal] = useState(false);
|
||||||
const [uploadedFiles, setUploadedFiles] = useState([]);
|
const [uploadedFiles, setUploadedFiles] = useState([]);
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const mediaRecorderRef = useRef(null);
|
||||||
|
const audioChunksRef = useRef([]);
|
||||||
const messagesEndRef = useRef(null);
|
const messagesEndRef = useRef(null);
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
@ -63,6 +68,40 @@ export default function Show({ conversation, initialMessages, conversations = []
|
|||||||
c.worker_name.toLowerCase().includes(searchTerm.toLowerCase())
|
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) => {
|
const sendMsg = (text) => {
|
||||||
if (!text.trim()) return;
|
if (!text.trim()) return;
|
||||||
|
|
||||||
@ -127,19 +166,63 @@ export default function Show({ conversation, initialMessages, conversations = []
|
|||||||
const handleFileUpload = (e) => {
|
const handleFileUpload = (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
sendFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
const newFile = {
|
const startRecording = async () => {
|
||||||
name: file.name,
|
try {
|
||||||
size: (file.size / 1024).toFixed(1) + ' KB',
|
// Check browser permission status if query API is supported
|
||||||
date: t('today', 'Today')
|
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]);
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
toast.success(t('document_attached', 'Document attached successfully!'));
|
audioChunksRef.current = [];
|
||||||
setShowAttachmentModal(false);
|
const recorder = new MediaRecorder(stream);
|
||||||
|
|
||||||
// Optimistically insert attachment message
|
recorder.ondataavailable = (e) => {
|
||||||
sendMsg(`[${t('document_attached_msg', 'Document Attached')}: ${file.name}]`);
|
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 = [
|
const chips = [
|
||||||
@ -286,29 +369,80 @@ export default function Show({ conversation, initialMessages, conversations = []
|
|||||||
|
|
||||||
{messages.map((msg, idx) => {
|
{messages.map((msg, idx) => {
|
||||||
const isEmp = msg.sender === 'employer';
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
className={`flex w-full ${isEmp ? 'justify-end' : 'justify-start'} animate-in fade-in slide-in-from-bottom-4 duration-300`}
|
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={`flex flex-col ${isEmp ? 'items-end' : 'items-start'} max-w-[85%] sm:max-w-[70%]`}>
|
||||||
<div className={`px-5 py-3 rounded-2xl text-xs font-semibold leading-relaxed shadow-xs relative ${
|
|
||||||
|
{/* Main Message Bubble (WhatsApp Style) */}
|
||||||
|
<div className={`rounded-2xl shadow-xs overflow-hidden border relative ${
|
||||||
isEmp
|
isEmp
|
||||||
? 'bg-[#185FA5] text-white rounded-tr-none'
|
? 'bg-[#185FA5] text-white border-transparent rounded-tr-none'
|
||||||
: 'bg-white border border-slate-100 text-slate-800 rounded-tl-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'}`}>
|
||||||
{msg.text}
|
|
||||||
</div>
|
{/* Image Attachment (WhatsApp Full Width style) */}
|
||||||
<div className="flex items-center space-x-1 mt-1 px-1 text-[9px] font-bold text-slate-400 uppercase">
|
{hasImage && (
|
||||||
<span>{msg.time}</span>
|
<div className="relative mb-2 rounded-xl overflow-hidden bg-black/5 aspect-video flex items-center justify-center">
|
||||||
{isEmp && (
|
<a href={msg.attachment_url} target="_blank" rel="noopener noreferrer" className="w-full h-full block">
|
||||||
<span className="flex items-center space-x-0.5 text-emerald-600">
|
<img
|
||||||
<span>•</span>
|
src={msg.attachment_url}
|
||||||
<span>{t('read', 'Read')}</span>
|
alt="Attachment"
|
||||||
<CheckCircle2 className="w-2.5 h-2.5" />
|
className="w-full h-full object-cover hover:scale-[1.02] transition-transform duration-200"
|
||||||
</span>
|
/>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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-400">
|
||||||
|
<span>•</span>
|
||||||
|
<CheckCircle2 className="w-2.5 h-2.5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -344,28 +478,40 @@ export default function Show({ conversation, initialMessages, conversations = []
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleFormSubmit} className="flex items-center gap-3">
|
<form onSubmit={handleFormSubmit} className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowAttachmentModal(true)}
|
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"
|
title="Attach Document Details"
|
||||||
>
|
>
|
||||||
<Paperclip className="w-5 h-5" />
|
<Paperclip className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
placeholder={t('type_message_placeholder', 'Type a compliant secure message...')}
|
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"
|
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
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
disabled={!input.trim()}
|
onClick={isRecording ? stopRecording : startRecording}
|
||||||
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={`p-3.5 rounded-xl transition-all border cursor-pointer ${
|
||||||
>
|
isRecording
|
||||||
<Send className="w-5 h-5" />
|
? 'bg-rose-50 border-rose-200 text-rose-600 animate-pulse'
|
||||||
</button>
|
: 'bg-slate-50 border-slate-100 text-slate-400 hover:text-[#185FA5]'
|
||||||
</form>
|
}`}
|
||||||
|
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 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Send className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,7 @@
|
|||||||
Route::post('/workers/{worker}/verify', [\App\Http\Controllers\Admin\WorkerVerificationController::class, 'verify'])->name('admin.workers.verify');
|
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', [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}/verify', [SponsorController::class, 'verify'])->name('admin.employers.verify');
|
||||||
Route::post('/employers/{id}/suspend', [SponsorController::class, 'suspend'])->name('admin.employers.suspend');
|
Route::post('/employers/{id}/suspend', [SponsorController::class, 'suspend'])->name('admin.employers.suspend');
|
||||||
Route::post('/employers/{id}/activate', [SponsorController::class, 'activate'])->name('admin.employers.activate');
|
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::post('/payments/{id}/refund', [\App\Http\Controllers\Admin\AdminExtraController::class, 'refundPayment'])->name('admin.payments.refund');
|
||||||
|
|
||||||
Route::get('/master-data/categories', function () {
|
Route::get('/master-data/skills', function () {
|
||||||
return Inertia::render('Admin/MasterData/WorkerCategories');
|
$skills = \App\Models\Skill::all()->map(function ($skill) {
|
||||||
})->name('admin.categories');
|
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 () {
|
Route::get('/announcements', function () {
|
||||||
return Inertia::render('Admin/Announcements/Index');
|
return Inertia::render('Admin/Announcements/Index');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user