chat - voice,note attachement

This commit is contained in:
mohanmd 2026-06-02 22:57:45 +05:30
parent c851f2e4da
commit 52fb5c0241
11 changed files with 706 additions and 376 deletions

View File

@ -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.
*/

View File

@ -163,6 +163,8 @@ public function getMessages(Request $request, $id)
'time' => $msg->created_at->format('g:i A') . ($msg->created_at->isToday() ? ' Today' : ' ' . $msg->created_at->format('M d')),
'read_at' => $msg->read_at ? $msg->read_at->toISOString() : null,
'created_at' => $msg->created_at->toISOString(),
'attachment_url' => $msg->attachment_path ? asset('storage/' . $msg->attachment_path) : null,
'attachment_type' => $msg->attachment_type,
];
});
@ -204,7 +206,8 @@ public function sendMessage(Request $request, $id)
$worker = $request->attributes->get('worker');
$validator = Validator::make($request->all(), [
'text' => 'required|string|max:1000',
'text' => 'required_without:file|string|max:1000|nullable',
'file' => 'nullable|file|mimes:jpg,jpeg,png,pdf,mp3,wav,m4a,ogg,webm,mp4,aac,3gp,amr|max:10240', // 10MB limit
]);
if ($validator->fails()) {
@ -227,20 +230,41 @@ public function sendMessage(Request $request, $id)
], 404);
}
$attachmentPath = null;
$attachmentType = null;
if ($request->hasFile('file')) {
$file = $request->file('file');
$attachmentPath = $file->store('chat_attachments', 'public');
$mime = $file->getMimeType();
if (str_starts_with($mime, 'image/')) {
$attachmentType = 'image';
} elseif (str_starts_with($mime, 'audio/')) {
$attachmentType = 'voice';
} else {
$attachmentType = 'document';
}
}
$message = null;
DB::transaction(function () use ($conv, $worker, $request, &$message) {
DB::transaction(function () use ($conv, $worker, $request, $attachmentPath, $attachmentType, &$message) {
$message = Message::create([
'conversation_id' => $conv->id,
'sender_type' => 'worker',
'sender_id' => $worker->id,
'text' => $request->text,
'attachment_path' => $attachmentPath,
'attachment_type' => $attachmentType,
]);
// Touch conversation updated_at for sorting
$conv->touch();
// Process the worker response to update status to Hired or Hidden
self::processWorkerResponse($conv, $worker, $request->text);
if ($request->text) {
self::processWorkerResponse($conv, $worker, $request->text);
}
});
return response()->json([
@ -253,6 +277,8 @@ public function sendMessage(Request $request, $id)
'text' => $message->text,
'time' => $message->created_at->format('g:i A') . ' Today',
'created_at' => $message->created_at->toISOString(),
'attachment_url' => $message->attachment_path ? asset('storage/' . $message->attachment_path) : null,
'attachment_type' => $message->attachment_type,
]
]
], 201);

View File

@ -180,6 +180,8 @@ public function show($id)
'sender' => $msg->sender_type, // employer or worker
'text' => $msg->text,
'time' => $msg->created_at->format('g:i A') . ($msg->created_at->isToday() ? ' Today' : ' ' . $msg->created_at->format('M d')),
'attachment_url' => $msg->attachment_path ? asset('storage/' . $msg->attachment_path) : null,
'attachment_type' => $msg->attachment_type,
];
})->toArray();
@ -198,44 +200,66 @@ public function send(Request $request, $id)
}
$request->validate([
'text' => 'required|string|max:1000',
'text' => 'required_without:file|string|max:1000|nullable',
'file' => 'nullable|file|mimes:jpg,jpeg,png,pdf,mp3,wav,m4a,ogg,webm,mp4,aac,3gp,amr|max:10240', // 10MB limit
]);
$conv = Conversation::where('employer_id', $user->id)->where('id', $id)->firstOrFail();
$attachmentPath = null;
$attachmentType = null;
if ($request->hasFile('file')) {
$file = $request->file('file');
$attachmentPath = $file->store('chat_attachments', 'public');
$mime = $file->getMimeType();
if (str_starts_with($mime, 'image/')) {
$attachmentType = 'image';
} elseif (str_starts_with($mime, 'audio/')) {
$attachmentType = 'voice';
} else {
$attachmentType = 'document';
}
}
$message = Message::create([
'conversation_id' => $conv->id,
'sender_type' => 'employer',
'sender_id' => $user->id,
'text' => $request->text,
'attachment_path' => $attachmentPath,
'attachment_type' => $attachmentType,
]);
// Touch conversation updated_at for sorting
$conv->touch();
// Check if employer sent predefined message "are you looking job?"
$textLower = strtolower($request->text);
if (
str_contains($textLower, 'looking job') ||
str_contains($textLower, 'looking for a job') ||
str_contains($textLower, 'looking for job') ||
str_contains($textLower, 'are you looking')
) {
// Automatically simulate worker response 'Yes' (triggers the S6 hired flow)
$workerResponseText = 'Yes';
if ($request->text) {
$textLower = strtolower($request->text);
if (
str_contains($textLower, 'looking job') ||
str_contains($textLower, 'looking for a job') ||
str_contains($textLower, 'looking for job') ||
str_contains($textLower, 'are you looking')
) {
// Automatically simulate worker response 'Yes' (triggers the S6 hired flow)
$workerResponseText = 'Yes';
// Create the message in database
Message::create([
'conversation_id' => $conv->id,
'sender_type' => 'worker',
'sender_id' => $conv->worker_id,
'text' => $workerResponseText,
]);
// Create the message in database
Message::create([
'conversation_id' => $conv->id,
'sender_type' => 'worker',
'sender_id' => $conv->worker_id,
'text' => $workerResponseText,
]);
// Process the worker response to update status to Hired!
$worker = Worker::find($conv->worker_id);
if ($worker) {
self::processWorkerResponse($conv, $worker, $workerResponseText);
// Process the worker response to update status to Hired!
$worker = Worker::find($conv->worker_id);
if ($worker) {
self::processWorkerResponse($conv, $worker, $workerResponseText);
}
}
}

View File

@ -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."
}
}
}

