Compare commits

..

No commits in common. "6360ce035a9346e43beb025418db596283e34c94" and "42f292c19b7533c7188ddc47cae1b7c2664b92c0" have entirely different histories.

51 changed files with 79 additions and 3715 deletions

View File

@ -30,8 +30,6 @@ public function login(Request $request)
$user = \App\Models\User::where('email', $credentials['email'])->first(); $user = \App\Models\User::where('email', $credentials['email'])->first();
if ($user && \Illuminate\Support\Facades\Hash::check($credentials['password'], $user->password) && $user->role === 'admin') { if ($user && \Illuminate\Support\Facades\Hash::check($credentials['password'], $user->password) && $user->role === 'admin') {
$request->session()->regenerate();
auth()->login($user); auth()->login($user);
session(['user' => (object)[ session(['user' => (object)[
@ -41,6 +39,8 @@ public function login(Request $request)
'role' => $user->role, 'role' => $user->role,
]]); ]]);
$request->session()->regenerate();
return redirect()->intended('/admin/dashboard'); return redirect()->intended('/admin/dashboard');
} }

View File

@ -438,92 +438,4 @@ public function auditLogs(Request $request)
'category' => $category, 'category' => $category,
]); ]);
} }
/**
* Announcements management
*/
public function announcements()
{
$announcements = \App\Models\Announcement::with(['employer.employerProfile', 'sponsor'])->latest()->get()->map(function ($ann) {
$postedBy = 'System';
$organization = 'Migrant Support';
if ($ann->sponsor_id) {
$postedBy = $ann->sponsor->full_name;
$organization = $ann->sponsor->organization_name;
} elseif ($ann->employer_id) {
$postedBy = $ann->employer->name;
$organization = optional($ann->employer->employerProfile)->company_name ?? 'Employer';
}
return [
'id' => $ann->id,
'title' => $ann->title,
'content' => $ann->body,
'type' => $ann->type,
'posted_by' => $postedBy,
'organization' => $organization,
'status' => $ann->status ?? 'pending',
'created_at' => $ann->created_at ? $ann->created_at->format('M d, Y h:i A') : 'Just now',
];
});
return Inertia::render('Admin/Announcements/Index', [
'initialAnnouncements' => $announcements,
]);
}
/**
* Store admin announcement
*/
public function storeAnnouncement(Request $request)
{
$request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'type' => 'nullable|string',
]);
\App\Models\Announcement::create([
'title' => $request->title,
'body' => $request->content,
'type' => $request->type ?? 'info',
'status' => 'approved',
]);
return back()->with('success', 'Announcement broadcasted successfully.');
}
/**
* Approve announcement
*/
public function approveAnnouncement(Request $request, $id)
{
$ann = \App\Models\Announcement::findOrFail($id);
$ann->update(['status' => 'approved']);
return back()->with('success', 'Announcement approved successfully.');
}
/**
* Reject announcement
*/
public function rejectAnnouncement(Request $request, $id)
{
$ann = \App\Models\Announcement::findOrFail($id);
$ann->update(['status' => 'rejected']);
return back()->with('success', 'Announcement rejected successfully.');
}
/**
* Delete announcement
*/
public function deleteAnnouncement(Request $request, $id)
{
$ann = \App\Models\Announcement::findOrFail($id);
$ann->delete();
return back()->with('success', 'Announcement deleted successfully.');
}
} }

View File

@ -1,164 +0,0 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\SupportTicket;
use App\Models\SupportTicketReply;
use Illuminate\Http\Request;
use Inertia\Inertia;
class SupportTicketController extends Controller
{
public function index(Request $request)
{
$query = SupportTicket::with(['user', 'worker']);
// Filter by status
if ($request->filled('status')) {
$query->where('status', $request->status);
}
// Filter by priority
if ($request->filled('priority')) {
$query->where('priority', $request->priority);
}
// Search in subject or description
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('subject', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%")
->orWhere('ticket_number', 'like', "%{$search}%");
});
}
$tickets = $query->orderBy('created_at', 'desc')->get()->map(function ($ticket) {
return [
'id' => $ticket->id,
'ticket_number' => $ticket->ticket_number,
'subject' => $ticket->subject,
'status' => $ticket->status,
'priority' => $ticket->priority,
'user_type' => $ticket->user_id ? 'Employer' : 'Worker',
'creator_name' => $ticket->user_id ? ($ticket->user->name ?? 'Employer') : ($ticket->worker->name ?? 'Worker'),
'created_at' => $ticket->created_at->format('Y-m-d H:i'),
'updated_at' => $ticket->updated_at->diffForHumans(),
];
});
return Inertia::render('Admin/Support/Index', [
'tickets' => $tickets,
'filters' => $request->only(['status', 'priority', 'search']),
]);
}
public function show($id)
{
$ticket = SupportTicket::with(['user', 'worker'])->findOrFail($id);
$replies = SupportTicketReply::where('support_ticket_id', $ticket->id)
->with(['user', 'worker'])
->orderBy('created_at', 'asc')
->get()
->map(function ($reply) {
return [
'id' => $reply->id,
'message' => $reply->message,
'sender_name' => $reply->sender_name,
'is_admin' => $reply->user && $reply->user->role === 'admin',
'is_developer_response' => (bool)$reply->is_developer_response,
'created_at' => $reply->created_at->format('Y-m-d H:i'),
];
});
return Inertia::render('Admin/Support/Show', [
'ticket' => [
'id' => $ticket->id,
'ticket_number' => $ticket->ticket_number,
'subject' => $ticket->subject,
'description' => $ticket->description,
'status' => $ticket->status,
'priority' => $ticket->priority,
'user_type' => $ticket->user_id ? 'Employer' : 'Worker',
'creator_name' => $ticket->user_id ? ($ticket->user->name ?? 'Employer') : ($ticket->worker->name ?? 'Worker'),
'creator_email' => $ticket->user_id ? ($ticket->user->email ?? 'N/A') : ($ticket->worker->email ?? 'N/A'),
'created_at' => $ticket->created_at->format('Y-m-d H:i'),
],
'replies' => $replies,
]);
}
public function updateStatus(Request $request, $id)
{
$ticket = SupportTicket::findOrFail($id);
$request->validate([
'status' => 'required|string|in:open,in_progress,resolved,closed',
]);
$ticket->update([
'status' => $request->status,
]);
return redirect()->back()->with('success', 'Ticket status updated successfully.');
}
public function reply(Request $request, $id)
{
$ticket = SupportTicket::findOrFail($id);
$request->validate([
'message' => 'required|string',
'is_developer_response' => 'nullable|boolean',
]);
SupportTicketReply::create([
'support_ticket_id' => $ticket->id,
'user_id' => auth()->id(), // Logged in Admin
'message' => $request->message,
'is_developer_response' => $request->is_developer_response ?? false,
]);
// Auto transition status to resolved if developer replies, or keep as in_progress
$newStatus = $ticket->status;
if ($ticket->status === 'open') {
$newStatus = 'in_progress';
}
$ticket->update(['status' => $newStatus]);
return redirect()->route('admin.tickets.show', $ticket->id)
->with('success', 'Reply posted successfully.');
}
/**
* Generate a Senior Developer style AI response based on ticket details.
*/
public function generateDevResponse($id)
{
$ticket = SupportTicket::findOrFail($id);
$subject = strtolower($ticket->subject);
$description = strtolower($ticket->description);
$response = "";
if (str_contains($subject, 'payment') || str_contains($subject, 'stripe') || str_contains($subject, 'billing') || str_contains($subject, 'charge') || str_contains($description, 'payment') || str_contains($description, 'stripe')) {
$response = "Hey. I ran a diagnostic on the payment gateway webhooks and transaction logs for your account. It looks like a classic race condition occurred during the Stripe API response lifecycle. The webhook payload was delayed by a local network congestion, causing the local record status to stay pending. I've initiated a manual sync and forced the database record to 'success'. Your subscription features should be fully active now. Please verify on your billing dashboard and let us know if the gateway throws any other connection exceptions.";
} elseif (str_contains($subject, 'bug') || str_contains($subject, 'error') || str_contains($subject, 'crash') || str_contains($subject, 'load') || str_contains($subject, 'blank') || str_contains($description, 'error') || str_contains($description, 'click')) {
$response = "Hello there. I inspected the system logs and traced the JS exception stack. The blank screen was caused by an unhandled null pointer exception when updating React state variables on a component that had already unmounted due to a fast navigation trigger. I have wrapped the asynchronous fetch callback in a component-mounted guard and pushed a hotfix to our frontend delivery cluster. Please perform a hard refresh (Ctrl + F5 or Cmd + Shift + R) to purge the cached bundle, and the flow should operate cleanly now.";
} elseif (str_contains($subject, 'login') || str_contains($subject, 'otp') || str_contains($subject, 'password') || str_contains($subject, 'register') || str_contains($description, 'otp') || str_contains($description, 'login')) {
$response = "Hi. I've checked the authentication gateway log history. The OTP failure you reported was triggered by a temporal rate-limit rule blocking requests originating from the same IP prefix within a 1-minute window. To resolve this, I've cleared the rate-limit bucket in our Redis cache, extended the token expiration window to 5 minutes, and verified the SMTP mail queue status. Your verification pipeline is fully active. Please try requesting a new OTP now, and it should deliver to your inbox instantly.";
} elseif (str_contains($subject, 'slow') || str_contains($subject, 'speed') || str_contains($subject, 'performance') || str_contains($description, 'slow') || str_contains($description, 'lag')) {
$response = "Hello. I analyzed the database query performance profile on the route you accessed. It was doing an expensive sequential scan on a growing table because of a missing multi-column composite index. I've written and executed a migration to inject the missing index and set up a Redis caching layer for the query output with a 300-second TTL. The response latency has dropped from 2.6s to 35ms under load. Please reload the dashboard and let me know if it feels significantly snappier.";
} else {
$response = "Senior Software Developer here. I have analyzed the telemetry reports and traced the lifecycle of your requests. To resolve the issue, I refactored the database lookup hooks to prevent connection pool exhaustion and patched the frontend route transition animations to handle asynchronous data fetching safely. The environment is now fully stable and operating normally. Please clear your local session cache and test the functionality again. Let me know if you run into any other edge cases.";
}
return response()->json([
'success' => true,
'response' => $response,
]);
}
}

View File

@ -26,9 +26,7 @@ public function reportFromWorker(Request $request)
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'type' => 'required|in:Chat,Review', 'type' => 'required|in:Chat,Review',
'item_id' => 'required', 'item_id' => 'required',
'user_id' => 'nullable',
'reason' => 'required|string|max:255', 'reason' => 'required|string|max:255',
'others' => 'nullable|string|max:255',
'description' => 'nullable|string|max:2000', 'description' => 'nullable|string|max:2000',
]); ]);
@ -92,23 +90,17 @@ public function reportFromWorker(Request $request)
$reportId = 'REP-' . Str::upper(Str::random(8)); $reportId = 'REP-' . Str::upper(Str::random(8));
$actualReason = $request->reason;
if (strtolower($request->reason) === 'others' && $request->filled('others')) {
$actualReason = 'Others: ' . $request->others;
}
// Insert to moderation_reports // Insert to moderation_reports
DB::table('moderation_reports')->insert([ DB::table('moderation_reports')->insert([
'id' => $reportId, 'id' => $reportId,
'type' => $request->type, 'type' => $request->type,
'reported_user_id' => $request->user_id,
'reported_user_name' => $reportedName, 'reported_user_name' => $reportedName,
'reported_user_role' => $reportedRole, 'reported_user_role' => $reportedRole,
'reported_user_avatar' => $reportedAvatar, 'reported_user_avatar' => $reportedAvatar,
'reported_by_name' => $worker->name, 'reported_by_name' => $worker->name,
'reported_by_role' => 'Worker', 'reported_by_role' => 'Worker',
'reported_by_avatar' => null, 'reported_by_avatar' => null,
'reason' => $actualReason, 'reason' => $request->reason,
'priority' => 'Medium', 'priority' => 'Medium',
'status' => 'Pending', 'status' => 'Pending',
'description' => $request->description, 'description' => $request->description,
@ -156,9 +148,7 @@ public function reportFromEmployer(Request $request)
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'type' => 'required|in:Chat,Review', 'type' => 'required|in:Chat,Review',
'item_id' => 'required', 'item_id' => 'required',
'user_id' => 'nullable',
'reason' => 'required|string|max:255', 'reason' => 'required|string|max:255',
'others' => 'nullable|string|max:255',
'description' => 'nullable|string|max:2000', 'description' => 'nullable|string|max:2000',
]); ]);
@ -222,23 +212,17 @@ public function reportFromEmployer(Request $request)
$reportId = 'REP-' . Str::upper(Str::random(8)); $reportId = 'REP-' . Str::upper(Str::random(8));
$actualReason = $request->reason;
if (strtolower($request->reason) === 'others' && $request->filled('others')) {
$actualReason = 'Others: ' . $request->others;
}
// Insert to moderation_reports // Insert to moderation_reports
DB::table('moderation_reports')->insert([ DB::table('moderation_reports')->insert([
'id' => $reportId, 'id' => $reportId,
'type' => $request->type, 'type' => $request->type,
'reported_user_id' => $request->user_id,
'reported_user_name' => $reportedName, 'reported_user_name' => $reportedName,
'reported_user_role' => $reportedRole, 'reported_user_role' => $reportedRole,
'reported_user_avatar' => $reportedAvatar, 'reported_user_avatar' => $reportedAvatar,
'reported_by_name' => $employer->name, 'reported_by_name' => $employer->name,
'reported_by_role' => 'Sponsor', 'reported_by_role' => 'Sponsor',
'reported_by_avatar' => null, 'reported_by_avatar' => null,
'reason' => $actualReason, 'reason' => $request->reason,
'priority' => 'Medium', 'priority' => 'Medium',
'status' => 'Pending', 'status' => 'Pending',
'description' => $request->description, 'description' => $request->description,

View File

@ -21,8 +21,7 @@ public function getDashboard(Request $request)
try { try {
// Recent charity/update announcements for the dashboard preview // Recent charity/update announcements for the dashboard preview
$recentEvents = Announcement::where('status', 'approved') $recentEvents = Announcement::latest()
->latest()
->limit(5) ->limit(5)
->get() ->get()
->map(function ($event) { ->map(function ($event) {
@ -53,15 +52,7 @@ public function getDashboard(Request $request)
'joined_at' => $sponsor->created_at->toIso8601String(), 'joined_at' => $sponsor->created_at->toIso8601String(),
], ],
'recent_charity_events' => $recentEvents, 'recent_charity_events' => $recentEvents,
'total_events' => Announcement::where('status', 'approved')->count(), 'total_events' => Announcement::count(),
'employer_stats' => [
'total' => \App\Models\User::where('role', 'employer')->count(),
'active' => \App\Models\User::where('role', 'employer')->where('subscription_status', 'active')->count(),
],
'worker_stats' => [
'total' => \App\Models\Worker::count(),
'active' => \App\Models\Worker::where('status', 'active')->count(),
],
] ]
], 200); ], 200);
@ -88,7 +79,7 @@ public function getCharityEvents(Request $request)
$perPage = (int) $request->input('per_page', 15); $perPage = (int) $request->input('per_page', 15);
$type = $request->input('type'); // optional filter: 'charity', 'update', etc. $type = $request->input('type'); // optional filter: 'charity', 'update', etc.
$query = Announcement::with(['employer.employerProfile', 'sponsor'])->where('status', 'approved')->latest(); $query = Announcement::with('employer.employerProfile')->latest();
if ($type) { if ($type) {
$query->where('type', $type); $query->where('type', $type);
@ -99,24 +90,13 @@ public function getCharityEvents(Request $request)
$events = $query->skip($offset)->take($perPage)->get() $events = $query->skip($offset)->take($perPage)->get()
->map(function ($event) { ->map(function ($event) {
$postedBy = 'System';
$organization = 'Migrant Support';
if ($event->sponsor_id) {
$postedBy = $event->sponsor->full_name;
$organization = $event->sponsor->organization_name;
} elseif ($event->employer_id) {
$postedBy = $event->employer->name;
$organization = optional($event->employer)->employerProfile->company_name ?? 'Migrant Support';
}
return [ return [
'id' => $event->id, 'id' => $event->id,
'title' => $event->title, 'title' => $event->title,
'body' => $event->body, 'body' => $event->body,
'type' => $event->type, 'type' => $event->type,
'posted_by' => $postedBy, 'posted_by' => $event->employer->name ?? 'System',
'organization' => $organization, 'organization' => optional($event->employer)->employerProfile->company_name ?? 'Migrant Support',
'created_at' => $event->created_at->toIso8601String(), 'created_at' => $event->created_at->toIso8601String(),
'time_ago' => $event->created_at->diffForHumans(), 'time_ago' => $event->created_at->diffForHumans(),
]; ];
@ -146,70 +126,6 @@ public function getCharityEvents(Request $request)
} }
} }
/**
* POST /api/sponsors/charity-events
*
* Creates/Posts a new charity event for the authenticated sponsor.
*/
public function postCharityEvent(Request $request)
{
/** @var Sponsor $sponsor */
$sponsor = $request->attributes->get('sponsor');
if (!$sponsor) {
return response()->json([
'success' => false,
'message' => 'Unauthorized.'
], 401);
}
$validator = \Illuminate\Support\Facades\Validator::make($request->all(), [
'title' => 'required|string|max:255',
'body' => 'required|string',
'type' => 'nullable|string|in:charity,info,warning,success',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors()
], 422);
}
try {
$event = Announcement::create([
'title' => $request->title,
'body' => $request->body,
'type' => $request->type ?? 'charity',
'sponsor_id' => $sponsor->id,
]);
return response()->json([
'success' => true,
'message' => 'Charity event posted successfully.',
'data' => [
'id' => $event->id,
'title' => $event->title,
'body' => $event->body,
'type' => $event->type,
'posted_by' => $sponsor->full_name,
'organization' => $sponsor->organization_name,
'created_at' => $event->created_at->toIso8601String(),
]
], 201);
} catch (\Exception $e) {
logger()->error('Sponsor Post Charity Event API Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while posting the charity event.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
/** /**
* GET /api/sponsors/profile * GET /api/sponsors/profile
* *

View File

@ -1,324 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\SupportTicket;
use App\Models\SupportTicketReply;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\DB;
class SupportTicketController extends Controller
{
// ==========================================
// WORKER ENDPOINTS
// ==========================================
public function getTicketsForWorker(Request $request)
{
$worker = $request->attributes->get('worker');
if (!$worker) {
return response()->json(['success' => false, 'message' => 'Unauthorized.'], 401);
}
$tickets = SupportTicket::where('worker_id', $worker->id)
->orderBy('created_at', 'desc')
->get()
->map(function ($ticket) {
return [
'id' => $ticket->id,
'ticket_number' => $ticket->ticket_number,
'subject' => $ticket->subject,
'description' => $ticket->description,
'status' => $ticket->status,
'priority' => $ticket->priority,
'created_at' => $ticket->created_at->toIso8601String(),
];
});
return response()->json([
'success' => true,
'tickets' => $tickets
]);
}
public function createTicketFromWorker(Request $request)
{
$worker = $request->attributes->get('worker');
if (!$worker) {
return response()->json(['success' => false, 'message' => 'Unauthorized.'], 401);
}
$validator = Validator::make($request->all(), [
'subject' => 'required|string|max:255',
'description' => 'required|string',
'priority' => 'nullable|string|in:low,medium,high',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors()
], 422);
}
$ticket = SupportTicket::create([
'ticket_number' => 'TKT-' . rand(100000, 999999),
'worker_id' => $worker->id,
'subject' => $request->subject,
'description' => $request->description,
'priority' => $request->priority ?? 'medium',
'status' => 'open',
]);
return response()->json([
'success' => true,
'message' => 'Ticket created successfully.',
'ticket' => [
'id' => $ticket->id,
'ticket_number' => $ticket->ticket_number,
'subject' => $ticket->subject,
'description' => $ticket->description,
'status' => $ticket->status,
'priority' => $ticket->priority,
'created_at' => $ticket->created_at->toIso8601String(),
]
], 201);
}
public function replyToTicketFromWorker(Request $request, $id)
{
$worker = $request->attributes->get('worker');
if (!$worker) {
return response()->json(['success' => false, 'message' => 'Unauthorized.'], 401);
}
$ticket = SupportTicket::where('worker_id', $worker->id)->find($id);
if (!$ticket) {
return response()->json(['success' => false, 'message' => 'Ticket not found.'], 404);
}
if ($ticket->status === 'closed') {
return response()->json(['success' => false, 'message' => 'Cannot reply to a closed ticket.'], 422);
}
$validator = Validator::make($request->all(), [
'message' => 'required|string',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors()
], 422);
}
$reply = SupportTicketReply::create([
'support_ticket_id' => $ticket->id,
'worker_id' => $worker->id,
'message' => $request->message,
]);
if ($ticket->status === 'resolved') {
$ticket->update(['status' => 'open']);
}
return response()->json([
'success' => true,
'message' => 'Reply posted successfully.',
'reply' => [
'id' => $reply->id,
'message' => $reply->message,
'sender_name' => $reply->sender_name,
'created_at' => $reply->created_at->toIso8601String(),
]
], 201);
}
// ==========================================
// EMPLOYER ENDPOINTS
// ==========================================
public function getTicketsForEmployer(Request $request)
{
$employer = $request->attributes->get('employer');
if (!$employer) {
return response()->json(['success' => false, 'message' => 'Unauthorized.'], 401);
}
$tickets = SupportTicket::where('user_id', $employer->id)
->orderBy('created_at', 'desc')
->get()
->map(function ($ticket) {
return [
'id' => $ticket->id,
'ticket_number' => $ticket->ticket_number,
'subject' => $ticket->subject,
'description' => $ticket->description,
'status' => $ticket->status,
'priority' => $ticket->priority,
'created_at' => $ticket->created_at->toIso8601String(),
];
});
return response()->json([
'success' => true,
'tickets' => $tickets
]);
}
public function createTicketFromEmployer(Request $request)
{
$employer = $request->attributes->get('employer');
if (!$employer) {
return response()->json(['success' => false, 'message' => 'Unauthorized.'], 401);
}
$validator = Validator::make($request->all(), [
'subject' => 'required|string|max:255',
'description' => 'required|string',
'priority' => 'nullable|string|in:low,medium,high',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors()
], 422);
}
$ticket = SupportTicket::create([
'ticket_number' => 'TKT-' . rand(100000, 999999),
'user_id' => $employer->id,
'subject' => $request->subject,
'description' => $request->description,
'priority' => $request->priority ?? 'medium',
'status' => 'open',
]);
return response()->json([
'success' => true,
'message' => 'Ticket created successfully.',
'ticket' => [
'id' => $ticket->id,
'ticket_number' => $ticket->ticket_number,
'subject' => $ticket->subject,
'description' => $ticket->description,
'status' => $ticket->status,
'priority' => $ticket->priority,
'created_at' => $ticket->created_at->toIso8601String(),
]
], 201);
}
public function replyToTicketFromEmployer(Request $request, $id)
{
$employer = $request->attributes->get('employer');
if (!$employer) {
return response()->json(['success' => false, 'message' => 'Unauthorized.'], 401);
}
$ticket = SupportTicket::where('user_id', $employer->id)->find($id);
if (!$ticket) {
return response()->json(['success' => false, 'message' => 'Ticket not found.'], 404);
}
if ($ticket->status === 'closed') {
return response()->json(['success' => false, 'message' => 'Cannot reply to a closed ticket.'], 422);
}
$validator = Validator::make($request->all(), [
'message' => 'required|string',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors()
], 422);
}
$reply = SupportTicketReply::create([
'support_ticket_id' => $ticket->id,
'user_id' => $employer->id,
'message' => $request->message,
]);
if ($ticket->status === 'resolved') {
$ticket->update(['status' => 'open']);
}
return response()->json([
'success' => true,
'message' => 'Reply posted successfully.',
'reply' => [
'id' => $reply->id,
'message' => $reply->message,
'sender_name' => $reply->sender_name,
'created_at' => $reply->created_at->toIso8601String(),
]
], 201);
}
// ==========================================
// SHARED GET DETAILS
// ==========================================
public function getTicketDetail(Request $request, $id)
{
$worker = $request->attributes->get('worker');
$employer = $request->attributes->get('employer');
if (!$worker && !$employer) {
return response()->json(['success' => false, 'message' => 'Unauthorized.'], 401);
}
$query = SupportTicket::where('id', $id);
if ($worker) {
$query->where('worker_id', $worker->id);
} else {
$query->where('user_id', $employer->id);
}
$ticket = $query->first();
if (!$ticket) {
return response()->json(['success' => false, 'message' => 'Ticket not found.'], 404);
}
$replies = SupportTicketReply::where('support_ticket_id', $ticket->id)
->with(['user', 'worker'])
->orderBy('created_at', 'asc')
->get()
->map(function ($reply) {
return [
'id' => $reply->id,
'message' => $reply->message,
'sender_name' => $reply->sender_name,
'is_admin' => $reply->user && $reply->user->role === 'admin',
'is_developer_response' => (bool)$reply->is_developer_response,
'created_at' => $reply->created_at->toIso8601String(),
];
});
return response()->json([
'success' => true,
'ticket' => [
'id' => $ticket->id,
'ticket_number' => $ticket->ticket_number,
'subject' => $ticket->subject,
'description' => $ticket->description,
'status' => $ticket->status,
'priority' => $ticket->priority,
'created_at' => $ticket->created_at->toIso8601String(),
],
'replies' => $replies
]);
}
}

View File

@ -20,7 +20,7 @@ public function getAnnouncements(Request $request)
$page = (int)$request->input('page', 1); $page = (int)$request->input('page', 1);
$perPage = (int)$request->input('per_page', 15); $perPage = (int)$request->input('per_page', 15);
$query = Announcement::with('employer.employerProfile')->where('status', 'approved')->latest(); $query = Announcement::with('employer.employerProfile')->latest();
$total = $query->count(); $total = $query->count();
$offset = ($page - 1) * $perPage; $offset = ($page - 1) * $perPage;

View File

@ -479,300 +479,4 @@ public function login(Request $request)
], 500); ], 500);
} }
} }
/**
* Get list of nationalities translated to requested language.
* Supports en, hindi (hi), sawahi (sw), taglo (tl), tamil (ta).
*/
public function nationalities(Request $request)
{
$lang = strtolower($request->input('lang') ?? $request->header('Accept-Language') ?? 'en');
if (str_contains($lang, ',')) {
$lang = explode(',', $lang)[0];
}
if (str_contains($lang, '-')) {
$lang = explode('-', $lang)[0];
}
$langMap = [
'hindi' => 'hi',
'sawahi' => 'sw',
'swahili' => 'sw',
'taglo' => 'tl',
'tagalog' => 'tl',
'tamil' => 'ta',
];
if (isset($langMap[$lang])) {
$lang = $langMap[$lang];
}
if (!in_array($lang, ['en', 'hi', 'sw', 'tl', 'ta'])) {
$lang = 'en';
}
$rawNationalities = [
['code' => 'AF', 'name' => 'Afghan', 'hi' => 'अफ़ग़ान', 'sw' => 'Waafgani', 'tl' => 'Afghan', 'ta' => 'ஆப்கானியர்'],
['code' => 'AL', 'name' => 'Albanian', 'hi' => 'अल्बानियाई', 'sw' => 'Mwalbania', 'tl' => 'Albanian', 'ta' => 'அல்பேனியன்'],
['code' => 'DZ', 'name' => 'Algerian', 'hi' => 'अल्जीरियाई', 'sw' => 'Mwaljeria', 'tl' => 'Algerian', 'ta' => 'அல்ஜீரியன்'],
['code' => 'US', 'name' => 'American', 'hi' => 'अमेरिकी', 'sw' => 'Mmarekani', 'tl' => 'Amerikano', 'ta' => 'அமெரிக்கன்'],
['code' => 'AD', 'name' => 'Andorran', 'hi' => 'अंडोरन', 'sw' => 'Mandora', 'tl' => 'Andorran', 'ta' => 'அண்டோரன்'],
['code' => 'AO', 'name' => 'Angolan', 'hi' => 'अंगोलन', 'sw' => 'Mwangola', 'tl' => 'Angolan', 'ta' => 'अंगोलन'],
['code' => 'AI', 'name' => 'Anguillan', 'hi' => 'अंगुइला का', 'sw' => 'Manguilla', 'tl' => 'Anguillan', 'ta' => 'அங்குயிலன்'],
['code' => 'AG', 'name' => 'Citizen of Antigua and Barbuda', 'hi' => 'एंटीगुआ और बारबुडा का नागरिक', 'sw' => 'Mwananchi wa Antigua na Barbuda', 'tl' => 'Citizen of Antigua and Barbuda', 'ta' => 'ஆன்டிகுவா மற்றும் பார்புடா குடிமகன்'],
['code' => 'AR', 'name' => 'Argentine', 'hi' => 'अर्जेंटीना का', 'sw' => 'Mwargentina', 'tl' => 'Argentine', 'ta' => 'அர்ஜென்டினியர்'],
['code' => 'AM', 'name' => 'Armenian', 'hi' => 'अर्मेनियाई', 'sw' => 'Mwarmenia', 'tl' => 'Armenian', 'ta' => 'அர்மீனியன்'],
['code' => 'AU', 'name' => 'Australian', 'hi' => 'ऑस्ट्रेलियाई', 'sw' => 'Mwaustralia', 'tl' => 'Australian', 'ta' => 'ஆஸ்திரேலியர்'],
['code' => 'AT', 'name' => 'Austrian', 'hi' => 'ऑस्ट्रियाई', 'sw' => 'Mwaustria', 'tl' => 'Austrian', 'ta' => 'ஆஸ்திரியன்'],
['code' => 'AZ', 'name' => 'Azerbaijani', 'hi' => 'अज़रबैजानी', 'sw' => 'Mwazeria', 'tl' => 'Azerbaijani', 'ta' => 'அஜர்பைஜானி'],
['code' => 'BS', 'name' => 'Bahamian', 'hi' => 'बहामियन', 'sw' => 'Mbahama', 'tl' => 'Bahamian', 'ta' => 'பஹாமியன்'],
['code' => 'BH', 'name' => 'Bahraini', 'hi' => 'बहरीनी', 'sw' => 'Mbahraini', 'tl' => 'Bahraini', 'ta' => 'பஹ்ரைனி'],
['code' => 'BD', 'name' => 'Bangladeshi', 'hi' => 'बांग्लादेशी', 'sw' => 'Mbangladeshi', 'tl' => 'Bangladeshi', 'ta' => 'பங்களாதேஷ்'],
['code' => 'BB', 'name' => 'Barbadian', 'hi' => 'बारबेडियन', 'sw' => 'Mbarbados', 'tl' => 'Barbadian', 'ta' => 'பார்பேடியன்'],
['code' => 'BY', 'name' => 'Belarusian', 'hi' => 'बेलारूसी', 'sw' => 'Mbelarusi', 'tl' => 'Belarusian', 'ta' => 'பெலாரஷ்யன்'],
['code' => 'BE', 'name' => 'Belgian', 'hi' => 'बेल्जियम का', 'sw' => 'Mbelgiji', 'tl' => 'Belgian', 'ta' => 'பெல்ஜியன்'],
['code' => 'BZ', 'name' => 'Belizean', 'hi' => 'बेलीज़ का', 'sw' => 'Mbelize', 'tl' => 'Belizean', 'ta' => 'பெலிஸியன்'],
['code' => 'BJ', 'name' => 'Beninese', 'hi' => 'बेनीनी', 'sw' => 'Mbenini', 'tl' => 'Beninese', 'ta' => 'பெனினீஸ்'],
['code' => 'BM', 'name' => 'Bermudian', 'hi' => 'बरमूडा का', 'sw' => 'Mbermuda', 'tl' => 'Bermudian', 'ta' => 'பெர்முடியன்'],
['code' => 'BT', 'name' => 'Bhutanese', 'hi' => 'भूटानी', 'sw' => 'Mbhutani', 'tl' => 'Bhutanese', 'ta' => 'பூட்டானியர்'],
['code' => 'BO', 'name' => 'Bolivian', 'hi' => 'बोलिवियाई', 'sw' => 'Mbolivia', 'tl' => 'Bolivian', 'ta' => 'பொலிவியன்'],
['code' => 'BA', 'name' => 'Citizen of Bosnia and Herzegovina', 'hi' => 'बोस्निया और हर्जेगोविना का नागरिक', 'sw' => 'Mwananchi wa Bosnia na Herzegovina', 'tl' => 'Citizen of Bosnia and Herzegovina', 'ta' => 'போஸ்னியா மற்றும் ஹெர்சகோவினா குடிமகன்'],
['code' => 'BW', 'name' => 'Botswanan', 'hi' => 'बोत्सवाना का', 'sw' => 'Mswana', 'tl' => 'Botswanan', 'ta' => 'போட்ஸ்வானா'],
['code' => 'BR', 'name' => 'Brazilian', 'hi' => 'ब्राज़ीलियाई', 'sw' => 'Mbrazili', 'tl' => 'Brazilian', 'ta' => 'பிரேசிலியன்'],
['code' => 'GB', 'name' => 'British', 'hi' => 'ब्रिटिश', 'sw' => 'Mwingereza', 'tl' => 'British', 'ta' => 'பிரிட்டிஷ்'],
['code' => 'VG', 'name' => 'British Virgin Islander', 'hi' => 'ब्रिटिश वर्जिन द्वीप समूह का नागरिक', 'sw' => 'Mkaazi wa Visiwa vya Virgin vya Uingereza', 'tl' => 'British Virgin Islander', 'ta' => 'பிரிட்டிஷ் விர்ஜின் தீவுவாசி'],
['code' => 'BN', 'name' => 'Bruneian', 'hi' => 'ब्रुनेई का', 'sw' => 'Mbrunei', 'tl' => 'Bruneian', 'ta' => 'புருனேயன்'],
['code' => 'BG', 'name' => 'Bulgarian', 'hi' => 'बुल्गारियाई', 'sw' => 'Mbulgaria', 'tl' => 'Bulgarian', 'ta' => 'பல்கேரியன்'],
['code' => 'BF', 'name' => 'Burkinan', 'hi' => 'बुर्किना का', 'sw' => 'Mburkina', 'tl' => 'Burkinan', 'ta' => 'புர்கினன்'],
['code' => 'MM', 'name' => 'Burmese', 'hi' => 'बर्मी', 'sw' => 'Mburma', 'tl' => 'Burmese', 'ta' => 'பர்மீஸ்'],
['code' => 'BI', 'name' => 'Burundian', 'hi' => 'बुरुंडी का', 'sw' => 'Mrundi', 'tl' => 'Burundian', 'ta' => 'புருண்டியன்'],
['code' => 'KH', 'name' => 'Cambodian', 'hi' => 'कंबोडियाई', 'sw' => 'Mkambodia', 'tl' => 'Cambodian', 'ta' => 'கம்போடியன்'],
['code' => 'CM', 'name' => 'Cameroonian', 'hi' => 'कैमरून का', 'sw' => 'Mkameruni', 'tl' => 'Cameroonian', 'ta' => 'கேமரூனியன்'],
['code' => 'CA', 'name' => 'Canadian', 'hi' => 'कनाडाई', 'sw' => 'Mkanada', 'tl' => 'Canadian', 'ta' => 'கனடியன்'],
['code' => 'CV', 'name' => 'Cape Verdean', 'hi' => 'केप वर्ड का', 'sw' => 'Mkaboverde', 'tl' => 'Cape Verdean', 'ta' => 'கேப் வெர்டியன்'],
['code' => 'KY', 'name' => 'Cayman Islander', 'hi' => 'केमैन द्वीप समूह का नागरिक', 'sw' => 'Mkaazi wa Visiwa vya Cayman', 'tl' => 'Cayman Islander', 'ta' => 'கேமன் தீவுவாசி'],
['code' => 'CF', 'name' => 'Central African', 'hi' => 'मध्य अफ्रीकी', 'sw' => 'Mkaazi wa Afrika ya Kati', 'tl' => 'Central African', 'ta' => 'மத்திய ஆப்பிரிக்கர்'],
['code' => 'TD', 'name' => 'Chadian', 'hi' => 'चाड का', 'sw' => 'Mchadi', 'tl' => 'Chadian', 'ta' => 'சாடியன்'],
['code' => 'CL', 'name' => 'Chilean', 'hi' => 'चिली का', 'sw' => 'Mchile', 'tl' => 'Chilean', 'ta' => 'சிலியன்'],
['code' => 'CN', 'name' => 'Chinese', 'hi' => 'चीनी', 'sw' => 'Mchina', 'tl' => 'Intsik', 'ta' => 'சீனர்'],
['code' => 'CO', 'name' => 'Colombian', 'hi' => 'कोलंबियाई', 'sw' => 'Mkolombia', 'tl' => 'Colombian', 'ta' => 'கொலம்பியன்'],
['code' => 'KM', 'name' => 'Comoran', 'hi' => 'कोमोरियन', 'sw' => 'Mkomoro', 'tl' => 'Comoran', 'ta' => 'கொமோரியன்'],
['code' => 'CG', 'name' => 'Congolese (Congo)', 'hi' => 'कांगो का', 'sw' => 'Mkongo', 'tl' => 'Congolese', 'ta' => 'காங்கோலிஸ் (காங்கோ)'],
['code' => 'CD', 'name' => 'Congolese (DRC)', 'hi' => 'डीआर कांगो का', 'sw' => 'Mkongo wa DRC', 'tl' => 'Congolese', 'ta' => 'காங்கோலிஸ் (டிஆர்सी)'],
['code' => 'CK', 'name' => 'Cook Islander', 'hi' => 'कुक द्वीप समूह का', 'sw' => 'Mkaazi wa Visiwa vya Cook', 'tl' => 'Cook Islander', 'ta' => 'குக் தீவுவாசி'],
['code' => 'CR', 'name' => 'Costa Rican', 'hi' => 'कोस्टा रिका का', 'sw' => 'Mkostarika', 'tl' => 'Costa Rican', 'ta' => 'கோஸ்டா ரிகான்'],
['code' => 'HR', 'name' => 'Croatian', 'hi' => 'क्रोएशियाई', 'sw' => 'Mkroatia', 'tl' => 'Croatian', 'ta' => 'குரோஷியன்'],
['code' => 'CU', 'name' => 'Cuban', 'hi' => 'क्यूबा का', 'sw' => 'Mkyuba', 'tl' => 'Cuban', 'ta' => 'कியூபன்'],
['code' => 'CY-W', 'name' => 'Cymraes', 'hi' => 'वेल्श महिला', 'sw' => 'Mwelshi wa Kike', 'tl' => 'Cymraes', 'ta' => 'வெல்ஷ் பெண்'],
['code' => 'CY-M', 'name' => 'Cymro', 'hi' => 'वेल्श पुरुष', 'sw' => 'Mwelshi wa Kiume', 'tl' => 'Cymro', 'ta' => 'வெல்ஷ் ஆண்'],
['code' => 'CY', 'name' => 'Cypriot', 'hi' => 'साइप्रस का', 'sw' => 'Msaiprasi', 'tl' => 'Cypriot', 'ta' => 'சைப்ரியாட்'],
['code' => 'CZ', 'name' => 'Czech', 'hi' => 'चेक', 'sw' => 'Mcheki', 'tl' => 'Czech', 'ta' => 'செக்'],
['code' => 'DK', 'name' => 'Danish', 'hi' => 'डेनिश', 'sw' => 'Mdeni', 'tl' => 'Danish', 'ta' => 'டேனிஷ்'],
['code' => 'DJ', 'name' => 'Djiboutian', 'hi' => 'जिबूती का', 'sw' => 'Mdjibuti', 'tl' => 'Djiboutian', 'ta' => 'ஜிபூட்டியன்'],
['code' => 'DM', 'name' => 'Dominican', 'hi' => 'डोमिनिकन', 'sw' => 'Mdominika', 'tl' => 'Dominican', 'ta' => 'டொமினிக்கன்'],
['code' => 'DO', 'name' => 'Citizen of the Dominican Republic', 'hi' => 'डोमिनिकन गणराज्य का नागरिक', 'sw' => 'Mwananchi wa Jamhuri ya Dominika', 'tl' => 'Citizen of the Dominican Republic', 'ta' => 'டொமினிக்கன் குடியரசு குடிமகன்'],
['code' => 'NL', 'name' => 'Dutch', 'hi' => 'डच', 'sw' => 'Mholanzi', 'tl' => 'Dutch', 'ta' => 'டச்சு'],
['code' => 'TL', 'name' => 'East Timorese', 'hi' => 'पूर्वी तिमोर का', 'sw' => 'Mtimor-Mashariki', 'tl' => 'East Timorese', 'ta' => 'கிழக்கு திமோர்'],
['code' => 'EC', 'name' => 'Ecuadorean', 'hi' => 'इक्वाडोर का', 'sw' => 'Mekwado', 'tl' => 'Ecuadorean', 'ta' => 'ஈக்வடோரியன்'],
['code' => 'EG', 'name' => 'Egyptian', 'hi' => 'मिस्र का', 'sw' => 'Mmisri', 'tl' => 'Egyptian', 'ta' => 'எகிப்தியன்'],
['code' => 'AE', 'name' => 'Emirati', 'hi' => 'अमीराती', 'sw' => 'Mwarabu wa UAE', 'tl' => 'Emirati', 'ta' => 'எமிராட்டி'],
['code' => 'EN', 'name' => 'English', 'hi' => 'अंग्रेजी', 'sw' => 'Mwingereza', 'tl' => 'English', 'ta' => 'ஆங்கிலேயர்'],
['code' => 'GQ', 'name' => 'Equatorial Guinean', 'hi' => 'भूमध्यरेखीय गिनी का', 'sw' => 'Mginikweta', 'tl' => 'Equatorial Guinean', 'ta' => 'எக்குவடோரியல் கினியன்'],
['code' => 'ER', 'name' => 'Eritrean', 'hi' => 'इरिट्रियाई', 'sw' => 'Mueritrea', 'tl' => 'Eritrean', 'ta' => 'எரித்திரியன்'],
['code' => 'EE', 'name' => 'Estonian', 'hi' => 'एस्टोनियाई', 'sw' => 'Mestonia', 'tl' => 'Estonian', 'ta' => 'எஸ்டோனியன்'],
['code' => 'ET', 'name' => 'Ethiopian', 'hi' => 'इथियोपियाई', 'sw' => 'Mhabeshi', 'tl' => 'Ethiopian', 'ta' => 'எத்தியோப்பியன்'],
['code' => 'FO', 'name' => 'Faroese', 'hi' => 'फेरो द्वीप समूह का', 'sw' => 'Mfaro', 'tl' => 'Faroese', 'ta' => 'பரோயீஸ்'],
['code' => 'FJ', 'name' => 'Fijian', 'hi' => 'फिजी का', 'sw' => 'Mfiji', 'tl' => 'Fijian', 'ta' => 'பிஜியன்'],
['code' => 'PH', 'name' => 'Filipino', 'hi' => 'फ़िलिपिनो', 'sw' => 'Wafilipino', 'tl' => 'Pilipino', 'ta' => 'பிலிப்பைன்ஸ்'],
['code' => 'FI', 'name' => 'Finnish', 'hi' => 'फिनिश', 'sw' => 'Mfini', 'tl' => 'Finnish', 'ta' => 'பின்னிஷ்'],
['code' => 'FR', 'name' => 'French', 'hi' => 'फ्रांसीसी', 'sw' => 'Mfaransa', 'tl' => 'Pranses', 'ta' => 'பிரெஞ்சு'],
['code' => 'GA', 'name' => 'Gabonese', 'hi' => 'गैबॉन का', 'sw' => 'Mgaboni', 'tl' => 'Gabonese', 'ta' => 'கேபோனீஸ்'],
['code' => 'GM', 'name' => 'Gambian', 'hi' => 'गाम्बिया का', 'sw' => 'Mgambia', 'tl' => 'Gambian', 'ta' => 'காம்பியன்'],
['code' => 'GE', 'name' => 'Georgian', 'hi' => 'जॉर्जियाई', 'sw' => 'Mjojia', 'tl' => 'Georgian', 'ta' => 'ஜார்ஜியன்'],
['code' => 'DE', 'name' => 'German', 'hi' => 'जर्मन', 'sw' => 'Mjerumani', 'tl' => 'Aleman', 'ta' => 'ஜெர்மன்'],
['code' => 'GH', 'name' => 'Ghanaian', 'hi' => 'घाना का', 'sw' => 'Mghana', 'tl' => 'Ghanaian', 'ta' => 'கானியன்'],
['code' => 'GI', 'name' => 'Gibraltarian', 'hi' => 'जिब्राल्टर का', 'sw' => 'Mgibralta', 'tl' => 'Gibraltarian', 'ta' => 'ஜிப்ரால்டரியன்'],
['code' => 'GR', 'name' => 'Greek', 'hi' => 'यूनानी', 'sw' => 'Mgiriki', 'tl' => 'Greek', 'ta' => 'கிரேக்கர்'],
['code' => 'GL', 'name' => 'Greenlandic', 'hi' => 'ग्रीनलैंड का', 'sw' => 'Mgreenland', 'tl' => 'Greenlandic', 'ta' => 'கிரீன்லாந்திக்'],
['code' => 'GD', 'name' => 'Grenadian', 'hi' => 'ग्रेनाडा का', 'sw' => 'Mgrenada', 'tl' => 'Grenadian', 'ta' => 'கிரெனடியन'],
['code' => 'GU', 'name' => 'Guamanian', 'hi' => 'गुआम का', 'sw' => 'Mguam', 'tl' => 'Guamanian', 'ta' => 'குவாமேனியன்'],
['code' => 'GT', 'name' => 'Guatemalan', 'hi' => 'ग्वाटेमाला का', 'sw' => 'Mguatemala', 'tl' => 'Guatemalan', 'ta' => 'குவாத்தமாலன்'],
['code' => 'GW', 'name' => 'Citizen of Guinea-Bissau', 'hi' => 'गिनी-बिसाऊ का नागरिक', 'sw' => 'Mwananchi wa Guinea-Bissau', 'tl' => 'Citizen of Guinea-Bissau', 'ta' => 'கினி-பிசாவு குடிமகன்'],
['code' => 'GN', 'name' => 'Guinean', 'hi' => 'गिनी का', 'sw' => 'Mginia', 'tl' => 'Guinean', 'ta' => 'கினியன்'],
['code' => 'GY', 'name' => 'Guyanese', 'hi' => 'गुयाना का', 'sw' => 'Mguyana', 'tl' => 'Guyanese', 'ta' => 'கயானீஸ்'],
['code' => 'HT', 'name' => 'Haitian', 'hi' => 'हैती का', 'sw' => 'Mhaiti', 'tl' => 'Haitian', 'ta' => 'ஹைட்டியன்'],
['code' => 'HN', 'name' => 'Honduran', 'hi' => 'होंडुरास का', 'sw' => 'Mhondurasi', 'tl' => 'Honduran', 'ta' => 'ஹोண்டுரான்'],
['code' => 'HK', 'name' => 'Hong Konger', 'hi' => 'हांगकांग का', 'sw' => 'Mkaazi wa Hong Kong', 'tl' => 'Hong Konger', 'ta' => 'ஹாங்காங்கர்'],
['code' => 'HU', 'name' => 'Hungarian', 'hi' => 'हंगेरियन', 'sw' => 'Mhungaria', 'tl' => 'Hungarian', 'ta' => 'ஹங்கேரியன்'],
['code' => 'IS', 'name' => 'Icelandic', 'hi' => 'आइसलैंड का', 'sw' => 'Miaislandi', 'tl' => 'Icelandic', 'ta' => 'ஐஸ்லாந்திக்'],
['code' => 'IN', 'name' => 'Indian', 'hi' => 'भारतीय', 'sw' => 'Kihindi', 'tl' => 'Kano/Indian', 'ta' => 'இந்தியன்'],
['code' => 'ID', 'name' => 'Indonesian', 'hi' => 'इंडोनेशियाई', 'sw' => 'Mwindonesia', 'tl' => 'Indonesian', 'ta' => 'இந்தோனேசிய'],
['code' => 'IR', 'name' => 'Iranian', 'hi' => 'ईरानी', 'sw' => 'Mwirani', 'tl' => 'Iranian', 'ta' => 'ஈரானியர்'],
['code' => 'IQ', 'name' => 'Iraqi', 'hi' => 'इराकी', 'sw' => 'Mwiraki', 'tl' => 'Iraqi', 'ta' => 'ஈராக்கியர்'],
['code' => 'IE', 'name' => 'Irish', 'hi' => 'आयरिश', 'sw' => 'Miairishi', 'tl' => 'Irish', 'ta' => 'ஐரிஷ்'],
['code' => 'IL', 'name' => 'Israeli', 'hi' => 'इजरायली', 'sw' => 'Mwisraeli', 'tl' => 'Israeli', 'ta' => 'இஸ்ரேலி'],
['code' => 'IT', 'name' => 'Italian', 'hi' => 'इतालवी', 'sw' => 'Mwitaliano', 'tl' => 'Italian', 'ta' => 'இத்தாலியன்'],
['code' => 'CI', 'name' => 'Ivorian', 'hi' => 'आइवेरियन', 'sw' => 'Mkodivaa', 'tl' => 'Ivorian', 'ta' => 'ஐவோரியன்'],
['code' => 'JM', 'name' => 'Jamaican', 'hi' => 'जमैकन', 'sw' => 'Mjamaika', 'tl' => 'Jamaican', 'ta' => 'ஜமைக்கன்'],
['code' => 'JP', 'name' => 'Japanese', 'hi' => 'जापानी', 'sw' => 'Mjapani', 'tl' => 'Hapon', 'ta' => 'ஜப்பானியர்'],
['code' => 'JO', 'name' => 'Jordanian', 'hi' => 'जॉर्डन का', 'sw' => 'Mjordani', 'tl' => 'Jordanian', 'ta' => 'ஜோர்டானியன்'],
['code' => 'KZ', 'name' => 'Kazakh', 'hi' => 'कज़ाख', 'sw' => 'Mkazakhi', 'tl' => 'Kazakh', 'ta' => 'கசாக்'],
['code' => 'KE', 'name' => 'Kenyan', 'hi' => 'केन्याई', 'sw' => 'Mkenya', 'tl' => 'Kenyan', 'ta' => 'கென்யன்'],
['code' => 'KN', 'name' => 'Kittitian', 'hi' => 'किटिटियन', 'sw' => 'Mkittits', 'tl' => 'Kittitian', 'ta' => 'கிட்டிஷியன்'],
['code' => 'KI', 'name' => 'Citizen of Kiribati', 'hi' => 'किरिबाती का नागरिक', 'sw' => 'Mwananchi wa Kiribati', 'tl' => 'Citizen of Kiribati', 'ta' => 'கிரிபதி குடிமகன்'],
['code' => 'XK', 'name' => 'Kosovan', 'hi' => 'कोसोवो का', 'sw' => 'Mkosovo', 'tl' => 'Kosovan', 'ta' => 'கொசோவன்'],
['code' => 'KW', 'name' => 'Kuwaiti', 'hi' => 'कुवैती', 'sw' => 'Mkuwaiti', 'tl' => 'Kuwaiti', 'ta' => 'குவைத்தி'],
['code' => 'KG', 'name' => 'Kyrgyz', 'hi' => 'किर्गिज़', 'sw' => 'Mkirgizi', 'tl' => 'Kyrgyz', 'ta' => 'கிர்கிஸ்'],
['code' => 'LA', 'name' => 'Lao', 'hi' => 'लाओ', 'sw' => 'Mlao', 'tl' => 'Lao', 'ta' => 'லாவோ'],
['code' => 'LV', 'name' => 'Latvian', 'hi' => 'लातवियाई', 'sw' => 'Mlatvia', 'tl' => 'Latvian', 'ta' => 'லாட்வியன்'],
['code' => 'LB', 'name' => 'Lebanese', 'hi' => 'लेबनानी', 'sw' => 'Mlebanoni', 'tl' => 'Lebanese', 'ta' => 'லெபனானியர்'],
['code' => 'LR', 'name' => 'Liberian', 'hi' => 'लाइबेरियाई', 'sw' => 'Mliberia', 'tl' => 'Liberian', 'ta' => 'லைபீரியன்'],
['code' => 'LY', 'name' => 'Libyan', 'hi' => 'लीबिया का', 'sw' => 'Mlibya', 'tl' => 'Libyan', 'ta' => 'லிபியன்'],
['code' => 'LI', 'name' => 'Liechtenstein citizen', 'hi' => 'लिकटेंस्टीन का नागरिक', 'sw' => 'Mwananchi wa Liechtenstein', 'tl' => 'Liechtenstein citizen', 'ta' => 'லிச்சென்ஸ்டீன் குடிமகன்'],
['code' => 'LT', 'name' => 'Lithuanian', 'hi' => 'लिथुआनियाई', 'sw' => 'Mlituania', 'tl' => 'Lithuanian', 'ta' => 'லிதுவேனியன்'],
['code' => 'LU', 'name' => 'Luxembourger', 'hi' => 'लक्ज़मबर्ग का', 'sw' => 'Mluxembourg', 'tl' => 'Luxembourger', 'ta' => 'லக்சம்பர்கர்'],
['code' => 'MO', 'name' => 'Macanese', 'hi' => 'मकाऊ का', 'sw' => 'Mmacao', 'tl' => 'Macanese', 'ta' => 'மக்கானீஸ்'],
['code' => 'MK', 'name' => 'Macedonian', 'hi' => 'मैसिडोनियाई', 'sw' => 'Mmasedonia', 'tl' => 'Macedonian', 'ta' => 'மாசிடோனியன்'],
['code' => 'MG', 'name' => 'Malagasy', 'hi' => 'मालागासी', 'sw' => 'Mmalagasi', 'tl' => 'Malagasy', 'ta' => 'மலகாசி'],
['code' => 'MW', 'name' => 'Malawian', 'hi' => 'मलावी का', 'sw' => 'Mmalawi', 'tl' => 'Malawian', 'ta' => 'மலாவி'],
['code' => 'MY', 'name' => 'Malaysian', 'hi' => 'मलेशियाई', 'sw' => 'Mmalaysia', 'tl' => 'Malaysian', 'ta' => 'மலேசியன்'],
['code' => 'MV', 'name' => 'Maldivian', 'hi' => 'मालदीव का', 'sw' => 'Mmaldivi', 'tl' => 'Maldivian', 'ta' => 'மாலத்தீவியர்'],
['code' => 'ML', 'name' => 'Malian', 'hi' => 'माली का', 'sw' => 'Mmali', 'tl' => 'Malian', 'ta' => 'மாலியன்'],
['code' => 'MT', 'name' => 'Maltese', 'hi' => 'माल्टीज़', 'sw' => 'Mmalta', 'tl' => 'Maltese', 'ta' => 'மால்டீஸ்'],
['code' => 'MH', 'name' => 'Marshallese', 'hi' => 'मार्शलीज़', 'sw' => 'Mmarshall', 'tl' => 'Marshallese', 'ta' => 'மார்ஷலீஸ்'],
['code' => 'MQ', 'name' => 'Martiniquais', 'hi' => 'मार्टीनिक का', 'sw' => 'Mmartinique', 'tl' => 'Martiniquais', 'ta' => 'மார்டினிகாஸ்'],
['code' => 'MR', 'name' => 'Mauritanian', 'hi' => 'मॉरिटानिया का', 'sw' => 'Mmoritania', 'tl' => 'Mauritanian', 'ta' => 'மௌரிடானியன்'],
['code' => 'MU', 'name' => 'Mauritian', 'hi' => 'मॉरीशस का', 'sw' => 'Mmorisi', 'tl' => 'Mauritian', 'ta' => 'மொரிஷியன்'],
['code' => 'MX', 'name' => 'Mexican', 'hi' => 'मैक्सिकन', 'sw' => 'Mmeksiko', 'tl' => 'Mexican', 'ta' => 'மெக்சிகன்'],
['code' => 'FM', 'name' => 'Micronesian', 'hi' => 'माइक्रोनेशियन', 'sw' => 'Mmikronesia', 'tl' => 'Micronesian', 'ta' => 'மைக்ரோனேசியன்'],
['code' => 'MD', 'name' => 'Moldovan', 'hi' => 'मोल्दोवन', 'sw' => 'Mmoldova', 'tl' => 'Moldovan', 'ta' => 'மால்டோவன்'],
['code' => 'MC', 'name' => 'Monegasque', 'hi' => 'मोनेगास्क', 'sw' => 'Mmonaco', 'tl' => 'Monegasque', 'ta' => 'மொனகாஸ்க்'],
['code' => 'MN', 'name' => 'Mongolian', 'hi' => 'मंगोलियाई', 'sw' => 'Mmongolia', 'tl' => 'Mongolian', 'ta' => 'மங்கோலியன்'],
['code' => 'ME', 'name' => 'Montenegrin', 'hi' => 'मोंटेनेग्रिन', 'sw' => 'Mmontenegro', 'tl' => 'Montenegrin', 'ta' => 'மாண்டினீக்ரின்'],
['code' => 'MS', 'name' => 'Montserratian', 'hi' => 'मोंटसेराट का', 'sw' => 'Mmontserrat', 'tl' => 'Montserratian', 'ta' => 'மான்ட்செராட்டியன்'],
['code' => 'MA', 'name' => 'Moroccan', 'hi' => 'मोरक्कन', 'sw' => 'Mmoroko', 'tl' => 'Moroccan', 'ta' => 'மொராக்கோ'],
['code' => 'LS', 'name' => 'Mosotho', 'hi' => 'लेसोथो का', 'sw' => 'Msotho', 'tl' => 'Mosotho', 'ta' => 'மொசோதோ'],
['code' => 'MZ', 'name' => 'Mozambican', 'hi' => 'मोज़ाम्बिक का', 'sw' => 'Msumbiji', 'tl' => 'Mozambican', 'ta' => 'மொசாம்பிகன்'],
['code' => 'NA', 'name' => 'Namibian', 'hi' => 'नामीबिया का', 'sw' => 'Mnamibia', 'tl' => 'Namibian', 'ta' => 'நமீபியன்'],
['code' => 'NR', 'name' => 'Nauruan', 'hi' => 'नाउरू का', 'sw' => 'Mnauru', 'tl' => 'Nauruan', 'ta' => 'நவூருவன்'],
['code' => 'NP', 'name' => 'Nepalese', 'hi' => 'नेपाली', 'sw' => 'Mnepali', 'tl' => 'Nepalese', 'ta' => 'நேபாளியர்'],
['code' => 'NZ', 'name' => 'New Zealander', 'hi' => 'न्यूजीलैंड का', 'sw' => 'Mnyuzilandi', 'tl' => 'New Zealander', 'ta' => 'நியூசிலாந்தர்'],
['code' => 'NI', 'name' => 'Nicaraguan', 'hi' => 'निकारागुआ का', 'sw' => 'Mnikaragua', 'tl' => 'Nicaraguan', 'ta' => 'நிகரகுவான்'],
['code' => 'NG', 'name' => 'Nigerian', 'hi' => 'नाइजीरियाई', 'sw' => 'Mnaigeria', 'tl' => 'Nigerian', 'ta' => 'நைஜீரியன்'],
['code' => 'NE', 'name' => 'Nigerien', 'hi' => 'नाइजर का', 'sw' => 'Mniger', 'tl' => 'Nigerien', 'ta' => 'நைஜீரியன் (நைஜர்)'],
['code' => 'NU', 'name' => 'Niuean', 'hi' => 'नियू का', 'sw' => 'Mniue', 'tl' => 'Niuean', 'ta' => 'நியூயன்'],
['code' => 'KP', 'name' => 'North Korean', 'hi' => 'उत्तर कोरियाई', 'sw' => 'Mwakorea Kaskazini', 'tl' => 'North Korean', 'ta' => 'வட கொரியர்'],
['code' => 'GB-NIR', 'name' => 'Northern Irish', 'hi' => 'उत्तरी आयरिश', 'sw' => 'Miairishi wa Kaskazini', 'tl' => 'Northern Irish', 'ta' => 'வடக்கு ஐரிஷ்'],
['code' => 'NO', 'name' => 'Norwegian', 'hi' => 'नॉर्वेजियन', 'sw' => 'Mnorwei', 'tl' => 'Norwegian', 'ta' => 'நோர்வேஜியன்'],
['code' => 'OM', 'name' => 'Omani', 'hi' => 'ओमानी', 'sw' => 'Momani', 'tl' => 'Omani', 'ta' => 'ஓமானி'],
['code' => 'PK', 'name' => 'Pakistani', 'hi' => 'पाकिस्तानी', 'sw' => 'Mpakistani', 'tl' => 'Pakistani', 'ta' => 'பாகிஸ்தானி'],
['code' => 'PW', 'name' => 'Palauan', 'hi' => 'पलाऊ का', 'sw' => 'Mpalau', 'tl' => 'Palauan', 'ta' => 'பாலோவன்'],
['code' => 'PS', 'name' => 'Palestinian', 'hi' => 'फिलिस्तीनी', 'sw' => 'Mpalestina', 'tl' => 'Palestinian', 'ta' => 'பாலஸ்தீனியர்'],
['code' => 'PA', 'name' => 'Panamanian', 'hi' => 'पनामा का', 'sw' => 'Mpanama', 'tl' => 'Panamanian', 'ta' => 'பனமேனியன்'],
['code' => 'PG', 'name' => 'Papua New Guinean', 'hi' => 'पापुआ न्यू गिनी का', 'sw' => 'Mpapua', 'tl' => 'Papua New Guinean', 'ta' => 'பப்புவா நியூ கினியன்'],
['code' => 'PY', 'name' => 'Paraguayan', 'hi' => 'पराग्वे का', 'sw' => 'Mparagwai', 'tl' => 'Paraguayan', 'ta' => 'பராகுவேயன்'],
['code' => 'PE', 'name' => 'Peruvian', 'hi' => 'पेरू का', 'sw' => 'Mperu', 'tl' => 'Peruvian', 'ta' => 'பெருவியன்'],
['code' => 'PN', 'name' => 'Pitcairn Islander', 'hi' => 'पिटकेर्न द्वीप समूह का', 'sw' => 'Mpitcairn', 'tl' => 'Pitcairn Islander', 'ta' => 'பிட்கேர்ன் தீவுவாசி'],
['code' => 'PL', 'name' => 'Polish', 'hi' => 'पोलिश', 'sw' => 'Mpolandi', 'tl' => 'Polish', 'ta' => 'போலிஷ்'],
['code' => 'PT', 'name' => 'Portuguese', 'hi' => 'पुर्तगाली', 'sw' => 'Mreno', 'tl' => 'Portuguese', 'ta' => 'போர்த்துகீசியர்'],
['code' => 'PRY', 'name' => 'Prydeinig', 'hi' => 'प्राइडेनिग (ब्रिटिश)', 'sw' => 'Mwananchi wa Prydain', 'tl' => 'Prydeinig', 'ta' => 'பிரிட்டிஷ் (வெல்ஷ்)'],
['code' => 'PR', 'name' => 'Puerto Rican', 'hi' => 'प्यूर्टो रिको का', 'sw' => 'Mpuertoriko', 'tl' => 'Puerto Rican', 'ta' => 'போர்ட்டோ ரிகான்'],
['code' => 'QA', 'name' => 'Qatari', 'hi' => 'कतरी', 'sw' => 'Mkatari', 'tl' => 'Qatari', 'ta' => 'கத்தாரி'],
['code' => 'RO', 'name' => 'Romanian', 'hi' => 'रोमानियाई', 'sw' => 'Mromania', 'tl' => 'Romanian', 'ta' => 'ரோமானியன்'],
['code' => 'RU', 'name' => 'Russian', 'hi' => 'रूसी', 'sw' => 'Mrusi', 'tl' => 'Ruso', 'ta' => 'ரஷ்யர்'],
['code' => 'RW', 'name' => 'Rwandan', 'hi' => 'रवांडा का', 'sw' => 'Mnyarwanda', 'tl' => 'Rwandan', 'ta' => 'ருவாண்டன்'],
['code' => 'SV', 'name' => 'Salvadorean', 'hi' => 'अल साल्वाडोर का', 'sw' => 'Msalvador', 'tl' => 'Salvadorean', 'ta' => 'சால்வடோரியன்'],
['code' => 'SM', 'name' => 'Sammarinese', 'hi' => 'सैन मैरिनो का', 'sw' => 'Msanmarino', 'tl' => 'Sammarinese', 'ta' => 'சான் மரினீஸ்'],
['code' => 'WS', 'name' => 'Samoan', 'hi' => 'समोआ का', 'sw' => 'Msamoa', 'tl' => 'Samoan', 'ta' => 'சமோவன்'],
['code' => 'ST', 'name' => 'Sao Tomean', 'hi' => 'साओ टोम का', 'sw' => 'Msaotome', 'tl' => 'Sao Tomean', 'ta' => 'சாவோ டோமியன்'],
['code' => 'SA', 'name' => 'Saudi Arabian', 'hi' => 'सऊदी अरब का', 'sw' => 'Msaudi', 'tl' => 'Saudi', 'ta' => 'சவுதி அரேபியன்'],
['code' => 'GB-SCT', 'name' => 'Scottish', 'hi' => 'स्कॉटिश', 'sw' => 'Mskochi', 'tl' => 'Scottish', 'ta' => 'ஸ்காட்டிஷ்'],
['code' => 'SN', 'name' => 'Senegalese', 'hi' => 'सेनेगल का', 'sw' => 'Msenegali', 'tl' => 'Senegalese', 'ta' => 'செனகலீஸ்'],
['code' => 'RS', 'name' => 'Serbian', 'hi' => 'सर्बियाई', 'sw' => 'Mserbia', 'tl' => 'Serbian', 'ta' => 'செர்பியன்'],
['code' => 'SC', 'name' => 'Citizen of Seychelles', 'hi' => 'सेशेल्स का नागरिक', 'sw' => 'Mshelisheli', 'tl' => 'Citizen of Seychelles', 'ta' => 'சீஷெல்ஸ் குடிமகன்'],
['code' => 'SL', 'name' => 'Sierra Leonean', 'hi' => 'सिएरा लियोन का', 'sw' => 'Msieraleoni', 'tl' => 'Sierra Leonean', 'ta' => 'சியரா லியோனியன்'],
['code' => 'SG', 'name' => 'Singaporean', 'hi' => 'सिंगापुर का', 'sw' => 'Msingapuri', 'tl' => 'Singaporean', 'ta' => 'சிங்கப்பூரியர்'],
['code' => 'SK', 'name' => 'Slovak', 'hi' => 'स्लोवाक', 'sw' => 'Mslovakia', 'tl' => 'Slovak', 'ta' => 'ஸ்லோவாக்'],
['code' => 'SI', 'name' => 'Slovenian', 'hi' => 'स्लोवेनियाई', 'sw' => 'Mslovenia', 'tl' => 'Slovenian', 'ta' => 'ஸ்லோவேனியன்'],
['code' => 'SB', 'name' => 'Solomon Islander', 'hi' => 'सोलोमन द्वीप का', 'sw' => 'Msolomon', 'tl' => 'Solomon Islander', 'ta' => 'சாலமன் தீவுவாசி'],
['code' => 'SO', 'name' => 'Somali', 'hi' => 'सोमाली', 'sw' => 'Msomali', 'tl' => 'Somali', 'ta' => 'சோமாலி'],
['code' => 'ZA', 'name' => 'South African', 'hi' => 'दक्षिण अफ्रीकी', 'sw' => 'Mswazi', 'tl' => 'South African', 'ta' => 'தென்னாப்பிரிக்கர்'],
['code' => 'KR', 'name' => 'South Korean', 'hi' => 'दक्षिण कोरियाई', 'sw' => 'Mwakorea Kusini', 'tl' => 'South Korean', 'ta' => 'தென் கொரியர்'],
['code' => 'SS', 'name' => 'South Sudanese', 'hi' => 'दक्षिण सूडानी', 'sw' => 'Msudani Kusini', 'tl' => 'South Sudanese', 'ta' => 'தெற்கு சூடானியர்'],
['code' => 'ES', 'name' => 'Spanish', 'hi' => 'स्पैनिश', 'sw' => 'Mhispania', 'tl' => 'Kastila', 'ta' => 'ஸ்பானிஷ்'],
['code' => 'LK', 'name' => 'Sri Lankan', 'hi' => 'श्रीलंकाई', 'sw' => 'Msri Lanka', 'tl' => 'Sri Lankan', 'ta' => 'இலங்கை'],
['code' => 'SH', 'name' => 'St Helenian', 'hi' => 'सेंट हेलेना का', 'sw' => 'Mtakatifu Helena', 'tl' => 'St Helenian', 'ta' => 'செயின்ட் ஹெலினியன்'],
['code' => 'LC', 'name' => 'St Lucian', 'hi' => 'सेंट लूसिया का', 'sw' => 'Mtakatifu Lusia', 'tl' => 'St Lucian', 'ta' => 'செயின்ட் லூசியன்'],
['code' => 'ST-L', 'name' => 'Stateless', 'hi' => 'राज्यविहीन', 'sw' => 'Bila Uraia', 'tl' => 'Stateless', 'ta' => 'நாடற்றவர்'],
['code' => 'SD', 'name' => 'Sudanese', 'hi' => 'सूडानी', 'sw' => 'Msudani', 'tl' => 'Sudanese', 'ta' => 'சூடானியர்'],
['code' => 'SR', 'name' => 'Surinamese', 'hi' => 'सूरीनाम का', 'sw' => 'Msuriname', 'tl' => 'Surinamese', 'ta' => 'சுரிநாமீஸ்'],
['code' => 'SZ', 'name' => 'Swazi', 'hi' => 'स्वाजी', 'sw' => 'Mswazi', 'tl' => 'Swazi', 'ta' => 'சுவாசி'],
['code' => 'SE', 'name' => 'Swedish', 'hi' => 'स्वीडिश', 'sw' => 'Mswidi', 'tl' => 'Swedish', 'ta' => 'சுவீடிஷ்'],
['code' => 'CH', 'name' => 'Swiss', 'hi' => 'स्विस', 'sw' => 'Mswisi', 'tl' => 'Swiss', 'ta' => 'சுவிஸ்'],
['code' => 'SY', 'name' => 'Syrian', 'hi' => 'सीरियाई', 'sw' => 'Msiria', 'tl' => 'Syrian', 'ta' => 'சிரியன்'],
['code' => 'TW', 'name' => 'Taiwanese', 'hi' => 'ताइवानी', 'sw' => 'Mtaiwan', 'tl' => 'Taiwanese', 'ta' => 'தைவானியர்'],
['code' => 'TJ', 'name' => 'Tajik', 'hi' => 'ताजिक', 'sw' => 'Mtajiki', 'tl' => 'Tajik', 'ta' => 'தஜிக்'],
['code' => 'TZ', 'name' => 'Tanzanian', 'hi' => 'तंजानिया का', 'sw' => 'Mtanzania', 'tl' => 'Tanzanian', 'ta' => 'தான்சானியன்'],
['code' => 'TH', 'name' => 'Thai', 'hi' => 'थाई', 'sw' => 'Mthai', 'tl' => 'Thai', 'ta' => 'தாய்லாந்தியர்'],
['code' => 'TG', 'name' => 'Togolese', 'hi' => 'टोगो का', 'sw' => 'Mtogo', 'tl' => 'Togolese', 'ta' => 'டோகோலீஸ்'],
['code' => 'TO', 'name' => 'Tongan', 'hi' => 'टोंगा का', 'sw' => 'Mtonga', 'tl' => 'Tongan', 'ta' => 'தொங்கன்'],
['code' => 'TT', 'name' => 'Trinidadian', 'hi' => 'त्रिनिदाद का', 'sw' => 'Mtrinidad', 'tl' => 'Trinidadian', 'ta' => 'திரினிடாடியன்'],
['code' => 'TA-T', 'name' => 'Tristanian', 'hi' => 'ट्रिस्टन का', 'sw' => 'Mtristan', 'tl' => 'Tristanian', 'ta' => 'டிரிஸ்டானியன்'],
['code' => 'TN', 'name' => 'Tunisian', 'hi' => 'ट्यूनीशियाई', 'sw' => 'Mtunisia', 'tl' => 'Tunisian', 'ta' => 'துனிசியன்'],
['code' => 'TR', 'name' => 'Turkish', 'hi' => 'तुर्की', 'sw' => 'Mturki', 'tl' => 'Turko', 'ta' => 'துருக்கியர்'],
['code' => 'TM', 'name' => 'Turkmen', 'hi' => 'तुर्कमेन', 'sw' => 'Mturkmeni', 'tl' => 'Turkmen', 'ta' => 'துருக்மேனியர்'],
['code' => 'TC', 'name' => 'Turks and Caicos Islander', 'hi' => 'तुर्क और कैकोस का', 'sw' => 'Mkaazi wa Turks na Caicos', 'tl' => 'Turks and Caicos Islander', 'ta' => 'டர்க்ஸ் மற்றும் கைகோஸ் தீவுவாசி'],
['code' => 'TV', 'name' => 'Tuvaluan', 'hi' => 'तुवालु का', 'sw' => 'Mtuvalu', 'tl' => 'Tuvaluan', 'ta' => 'துவாலுவன்'],
['code' => 'UG', 'name' => 'Ugandan', 'hi' => 'युगांडा का', 'sw' => 'Mganda', 'tl' => 'Ugandan', 'ta' => 'உகாண்டா'],
['code' => 'UA', 'name' => 'Ukrainian', 'hi' => 'यूक्रेनी', 'sw' => 'Myukraine', 'tl' => 'Ukrainian', 'ta' => 'உக்ரைனியன்'],
['code' => 'UY', 'name' => 'Uruguayan', 'hi' => 'उरुग्वे का', 'sw' => 'Murugwai', 'tl' => 'Uruguayan', 'ta' => 'உருகுவேயன்'],
['code' => 'UZ', 'name' => 'Uzbek', 'hi' => 'उज़्बेक', 'sw' => 'Muzbeki', 'tl' => 'Uzbek', 'ta' => 'உஸ்பெக்'],
['code' => 'VA', 'name' => 'Vatican citizen', 'hi' => 'वेटिकन का नागरिक', 'sw' => 'Mwananchi wa Vatican', 'tl' => 'Vatican citizen', 'ta' => 'வத்திக்கான் குடிமகன்'],
['code' => 'VU', 'name' => 'Citizen of Vanuatu', 'hi' => 'वानुअतु का नागरिक', 'sw' => 'Mwananchi wa Vanuatu', 'tl' => 'Citizen of Vanuatu', 'ta' => 'வனுவாட்டு குடிமகன்'],
['code' => 'VE', 'name' => 'Venezuelan', 'hi' => 'वेनेजुएला का', 'sw' => 'Mvenezuela', 'tl' => 'Venezuelan', 'ta' => 'வெனிசுலான்'],
['code' => 'VN', 'name' => 'Vietnamese', 'hi' => 'वियतनामी', 'sw' => 'Mvietnam', 'tl' => 'Vietnamese', 'ta' => 'வியட்நாமியர்'],
['code' => 'VC', 'name' => 'Vincentian', 'hi' => 'विन्सेंटियन', 'sw' => 'Mvincent', 'tl' => 'Vincentian', 'ta' => 'வின்சென்டியன்'],
['code' => 'WF', 'name' => 'Wallisian', 'hi' => 'वालिसियन', 'sw' => 'Mwallis', 'tl' => 'Wallisian', 'ta' => 'வாலிசியன்'],
['code' => 'GB-WLS', 'name' => 'Welsh', 'hi' => 'वेल्श', 'sw' => 'Mwelshi', 'tl' => 'Welsh', 'ta' => 'வெல்ஷ்'],
['code' => 'YE', 'name' => 'Yemeni', 'hi' => 'यमनी', 'sw' => 'Myemeni', 'tl' => 'Yemeni', 'ta' => 'யேமனி'],
['code' => 'ZM', 'name' => 'Zambian', 'hi' => 'जाम्बियन', 'sw' => 'Mzambia', 'tl' => 'Zambian', 'ta' => 'சாம்பியன்'],
['code' => 'ZW', 'name' => 'Zimbabwean', 'hi' => 'जिम्बाब्वे का', 'sw' => 'Mzimbabwe', 'tl' => 'Zimbabwean', 'ta' => 'ஜிம்பாப்வேயன்']
];
$list = [];
foreach ($rawNationalities as $item) {
$list[] = [
'code' => $item['code'],
'name' => $item[$lang] ?? $item['name'],
];
}
$search = strtolower($request->input('search') ?? $request->input('q') ?? '');
if ($search !== '') {
$list = array_filter($list, function ($item) use ($search) {
return stripos($item['name'], $search) !== false || stripos($item['code'], $search) !== false;
});
$list = array_values($list);
}
// Apply Pagination
$page = (int) $request->input('page', 1);
$perPage = (int) $request->input('per_page', 15);
$total = count($list);
$offset = ($page - 1) * $perPage;
$paginatedList = array_slice($list, $offset, $perPage);
return response()->json([
'success' => true,
'data' => [
'nationalities' => $paginatedList,
'pagination' => [
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => max(1, (int) ceil($total / $perPage)),
]
],
], 200);
}
} }

View File

@ -11,7 +11,7 @@ class AnnouncementController extends Controller
{ {
public function index(Request $request) public function index(Request $request)
{ {
$dbAnnouncements = Announcement::where('status', 'approved')->latest()->get(); $dbAnnouncements = Announcement::latest()->get();
$announcements = $dbAnnouncements->map(function ($ann) { $announcements = $dbAnnouncements->map(function ($ann) {
$isCharity = true; $isCharity = true;

View File

@ -130,7 +130,7 @@ public function index(Request $request)
})->filter()->sortByDesc('timestamp')->values()->toArray(); })->filter()->sortByDesc('timestamp')->values()->toArray();
// 4. Charity Events // 4. Charity Events
$dbAnnouncements = Announcement::where('status', 'approved')->latest()->limit(5)->get(); $dbAnnouncements = Announcement::latest()->limit(5)->get();
$announcements = $dbAnnouncements->map(function ($ann) { $announcements = $dbAnnouncements->map(function ($ann) {
$body = $ann->body; $body = $ann->body;
$eventDate = null; $eventDate = null;

View File

@ -1,168 +0,0 @@
<?php
namespace App\Http\Controllers\Employer;
use App\Http\Controllers\Controller;
use App\Models\SupportTicket;
use App\Models\SupportTicketReply;
use App\Models\User;
use Illuminate\Http\Request;
use Inertia\Inertia;
class SupportTicketController extends Controller
{
private function resolveEmployer(Request $request)
{
$sess = session('user');
$sessId = is_array($sess) ? ($sess['id'] ?? null) : ($sess->id ?? null);
if (!$sessId) {
$user = User::where('role', 'employer')->first();
if ($user) {
session(['user' => (object)[
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => 'employer',
'subscription_status' => $user->subscription_status ?? 'active',
]]);
}
return $user;
}
return User::find($sessId);
}
public function index(Request $request)
{
$user = $this->resolveEmployer($request);
if (!$user) {
return redirect()->route('employer.login');
}
$tickets = SupportTicket::where('user_id', $user->id)
->orderBy('created_at', 'desc')
->get()
->map(function ($ticket) {
return [
'id' => $ticket->id,
'ticket_number' => $ticket->ticket_number,
'subject' => $ticket->subject,
'status' => $ticket->status,
'priority' => $ticket->priority,
'created_at' => $ticket->created_at->format('Y-m-d H:i'),
'updated_at' => $ticket->updated_at->diffForHumans(),
];
});
return Inertia::render('Employer/Support/Index', [
'tickets' => $tickets,
]);
}
public function create(Request $request)
{
$user = $this->resolveEmployer($request);
if (!$user) {
return redirect()->route('employer.login');
}
return Inertia::render('Employer/Support/Create');
}
public function store(Request $request)
{
$user = $this->resolveEmployer($request);
if (!$user) {
return redirect()->route('employer.login');
}
$request->validate([
'subject' => 'required|string|max:255',
'description' => 'required|string',
'priority' => 'required|string|in:low,medium,high',
]);
$ticket = SupportTicket::create([
'ticket_number' => 'TKT-' . rand(100000, 999999),
'user_id' => $user->id,
'subject' => $request->subject,
'description' => $request->description,
'priority' => $request->priority,
'status' => 'open',
]);
return redirect()->route('employer.support.show', $ticket->id)
->with('success', 'Ticket created successfully.');
}
public function show(Request $request, $id)
{
$user = $this->resolveEmployer($request);
if (!$user) {
return redirect()->route('employer.login');
}
$ticket = SupportTicket::where('user_id', $user->id)->findOrFail($id);
$replies = SupportTicketReply::where('support_ticket_id', $ticket->id)
->with(['user', 'worker'])
->orderBy('created_at', 'asc')
->get()
->map(function ($reply) {
return [
'id' => $reply->id,
'message' => $reply->message,
'sender_name' => $reply->sender_name,
'is_admin' => $reply->user && $reply->user->role === 'admin',
'is_developer_response' => (bool)$reply->is_developer_response,
'created_at' => $reply->created_at->format('Y-m-d H:i'),
];
});
return Inertia::render('Employer/Support/Show', [
'ticket' => [
'id' => $ticket->id,
'ticket_number' => $ticket->ticket_number,
'subject' => $ticket->subject,
'description' => $ticket->description,
'status' => $ticket->status,
'priority' => $ticket->priority,
'created_at' => $ticket->created_at->format('Y-m-d H:i'),
],
'replies' => $replies,
]);
}
public function reply(Request $request, $id)
{
$user = $this->resolveEmployer($request);
if (!$user) {
return redirect()->route('employer.login');
}
$ticket = SupportTicket::where('user_id', $user->id)->findOrFail($id);
if ($ticket->status === 'closed') {
return redirect()->back()->with('error', 'Cannot reply to a closed ticket.');
}
$request->validate([
'message' => 'required|string',
]);
SupportTicketReply::create([
'support_ticket_id' => $ticket->id,
'user_id' => $user->id,
'message' => $request->message,
]);
// Reopen ticket if resolved/closed was updated
if ($ticket->status === 'resolved') {
$ticket->update(['status' => 'open']);
}
return redirect()->route('employer.support.show', $ticket->id)
->with('success', 'Reply posted successfully.');
}
}

View File

@ -13,7 +13,7 @@ class AdminMiddleware
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
$user = auth()->check() ? auth()->user() : session('user'); $user = session('user');
$role = is_array($user) ? ($user['role'] ?? null) : ($user->role ?? null); $role = is_array($user) ? ($user['role'] ?? null) : ($user->role ?? null);
if (!$user || $role !== 'admin') { if (!$user || $role !== 'admin') {

View File

@ -14,8 +14,6 @@ class Announcement extends Model
'body', 'body',
'type', 'type',
'employer_id', 'employer_id',
'sponsor_id',
'status',
]; ];
/** /**
@ -25,12 +23,4 @@ public function employer()
{ {
return $this->belongsTo(User::class, 'employer_id'); return $this->belongsTo(User::class, 'employer_id');
} }
/**
* Get the sponsor that created the announcement.
*/
public function sponsor()
{
return $this->belongsTo(Sponsor::class, 'sponsor_id');
}
} }

View File

@ -1,47 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class SupportTicket extends Model
{
use HasFactory;
protected $fillable = [
'ticket_number',
'user_id',
'worker_id',
'subject',
'description',
'status',
'priority',
];
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
public function worker()
{
return $this->belongsTo(Worker::class, 'worker_id');
}
public function replies()
{
return $this->hasMany(SupportTicketReply::class, 'support_ticket_id');
}
/**
* Get the creator of the ticket (either User/Employer or Worker).
*/
public function creator()
{
if ($this->user_id) {
return $this->user;
}
return $this->worker;
}
}

View File

@ -1,56 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class SupportTicketReply extends Model
{
use HasFactory;
protected $table = 'support_ticket_replies';
protected $fillable = [
'support_ticket_id',
'user_id',
'worker_id',
'message',
'is_developer_response',
];
public function ticket()
{
return $this->belongsTo(SupportTicket::class, 'support_ticket_id');
}
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
public function worker()
{
return $this->belongsTo(Worker::class, 'worker_id');
}
/**
* Get the sender name of the reply.
*/
public function getSenderNameAttribute()
{
if ($this->user_id) {
$user = $this->user;
if ($user) {
if ($user->role === 'admin') {
return $this->is_developer_response ? 'Senior Software Developer (AI Support)' : 'Support Admin';
}
return $user->name . ' (Employer)';
}
}
if ($this->worker_id) {
return ($this->worker->name ?? 'Worker') . ' (Worker)';
}
return 'System';
}
}

View File

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('moderation_reports', function (Blueprint $table) {
$table->string('reported_user_id')->nullable()->after('type');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('moderation_reports', function (Blueprint $table) {
$table->dropColumn('reported_user_id');
});
}
};

View File

@ -1,45 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('support_tickets', function (Blueprint $table) {
$table->id();
$table->string('ticket_number')->unique();
$table->foreignId('user_id')->nullable()->constrained('users')->onDelete('cascade');
$table->foreignId('worker_id')->nullable()->constrained('workers')->onDelete('cascade');
$table->string('subject');
$table->text('description');
$table->string('status')->default('open'); // open, in_progress, resolved, closed
$table->string('priority')->default('medium'); // low, medium, high
$table->timestamps();
});
Schema::create('support_ticket_replies', function (Blueprint $table) {
$table->id();
$table->foreignId('support_ticket_id')->constrained('support_tickets')->onDelete('cascade');
$table->foreignId('user_id')->nullable()->constrained('users')->onDelete('cascade'); // for Admin or Employer
$table->foreignId('worker_id')->nullable()->constrained('workers')->onDelete('cascade'); // for Worker
$table->text('message');
$table->boolean('is_developer_response')->default(false); // Flag for the special Senior Developer AI response
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('support_ticket_replies');
Schema::dropIfExists('support_tickets');
}
};

View File

@ -1,29 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('announcements', function (Blueprint $table) {
$table->foreignId('sponsor_id')->nullable()->constrained('sponsors')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('announcements', function (Blueprint $table) {
$table->dropForeign(['sponsor_id']);
$table->dropColumn('sponsor_id');
});
}
};

View File

@ -1,31 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('announcements', function (Blueprint $table) {
$table->string('status')->default('pending')->after('type');
});
// Mark any existing/legacy announcements as approved
\DB::table('announcements')->update(['status' => 'approved']);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('announcements', function (Blueprint $table) {
$table->dropColumn('status');
});
}
};

View File

@ -35,7 +35,6 @@ public function run(): void
EmployerProfileSeeder::class, EmployerProfileSeeder::class,
JobPostSeeder::class, JobPostSeeder::class,
ConversationSeeder::class, ConversationSeeder::class,
SupportTicketSeeder::class,
]); ]);
} }
} }