View File

@ -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) => {

View File

@ -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}
</span>
<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">
{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,8 +476,8 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
Next
</Link>
</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">
<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">
@ -564,19 +588,19 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
</div>
<div className="flex items-center space-x-3">
{selectedSponsor?.status !== 'suspended' ? (
{selectedSponsor?.status !== 'suspended' ? (
<button
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">
@ -697,11 +721,11 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
>
Cancel
</button>
<button
<button
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>

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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">

View File

@ -25,7 +25,9 @@ import {
MessageSquare,
Eye,
UploadCloud,
FileText
FileText,
Mic,
Square
} from 'lucide-react';
import { toast } from 'sonner';
@ -38,6 +40,9 @@ export default function Show({ conversation, initialMessages, conversations = []
const [showProfile, setShowProfile] = useState(true);
const [showAttachmentModal, setShowAttachmentModal] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState([]);
const [isRecording, setIsRecording] = useState(false);
const mediaRecorderRef = useRef(null);
const audioChunksRef = useRef([]);
const messagesEndRef = useRef(null);
const scrollToBottom = () => {
@ -63,6 +68,40 @@ export default function Show({ conversation, initialMessages, conversations = []
c.worker_name.toLowerCase().includes(searchTerm.toLowerCase())
);
const sendFile = (file) => {
if (!file) return;
const isImage = file.type.startsWith('image/');
const isAudio = file.type.startsWith('audio/');
const type = isImage ? 'image' : (isAudio ? 'voice' : 'document');
// Optimistically update message feed
const newMsg = {
id: Date.now(),
sender: 'employer',
text: file.name,
time: t('just_now', 'Just Now'),
read: false,
attachment_url: URL.createObjectURL(file),
attachment_type: type
};
setMessages(prev => [...prev, newMsg]);
router.post(`/employer/messages/${conversation.id}/send`, {
text: file.name,
file: file
}, {
preserveScroll: true,
onSuccess: () => {
toast.success(t('file_sent_success', 'File and notification sent successfully!'));
setShowAttachmentModal(false);
},
onError: (err) => {
toast.error(t('file_sent_error', 'Failed to upload file attachment.'));
}
});
};
const sendMsg = (text) => {
if (!text.trim()) return;
@ -127,19 +166,63 @@ export default function Show({ conversation, initialMessages, conversations = []
const handleFileUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
sendFile(file);
};
const newFile = {
name: file.name,
size: (file.size / 1024).toFixed(1) + ' KB',
date: t('today', 'Today')
};
const startRecording = async () => {
try {
// Check browser permission status if query API is supported
if (navigator.permissions && navigator.permissions.query) {
try {
const permissionStatus = await navigator.permissions.query({ name: 'microphone' });
if (permissionStatus.state === 'denied') {
toast.error(t('microphone_blocked', 'Microphone access is blocked. Please enable microphone permissions in your browser settings.'));
return;
}
} catch (pe) {
// Ignore query check failures if name is not supported in some browsers
}
}
setUploadedFiles([...uploadedFiles, newFile]);
toast.success(t('document_attached', 'Document attached successfully!'));
setShowAttachmentModal(false);
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioChunksRef.current = [];
const recorder = new MediaRecorder(stream);
// Optimistically insert attachment message
sendMsg(`[${t('document_attached_msg', 'Document Attached')}: ${file.name}]`);
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'
}`}>
{msg.text}
</div>
<div className="flex items-center space-x-1 mt-1 px-1 text-[9px] font-bold text-slate-400 uppercase">
<span>{msg.time}</span>
{isEmp && (
<span className="flex items-center space-x-0.5 text-emerald-600">
<span></span>
<span>{t('read', 'Read')}</span>
<CheckCircle2 className="w-2.5 h-2.5" />
</span>
? '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>
)}
{/* 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>
);
@ -344,28 +478,40 @@ export default function Show({ conversation, initialMessages, conversations = []
</div>
<form onSubmit={handleFormSubmit} className="flex items-center gap-3">
<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"
title="Attach Document Details"
>
<Paperclip className="w-5 h-5" />
</button>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t('type_message_placeholder', 'Type a compliant secure message...')}
className="flex-1 px-5 py-3.5 bg-slate-50 rounded-xl text-xs font-semibold outline-none border border-transparent focus:border-slate-200 transition-all"
/>
<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"
>
<Send className="w-5 h-5" />
</button>
</form>
<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 cursor-pointer"
title="Attach Document Details"
>
<Paperclip className="w-5 h-5" />
</button>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t('type_message_placeholder', 'Type a compliant secure message...')}
className="flex-1 px-5 py-3.5 bg-slate-50 rounded-xl text-xs font-semibold outline-none border border-transparent focus:border-slate-200 transition-all"
/>
<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 cursor-pointer"
>
<Send className="w-5 h-5" />
</button>
</form>
</div>
</div>

View File

@ -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');