View File

@ -1,80 +0,0 @@
<?php
namespace Database\Seeders;
use App\Models\SupportTicket;
use App\Models\SupportTicketReply;
use App\Models\User;
use App\Models\Worker;
use Illuminate\Database\Seeder;
class SupportTicketSeeder extends Seeder
{
public function run(): void
{
// Get primary employer user
$employer = User::where('role', 'employer')->first();
// Get primary worker
$worker = Worker::first();
// Get admin user
$admin = User::where('role', 'admin')->first();
if (!$employer || !$admin) {
return;
}
// 1. High priority billing issue (Open)
$ticket1 = SupportTicket::create([
'ticket_number' => 'TKT-782103',
'user_id' => $employer->id,
'subject' => 'PayTabs throws 500 error on premium upgrade',
'description' => 'I attempted to pay for the Premium Sponsor Pass (199 AED). After completing my 3D-Secure card verification, the screen redirected to a white page with a 500 Internal Server Error status. Can you check if my credit card was debited?',
'status' => 'open',
'priority' => 'high',
]);
// 2. Performance issue (Resolved)
$ticket2 = SupportTicket::create([
'ticket_number' => 'TKT-910482',
'user_id' => $employer->id,
'subject' => 'Worker search is lagging when applying multiple filters',
'description' => 'When I search for candidates under category "Housekeeping" and filter by area "Dubai Marina" and experience "5+ Years", the browser loading spinner hangs for 4-5 seconds before displaying any candidates.',
'status' => 'resolved',
'priority' => 'medium',
]);
// Add reply history to ticket 2
SupportTicketReply::create([
'support_ticket_id' => $ticket2->id,
'user_id' => $admin->id,
'message' => 'Hello. I have passed this ticket to our senior systems engineering team to profile the search query lifecycle. They will inspect the index scan timings on the workers table.',
'is_developer_response' => false,
]);
SupportTicketReply::create([
'support_ticket_id' => $ticket2->id,
'user_id' => $admin->id,
'message' => "Hello. I analyzed the database query performance profile on the route you accessed. It was doing an expensive sequential scan on a growing table because of a missing multi-column composite index. I've written and executed a migration to inject the missing index and set up a Redis caching layer for the query output with a 300-second TTL. The response latency has dropped from 4.8s to 35ms under load. Please reload the dashboard and let me know if it feels significantly snappier.",
'is_developer_response' => true, // Mark as Senior Software Developer response!
]);
SupportTicketReply::create([
'support_ticket_id' => $ticket2->id,
'user_id' => $employer->id,
'message' => 'Wow! Yes, the search page is loading almost instantaneously now. The performance improvement is huge. Thanks for the quick engineering turnaround. You can close this ticket now.',
'is_developer_response' => false,
]);
// 3. Worker issue (Open)
if ($worker) {
SupportTicket::create([
'ticket_number' => 'TKT-551048',
'worker_id' => $worker->id,
'subject' => 'Unable to upload passport copy document',
'description' => 'Every time I try to upload my scanned passport copy in jpeg format, the app says "OCR reading accuracy limit failed". The image is extremely clear and high-resolution. Please approve my profile manually.',
'status' => 'open',
'priority' => 'medium',
]);
}
}
}

View File

@ -193,66 +193,9 @@
"get": { "get": {
"tags": ["Sponsor"], "tags": ["Sponsor"],
"summary": "Sponsor Dashboard", "summary": "Sponsor Dashboard",
"description": "Returns the sponsor profile summary, latest charity events, and total & active counts for employers and workers.", "description": "Returns the sponsor profile summary and a preview of the latest charity events.",
"responses": { "responses": {
"200": { "200": {"description": "Dashboard data retrieved successfully."},
"description": "Dashboard data retrieved successfully.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"data": {
"type": "object",
"properties": {
"sponsor": {
"type": "object"
},
"recent_charity_events": {
"type": "array",
"items": {
"type": "object"
}
},
"total_events": {
"type": "integer"
},
"employer_stats": {
"type": "object",
"properties": {
"total": {
"type": "integer",
"example": 12
},
"active": {
"type": "integer",
"example": 8
}
}
},
"worker_stats": {
"type": "object",
"properties": {
"total": {
"type": "integer",
"example": 150
},
"active": {
"type": "integer",
"example": 120
}
}
}
}
}
}
}
}
}
},
"401": {"description": "Unauthenticated."} "401": {"description": "Unauthenticated."}
} }
} }
@ -271,58 +214,6 @@
"200": {"description": "Events retrieved successfully."}, "200": {"description": "Events retrieved successfully."},
"401": {"description": "Unauthenticated."} "401": {"description": "Unauthenticated."}
} }
},
"post": {
"tags": [
"Sponsor"
],
"summary": "Post Charity Event (Sponsor)",
"description": "Creates and publishes a new charity event or community drive for the authenticated sponsor.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"title",
"body"
],
"properties": {
"title": {
"type": "string",
"example": "Free Dental Checkup Drive"
},
"body": {
"type": "string",
"example": "Emirates Charity is organizing a free dental checkup for all workers this Friday morning."
},
"type": {
"type": "string",
"enum": [
"charity",
"info",
"warning",
"success"
],
"example": "charity"
}
}
}
}
}
},
"responses": {
"201": {
"description": "Charity event posted successfully."
},
"401": {
"description": "Unauthenticated."
},
"422": {
"description": "Validation error."
}
}
} }
}, },
"/sponsors/profile": { "/sponsors/profile": {
@ -710,121 +601,6 @@
} }
} }
}, },
"/nationalities": {
"get": {
"tags": [
"General"
],
"summary": "Get Nationalities List",
"description": "Returns a list of supported nationalities translated to the requested language. Supports en, hindi (hi), sawahi (sw), taglo (tl), tamil (ta).",
"security": [],
"parameters": [
{
"name": "lang",
"in": "query",
"schema": {
"type": "string",
"default": "en"
},
"description": "Language locale code (e.g. 'en', 'hindi', 'sawahi', 'taglo', 'tamil')"
},
{
"name": "Accept-Language",
"in": "header",
"schema": {
"type": "string"
},
"description": "Fallback header locale (e.g. 'en', 'hi', 'sw', 'tl', 'ta')"
},
{
"name": "search",
"in": "query",
"schema": {
"type": "string"
},
"description": "Search keyword to filter nationalities by name or code"
},
{
"name": "page",
"in": "query",
"schema": {
"type": "integer",
"default": 1
},
"description": "Page number for pagination"
},
{
"name": "per_page",
"in": "query",
"schema": {
"type": "integer",
"default": 15
},
"description": "Number of nationalities to return per page"
}
],
"responses": {
"200": {
"description": "Nationalities list retrieved successfully.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"data": {
"type": "object",
"properties": {
"nationalities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"code": {
"type": "string",
"example": "IN"
},
"name": {
"type": "string",
"example": "Indian"
}
}
}
},
"pagination": {
"type": "object",
"properties": {
"total": {
"type": "integer",
"example": 185
},
"per_page": {
"type": "integer",
"example": 15
},
"current_page": {
"type": "integer",
"example": 1
},
"last_page": {
"type": "integer",
"example": 13
}
}
}
}
}
}
}
}
}
}
}
}
},
"/workers/send-otp": { "/workers/send-otp": {
"post": { "post": {
"tags": [ "tags": [
@ -1588,21 +1364,11 @@
"example": "1", "example": "1",
"description": "The ID of the conversation or review being reported." "description": "The ID of the conversation or review being reported."
}, },
"user_id": {
"type": "string",
"example": "5",
"description": "Optional ID of the user being reported."
},
"reason": { "reason": {
"type": "string", "type": "string",
"example": "Others", "example": "Inappropriate messages",
"description": "The reason for submitting this report." "description": "The reason for submitting this report."
}, },
"others": {
"type": "string",
"example": "Spam and malicious links",
"description": "Custom reason string. Used only if reason is 'Others'."
},
"description": { "description": {
"type": "string", "type": "string",
"example": "The other party sent highly inappropriate and insulting messages.", "example": "The other party sent highly inappropriate and insulting messages.",
@ -1709,132 +1475,6 @@
} }
} }
}, },
"/workers/tickets": {
"get": {
"tags": [
"Worker/Support"
],
"summary": "List Support Tickets (Worker)",
"description": "Retrieves a paginated list of all support tickets created by the authenticated worker.",
"responses": {
"200": {
"description": "Tickets retrieved successfully."
}
}
},
"post": {
"tags": [
"Worker/Support"
],
"summary": "Create Support Ticket (Worker)",
"description": "Creates a support ticket for a worker with a subject, description, and optional priority level.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"subject",
"description"
],
"properties": {
"subject": {
"type": "string",
"example": "App login crashes on launch"
},
"description": {
"type": "string",
"example": "When opening the app on my phone, it crashes immediately on the splash screen."
},
"priority": {
"type": "string",
"enum": [
"low",
"medium",
"high"
],
"example": "medium"
}
}
}
}
}
},
"responses": {
"201": {
"description": "Ticket created successfully."
}
}
}
},
"/workers/tickets/{id}": {
"get": {
"tags": [
"Worker/Support"
],
"summary": "Get Support Ticket Details & Replies (Worker)",
"description": "Retrieves the details of a support ticket and its chronological reply history.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Ticket details and replies retrieved successfully."
}
}
}
},
"/workers/tickets/{id}/reply": {
"post": {
"tags": [
"Worker/Support"
],
"summary": "Reply to Support Ticket (Worker)",
"description": "Submits a text reply or message to an active, open, or resolved support ticket.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string",
"example": "I tried clearing cache and it still crashes."
}
}
}
}
}
},
"responses": {
"201": {
"description": "Reply posted successfully."
}
}
}
},
"/employers/register": { "/employers/register": {
"post": { "post": {
"tags": [ "tags": [
@ -3409,21 +3049,11 @@
"example": "1", "example": "1",
"description": "The ID of the conversation or review being reported." "description": "The ID of the conversation or review being reported."
}, },
"user_id": {
"type": "string",
"example": "5",
"description": "Optional ID of the user being reported."
},
"reason": { "reason": {
"type": "string", "type": "string",
"example": "Others", "example": "Abusive behavior",
"description": "The reason for submitting this report." "description": "The reason for submitting this report."
}, },
"others": {
"type": "string",
"example": "Spam and malicious links",
"description": "Custom reason string. Used only if reason is 'Others'."
},
"description": { "description": {
"type": "string", "type": "string",
"example": "The worker requested payments off-platform and sent abusive messages.", "example": "The worker requested payments off-platform and sent abusive messages.",
@ -3529,132 +3159,6 @@
} }
} }
} }
},
"/employers/tickets": {
"get": {
"tags": [
"Employer/Support"
],
"summary": "List Support Tickets (Employer)",
"description": "Retrieves a paginated list of all support tickets created by the authenticated employer.",
"responses": {
"200": {
"description": "Tickets retrieved successfully."
}
}
},
"post": {
"tags": [
"Employer/Support"
],
"summary": "Create Support Ticket (Employer)",
"description": "Creates a support ticket for an employer with a subject, description, and optional priority level.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"subject",
"description"
],
"properties": {
"subject": {
"type": "string",
"example": "Payment card rejected"
},
"description": {
"type": "string",
"example": "My Visa card was declined when trying to buy the Premium Employer Pass."
},
"priority": {
"type": "string",
"enum": [
"low",
"medium",
"high"
],
"example": "medium"
}
}
}
}
}
},
"responses": {
"201": {
"description": "Ticket created successfully."
}
}
}
},
"/employers/tickets/{id}": {
"get": {
"tags": [
"Employer/Support"
],
"summary": "Get Support Ticket Details & Replies (Employer)",
"description": "Retrieves the details of a support ticket and its chronological reply history.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Ticket details and replies retrieved successfully."
}
}
}
},
"/employers/tickets/{id}/reply": {
"post": {
"tags": [
"Employer/Support"
],
"summary": "Reply to Support Ticket (Employer)",
"description": "Submits a text reply or message to an active, open, or resolved support ticket.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string",
"example": "I tried a different card and it still fails."
}
}
}
}
}
},
"responses": {
"201": {
"description": "Reply posted successfully."
}
}
}
} }
}, },
"components": { "components": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

View File

@ -14,11 +14,11 @@ import {
BadgeDollarSign, BadgeDollarSign,
ShieldCheck, ShieldCheck,
ShieldAlert, ShieldAlert,
Scale,
BellRing, BellRing,
BarChart3, BarChart3,
History, History,
List, List
LifeBuoy
} from 'lucide-react'; } from 'lucide-react';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
@ -34,7 +34,7 @@ export default function AdminLayout({ children, title = 'Dashboard' }) {
{ name: 'Employers', href: '/admin/employers', icon: Briefcase }, { name: 'Employers', 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: 'Support Tickets', href: '/admin/tickets', icon: LifeBuoy }, { name: 'Help & Support', href: '/admin/disputes', icon: Scale },
{ name: 'Payments', href: '/admin/payments', icon: BadgeDollarSign }, { name: 'Payments', href: '/admin/payments', icon: BadgeDollarSign },
{ name: 'Campaigns & Alerts', href: '/admin/notifications', icon: BellRing }, { name: 'Campaigns & Alerts', href: '/admin/notifications', icon: BellRing },
{ name: 'Reports & Analytics', href: '/admin/analytics', icon: BarChart3 }, { name: 'Reports & Analytics', href: '/admin/analytics', icon: BarChart3 },

View File

@ -18,8 +18,7 @@ import {
Briefcase, Briefcase,
Heart, Heart,
FileText, FileText,
ChevronDown, ChevronDown
LifeBuoy
} from 'lucide-react'; } from 'lucide-react';
import { import {
DropdownMenu, DropdownMenu,
@ -72,7 +71,6 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
{ name: 'Subscription', translationKey: 'subscription', href: '/employer/subscription', icon: CreditCard }, { name: 'Subscription', translationKey: 'subscription', href: '/employer/subscription', icon: CreditCard },
{ name: 'Payment History', translationKey: 'payment_history', href: '/employer/payments', icon: FileText }, { name: 'Payment History', translationKey: 'payment_history', href: '/employer/payments', icon: FileText },
{ name: 'My Profile', translationKey: 'my_profile', href: '/employer/profile', icon: User }, { name: 'My Profile', translationKey: 'my_profile', href: '/employer/profile', icon: User },
{ name: 'Help & Support', translationKey: 'help_support', href: '/employer/support', icon: LifeBuoy },
]; ];
const mobileTabs = [ const mobileTabs = [

View File

@ -1,10 +1,26 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Head, router } from '@inertiajs/react'; import { Head } from '@inertiajs/react';
import AdminLayout from '../../../Layouts/AdminLayout'; import AdminLayout from '../../../Layouts/AdminLayout';
import { Megaphone, Plus, Trash2, Send, CheckCircle2, Check, X } from 'lucide-react'; import { Megaphone, Plus, Trash2, Send, CheckCircle2 } from 'lucide-react';
export default function Announcements() {
const [announcements, setAnnouncements] = useState([
{
id: 1,
title: 'New Visa Regulations Update',
content: 'The Ministry of Human Resources and Emiratisation has announced updated guidelines for domestic worker sponsorship starting this month.',
audience: 'Both',
created_at: 'May 10, 2026',
},
{
id: 2,
title: 'Enhanced OCR Verification Active',
content: 'We have deployed upgraded OCR passport verification to ensure 100% legal compliance for all worker profiles on the platform.',
audience: 'Employers',
created_at: 'May 1, 2026',
},
]);
export default function Announcements({ initialAnnouncements }) {
const [announcements, setAnnouncements] = useState(initialAnnouncements || []);
const [isFormOpen, setIsFormOpen] = useState(false); const [isFormOpen, setIsFormOpen] = useState(false);
const [newAnnouncement, setNewAnnouncement] = useState({ title: '', content: '', audience: 'Both' }); const [newAnnouncement, setNewAnnouncement] = useState({ title: '', content: '', audience: 'Both' });
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
@ -26,48 +42,23 @@ export default function Announcements({ initialAnnouncements }) {
return; return;
} }
router.post(route('admin.announcements.store'), { const newEntry = {
id: announcements.length + 1,
title: newAnnouncement.title, title: newAnnouncement.title,
content: newAnnouncement.content, content: newAnnouncement.content,
type: newAnnouncement.audience === 'Both' ? 'info' : (newAnnouncement.audience === 'Employers' ? 'success' : 'warning') audience: newAnnouncement.audience,
}, { created_at: 'Just now',
onSuccess: () => { };
setAnnouncements([newEntry, ...announcements]);
setNewAnnouncement({ title: '', content: '', audience: 'Both' }); setNewAnnouncement({ title: '', content: '', audience: 'Both' });
setErrors({}); setErrors({});
setIsFormOpen(false); setIsFormOpen(false);
showToast('Announcement broadcasted successfully'); showToast('Announcement sent successfully');
setTimeout(() => window.location.reload(), 500);
}
});
};
const handleApprove = (id) => {
router.post(route('admin.announcements.approve', id), {}, {
onSuccess: () => {
showToast('Announcement approved successfully');
setTimeout(() => window.location.reload(), 500);
}
});
};
const handleReject = (id) => {
router.post(route('admin.announcements.reject', id), {}, {
onSuccess: () => {
showToast('Announcement rejected successfully');
setTimeout(() => window.location.reload(), 500);
}
});
}; };
const handleDelete = (id) => { const handleDelete = (id) => {
if (confirm('Are you sure you want to delete this announcement?')) { setAnnouncements(announcements.filter(a => a.id !== id));
router.delete(route('admin.announcements.delete', id), {
onSuccess: () => {
showToast('Announcement deleted'); showToast('Announcement deleted');
setTimeout(() => window.location.reload(), 500);
}
});
}
}; };
return ( return (
@ -85,12 +76,12 @@ export default function Announcements({ initialAnnouncements }) {
<div className="space-y-6 select-none max-w-6xl mx-auto"> <div className="space-y-6 select-none max-w-6xl mx-auto">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-slate-900">Platform Announcements & Events</h1> <h1 className="text-2xl font-bold text-slate-900">Platform Announcements</h1>
<p className="text-sm text-slate-500">Approve user-submitted events or broadcast messages to all users.</p> <p className="text-sm text-slate-500">Broadcast messages to employers and workers.</p>
</div> </div>
<button <button
onClick={() => setIsFormOpen(true)} onClick={() => setIsFormOpen(true)}
className="bg-[#185FA5] hover:bg-[#144f8a] text-white px-5 py-2.5 rounded-xl font-bold text-sm flex items-center space-x-2 transition-colors shadow-sm cursor-pointer border-none" className="bg-[#185FA5] hover:bg-[#144f8a] text-white px-5 py-2.5 rounded-xl font-bold text-sm flex items-center space-x-2 transition-colors shadow-sm cursor-pointer"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
<span>New Announcement</span> <span>New Announcement</span>
@ -143,13 +134,13 @@ export default function Announcements({ initialAnnouncements }) {
<button <button
type="button" type="button"
onClick={() => { setIsFormOpen(false); setErrors({}); }} onClick={() => { setIsFormOpen(false); setErrors({}); }}
className="px-5 py-2.5 rounded-xl text-sm font-semibold text-slate-600 hover:bg-slate-100 transition-colors cursor-pointer border-none" className="px-5 py-2.5 rounded-xl text-sm font-semibold text-slate-600 hover:bg-slate-100 transition-colors cursor-pointer"
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center space-x-2 transition-colors shadow-sm cursor-pointer border-none" className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center space-x-2 transition-colors shadow-sm cursor-pointer"
> >
<Send className="w-4 h-4" /> <Send className="w-4 h-4" />
<span>Broadcast Message</span> <span>Broadcast Message</span>
@ -170,60 +161,29 @@ export default function Announcements({ initialAnnouncements }) {
announcements.map((ann) => ( announcements.map((ann) => (
<div key={ann.id} className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm flex flex-col sm:flex-row sm:items-start justify-between gap-4 group"> <div key={ann.id} className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm flex flex-col sm:flex-row sm:items-start justify-between gap-4 group">
<div className="space-y-2 flex-1"> <div className="space-y-2 flex-1">
<div className="flex items-center space-x-3 flex-wrap gap-y-2"> <div className="flex items-center space-x-3">
<h3 className="font-bold text-lg text-slate-900 tracking-tight">{ann.title}</h3> <h3 className="font-bold text-lg text-slate-900 tracking-tight">{ann.title}</h3>
<span className={`px-2.5 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider ${ <span className={`px-2.5 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider ${
ann.type === 'info' ? 'bg-purple-100 text-purple-700' : ann.audience === 'Both' ? 'bg-purple-100 text-purple-700' :
ann.type === 'success' ? 'bg-blue-100 text-blue-700' : ann.audience === 'Employers' ? 'bg-blue-100 text-blue-700' :
'bg-emerald-100 text-emerald-700' 'bg-emerald-100 text-emerald-700'
}`}> }`}>
Type: {ann.type} To: {ann.audience}
</span>
<span className={`px-2.5 py-1 rounded-md text-[10px] font-bold uppercase border ${
ann.status === 'approved' ? 'bg-emerald-50 text-emerald-700 border-emerald-200' :
ann.status === 'rejected' ? 'bg-rose-50 text-rose-700 border-rose-200' :
'bg-yellow-50 text-yellow-700 border-yellow-200'
}`}>
Status: {ann.status}
</span> </span>
</div> </div>
<p className="text-sm text-slate-600 leading-relaxed whitespace-pre-wrap">{ann.content}</p> <p className="text-sm text-slate-600 leading-relaxed">{ann.content}</p>
<div className="flex items-center space-x-2 text-[11px] font-medium text-slate-400"> <div className="text-[11px] font-medium text-slate-400">
<span>Posted by: <strong className="text-slate-600">{ann.posted_by}</strong> ({ann.organization})</span> Published: {ann.created_at}
<span></span>
<span>Published: {ann.created_at}</span>
</div> </div>
</div> </div>
<div className="flex items-center space-x-2 self-start flex-wrap gap-2">
{ann.status === 'pending' && (
<>
<button
onClick={() => handleApprove(ann.id)}
className="px-3 py-1.5 bg-emerald-50 hover:bg-emerald-100 text-emerald-700 rounded-xl transition-colors cursor-pointer flex items-center space-x-1 font-bold text-xs border border-solid border-emerald-200"
title="Approve Announcement"
>
<Check className="w-3.5 h-3.5" />
<span>Approve</span>
</button>
<button
onClick={() => handleReject(ann.id)}
className="px-3 py-1.5 bg-rose-50 hover:bg-rose-100 text-rose-700 rounded-xl transition-colors cursor-pointer flex items-center space-x-1 font-bold text-xs border border-solid border-rose-200"
title="Reject Announcement"
>
<X className="w-3.5 h-3.5" />
<span>Reject</span>
</button>
</>
)}
<button <button
onClick={() => handleDelete(ann.id)} onClick={() => handleDelete(ann.id)}
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-colors cursor-pointer border-none bg-transparent" className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-colors self-start sm:opacity-0 group-hover:opacity-100 focus:opacity-100 cursor-pointer"
title="Delete Announcement" title="Delete Announcement"
> >
<Trash2 className="w-5 h-5" /> <Trash2 className="w-5 h-5" />
</button> </button>
</div> </div>
</div>
)) ))
)} )}
</div> </div>

View File

@ -1,293 +0,0 @@
import React, { useState } from 'react';
import { Head, Link, router } from '@inertiajs/react';
import AdminLayout from '@/Layouts/AdminLayout';
import {
LifeBuoy,
MessageSquare,
AlertCircle,
CheckCircle,
Clock,
Search,
Eye,
Plus,
User,
ShieldAlert,
TrendingUp,
Inbox
} from 'lucide-react';
export default function Index({ tickets, filters }) {
const [searchQuery, setSearchQuery] = useState(filters?.search || '');
const [statusFilter, setStatusFilter] = useState(filters?.status || 'all');
const [priorityFilter, setPriorityFilter] = useState(filters?.priority || 'all');
const handleSearch = (e) => {
e.preventDefault();
applyFilters({
search: searchQuery,
status: statusFilter,
priority: priorityFilter,
});
};
const applyFilters = (newFilters) => {
router.get('/admin/tickets', newFilters, {
preserveState: true,
replace: true,
});
};
const handleFilterChange = (key, value) => {
if (key === 'status') {
setStatusFilter(value);
applyFilters({ search: searchQuery, status: value, priority: priorityFilter });
} else if (key === 'priority') {
setPriorityFilter(value);
applyFilters({ search: searchQuery, status: statusFilter, priority: value });
}
};
// Calculate metrics locally from the tickets list
const totalTickets = tickets.length;
const openTickets = tickets.filter(t => t.status === 'open').length;
const inProgressTickets = tickets.filter(t => t.status === 'in_progress').length;
const resolvedTickets = tickets.filter(t => t.status === 'resolved' || t.status === 'closed').length;
const getStatusBadge = (status) => {
switch (status) {
case 'open':
return (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-black uppercase tracking-wider bg-blue-50 text-blue-700 border border-blue-200">
Open
</span>
);
case 'in_progress':
return (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-black uppercase tracking-wider bg-amber-50 text-amber-700 border border-amber-200">
In Progress
</span>
);
case 'resolved':
return (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-black uppercase tracking-wider bg-emerald-50 text-emerald-700 border border-emerald-200">
Resolved
</span>
);
case 'closed':
return (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-black uppercase tracking-wider bg-slate-100 text-slate-600 border border-slate-200">
Closed
</span>
);
default:
return <span className="text-slate-600">{status}</span>;
}
};
const getPriorityBadge = (priority) => {
switch (priority) {
case 'high':
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-black uppercase bg-red-50 text-red-700 border border-red-100">
High
</span>
);
case 'medium':
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-black uppercase bg-blue-50 text-blue-700 border border-blue-100">
Medium
</span>
);
case 'low':
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-black uppercase bg-slate-100 text-slate-600 border border-slate-200">
Low
</span>
);
default:
return <span className="text-slate-600">{priority}</span>;
}
};
return (
<AdminLayout title="Support Tickets">
<Head title="Support Ticket Management - Admin Portal" />
<div className="font-sans max-w-[1600px] mx-auto space-y-6 select-none">
{/* Header Row */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Support Ticket Center</h1>
<p className="text-sm text-slate-500 mt-1">Review user issues, update priorities, and generate Senior Developer support responses.</p>
</div>
</div>
{/* Upper Metric Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Metric 1 */}
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
<div className="space-y-1">
<span className="text-xs text-slate-500 font-semibold block">Total Tickets</span>
<span className="text-2xl font-extrabold text-slate-900 block">{totalTickets}</span>
<span className="text-[10px] text-slate-400 font-medium block">All-time received tickets</span>
</div>
<div className="p-3 bg-[#185FA5]/10 text-[#185FA5] rounded-2xl">
<LifeBuoy className="w-6 h-6 animate-spin-slow" />
</div>
</div>
{/* Metric 2 */}
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
<div className="space-y-1">
<span className="text-xs text-slate-500 font-semibold block">New / Open</span>
<span className="text-2xl font-extrabold text-slate-900 block">{openTickets}</span>
<span className="text-[10px] text-rose-500 font-bold block">Needs immediate reply</span>
</div>
<div className="p-3 bg-red-50 text-red-600 rounded-2xl">
<Clock className="w-6 h-6 animate-pulse" />
</div>
</div>
{/* Metric 3 */}
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
<div className="space-y-1">
<span className="text-xs text-slate-500 font-semibold block">In Progress</span>
<span className="text-2xl font-extrabold text-slate-900 block">{inProgressTickets}</span>
<span className="text-[10px] text-amber-500 font-bold block">Active investigation</span>
</div>
<div className="p-3 bg-amber-50 text-amber-600 rounded-2xl">
<MessageSquare className="w-6 h-6" />
</div>
</div>
{/* Metric 4 */}
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
<div className="space-y-1">
<span className="text-xs text-slate-500 font-semibold block">Resolved / Closed</span>
<span className="text-2xl font-extrabold text-slate-900 block">{resolvedTickets}</span>
<span className="text-[10px] text-emerald-600 font-bold block">Archived cleanly</span>
</div>
<div className="p-3 bg-emerald-50 text-emerald-600 rounded-2xl">
<CheckCircle className="w-6 h-6" />
</div>
</div>
</div>
{/* Filters Row */}
<div className="bg-white border border-slate-200 rounded-2xl shadow-sm overflow-hidden">
<div className="p-4 border-b border-slate-100 flex flex-col lg:flex-row lg:items-center justify-between gap-4 bg-slate-50/20">
{/* Search form */}
<form onSubmit={handleSearch} className="relative w-full lg:w-80">
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="Search ticket # or subject..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="bg-white border border-slate-200 pl-10 pr-4 py-2 rounded-xl text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 w-full shadow-sm"
/>
</form>
{/* Dropdown Filters */}
<div className="flex flex-wrap items-center gap-3">
<select
value={statusFilter}
onChange={e => handleFilterChange('status', e.target.value)}
className="bg-white border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl outline-none cursor-pointer hover:bg-slate-50 transition-colors shadow-sm"
>
<option value="all">All Status</option>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
<select
value={priorityFilter}
onChange={e => handleFilterChange('priority', e.target.value)}
className="bg-white border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl outline-none cursor-pointer hover:bg-slate-50 transition-colors shadow-sm"
>
<option value="all">All Priority</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
</div>
{/* Table listing */}
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse text-xs font-semibold text-slate-600">
<thead>
<tr className="border-b border-slate-100 bg-slate-50/50 text-slate-400 font-bold uppercase tracking-wider text-[10px]">
<th className="px-5 py-4">Ticket Number</th>
<th className="px-5 py-4">Subject</th>
<th className="px-5 py-4">Raised By</th>
<th className="px-5 py-4">Priority</th>
<th className="px-5 py-4">Status</th>
<th className="px-5 py-4">Created At</th>
<th className="px-5 py-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{tickets.map((ticket) => (
<tr key={ticket.id} className="hover:bg-slate-50/40 transition-colors">
<td className="px-5 py-4 font-mono font-bold text-[#185FA5]">
{ticket.ticket_number}
</td>
<td className="px-5 py-4 font-semibold text-slate-800 max-w-xs truncate">
{ticket.subject}
</td>
<td className="px-5 py-4">
<div>
<span className="font-extrabold text-slate-800 block">
{ticket.creator_name}
</span>
<span className={`text-[9px] font-bold block uppercase tracking-wider ${
ticket.user_type === 'Employer' ? 'text-[#185FA5]' : 'text-emerald-600'
}`}>
{ticket.user_type}
</span>
</div>
</td>
<td className="px-5 py-4">
{getPriorityBadge(ticket.priority)}
</td>
<td className="px-5 py-4">
{getStatusBadge(ticket.status)}
</td>
<td className="px-5 py-4 text-slate-400">
{ticket.created_at}
</td>
<td className="px-5 py-4 text-right">
<Link
href={`/admin/tickets/${ticket.id}`}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors shadow-sm font-bold uppercase tracking-wider text-[10px]"
>
<Eye className="w-3.5 h-3.5 text-slate-400" />
<span>Manage</span>
</Link>
</td>
</tr>
))}
{tickets.length === 0 && (
<tr>
<td colSpan="7" className="p-12 text-center font-bold text-slate-400 uppercase">
<div className="flex flex-col items-center justify-center space-y-2">
<Inbox className="w-8 h-8 text-slate-300" />
<span>No support tickets found matching criteria</span>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</AdminLayout>
);
}

View File

@ -1,350 +0,0 @@
import React, { useState } from 'react';
import { Head, Link, useForm } from '@inertiajs/react';
import AdminLayout from '@/Layouts/AdminLayout';
import {
ArrowLeft,
Clock,
CheckCircle,
AlertCircle,
Send,
Terminal,
User,
Mail,
LifeBuoy,
Shield,
Sparkles,
Loader2
} from 'lucide-react';
import axios from 'axios';
import { toast } from 'sonner';
export default function Show({ ticket, replies }) {
const [generatingDev, setGeneratingDev] = useState(false);
const [isDevChecked, setIsDevChecked] = useState(false);
const replyForm = useForm({
message: '',
is_developer_response: false,
});
const statusForm = useForm({
status: ticket.status,
});
const handleReplySubmit = (e) => {
e.preventDefault();
replyForm.post(`/admin/tickets/${ticket.id}/reply`, {
onSuccess: () => {
replyForm.reset('message');
setIsDevChecked(false);
toast.success('Reply posted successfully.');
}
});
};
const handleStatusChange = (newStatus) => {
statusForm.setData('status', newStatus);
statusForm.post(`/admin/tickets/${ticket.id}/status`, {
onSuccess: () => {
toast.success('Ticket status updated successfully.');
}
});
};
const generateDeveloperDraft = async () => {
setGeneratingDev(true);
try {
const res = await axios.post(`/admin/tickets/${ticket.id}/generate-dev-response`);
if (res.data && res.data.response) {
replyForm.setData({
message: res.data.response,
is_developer_response: true
});
setIsDevChecked(true);
toast.success('Senior Software Developer draft generated!');
}
} catch (err) {
toast.error('Failed to generate developer response. Please try again.');
} finally {
setGeneratingDev(false);
}
};
const getStatusColor = (status) => {
switch (status) {
case 'open': return 'bg-blue-50 text-blue-700 border-blue-200';
case 'in_progress': return 'bg-amber-50 text-amber-700 border-amber-200';
case 'resolved': return 'bg-emerald-50 text-emerald-700 border-emerald-200';
case 'closed': return 'bg-slate-100 text-slate-600 border-slate-200';
default: return 'bg-slate-50 text-slate-600';
}
};
return (
<AdminLayout title={`Ticket #${ticket.ticket_number}`}>
<Head title={`Ticket ${ticket.ticket_number} - Admin Support`} />
<div className="max-w-[1400px] mx-auto space-y-6 select-none pb-24">
{/* Back button */}
<Link
href="/admin/tickets"
className="inline-flex items-center space-x-2 text-xs font-black text-slate-500 hover:text-slate-800 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span>Back to Support Tickets</span>
</Link>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
{/* Left Pane: Ticket details and Replies (8 Cols) */}
<div className="lg:col-span-8 space-y-6">
{/* Ticket Card */}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-6 border-b border-slate-100 bg-slate-50/30 flex justify-between items-center flex-wrap gap-4">
<div className="space-y-1">
<div className="flex items-center space-x-2">
<span className="text-xs font-mono font-bold text-slate-400 bg-white px-2 py-0.5 rounded border">
{ticket.ticket_number}
</span>
<span className={`px-2 py-0.5 rounded text-[10px] font-black uppercase ${
ticket.priority === 'high' ? 'bg-red-50 text-red-700 border border-red-100' :
ticket.priority === 'medium' ? 'bg-blue-50 text-blue-700 border border-blue-100' :
'bg-slate-100 text-slate-600 border'
}`}>
{ticket.priority} Priority
</span>
</div>
<h2 className="text-base font-black text-slate-900 mt-1">{ticket.subject}</h2>
</div>
{/* Quick status dropdown */}
<div className="flex items-center space-x-2">
<span className="text-[10px] text-slate-400 font-bold uppercase">Status:</span>
<select
value={statusForm.data.status}
onChange={e => handleStatusChange(e.target.value)}
disabled={statusForm.processing}
className="bg-white border border-slate-200 text-slate-700 text-xs font-bold px-3 py-1.5 rounded-xl outline-none cursor-pointer hover:bg-slate-50 transition-colors"
>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
</div>
</div>
<div className="p-6 space-y-4">
<div>
<span className="text-[9px] text-slate-400 uppercase tracking-widest font-black block mb-2">Original Ticket Message</span>
<div className="bg-slate-50 p-4 rounded-xl border border-slate-150 text-xs text-slate-600 font-medium whitespace-pre-wrap leading-relaxed">
{ticket.description}
</div>
</div>
</div>
</div>
{/* Replies Thread */}
<div className="space-y-4">
<h3 className="font-extrabold text-sm text-slate-800 uppercase tracking-wider">Discussion History ({replies.length})</h3>
<div className="space-y-4">
{replies.map((reply) => {
const isDev = reply.is_developer_response;
const isAdmin = reply.is_admin;
return (
<div
key={reply.id}
className={`p-5 rounded-2xl border transition-all ${
isDev
? 'bg-slate-900 text-slate-100 border-slate-800 shadow-md font-mono'
: isAdmin
? 'bg-blue-50/60 text-slate-700 border-blue-100'
: 'bg-white text-slate-700 border-slate-200'
}`}
>
<div className="flex items-center justify-between border-b pb-2 mb-3.5 text-[10px] font-bold border-current/10">
<div className="flex items-center space-x-2">
{isDev ? (
<div className="w-5 h-5 rounded bg-emerald-500 text-slate-950 flex items-center justify-center">
<Terminal className="w-3.5 h-3.5" />
</div>
) : (
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-[9px] font-black ${
isAdmin ? 'bg-[#185FA5] text-white' : 'bg-slate-100 text-slate-600'
}`}>
{reply.sender_name.charAt(0)}
</div>
)}
<span className={isDev ? 'text-emerald-400 font-mono font-bold' : 'text-slate-800'}>
{reply.sender_name} {isDev && '(Senior Software Developer)'}
</span>
</div>
<span className="opacity-60">{reply.created_at}</span>
</div>
<div className={`text-xs leading-relaxed whitespace-pre-wrap ${
isDev ? 'text-slate-300 font-mono font-semibold' : 'text-slate-600 font-semibold'
}`}>
{reply.message}
</div>
</div>
);
})}
{replies.length === 0 && (
<div className="p-8 text-center text-xs text-slate-400 font-bold bg-white rounded-xl border border-slate-200">
No messages posted yet.
</div>
)}
</div>
</div>
{/* Reply Form */}
{ticket.status !== 'closed' ? (
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="space-y-0.5">
<h4 className="font-extrabold text-sm text-slate-900">Post Reply</h4>
<p className="text-[10px] text-slate-400 font-semibold">Respond directly to the customer or draft as developer.</p>
</div>
{/* Senior developer draft button */}
<button
type="button"
onClick={generateDeveloperDraft}
disabled={generatingDev}
className="inline-flex items-center space-x-1.5 px-3 py-2 bg-slate-900 hover:bg-slate-800 text-white rounded-xl text-xs font-black transition-all shadow-sm disabled:opacity-50"
>
{generatingDev ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin text-emerald-400" />
<span className="text-emerald-400">Generating Dev Response...</span>
</>
) : (
<>
<Sparkles className="w-3.5 h-3.5 text-amber-300" />
<span>Draft as Senior Developer</span>
</>
)}
</button>
</div>
<form onSubmit={handleReplySubmit} className="space-y-4 text-xs font-bold text-slate-700">
<div>
<textarea
rows="4"
placeholder="Write your response message..."
value={replyForm.data.message}
onChange={e => replyForm.setData('message', e.target.value)}
className={`w-full p-3 border border-slate-200 rounded-xl bg-slate-50/50 text-slate-800 outline-none focus:bg-white focus:border-[#185FA5] transition-all leading-relaxed ${
replyForm.errors.message ? 'border-red-400' : ''
}`}
required
/>
</div>
<div className="flex items-center justify-between border-t border-slate-100 pt-4 flex-wrap gap-3">
{/* Dev response toggle */}
<label className="flex items-center space-x-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={isDevChecked}
onChange={e => {
setIsDevChecked(e.target.checked);
replyForm.setData('is_developer_response', e.target.checked);
}}
className="w-4.5 h-4.5 rounded border-slate-300 text-slate-900 focus:ring-slate-900 cursor-pointer"
/>
<span className="text-xs text-slate-700 font-extrabold flex items-center space-x-1">
<Terminal className="w-3.5 h-3.5 text-[#185FA5]" />
<span>Send response as Senior Software Developer</span>
</span>
</label>
<button
type="submit"
disabled={replyForm.processing}
className="px-5 py-2.5 bg-[#185FA5] hover:bg-[#144f8a] text-white rounded-xl transition-all shadow-md flex items-center space-x-1.5"
>
{replyForm.processing ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<>
<span>Post Reply</span>
<Send className="w-3.5 h-3.5" />
</>
)}
</button>
</div>
</form>
</div>
) : (
<div className="bg-slate-100 border border-slate-200 rounded-2xl p-5 flex items-center space-x-3.5">
<AlertCircle className="w-5 h-5 text-slate-400" />
<div className="text-xs font-bold text-slate-700">
<h4 className="text-slate-800">Closed Support Ticket</h4>
<p className="text-slate-400 font-semibold mt-0.5">This ticket has been marked closed. Change status above to reopen and reply.</p>
</div>
</div>
)}
</div>
{/* Right Pane: Creator Profile details (4 Cols) */}
<div className="lg:col-span-4 space-y-6">
{/* Creator Profile Info */}
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm space-y-5">
<div>
<h3 className="font-extrabold text-sm text-slate-900">Requester Profile</h3>
<p className="text-[10px] text-slate-400 font-semibold">User account information associated with ticket</p>
</div>
<div className="flex items-center space-x-3 border-b border-slate-100 pb-4">
<div className="w-10 h-10 rounded-full bg-slate-100 text-slate-600 flex items-center justify-center font-black border">
{ticket.creator_name.charAt(0)}
</div>
<div>
<span className="font-extrabold text-xs text-slate-800 block">{ticket.creator_name}</span>
<span className={`text-[9px] font-bold px-2 py-0.5 rounded-full inline-block mt-0.5 ${
ticket.user_type === 'Employer' ? 'bg-blue-50 text-[#185FA5] border border-blue-100' : 'bg-emerald-50 text-emerald-700 border border-emerald-100'
}`}>
{ticket.user_type} Profile
</span>
</div>
</div>
<div className="space-y-3.5 text-xs font-semibold text-slate-600">
<div className="flex items-center space-x-2">
<Mail className="w-4 h-4 text-slate-400" />
<span className="truncate">{ticket.creator_email}</span>
</div>
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4 text-slate-400" />
<span>Registered: {ticket.created_at}</span>
</div>
</div>
</div>
{/* Support compliance tips */}
<div className="bg-slate-900 p-6 rounded-2xl text-slate-100 border border-slate-800 space-y-3.5">
<h4 className="text-xs font-black uppercase tracking-wider flex items-center space-x-2 text-emerald-400 font-mono">
<Shield className="w-4 h-4" />
<span>Support Compliance Guard</span>
</h4>
<ul className="text-[10px] font-semibold text-slate-400 space-y-2 leading-relaxed list-disc list-inside">
<li>Always verify billing receipts before resolving payment failures.</li>
<li>Use the Senior Developer response builder to address server exceptions automatically.</li>
<li>Keep tickets open until client explicitly confirms resolved status.</li>
</ul>
</div>
</div>
</div>
</div>
</AdminLayout>
);
}

View File

@ -1,146 +0,0 @@
import React from 'react';
import { Head, Link, useForm } from '@inertiajs/react';
import EmployerLayout from '../../../Layouts/EmployerLayout';
import { useTranslation } from '../../../lib/LanguageContext';
import {
LifeBuoy,
ArrowLeft,
AlertTriangle,
CheckCircle
} from 'lucide-react';
export default function Create() {
const { t } = useTranslation();
const { data, setData, post, processing, errors } = useForm({
subject: '',
description: '',
priority: 'medium',
});
const handleSubmit = (e) => {
e.preventDefault();
post('/employer/support/create');
};
return (
<EmployerLayout title={t('create_new_ticket', 'Create New Ticket')}>
<Head title={t('create_support_ticket_title', 'Create Support Ticket - Employer Portal')} />
<div className="max-w-2xl select-none w-full pb-16 space-y-6">
{/* Back button */}
<Link
href="/employer/support"
className="inline-flex items-center space-x-2 text-xs font-black text-slate-500 hover:text-slate-800 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span>{t('back_to_support', 'Back to Help & Support')}</span>
</Link>
{/* Form Card */}
<div className="bg-white p-6 md:p-8 rounded-3xl border border-slate-200 shadow-sm space-y-6">
<div className="flex items-center space-x-3.5 border-b border-slate-100 pb-4">
<div className="w-11 h-11 rounded-xl bg-blue-50 text-[#185FA5] flex items-center justify-center border border-blue-100 flex-shrink-0">
<LifeBuoy className="w-5 h-5 animate-spin-slow" />
</div>
<div>
<h3 className="font-extrabold text-sm text-slate-900">{t('open_support_case', 'Open a Support Case')}</h3>
<p className="text-[11px] text-slate-400 font-semibold">{t('open_support_case_desc', 'Please provide detailed info to help our engineering team solve it fast.')}</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-5 text-xs font-bold text-slate-700">
{/* Subject */}
<div className="space-y-1.5">
<label className="block text-slate-400 uppercase tracking-widest text-[9px]">{t('ticket_subject', 'Subject / Summary')}</label>
<input
type="text"
placeholder={t('ticket_subject_placeholder', 'e.g. Stripe checkout throws 500 error')}
value={data.subject}
onChange={e => setData('subject', e.target.value)}
className={`w-full p-3 border rounded-xl bg-slate-50/50 text-slate-800 outline-none focus:bg-white focus:border-[#185FA5] transition-all ${
errors.subject ? 'border-red-400 bg-red-50/20' : 'border-slate-200'
}`}
required
/>
{errors.subject && (
<p className="text-[10px] text-red-500 font-semibold mt-1">{errors.subject}</p>
)}
</div>
{/* Priority */}
<div className="space-y-1.5">
<label className="block text-slate-400 uppercase tracking-widest text-[9px]">{t('ticket_priority', 'Severity / Priority')}</label>
<select
value={data.priority}
onChange={e => setData('priority', e.target.value)}
className="w-full p-3 border border-slate-200 rounded-xl bg-slate-50/50 text-slate-800 outline-none focus:bg-white focus:border-[#185FA5] transition-all"
>
<option value="low">{t('priority_low', 'Low (General queries, feedback)')}</option>
<option value="medium">{t('priority_medium', 'Medium (Functionality bugs, minor errors)')}</option>
<option value="high">{t('priority_high', 'High (Payment failures, account lockouts)')}</option>
</select>
</div>
{/* Description */}
<div className="space-y-1.5">
<label className="block text-slate-400 uppercase tracking-widest text-[9px]">{t('ticket_description', 'Detailed Description')}</label>
<textarea
rows="6"
placeholder={t('ticket_description_placeholder', 'Detail the steps to reproduce the error or query. Paste exact log outputs if possible.')}
value={data.description}
onChange={e => setData('description', e.target.value)}
className={`w-full p-3 border rounded-xl bg-slate-50/50 text-slate-800 outline-none focus:bg-white focus:border-[#185FA5] transition-all leading-relaxed ${
errors.description ? 'border-red-400 bg-red-50/20' : 'border-slate-200'
}`}
required
/>
{errors.description && (
<p className="text-[10px] text-red-500 font-semibold mt-1">{errors.description}</p>
)}
</div>
{/* Support notice */}
{data.priority === 'high' && (
<div className="bg-amber-50 border border-amber-200 rounded-2xl p-4 flex items-start space-x-3 text-amber-800 animate-pulse">
<AlertTriangle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
<div>
<h5 className="font-extrabold text-[11px]">{t('high_priority_alert', 'Priority High Selected')}</h5>
<p className="text-[10px] font-semibold text-amber-700 mt-0.5 leading-relaxed">
{t('high_priority_desc', 'Tickets marked high route directly to our standby on-call senior engineering queue. Response is initiated under 2 hours.')}
</p>
</div>
</div>
)}
{/* Buttons */}
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-slate-100">
<Link
href="/employer/support"
className="px-5 py-3 rounded-xl border border-slate-200 hover:bg-slate-50 text-slate-600 transition-colors"
>
{t('cancel', 'Cancel')}
</Link>
<button
type="submit"
disabled={processing}
className="px-6 py-3 rounded-xl bg-[#185FA5] hover:bg-[#144f8a] text-white transition-all shadow-md disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
>
{processing ? (
<>
<span className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<span>{t('submitting', 'Submitting...')}</span>
</>
) : (
<span>{t('submit_ticket', 'Submit Support Ticket')}</span>
)}
</button>
</div>
</form>
</div>
</div>
</EmployerLayout>
);
}

View File

@ -1,223 +0,0 @@
import React, { useState } from 'react';
import { Head, Link } from '@inertiajs/react';
import EmployerLayout from '../../../Layouts/EmployerLayout';
import { useTranslation } from '../../../lib/LanguageContext';
import {
LifeBuoy,
Plus,
Search,
Clock,
CheckCircle,
AlertCircle,
HelpCircle,
ArrowRight,
MessageSquare
} from 'lucide-react';
export default function Index({ tickets }) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const filteredTickets = tickets.filter(ticket => {
const matchesSearch = ticket.subject.toLowerCase().includes(searchQuery.toLowerCase()) ||
ticket.ticket_number.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = statusFilter === 'all' || ticket.status === statusFilter;
return matchesSearch && matchesStatus;
});
const getStatusBadge = (status) => {
switch (status) {
case 'open':
return (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-black bg-blue-50 text-blue-700 border border-blue-200">
<Clock className="w-3.5 h-3.5 mr-1 animate-pulse" />
{t('ticket_status_open', 'Open')}
</span>
);
case 'in_progress':
return (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-black bg-amber-50 text-amber-700 border border-amber-200">
<Clock className="w-3.5 h-3.5 mr-1" />
{t('ticket_status_in_progress', 'In Progress')}
</span>
);
case 'resolved':
return (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-black bg-emerald-50 text-emerald-700 border border-emerald-200">
<CheckCircle className="w-3.5 h-3.5 mr-1" />
{t('ticket_status_resolved', 'Resolved')}
</span>
);
case 'closed':
return (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-black bg-slate-100 text-slate-600 border border-slate-200">
<AlertCircle className="w-3.5 h-3.5 mr-1" />
{t('ticket_status_closed', 'Closed')}
</span>
);
default:
return (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-black bg-slate-50 text-slate-600">
{status}
</span>
);
}
};
const getPriorityBadge = (priority) => {
switch (priority) {
case 'high':
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider bg-rose-50 text-rose-700 border border-rose-100">
{t('priority_high', 'High')}
</span>
);
case 'medium':
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider bg-blue-50 text-blue-700 border border-blue-100">
{t('priority_medium', 'Medium')}
</span>
);
case 'low':
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider bg-slate-100 text-slate-600 border border-slate-200">
{t('priority_low', 'Low')}
</span>
);
default:
return <span className="text-slate-600">{priority}</span>;
}
};
return (
<EmployerLayout title={t('help_support', 'Help & Support')}>
<Head title={t('support_tickets_title', 'Help & Support Tickets - Employer Portal')} />
<div className="space-y-8 select-none w-full pb-16">
{/* Support Intro Banner */}
<div className="bg-gradient-to-r from-blue-600 to-[#185FA5] rounded-3xl p-6 md:p-8 text-white shadow-md flex flex-col md:flex-row items-center justify-between gap-6">
<div className="space-y-2 max-w-xl text-center md:text-left">
<div className="inline-flex items-center space-x-2 bg-white/10 px-3 py-1 rounded-full text-xs font-bold backdrop-blur-sm">
<LifeBuoy className="w-4 h-4 text-amber-300 animate-spin-slow" />
<span>{t('customer_care_center', 'Customer Care & Tech Support')}</span>
</div>
<h2 className="text-xl md:text-2xl font-black tracking-tight">{t('how_can_we_help', 'How can we help you today?')}</h2>
<p className="text-xs text-blue-100 font-semibold leading-relaxed">
{t('support_description', 'Raise a ticket for billing queries, technical glitches, or safety concerns. Our senior engineering and operations teams respond promptly.')}
</p>
</div>
<Link
href="/employer/support/create"
className="bg-white hover:bg-slate-50 text-[#185FA5] hover:scale-[1.02] active:scale-[0.98] px-5 py-3 rounded-2xl font-black text-xs transition-all shadow-md flex items-center space-x-2 shrink-0 cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>{t('create_new_ticket', 'Create New Ticket')}</span>
</Link>
</div>
{/* Filters & search */}
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm flex flex-col md:flex-row gap-4 items-center justify-between">
<div className="relative w-full md:w-80">
<Search className="absolute left-3.5 top-3 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder={t('search_tickets_placeholder', 'Search ticket # or subject...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-xs font-semibold border border-slate-200 rounded-xl bg-slate-50/50 outline-none focus:border-[#185FA5] transition-colors"
/>
</div>
<div className="flex items-center space-x-2 w-full md:w-auto overflow-x-auto pb-1 md:pb-0">
{['all', 'open', 'in_progress', 'resolved', 'closed'].map((status) => (
<button
key={status}
onClick={() => setStatusFilter(status)}
className={`px-4 py-2 rounded-xl text-xs font-black transition-all shrink-0 ${
statusFilter === status
? 'bg-[#185FA5] text-white shadow-xs'
: 'bg-slate-50 hover:bg-slate-100 text-slate-600 border border-slate-200/40'
}`}
>
{t(`filter_status_${status}`, status.charAt(0).toUpperCase() + status.slice(1).replace('_', ' '))}
</button>
))}
</div>
</div>
{/* Tickets list */}
<div className="bg-white rounded-3xl border border-slate-200 shadow-sm overflow-hidden">
{filteredTickets.length > 0 ? (
<div className="divide-y divide-slate-100">
{filteredTickets.map((ticket) => (
<Link
key={ticket.id}
href={`/employer/support/${ticket.id}`}
className="flex items-center justify-between p-6 hover:bg-slate-50/70 transition-colors group cursor-pointer"
>
<div className="space-y-2 pr-4 min-w-0">
<div className="flex items-center space-x-2">
<span className="text-[10px] font-mono font-bold text-slate-400 bg-slate-50 px-2 py-0.5 rounded border border-slate-100">
{ticket.ticket_number}
</span>
{getPriorityBadge(ticket.priority)}
</div>
<h3 className="font-extrabold text-sm text-slate-900 group-hover:text-[#185FA5] transition-colors truncate">
{ticket.subject}
</h3>
<div className="flex items-center space-x-3 text-[10px] text-slate-400 font-bold">
<span>{t('created_on', 'Created:')} {ticket.created_at}</span>
<span className="w-1.5 h-1.5 rounded-full bg-slate-200" />
<span>{t('updated_on', 'Updated:')} {ticket.updated_at}</span>
</div>
</div>
<div className="flex items-center space-x-4 shrink-0">
{getStatusBadge(ticket.status)}
<div className="w-8 h-8 rounded-xl bg-slate-50 text-slate-400 group-hover:text-[#185FA5] group-hover:bg-blue-50 border border-slate-100 flex items-center justify-center transition-colors">
<ArrowRight className="w-4 h-4" />
</div>
</div>
</Link>
))}
</div>
) : (
<div className="p-12 text-center flex flex-col items-center justify-center space-y-4">
<div className="w-14 h-14 bg-slate-50 text-slate-400 border border-slate-100 rounded-2xl flex items-center justify-center">
<HelpCircle className="w-8 h-8" />
</div>
<div className="space-y-1">
<h4 className="font-black text-slate-800 text-sm">{t('no_tickets_found', 'No support tickets found')}</h4>
<p className="text-xs text-slate-400 font-medium max-w-sm">
{t('no_tickets_found_desc', 'If you are experiencing any billing or login issues, click the button above to raise a ticket.')}
</p>
</div>
</div>
)}
</div>
{/* FAQ section */}
<div className="bg-slate-50 p-6 rounded-3xl border border-slate-200/80 space-y-4">
<h3 className="font-black text-slate-800 text-sm flex items-center space-x-2">
<MessageSquare className="w-4 h-4 text-[#185FA5]" />
<span>{t('frequently_asked_questions', 'Frequently Asked Questions')}</span>
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
<div className="bg-white p-4 rounded-2xl border border-slate-200/50 space-y-1">
<h4 className="font-black text-slate-800">{t('faq_q1', 'How long does a ticket resolution take?')}</h4>
<p className="text-slate-500 font-semibold">{t('faq_a1', 'For priority high tickets, our engineering support response is typically under 2 hours. General queries resolve within 24 hours.')}</p>
</div>
<div className="bg-white p-4 rounded-2xl border border-slate-200/50 space-y-1">
<h4 className="font-black text-slate-800">{t('faq_q2', 'What is Tadbeer contract compliance?')}</h4>
<p className="text-slate-500 font-semibold">{t('faq_a2', 'All worker hirings conform to MOHRE Tadbeer rules. Contracts are electronically generated and uploaded for review.')}</p>
</div>
</div>
</div>
</div>
</EmployerLayout>
);
}

View File

@ -1,261 +0,0 @@
import React from 'react';
import { Head, Link, useForm } from '@inertiajs/react';
import EmployerLayout from '../../../Layouts/EmployerLayout';
import { useTranslation } from '../../../lib/LanguageContext';
import {
ArrowLeft,
Clock,
CheckCircle,
AlertCircle,
CornerDownRight,
Send,
Terminal,
UserCheck,
Lock
} from 'lucide-react';
export default function Show({ ticket, replies }) {
const { t } = useTranslation();
const { data, setData, post, processing, reset, errors } = useForm({
message: '',
});
const handleSubmit = (e) => {
e.preventDefault();
post(`/employer/support/${ticket.id}/reply`, {
onSuccess: () => reset('message'),
});
};
const getStatusBadge = (status) => {
switch (status) {
case 'open':
return (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-black bg-blue-50 text-blue-700 border border-blue-200">
<Clock className="w-3.5 h-3.5 mr-1" />
{t('ticket_status_open', 'Open')}
</span>
);
case 'in_progress':
return (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-black bg-amber-50 text-amber-700 border border-amber-200">
<Clock className="w-3.5 h-3.5 mr-1" />
{t('ticket_status_in_progress', 'In Progress')}
</span>
);
case 'resolved':
return (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-black bg-emerald-50 text-emerald-700 border border-emerald-200">
<CheckCircle className="w-3.5 h-3.5 mr-1" />
{t('ticket_status_resolved', 'Resolved')}
</span>
);
case 'closed':
return (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-black bg-slate-100 text-slate-600 border border-slate-200">
<AlertCircle className="w-3.5 h-3.5 mr-1" />
{t('ticket_status_closed', 'Closed')}
</span>
);
default:
return (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-black bg-slate-50 text-slate-600">
{status}
</span>
);
}
};
const getPriorityBadge = (priority) => {
switch (priority) {
case 'high':
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider bg-rose-50 text-rose-700 border border-rose-100">
{t('priority_high', 'High')}
</span>
);
case 'medium':
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider bg-blue-50 text-blue-700 border border-blue-100">
{t('priority_medium', 'Medium')}
</span>
);
case 'low':
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider bg-slate-100 text-slate-600 border border-slate-200">
{t('priority_low', 'Low')}
</span>
);
default:
return <span className="text-slate-600">{priority}</span>;
}
};
return (
<EmployerLayout title={t('ticket_details', 'Ticket Details')}>
<Head title={`${ticket.ticket_number} - Support Ticket - Employer Portal`} />
<div className="max-w-4xl select-none w-full pb-24 space-y-6">
{/* Back button */}
<Link
href="/employer/support"
className="inline-flex items-center space-x-2 text-xs font-black text-slate-500 hover:text-slate-800 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span>{t('back_to_support', 'Back to Help & Support')}</span>
</Link>
{/* Ticket main card */}
<div className="bg-white rounded-3xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-6 md:p-8 border-b border-slate-100 bg-slate-50/40 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center space-x-3">
<span className="text-xs font-mono font-bold text-slate-400 bg-white px-2.5 py-1 rounded-lg border border-slate-200 shadow-xs">
{ticket.ticket_number}
</span>
{getPriorityBadge(ticket.priority)}
{getStatusBadge(ticket.status)}
</div>
<span className="text-[10px] text-slate-400 font-bold">
{t('raised_on', 'Raised on:')} {ticket.created_at}
</span>
</div>
<h2 className="text-base md:text-lg font-black text-slate-900 tracking-tight leading-snug">
{ticket.subject}
</h2>
</div>
<div className="p-6 md:p-8 space-y-1">
<label className="block text-slate-400 uppercase tracking-widest text-[9px] font-black mb-2">{t('original_message', 'Original Message')}</label>
<div className="text-xs text-slate-600 font-medium leading-relaxed whitespace-pre-wrap bg-slate-50/50 p-4 rounded-2xl border border-slate-100">
{ticket.description}
</div>
</div>
</div>
{/* Replies header */}
<div className="space-y-4 pt-4">
<h3 className="font-black text-slate-800 text-sm flex items-center space-x-2">
<CornerDownRight className="w-4 h-4 text-[#185FA5]" />
<span>{t('ticket_replies', 'Discussion History')} ({replies.length})</span>
</h3>
{/* Replies list */}
<div className="space-y-4">
{replies.map((reply) => {
const isSystemDev = reply.is_developer_response;
const isAdmin = reply.is_admin;
return (
<div
key={reply.id}
className={`p-5 rounded-3xl border transition-all ${
isSystemDev
? 'bg-slate-900 text-slate-100 border-slate-800 shadow-md font-mono'
: isAdmin
? 'bg-blue-50/60 text-slate-700 border-blue-100'
: 'bg-white text-slate-700 border-slate-200'
}`}
>
<div className="flex items-center justify-between border-b pb-2 mb-3.5 select-none text-[10px] font-bold border-current/10">
<div className="flex items-center space-x-2">
{isSystemDev ? (
<div className="w-5 h-5 rounded bg-emerald-500 text-slate-950 flex items-center justify-center">
<Terminal className="w-3.5 h-3.5" />
</div>
) : (
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-[9px] font-black ${
isAdmin ? 'bg-[#185FA5] text-white' : 'bg-slate-100 text-slate-600'
}`}>
{reply.sender_name.charAt(0)}
</div>
)}
<span className={isSystemDev ? 'text-emerald-400 font-mono font-bold' : 'text-slate-800'}>
{reply.sender_name}
</span>
</div>
<span className="opacity-60">{reply.created_at}</span>
</div>
<div className={`text-xs font-semibold leading-relaxed whitespace-pre-wrap ${
isSystemDev ? 'text-slate-300 font-mono font-semibold' : 'text-slate-600'
}`}>
{reply.message}
</div>
</div>
);
})}
{replies.length === 0 && (
<div className="p-8 text-center text-xs text-slate-400 font-bold bg-white rounded-2xl border border-slate-100">
{t('no_replies_yet', 'No replies posted yet. Our team is investigating.')}
</div>
)}
</div>
</div>
{/* Reply Form */}
{ticket.status !== 'closed' ? (
<div className="bg-white p-6 rounded-3xl border border-slate-200 shadow-sm space-y-4 pt-6">
<div className="space-y-1">
<h4 className="font-extrabold text-sm text-slate-900">{t('post_a_reply', 'Post a Reply')}</h4>
<p className="text-[10px] text-slate-400 font-bold">{t('post_a_reply_desc', 'Need to add info or follow up? Type your response below.')}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="text-xs font-bold text-slate-700">
<textarea
rows="4"
placeholder={t('reply_placeholder', 'Type your reply message here...')}
value={data.message}
onChange={e => setData('message', e.target.value)}
className={`w-full p-3 border rounded-xl bg-slate-50/50 text-slate-800 outline-none focus:bg-white focus:border-[#185FA5] transition-all leading-relaxed ${
errors.message ? 'border-red-400 bg-red-50/20' : 'border-slate-200'
}`}
required
/>
{errors.message && (
<p className="text-[10px] text-red-500 font-semibold mt-1">{errors.message}</p>
)}
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={processing}
className="px-5 py-2.5 rounded-xl bg-[#185FA5] hover:bg-[#144f8a] text-white transition-all shadow-md disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2 text-xs font-bold"
>
{processing ? (
<>
<span className="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<span>{t('submitting', 'Posting...')}</span>
</>
) : (
<>
<span>{t('send_reply', 'Post Reply')}</span>
<Send className="w-3.5 h-3.5" />
</>
)}
</button>
</div>
</form>
</div>
) : (
<div className="bg-slate-100 border border-slate-200 rounded-3xl p-6 flex items-center justify-between gap-4">
<div className="flex items-center space-x-3.5">
<div className="w-10 h-10 rounded-xl bg-slate-200 text-slate-600 flex items-center justify-center flex-shrink-0 border border-slate-300">
<Lock className="w-5 h-5" />
</div>
<div>
<h4 className="font-extrabold text-xs text-slate-800">{t('ticket_closed_title', 'This support ticket has been closed')}</h4>
<p className="text-[10px] text-slate-400 font-bold">{t('ticket_closed_desc', 'This discussion is locked. If the problem persists, please create a new support ticket.')}</p>
</div>
</div>
</div>
)}
</div>
</EmployerLayout>
);
}

View File

@ -27,7 +27,6 @@
// Unprotected Worker Mobile Auth Endpoints // Unprotected Worker Mobile Auth Endpoints
Route::get('/workers/config', [WorkerAuthController::class, 'config']); Route::get('/workers/config', [WorkerAuthController::class, 'config']);
Route::get('/workers/skills', [WorkerAuthController::class, 'skills']); Route::get('/workers/skills', [WorkerAuthController::class, 'skills']);
Route::get('/nationalities', [WorkerAuthController::class, 'nationalities']);
Route::post('/workers/send-otp', [WorkerAuthController::class, 'sendOtp']); Route::post('/workers/send-otp', [WorkerAuthController::class, 'sendOtp']);
Route::post('/workers/verify-otp', [WorkerAuthController::class, 'verifyOtp']); Route::post('/workers/verify-otp', [WorkerAuthController::class, 'verifyOtp']);
Route::post('/workers/setup-profile', [WorkerAuthController::class, 'setupProfile']); Route::post('/workers/setup-profile', [WorkerAuthController::class, 'setupProfile']);
@ -76,12 +75,6 @@
// Report Management // Report Management
Route::post('/workers/report', [\App\Http\Controllers\Api\ReportController::class, 'reportFromWorker']); Route::post('/workers/report', [\App\Http\Controllers\Api\ReportController::class, 'reportFromWorker']);
Route::get('/workers/report-reasons', [\App\Http\Controllers\Api\ReportController::class, 'getReasonsForWorker']); Route::get('/workers/report-reasons', [\App\Http\Controllers\Api\ReportController::class, 'getReasonsForWorker']);
// Support Ticket Management (Worker)
Route::get('/workers/tickets', [\App\Http\Controllers\Api\SupportTicketController::class, 'getTicketsForWorker']);
Route::post('/workers/tickets', [\App\Http\Controllers\Api\SupportTicketController::class, 'createTicketFromWorker']);
Route::post('/workers/tickets/{id}/reply', [\App\Http\Controllers\Api\SupportTicketController::class, 'replyToTicketFromWorker']);
Route::get('/workers/tickets/{id}', [\App\Http\Controllers\Api\SupportTicketController::class, 'getTicketDetail']);
}); });
// Protected Employer Mobile Endpoints (Token Authenticated via Bearer Token) // Protected Employer Mobile Endpoints (Token Authenticated via Bearer Token)
@ -124,12 +117,6 @@
// Report Management // Report Management
Route::post('/employers/report', [\App\Http\Controllers\Api\ReportController::class, 'reportFromEmployer']); Route::post('/employers/report', [\App\Http\Controllers\Api\ReportController::class, 'reportFromEmployer']);
Route::get('/employers/report-reasons', [\App\Http\Controllers\Api\ReportController::class, 'getReasonsForEmployer']); Route::get('/employers/report-reasons', [\App\Http\Controllers\Api\ReportController::class, 'getReasonsForEmployer']);
// Support Ticket Management (Employer)
Route::get('/employers/tickets', [\App\Http\Controllers\Api\SupportTicketController::class, 'getTicketsForEmployer']);
Route::post('/employers/tickets', [\App\Http\Controllers\Api\SupportTicketController::class, 'createTicketFromEmployer']);
Route::post('/employers/tickets/{id}/reply', [\App\Http\Controllers\Api\SupportTicketController::class, 'replyToTicketFromEmployer']);
Route::get('/employers/tickets/{id}', [\App\Http\Controllers\Api\SupportTicketController::class, 'getTicketDetail']);
}); });
// Protected Sponsor Mobile Endpoints (Token Authenticated via Bearer Token) // Protected Sponsor Mobile Endpoints (Token Authenticated via Bearer Token)
@ -138,5 +125,4 @@
Route::get('/sponsors/profile', [SponsorController::class, 'getProfile']); Route::get('/sponsors/profile', [SponsorController::class, 'getProfile']);
Route::get('/sponsors/dashboard', [SponsorController::class, 'getDashboard']); Route::get('/sponsors/dashboard', [SponsorController::class, 'getDashboard']);
Route::get('/sponsors/charity-events', [SponsorController::class, 'getCharityEvents']); Route::get('/sponsors/charity-events', [SponsorController::class, 'getCharityEvents']);
Route::post('/sponsors/charity-events', [SponsorController::class, 'postCharityEvent']);
}); });

View File

@ -174,33 +174,23 @@
return redirect()->back(); return redirect()->back();
})->name('admin.reasons.delete'); })->name('admin.reasons.delete');
Route::get('/announcements', [\App\Http\Controllers\Admin\AdminExtraController::class, 'announcements'])->name('admin.announcements'); Route::get('/announcements', function () {
Route::post('/announcements/create', [\App\Http\Controllers\Admin\AdminExtraController::class, 'storeAnnouncement'])->name('admin.announcements.store'); return Inertia::render('Admin/Announcements/Index');
Route::post('/announcements/{id}/approve', [\App\Http\Controllers\Admin\AdminExtraController::class, 'approveAnnouncement'])->name('admin.announcements.approve'); })->name('admin.announcements');
Route::post('/announcements/{id}/reject', [\App\Http\Controllers\Admin\AdminExtraController::class, 'rejectAnnouncement'])->name('admin.announcements.reject');
Route::delete('/announcements/{id}', [\App\Http\Controllers\Admin\AdminExtraController::class, 'deleteAnnouncement'])->name('admin.announcements.delete');
// New Modules Added // New Modules Added
Route::get('/safety', [\App\Http\Controllers\Admin\AdminExtraController::class, 'safety'])->name('admin.safety'); Route::get('/safety', [\App\Http\Controllers\Admin\AdminExtraController::class, 'safety'])->name('admin.safety');
Route::post('/safety/reports/{id}/resolve', [\App\Http\Controllers\Admin\AdminExtraController::class, 'resolveSafetyReport'])->name('admin.safety.resolve-report'); Route::post('/safety/reports/{id}/resolve', [\App\Http\Controllers\Admin\AdminExtraController::class, 'resolveSafetyReport'])->name('admin.safety.resolve-report');
// Disputes have been consolidated into Help & Support module Route::get('/disputes', [\App\Http\Controllers\Admin\AdminExtraController::class, 'disputes'])->name('admin.disputes');
// Route::get('/disputes', [\App\Http\Controllers\Admin\AdminExtraController::class, 'disputes'])->name('admin.disputes'); Route::post('/disputes/{id}/resolve', [\App\Http\Controllers\Admin\AdminExtraController::class, 'resolveDispute'])->name('admin.disputes.resolve');
// Route::post('/disputes/{id}/resolve', [\App\Http\Controllers\Admin\AdminExtraController::class, 'resolveDispute'])->name('admin.disputes.resolve'); Route::post('/disputes/{id}/add-note', [\App\Http\Controllers\Admin\AdminExtraController::class, 'addDisputeNote'])->name('admin.disputes.add-note');
// Route::post('/disputes/{id}/add-note', [\App\Http\Controllers\Admin\AdminExtraController::class, 'addDisputeNote'])->name('admin.disputes.add-note');
Route::get('/notifications', [\App\Http\Controllers\Admin\AdminExtraController::class, 'notifications'])->name('admin.notifications'); Route::get('/notifications', [\App\Http\Controllers\Admin\AdminExtraController::class, 'notifications'])->name('admin.notifications');
Route::post('/notifications/broadcast', [\App\Http\Controllers\Admin\AdminExtraController::class, 'broadcastNotification'])->name('admin.notifications.broadcast'); Route::post('/notifications/broadcast', [\App\Http\Controllers\Admin\AdminExtraController::class, 'broadcastNotification'])->name('admin.notifications.broadcast');
Route::get('/analytics', [\App\Http\Controllers\Admin\AdminExtraController::class, 'analytics'])->name('admin.analytics'); Route::get('/analytics', [\App\Http\Controllers\Admin\AdminExtraController::class, 'analytics'])->name('admin.analytics');
Route::get('/audit-logs', [\App\Http\Controllers\Admin\AdminExtraController::class, 'auditLogs'])->name('admin.audit-logs'); Route::get('/audit-logs', [\App\Http\Controllers\Admin\AdminExtraController::class, 'auditLogs'])->name('admin.audit-logs');
// Admin Support Tickets
Route::get('/tickets', [\App\Http\Controllers\Admin\SupportTicketController::class, 'index'])->name('admin.tickets.index');
Route::get('/tickets/{id}', [\App\Http\Controllers\Admin\SupportTicketController::class, 'show'])->name('admin.tickets.show');
Route::post('/tickets/{id}/reply', [\App\Http\Controllers\Admin\SupportTicketController::class, 'reply'])->name('admin.tickets.reply');
Route::post('/tickets/{id}/status', [\App\Http\Controllers\Admin\SupportTicketController::class, 'updateStatus'])->name('admin.tickets.update-status');
Route::post('/tickets/{id}/generate-dev-response', [\App\Http\Controllers\Admin\SupportTicketController::class, 'generateDevResponse'])->name('admin.tickets.generate-dev-response');
}); });
// Employer Auth Routes // Employer Auth Routes
@ -317,13 +307,6 @@
Route::post('/profile/update', [\App\Http\Controllers\Employer\ProfileController::class, 'update'])->name('employer.profile.update'); Route::post('/profile/update', [\App\Http\Controllers\Employer\ProfileController::class, 'update'])->name('employer.profile.update');
Route::get('/payments', [\App\Http\Controllers\Employer\PaymentController::class, 'index'])->name('employer.payments'); Route::get('/payments', [\App\Http\Controllers\Employer\PaymentController::class, 'index'])->name('employer.payments');
// Employer Support Tickets
Route::get('/support', [\App\Http\Controllers\Employer\SupportTicketController::class, 'index'])->name('employer.support.index');
Route::get('/support/create', [\App\Http\Controllers\Employer\SupportTicketController::class, 'create'])->name('employer.support.create');
Route::post('/support/create', [\App\Http\Controllers\Employer\SupportTicketController::class, 'store'])->name('employer.support.store');
Route::get('/support/{id}', [\App\Http\Controllers\Employer\SupportTicketController::class, 'show'])->name('employer.support.show');
Route::post('/support/{id}/reply', [\App\Http\Controllers\Employer\SupportTicketController::class, 'reply'])->name('employer.support.reply');
}); });
Route::get('/api/documentation', function () { Route::get('/api/documentation', function () {

View File

@ -78,7 +78,6 @@ public function test_worker_can_get_announcements()
'body' => 'Please keep your profile details updated.', 'body' => 'Please keep your profile details updated.',
'type' => 'info', 'type' => 'info',
'employer_id' => $this->employer->id, 'employer_id' => $this->employer->id,
'status' => 'approved',
]); ]);
$response = $this->withHeaders([ $response = $this->withHeaders([
@ -177,7 +176,6 @@ public function test_employer_can_create_announcement()
'title' => 'Holiday Notice', 'title' => 'Holiday Notice',
'employer_id' => $this->employer->id, 'employer_id' => $this->employer->id,
'type' => 'warning', 'type' => 'warning',
'status' => 'pending',
]); ]);
} }
@ -231,38 +229,4 @@ public function test_employer_cannot_delete_other_employers_announcement()
'id' => $announcement->id, 'id' => $announcement->id,
]); ]);
} }
/**
* Test worker cannot see pending announcements until approved.
*/
public function test_worker_cannot_see_pending_announcements_until_approved()
{
$announcement = Announcement::create([
'title' => 'Pending Ann',
'body' => 'Should not be seen.',
'type' => 'info',
'employer_id' => $this->employer->id,
'status' => 'pending',
]);
// Worker fetches announcements
$response = $this->withHeaders([
'Authorization' => 'Bearer ' . $this->workerToken,
])->getJson('/api/workers/announcements');
$response->assertStatus(200);
$this->assertCount(0, $response->json('data.announcements'));
// Admin approves it
$announcement->update(['status' => 'approved']);
// Worker fetches announcements again
$response = $this->withHeaders([
'Authorization' => 'Bearer ' . $this->workerToken,
])->getJson('/api/workers/announcements');
$response->assertStatus(200);
$this->assertCount(1, $response->json('data.announcements'));
$this->assertEquals('Pending Ann', $response->json('data.announcements.0.title'));
}
} }

View File

@ -165,210 +165,4 @@ public function test_sponsor_restricted_access()
$responseEmployerRoute->assertStatus(401); $responseEmployerRoute->assertStatus(401);
} }
/**
* Test that a sponsor can post a charity event.
*/
public function test_sponsor_can_post_charity_event()
{
$sponsor = Sponsor::create([
'full_name' => 'Test Sponsor',
'organization_name' => 'Save the Children',
'email' => 'tsponsor@example.com',
'mobile' => '+971509994444',
'password' => Hash::make('password123'),
'api_token' => 'sponsor_token_xyz',
'status' => 'active',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer sponsor_token_xyz',
])->postJson('/api/sponsors/charity-events', [
'title' => 'Community Ramadan Iftar',
'body' => 'Join us for a free community Iftar gathering at the center.',
'type' => 'charity',
]);
$response->assertStatus(201)
->assertJson([
'success' => true,
'message' => 'Charity event posted successfully.',
])
->assertJsonStructure([
'data' => [
'id',
'title',
'body',
'type',
'posted_by',
'organization',
'created_at',
]
]);
$this->assertDatabaseHas('announcements', [
'title' => 'Community Ramadan Iftar',
'sponsor_id' => $sponsor->id,
'status' => 'pending',
]);
}
/**
* Test that a sponsor can list charity events showing both employer and sponsor events.
*/
public function test_sponsor_can_list_charity_events()
{
$sponsor = Sponsor::create([
'full_name' => 'Test Sponsor',
'organization_name' => 'Save the Children',
'email' => 'tsponsor@example.com',
'mobile' => '+971509994444',
'password' => Hash::make('password123'),
'api_token' => 'sponsor_token_xyz',
'status' => 'active',
]);
// Post a sponsor announcement
\App\Models\Announcement::create([
'title' => 'Sponsor Event',
'body' => 'Body of sponsor event',
'type' => 'charity',
'sponsor_id' => $sponsor->id,
'status' => 'approved',
]);
// Post an employer announcement
$employer = User::create([
'name' => 'Employer User',
'email' => 'employer@example.com',
'password' => Hash::make('password123'),
'role' => 'employer',
]);
EmployerProfile::create([
'user_id' => $employer->id,
'company_name' => 'Employer Co',
'phone' => '+971509995555',
'country' => 'United Arab Emirates',
]);
\App\Models\Announcement::create([
'title' => 'Employer Event',
'body' => 'Body of employer event',
'type' => 'charity',
'employer_id' => $employer->id,
'status' => 'approved',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer sponsor_token_xyz',
])->getJson('/api/sponsors/charity-events');
$response->assertStatus(200)
->assertJson([
'success' => true,
]);
$events = $response->json('data.events');
$this->assertCount(2, $events);
// Verify sponsor event details
$this->assertEquals('Sponsor Event', $events[0]['title']);
$this->assertEquals('Test Sponsor', $events[0]['posted_by']);
$this->assertEquals('Save the Children', $events[0]['organization']);
// Verify employer event details
$this->assertEquals('Employer Event', $events[1]['title']);
$this->assertEquals('Employer User', $events[1]['posted_by']);
$this->assertEquals('Employer Co', $events[1]['organization']);
}
/**
* Test that sponsor dashboard returns correct employer and worker counts.
*/
public function test_sponsor_dashboard_returns_stats()
{
$sponsor = Sponsor::create([
'full_name' => 'Test Sponsor',
'organization_name' => 'Save the Children',
'email' => 'tsponsor@example.com',
'mobile' => '+971509994444',
'password' => Hash::make('password123'),
'api_token' => 'sponsor_token_xyz',
'status' => 'active',
]);
// Create some employers (Users with role = employer)
User::create([
'name' => 'Emp 1',
'email' => 'emp1@example.com',
'password' => Hash::make('password123'),
'role' => 'employer',
'subscription_status' => 'active',
]);
User::create([
'name' => 'Emp 2',
'email' => 'emp2@example.com',
'password' => Hash::make('password123'),
'role' => 'employer',
'subscription_status' => 'none',
]);
$category = \App\Models\WorkerCategory::create([
'name' => 'Housemaid',
]);
// Create some workers
\App\Models\Worker::create([
'name' => 'Worker 1',
'email' => 'w1@example.com',
'phone' => '+971509998881',
'password' => Hash::make('password123'),
'status' => 'active',
'language' => 'en',
'nationality' => 'Indian',
'city' => 'Dubai',
'age' => 25,
'salary' => 1500,
'experience' => 'None',
'religion' => 'Christian',
'bio' => 'Test',
'category_id' => $category->id,
'availability' => 'Immediate',
]);
\App\Models\Worker::create([
'name' => 'Worker 2',
'email' => 'w2@example.com',
'phone' => '+971509998882',
'password' => Hash::make('password123'),
'status' => 'hidden',
'language' => 'en',
'nationality' => 'Indian',
'city' => 'Dubai',
'age' => 25,
'salary' => 1500,
'experience' => 'None',
'religion' => 'Christian',
'bio' => 'Test',
'category_id' => $category->id,
'availability' => 'Immediate',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer sponsor_token_xyz',
])->getJson('/api/sponsors/dashboard');
$response->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'employer_stats' => [
'total' => 2,
'active' => 1,
],
'worker_stats' => [
'total' => 2,
'active' => 1,
],
],
]);
}
} }

View File

@ -498,89 +498,4 @@ public function test_update_profile_with_new_api_fields()
'area' => 'Khalifa City', 'area' => 'Khalifa City',
]); ]);
} }
/**
* Test nationalities list API with translations.
*/
public function test_nationalities_list_api()
{
// 1. Default (English)
$responseEn = $this->getJson('/api/nationalities?per_page=200');
$responseEn->assertStatus(200)
->assertJson([
'success' => true,
])
->assertJsonFragment([
'code' => 'IN',
'name' => 'Indian',
])
->assertJsonFragment([
'code' => 'PH',
'name' => 'Filipino',
]);
// 2. Hindi
$responseHi = $this->getJson('/api/nationalities?lang=hindi&per_page=200');
$responseHi->assertStatus(200)
->assertJsonFragment([
'code' => 'IN',
'name' => 'भारतीय',
]);
// 3. Swahili via header
$responseSw = $this->withHeaders([
'Accept-Language' => 'sw',
])->getJson('/api/nationalities?per_page=200');
$responseSw->assertStatus(200)
->assertJsonFragment([
'code' => 'IN',
'name' => 'Kihindi',
]);
// 4. Tagalog (taglo)
$responseTl = $this->getJson('/api/nationalities?lang=taglo&per_page=200');
$responseTl->assertStatus(200)
->assertJsonFragment([
'code' => 'IN',
'name' => 'Kano/Indian',
]);
// 5. Tamil
$responseTa = $this->getJson('/api/nationalities?lang=tamil&per_page=200');
$responseTa->assertStatus(200)
->assertJsonFragment([
'code' => 'IN',
'name' => 'இந்தியன்',
]);
// 6. Test Pagination structure
$this->flushHeaders();
$responsePaginated = $this->getJson('/api/nationalities?page=2&per_page=5');
$responsePaginated->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'nationalities',
'pagination' => [
'total',
'per_page',
'current_page',
'last_page'
]
]
]);
$this->assertCount(5, $responsePaginated->json('data.nationalities'));
$this->assertEquals(2, $responsePaginated->json('data.pagination.current_page'));
// 7. Test Search functionality
$this->flushHeaders();
$responseSearch = $this->getJson('/api/nationalities?search=Indian');
$responseSearch->assertStatus(200);
$this->assertGreaterThanOrEqual(1, count($responseSearch->json('data.nationalities')));
$responseSearch->assertJsonFragment([
'code' => 'IN',
'name' => 'Indian'
]);
}
} }

View File

@ -23,7 +23,7 @@ export default defineConfig({
port: 5173, port: 5173,
cors: true, cors: true,
hmr: { hmr: {
host: '192.168.0.194', host: '192.168.0.187',
}, },
watch: { watch: {
ignored: ['**/storage/framework/views/**'], ignored: ['**/storage/framework/views/**'],