Compare commits

..

7 Commits

Author SHA256 Message Date
divya abdar
7ef28e06ae order chnages 2025-12-19 11:18:55 +05:30
divya abdar
752f5ee873 order section changes 2025-12-19 11:12:06 +05:30
Abhishek Mali
3590e8f873 download option in invoide 2025-12-19 10:50:36 +05:30
Abhishek Mali
f6fb304b7a chat support download updated 2025-12-18 12:57:01 +05:30
Abhishek Mali
6b41a447bb chat support update 2025-12-17 19:49:14 +05:30
Abhishek Mali
5dc9fc7db4 chat support updates 2025-12-16 10:19:54 +05:30
Abhishek Mali
1aad6b231e chat support 2025-12-15 11:03:30 +05:30
44 changed files with 8204 additions and 1342 deletions

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Events;
use App\Models\ChatMessage;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Queue\SerializesModels;
class NewChatMessage implements ShouldBroadcastNow
{
use SerializesModels;
public $message;
/**
* Create a new event instance.
*/
public function __construct(ChatMessage $message)
{
// Also load sender polymorphic relationship
$message->load('sender');
$this->message = $message;
}
/**
* The channel the event should broadcast on.
*/
public function broadcastOn()
{
return [
new PrivateChannel('ticket.' . $this->message->ticket_id),
new PrivateChannel('admin.chat') // 👈 ADD THIS
];
}
/**
* Data sent to frontend (Blade + Flutter)
*/
public function broadcastWith()
{
\Log::info('APP_URL USED IN EVENT', [
'url' => config('app.url'),
]);
\Log::info("DEBUG: NewChatMessage broadcasting on channel ticket.".$this->message->ticket_id);
\Log::info("EVENT BROADCAST FIRED", [
'channel' => 'ticket.'.$this->message->ticket_id,
'sender_type' => $this->message->sender_type,
'sender_id' => $this->message->sender_id,
]);
return [
'id' => $this->message->id,
'ticket_id' => $this->message->ticket_id,
'sender_id' => $this->message->sender_id,
'sender_type' => $this->message->sender_type,
'message' => $this->message->message,
'client_id' => $this->message->client_id,
// ✅ relative path only
'file_path' => $this->message->file_path ?? null,
'file_type' => $this->message->file_type ?? null,
'sender' => [
'id' => $this->message->sender->id,
'name' => $this->getSenderName(),
'is_admin' => $this->message->sender_type === \App\Models\Admin::class,
],
'created_at' => $this->message->created_at->toDateTimeString(),
];
}
/**
* Helper to extract sender name
*/
private function getSenderName()
{
$sender = $this->message->sender;
// User has customer_name (in your app)
if ($this->message->sender_type === \App\Models\User::class) {
return $sender->customer_name ?? $sender->name ?? "User";
}
// Admin model has ->name
return $sender->name ?? "Admin";
}
public function broadcastAs()
{
return 'NewChatMessage';
}
}

View File

@@ -6,10 +6,11 @@ use App\Models\Order;
use Illuminate\Http\Request;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Carbon\Carbon;
class OrdersExport implements FromCollection, WithHeadings
{
protected $request;
protected Request $request;
public function __construct(Request $request)
{
@@ -18,61 +19,99 @@ class OrdersExport implements FromCollection, WithHeadings
private function buildQuery()
{
$query = Order::with(['markList', 'invoice', 'shipments']);
$query = Order::query()->with([
'markList',
'invoice',
'shipments',
]);
// SEARCH
if ($this->request->filled('search')) {
$search = $this->request->search;
$query->where(function($q) use ($search) {
$q->where('order_id', 'like', "%{$search}%")
->orWhereHas('markList', function($q2) use ($search) {
$search = trim($this->request->search);
$query->where(function ($q) use ($search) {
$q->where('orders.order_id', 'like', "%{$search}%")
->orWhereHas('markList', function ($q2) use ($search) {
$q2->where('company_name', 'like', "%{$search}%")
->orWhere('customer_id', 'like', "%{$search}%");
->orWhere('customer_id', 'like', "%{$search}%")
->orWhere('origin', 'like', "%{$search}%")
->orWhere('destination', 'like', "%{$search}%");
})
->orWhereHas('invoice', function($q3) use ($search) {
->orWhereHas('invoice', function ($q3) use ($search) {
$q3->where('invoice_number', 'like', "%{$search}%");
})
->orWhereHas('shipments', function ($q4) use ($search) {
// ✅ FIXED
$q4->where('shipments.shipment_id', 'like', "%{$search}%");
});
});
}
// INVOICE STATUS
// INVOICE STATUS (FIXED)
if ($this->request->filled('status')) {
$query->whereHas('invoice', function($q) {
$q->where('status', $this->request->status);
$query->where(function ($q) {
$q->whereHas('invoice', function ($q2) {
$q2->where('status', $this->request->status);
})
->orWhereDoesntHave('invoice');
});
}
// SHIPMENT STATUS (FIXED)
if ($this->request->filled('shipment')) {
$query->whereHas('shipments', function($q) {
$q->where('status', $this->request->shipment);
$query->where(function ($q) {
$q->whereHas('shipments', function ($q2) {
$q2->where('status', $this->request->shipment);
})
->orWhereDoesntHave('shipments');
});
}
return $query->latest('id');
// DATE RANGE
if ($this->request->filled('from_date')) {
$query->whereDate('orders.created_at', '>=', $this->request->from_date);
}
if ($this->request->filled('to_date')) {
$query->whereDate('orders.created_at', '<=', $this->request->to_date);
}
return $query->latest('orders.id');
}
public function collection()
{
$orders = $this->buildQuery()->get();
return $this->buildQuery()->get()->map(function ($order) {
// Map to simple array rows suitable for Excel
return $orders->map(function($order) {
$mark = $order->markList;
$invoice = $order->invoice;
$shipment = $order->shipments->first() ?? null;
$mark = $order->markList;
$invoice = $order->invoice;
$shipment = $order->shipments->first();
return [
'Order ID' => $order->order_id,
'Shipment ID' => $shipment->shipment_id ?? '-',
'Customer ID' => $mark->customer_id ?? '-',
'Company' => $mark->company_name ?? '-',
'Origin' => $mark->origin ?? $order->origin ?? '-',
'Destination' => $mark->destination ?? $order->destination ?? '-',
'Order Date' => $order->created_at ? $order->created_at->format('d-m-Y') : '-',
'Invoice No' => $invoice->invoice_number ?? '-',
'Invoice Date' => $invoice?->invoice_date ? \Carbon\Carbon::parse($invoice->invoice_date)->format('d-m-Y') : '-',
'Amount' => $invoice?->final_amount ? number_format($invoice->final_amount, 2) : '-',
'Amount + GST' => $invoice?->final_amount_with_gst ? number_format($invoice->final_amount_with_gst, 2) : '-',
'Invoice Status' => $invoice->status ? ucfirst($invoice->status) : 'Pending',
'Shipment Status' => $shipment?->status ? ucfirst(str_replace('_', ' ', $shipment->status)) : 'Pending',
'Order ID' => $order->order_id ?? '-',
'Shipment ID' => $shipment?->shipment_id ?? '-',
'Customer ID' => $mark?->customer_id ?? '-',
'Company' => $mark?->company_name ?? '-',
'Origin' => $mark?->origin ?? $order->origin ?? '-',
'Destination' => $mark?->destination ?? $order->destination ?? '-',
'Order Date' => $order->created_at
? $order->created_at->format('d-m-Y')
: '-',
'Invoice No' => $invoice?->invoice_number ?? '-',
'Invoice Date' => $invoice?->invoice_date
? Carbon::parse($invoice->invoice_date)->format('d-m-Y')
: '-',
'Amount' => $invoice?->final_amount !== null
? number_format($invoice->final_amount, 2)
: '0.00',
'Amount + GST' => $invoice?->final_amount_with_gst !== null
? number_format($invoice->final_amount_with_gst, 2)
: '0.00',
'Invoice Status' => ucfirst($invoice?->status ?? 'pending'),
'Shipment Status' => ucfirst(str_replace('_', ' ', $shipment?->status ?? 'pending')),
];
});
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\SupportTicket;
use App\Models\ChatMessage;
use App\Events\NewChatMessage;
class AdminChatController extends Controller
{
/**
* Page 1: List all active user chats
*/
public function index()
{
$tickets = SupportTicket::with('user')
->withCount([
'messages as unread_count' => function ($q) {
$q->where('sender_type', \App\Models\User::class)
->where('read_by_admin', false);
}
])
->orderBy('updated_at', 'desc')
->get();
return view('admin.chat_support', compact('tickets'));
}
/**
* Page 2: Open chat window for a specific user
*/
public function openChat($ticketId)
{
$ticket = SupportTicket::with('user')->findOrFail($ticketId);
// ✅ MARK USER MESSAGES AS READ FOR ADMIN
ChatMessage::where('ticket_id', $ticketId)
->where('sender_type', \App\Models\User::class)
->where('read_by_admin', false)
->update(['read_by_admin' => true]);
$messages = ChatMessage::where('ticket_id', $ticketId)
->orderBy('created_at', 'asc')
->with('sender')
->get();
return view('admin.chat_window', compact('ticket', 'messages'));
}
/**
* Admin sends a message to the user
*/
public function sendMessage(Request $request, $ticketId)
{
$request->validate([
'message' => 'nullable|string',
'file' => 'nullable|file|max:20480', // 20 MB
]);
$ticket = SupportTicket::findOrFail($ticketId);
$admin = auth('admin')->user();
$data = [
'ticket_id' => $ticketId,
'sender_id' => $admin->id,
'sender_type' => \App\Models\Admin::class,
'message' => $request->message,
'read_by_admin' => true,
'read_by_user' => false,
];
// File Upload
if ($request->hasFile('file')) {
$path = $request->file('file')->store('chat', 'public');
$data['file_path'] = $path;
$data['file_type'] = $request->file('file')->getMimeType();
}
// Save message
$message = ChatMessage::create($data);
$message->load('sender');
\Log::info("DEBUG: ChatController sendMessage called", [
'ticket_id' => $ticketId,
'payload' => $request->all()
]);
// Broadcast real-time
broadcast(new NewChatMessage($message));
\Log::info("DEBUG: ChatController sendMessage called 79", [
'ticket_id' => $ticketId,
'payload' => $request->all()
]);
return response()->json([
'success' => true,
'message' => $message
]);
}
}

View File

@@ -183,6 +183,8 @@ class AdminInvoiceController extends Controller
// Mark as 'paid' if GST-inclusive total is cleared
if ($newPaid >= $invoice->final_amount_with_gst) {
$invoice->update(['status' => 'paid']);
$this->generateInvoicePDF($invoice);
}
return response()->json([
@@ -210,6 +212,8 @@ class AdminInvoiceController extends Controller
// Update status if not fully paid anymore
if ($remaining > 0 && $invoice->status === "paid") {
$invoice->update(['status' => 'pending']);
$this->generateInvoicePDF($invoice);
}
return response()->json([

View File

@@ -365,6 +365,128 @@ class AdminOrderController extends Controller
}
public function see($id)
{
$order = Order::with([
'markList',
'items',
'invoice.items',
'shipments' => function ($q) use ($id) {
$q->whereHas('orders', function ($oq) use ($id) {
$oq->where('orders.id', $id)
->whereNull('orders.deleted_at');
})->with([
'orders' => function ($oq) use ($id) {
$oq->where('orders.id', $id)
->whereNull('orders.deleted_at')
->with('items');
}
]);
}
])->findOrFail($id);
/* ---------------- ORDER DATA ---------------- */
$orderData = [
'order_id' => $order->order_id,
'status' => $order->status,
'totals' => [
'ctn' => $order->ctn,
'qty' => $order->qty,
'ttl_qty' => $order->ttl_qty,
'cbm' => $order->cbm,
'ttl_cbm' => $order->ttl_cbm,
'kg' => $order->kg,
'ttl_kg' => $order->ttl_kg,
'amount' => $order->ttl_amount,
],
'items' => $order->items,
];
/* ---------------- SHIPMENTS DATA ---------------- */
$shipmentsData = [];
foreach ($order->shipments as $shipment) {
$shipmentOrders = [];
$totals = [
'ctn' => 0, 'qty' => 0, 'ttl_qty' => 0,
'cbm' => 0, 'ttl_cbm' => 0,
'kg' => 0, 'ttl_kg' => 0,
'amount' => 0,
];
foreach ($shipment->orders as $shipOrder) {
foreach ($shipOrder->items as $item) {
$shipmentOrders[] = [
'order_id' => $shipOrder->order_id,
'origin' => $shipOrder->origin,
'destination' => $shipOrder->destination,
'description' => $item->description,
'ctn' => $item->ctn,
'qty' => $item->qty,
'ttl_qty' => $item->ttl_qty,
'amount' => $item->ttl_amount,
];
$totals['ctn'] += $item->ctn;
$totals['qty'] += $item->qty;
$totals['ttl_qty'] += $item->ttl_qty;
$totals['cbm'] += $item->cbm;
$totals['ttl_cbm'] += $item->ttl_cbm;
$totals['kg'] += $item->kg;
$totals['ttl_kg'] += $item->ttl_kg;
$totals['amount'] += $item->ttl_amount;
}
}
if (empty($shipmentOrders)) {
continue;
}
$shipmentsData[] = [
'shipment_id' => $shipment->shipment_id,
'status' => $shipment->status,
'date' => $shipment->shipment_date,
'total_orders' => 1,
'orders' => $shipmentOrders,
'totals' => $totals,
];
}
/* ---------------- INVOICE DATA ---------------- */
$invoiceData = null;
if ($order->invoice) {
$invoice = $order->invoice;
$invoiceData = [
'invoice_no' => $invoice->invoice_number,
'status' => $invoice->status,
'invoice_date' => $invoice->invoice_date,
'due_date' => $invoice->due_date,
'customer' => [
'name' => $invoice->customer_name,
'mobile' => $invoice->customer_mobile,
'email' => $invoice->customer_email,
'address' => $invoice->customer_address,
'pincode' => $invoice->pincode,
],
'items' => $invoice->items,
'summary' => [
'amount' => $invoice->final_amount,
'cgst' => 0,
'sgst' => 0,
'total' => $invoice->final_amount_with_gst,
],
];
}
return view('admin.see_order', compact(
'order',
'orderData',
'shipmentsData',
'invoiceData'
));
}
public function resetTemp()
@@ -375,89 +497,143 @@ class AdminOrderController extends Controller
->with('success', 'Order reset successfully.');
}
public function orderShow()
{
$orders = Order::with([
'markList', // company, customer, origin, destination, date
'shipments', // shipment_id, shipment_date, status
'invoice' // invoice number, dates, amounts, status
])
->latest('id') // show latest orders first
->get();
public function orderShow()
{
$orders = Order::with([
'markList', // company, customer, origin, destination, date
'shipments', // shipment_id, shipment_date, status
'invoice' // invoice number, dates, amounts, status
])
->latest('id') // show latest orders first
->get();
return view('admin.orders', compact('orders'));
}
// inside AdminOrderController
return view('admin.orders', compact('orders'));
}
// inside AdminOrderController
private function buildOrdersQueryFromRequest(Request $request)
{
$query = Order::with(['markList', 'invoice', 'shipments']);
{
$query = Order::query()
->with(['markList', 'invoice', 'shipments']);
// Search across order_id, markList.company_name, markList.customer_id, invoice.invoice_number
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('order_id', 'like', "%{$search}%")
->orWhereHas('markList', function($q2) use ($search) {
$q2->where('company_name', 'like', "%{$search}%")
->orWhere('customer_id', 'like', "%{$search}%");
})
->orWhereHas('invoice', function($q3) use ($search) {
$q3->where('invoice_number', 'like', "%{$search}%");
/* ----------------------------------
| SEARCH FILTER
|----------------------------------*/
if ($request->filled('search')) {
$search = trim($request->search);
$query->where(function ($q) use ($search) {
$q->where('orders.order_id', 'like', "%{$search}%")
->orWhereHas('markList', function ($q2) use ($search) {
$q2->where('company_name', 'like', "%{$search}%")
->orWhere('customer_id', 'like', "%{$search}%")
->orWhere('origin', 'like', "%{$search}%")
->orWhere('destination', 'like', "%{$search}%");
})
->orWhereHas('invoice', function ($q3) use ($search) {
$q3->where('invoice_number', 'like', "%{$search}%");
})
->orWhereHas('shipments', function ($q4) use ($search) {
// ✅ VERY IMPORTANT: table name added
$q4->where('shipments.shipment_id', 'like', "%{$search}%");
});
});
});
}
/* ----------------------------------
| INVOICE STATUS FILTER
|----------------------------------*/
if ($request->filled('status')) {
$query->where(function ($q) use ($request) {
$q->whereHas('invoice', function ($q2) use ($request) {
$q2->where('status', $request->status);
})
->orWhereDoesntHave('invoice');
});
}
/* ----------------------------------
| SHIPMENT STATUS FILTER
|----------------------------------*/
if ($request->filled('shipment')) {
$query->where(function ($q) use ($request) {
$q->whereHas('shipments', function ($q2) use ($request) {
$q2->where('status', $request->shipment);
})
->orWhereDoesntHave('shipments');
});
}
/* ----------------------------------
| DATE RANGE FILTER (🔥 FIXED)
|----------------------------------*/
if ($request->filled('from_date')) {
$query->whereDate('orders.created_at', '>=', $request->from_date);
}
if ($request->filled('to_date')) {
$query->whereDate('orders.created_at', '<=', $request->to_date);
}
return $query->latest('orders.id');
}
// Invoice status filter
if ($request->filled('status')) {
$query->whereHas('invoice', function($q) use ($request) {
$q->where('status', $request->status);
});
}
// Shipment status filter
if ($request->filled('shipment')) {
$query->whereHas('shipments', function($q) use ($request) {
$q->where('status', $request->shipment);
});
}
// optional ordering
$query->latest('id');
return $query;
}
public function downloadPdf(Request $request)
{
// Build same filtered query used for table
// $query = $this->buildOrdersQueryFromRequest($request);
public function downloadPdf(Request $request)
{
// Build same filtered query used for table
$query = $this->buildOrdersQueryFromRequest($request);
// $orders = $query->get();
$orders = $query->get();
// // optional: pass filters to view for header
// $filters = [
// 'search' => $request->search ?? null,
// 'status' => $request->status ?? null,
// 'shipment' => $request->shipment ?? null,
// ];
// $pdf = PDF::loadView('admin.orders.pdf', compact('orders', 'filters'))
// ->setPaper('a4', 'landscape'); // adjust if needed
// $fileName = 'orders-report'
// . ($filters['status'] ? "-{$filters['status']}" : '')
// . '-' . date('Y-m-d') . '.pdf';
// return $pdf->download($fileName);
$orders = $this->buildOrdersQueryFromRequest($request)->get();
// optional: pass filters to view for header
$filters = [
'search' => $request->search ?? null,
'status' => $request->status ?? null,
'shipment' => $request->shipment ?? null,
'search' => $request->search,
'status' => $request->status,
'shipment' => $request->shipment,
'from' => $request->from_date,
'to' => $request->to_date,
];
$pdf = PDF::loadView('admin.orders.pdf', compact('orders', 'filters'))
->setPaper('a4', 'landscape'); // adjust if needed
->setPaper('a4', 'landscape');
$fileName = 'orders-report'
. ($filters['status'] ? "-{$filters['status']}" : '')
. '-' . date('Y-m-d') . '.pdf';
return $pdf->download(
'orders-report-' . now()->format('Y-m-d') . '.pdf'
);
}
return $pdf->download($fileName);
}
public function downloadExcel(Request $request)
{
// pass request to OrdersExport which will build Filtered query internally
return Excel::download(new OrdersExport($request), 'orders-report-' . date('Y-m-d') . '.xlsx');
}
public function downloadExcel(Request $request)
{
// pass request to OrdersExport which will build Filtered query internally
// return Excel::download(new OrdersExport($request), 'orders-report-' . date('Y-m-d') . '.xlsx');
return Excel::download(
new OrdersExport($request),
'orders-report-' . now()->format('Y-m-d') . '.xlsx'
);
}
public function addTempItem(Request $request)

View File

@@ -6,76 +6,48 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
use App\Models\User;
use Illuminate\Support\Facades\Log;
class UserAuthController extends Controller
{
public function refreshToken()
{
\Log::info('🔄 refreshToken() called');
public function refreshToken()
{
Log::info('🔄 [JWT-REFRESH] called');
try {
// Get current token
$currentToken = JWTAuth::getToken();
try {
$newToken = JWTAuth::parseToken()->refresh();
if (!$currentToken) {
\Log::warning('⚠ No token provided in refreshToken()');
return response()->json([
'success' => false,
'message' => 'Token not provided',
], 401);
}
Log::info('✅ [JWT-REFRESH] Token refreshed');
\Log::info('📥 Current Token:', ['token' => (string) $currentToken]);
return response()->json([
'success' => true,
'token' => $newToken,
]);
// Try refreshing token
$newToken = JWTAuth::refresh($currentToken);
} catch (\PHPOpenSourceSaver\JWTAuth\Exceptions\TokenExpiredException $e) {
Log::warning('⛔ [JWT-REFRESH] Refresh TTL expired');
\Log::info('✅ Token refreshed successfully', ['new_token' => $newToken]);
return response()->json([
'success' => false,
'message' => 'Refresh expired. Please login again.',
], 401);
return response()->json([
'success' => true,
'token' => $newToken,
]);
} catch (\Exception $e) {
Log::error('🔥 [JWT-REFRESH] Exception', [
'error' => $e->getMessage(),
]);
} catch (\Tymon\JWTAuth\Exceptions\TokenExpiredException $e) {
\Log::error('❌ TokenExpiredException in refreshToken()', [
'message' => $e->getMessage(),
]);
return response()->json([
'success' => false,
'message' => 'Token expired, cannot refresh.',
], 401);
} catch (\Tymon\JWTAuth\Exceptions\TokenInvalidException $e) {
\Log::error('❌ TokenInvalidException in refreshToken()', [
'message' => $e->getMessage(),
]);
return response()->json([
'success' => false,
'message' => 'Invalid token.',
], 401);
} catch (\Tymon\JWTAuth\Exceptions\JWTException $e) {
\Log::error('❌ JWTException in refreshToken()', [
'message' => $e->getMessage(),
]);
return response()->json([
'success' => false,
'message' => 'Could not refresh token.',
], 401);
} catch (\Exception $e) {
\Log::error('❌ General Exception in refreshToken()', [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'success' => false,
'message' => 'Unexpected error while refreshing token.',
], 500);
}
return response()->json([
'success' => false,
'message' => 'Unable to refresh token.',
], 401);
}
}
/**
* User Login

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\SupportTicket;
use App\Models\ChatMessage;
use App\Events\NewChatMessage;
class ChatController extends Controller
{
/**
* Start chat or return existing ticket for this user
*/
public function startChat()
{
// One chat ticket per user
$ticket = SupportTicket::firstOrCreate([
'user_id' => auth()->id(),
]);
return response()->json([
'success' => true,
'ticket' => $ticket
]);
}
/**
* Load all messages for this ticket
*/
public function getMessages($ticketId)
{
// Ensure this ticket belongs to the logged-in user
$ticket = SupportTicket::where('id', $ticketId)
->where('user_id', auth()->id())
->firstOrFail();
$messages = ChatMessage::where('ticket_id', $ticketId)
->orderBy('created_at', 'asc')
->with('sender')
->get();
return response()->json([
'success' => true,
'messages' => $messages
]);
}
/**
* Send text or file message from user admin/staff
*/
public function sendMessage(Request $request, $ticketId)
{
$request->validate([
'message' => 'nullable|string',
'file' => 'nullable|file|max:20480', // 20MB limit
]);
// Validate ticket ownership
$ticket = SupportTicket::where('id', $ticketId)
->where('user_id', auth()->id())
->firstOrFail();
$data = [
'ticket_id' => $ticketId,
'sender_id' => auth()->id(),
'sender_type' => \App\Models\User::class,
'message' => $request->message,
'client_id' => $request->client_id, // ✅ ADD
'read_by_admin' => false,
'read_by_user' => true,
];
// Handle file upload
if ($request->hasFile('file')) {
$path = $request->file('file')->store('chat', 'public');
$data['file_path'] = $path;
$data['file_type'] = $request->file('file')->getMimeType();
}
// Save message
$message = ChatMessage::create($data);
// Load sender info for broadcast
$message->load('sender');
// Fire real-time event
broadcast(new NewChatMessage($message));
return response()->json([
'success' => true,
'message' => $message
]);
}
}

View File

@@ -11,7 +11,8 @@ use Tymon\JWTAuth\Exceptions\JWTException;
class JwtRefreshMiddleware
{
public function handle($request, Closure $next)
{
{
try {
JWTAuth::parseToken()->authenticate();
} catch (TokenExpiredException $e) {

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class ChatMessage extends Model
{
use HasFactory;
protected $fillable = [
'ticket_id',
'sender_id',
'sender_type',
'message',
'file_path',
'file_type',
'read_by_admin',
'read_by_user',
'client_id',
];
/**
* The ticket this message belongs to.
*/
public function ticket()
{
return $this->belongsTo(SupportTicket::class, 'ticket_id');
}
/**
* Polymorphic sender (User or Admin)
*/
public function sender()
{
return $this->morphTo();
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class SupportTicket extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'status',
];
/**
* The user (customer) who owns this ticket.
*/
public function user()
{
return $this->belongsTo(User::class);
}
/**
* All chat messages for this ticket.
*/
public function messages()
{
return $this->hasMany(ChatMessage::class, 'ticket_id')->orderBy('created_at', 'asc');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\ServiceProvider;
class BroadcastServiceProvider extends ServiceProvider
{
public function boot(): void
{
Broadcast::routes([
'middleware' => ['web'],
]);
// 👇 FORCE admin guard for broadcasting
Auth::shouldUse('admin');
require base_path('routes/channels.php');
}
}

View File

@@ -6,13 +6,18 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
web: [
__DIR__.'/../routes/web.php',
__DIR__.'/../routes/channels.php',
],
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
->withMiddleware(function (Middleware $middleware) {
//
})
->withExceptions(function (Exceptions $exceptions): void {
->withExceptions(function (Exceptions $exceptions) {
//
})->create();
})
->create();

View File

@@ -4,4 +4,6 @@ return [
App\Providers\AppServiceProvider::class,
App\Providers\RouteServiceProvider::class,
App\Providers\AuthServiceProvider::class,
App\Providers\BroadcastServiceProvider::class,
];

View File

@@ -9,8 +9,9 @@
"php": "^8.2",
"barryvdh/laravel-dompdf": "^3.1",
"laravel/framework": "^12.0",
"laravel/reverb": "^1.6",
"laravel/tinker": "^2.10.1",
"maatwebsite/excel": "^1.1",
"maatwebsite/excel": "^3.1",
"mpdf/mpdf": "^8.2",
"php-open-source-saver/jwt-auth": "2.8",
"spatie/laravel-permission": "^6.23"

1561
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -89,6 +89,8 @@ return [
'driver' => 'eloquent',
'model' => App\Models\Staff::class,
],
],

31
config/broadcasting.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
return [
'default' => env('BROADCAST_DRIVER', 'null'),
'connections' => [
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT'),
'scheme' => env('REVERB_SCHEME'),
'useTLS' => false,
],
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];

View File

@@ -89,7 +89,7 @@ return [
|
*/
'ttl' => (int) env('JWT_TTL', 60),
'ttl' => (int) env('JWT_TTL', 15),
/*
|--------------------------------------------------------------------------
@@ -108,7 +108,7 @@ return [
|
*/
'refresh_ttl' => (int) env('JWT_REFRESH_TTL', 20160),
'refresh_ttl' => (int) env('JWT_REFRESH_TTL', 60),
/*
|--------------------------------------------------------------------------

96
config/reverb.php Normal file
View File

@@ -0,0 +1,96 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
*/
'default' => env('REVERB_SERVER', 'reverb'),
/*
|--------------------------------------------------------------------------
| Reverb Servers
|--------------------------------------------------------------------------
*/
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'), // WebSocket listens here
'port' => env('REVERB_SERVER_PORT', 8080), // WebSocket port
'path' => env('REVERB_SERVER_PATH', ''),
// Used for Echo client hostname
'hostname' => env('REVERB_HOST', 'localhost'),
'options' => [
'tls' => [], // No TLS for localhost
],
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10000),
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
],
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
/*
|--------------------------------------------------------------------------
| Reverb Applications
|--------------------------------------------------------------------------
*/
'apps' => [
'provider' => 'config',
'apps' => [
[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
/*
|--------------------------------------------------------------------------
| Echo + Flutter Client Options
|--------------------------------------------------------------------------
*/
'options' => [
'host' => env('REVERB_HOST', 'localhost'), // for client connections
'port' => env('REVERB_PORT', 8080), // SAME as WebSocket server port
'scheme' => env('REVERB_SCHEME', 'http'),
'useTLS' => false,
],
/*
|--------------------------------------------------------------------------
| Allowed Origins (Important)
|--------------------------------------------------------------------------
|
| "*" allows all origins:
| - Flutter (Android/iOS/Web)
| - Admin Panel
| - Localhost
|
*/
'allowed_origins' => ['*'],
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10000),
],
],
],
];

View File

@@ -13,16 +13,28 @@ return new class extends Migration
{
Schema::create('chat_messages', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('ticket_id'); // support ticket ID
$table->unsignedBigInteger('sender_id'); // user or admin/staff
$table->text('message')->nullable(); // message content
$table->string('file_path')->nullable(); // image/pdf/video
$table->string('file_type')->default('text'); // text/image/pdf/video
// Chat belongs to a ticket
$table->unsignedBigInteger('ticket_id');
// POLYMORPHIC sender (User OR Admin)
$table->unsignedBigInteger('sender_id');
$table->string('sender_type');
// Example values:
// - "App\Models\User"
// - "App\Models\Admin"
// Content
$table->text('message')->nullable();
$table->string('file_path')->nullable(); // storage/app/public/chat/...
$table->string('file_type')->default('text'); // text / image / video / pdf
$table->timestamps();
// foreign keys
$table->foreign('ticket_id')->references('id')->on('support_tickets')->onDelete('cascade');
$table->foreign('sender_id')->references('id')->on('users')->onDelete('cascade'); // admin also stored in users table? If admin separate, change later.
// FK to tickets table
$table->foreign('ticket_id')
->references('id')->on('support_tickets')
->onDelete('cascade');
});
}

View File

@@ -0,0 +1,30 @@
<?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('chat_messages', function (Blueprint $table) {
$table->boolean('read_by_admin')->default(false)->after('file_type');
$table->boolean('read_by_user')->default(false)->after('read_by_admin');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('chat_messages', function (Blueprint $table) {
//
});
}
};

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('chat_messages', function (Blueprint $table) {
$table->string('client_id')
->nullable()
->after('sender_type')
->index();
});
}
public function down(): void
{
Schema::table('chat_messages', function (Blueprint $table) {
$table->dropColumn('client_id');
});
}
};

2525
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -13,5 +13,9 @@
"laravel-vite-plugin": "^2.0.0",
"tailwindcss": "^4.0.0",
"vite": "^7.0.7"
},
"dependencies": {
"laravel-echo": "^2.2.6",
"pusher-js": "^8.4.0"
}
}

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -1 +1,6 @@
import './bootstrap';
import "./bootstrap";
// VERY IMPORTANT — Load Echo globally
import "./echo";
console.log("[APP] app.js loaded");

View File

@@ -1,4 +1,9 @@
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
axios.defaults.withCredentials = true;
axios.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest";
axios.defaults.headers.common["X-CSRF-TOKEN"] = document.querySelector(
'meta[name="csrf-token"]'
).content;

30
resources/js/echo.js Normal file
View File

@@ -0,0 +1,30 @@
import Echo from "laravel-echo";
import Pusher from "pusher-js";
window.Pusher = Pusher;
console.log("[ECHO] Initializing Reverb...");
window.Echo = new Echo({
broadcaster: "reverb",
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: Number(import.meta.env.VITE_REVERB_PORT),
forceTLS: false,
disableStats: true,
authEndpoint: "/broadcasting/auth",
auth: {
headers: {
"X-CSRF-TOKEN": document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content"),
"X-Requested-With": "XMLHttpRequest",
},
},
});
console.log("[ECHO] Loaded Successfully!", window.Echo);

View File

@@ -1,12 +1,141 @@
@extends('admin.layouts.app')
@section('page-title', 'Dashboard')
@section('page-title', 'Chat Support')
@section('content')
<div class="card shadow-sm">
<div class="card-body">
<h4>Welcome to the Admin chat</h4>
<p>Here you can manage all system modules.</p>
</div>
<div class="container py-4">
<h2 class="mb-4 fw-bold">Customer Support Chat</h2>
<div class="card shadow-sm">
<div class="card-body p-0">
@if($tickets->count() === 0)
<div class="p-4 text-center text-muted">
<h5>No customer chats yet.</h5>
</div>
@else
<ul class="list-group list-group-flush">
@foreach($tickets as $ticket)
@php
// Get last message
$lastMsg = $ticket->messages()->latest()->first();
@endphp
<li class="list-group-item py-3">
<div class="d-flex align-items-center justify-content-between">
<!-- LEFT -->
<div class="d-flex align-items-center gap-3">
<!-- Avatar -->
<div class="rounded-circle bg-primary text-white d-flex align-items-center justify-content-center"
style="width: 45px; height: 45px; font-size: 18px;">
{{ strtoupper(substr($ticket->user->customer_name ?? $ticket->user->name, 0, 1)) }}
</div>
<div>
<!-- Name + unread badge -->
<h6 class="mb-1 fw-semibold">
{{ $ticket->user->customer_name ?? $ticket->user->name }}
<span
id="badge-{{ $ticket->id }}"
class="badge bg-danger ms-2 {{ $ticket->unread_count == 0 ? 'd-none' : '' }}">
{{ $ticket->unread_count }}
</span>
</h6>
<!-- Last message -->
<small class="text-muted">
@if($lastMsg)
@if($lastMsg->message)
{{ Str::limit($lastMsg->message, 35) }}
@elseif(Str::startsWith($lastMsg->file_type, 'image'))
📷 Image
@else
📎 Attachment
@endif
@else
<i>No messages yet</i>
@endif
</small>
</div>
</div>
<!-- RIGHT -->
<div class="text-end">
<span class="badge {{ $ticket->status === 'open' ? 'bg-success' : 'bg-danger' }}">
{{ ucfirst($ticket->status) }}
</span>
<a href="{{ route('admin.chat.open', $ticket->id) }}"
class="btn btn-sm btn-primary ms-2">
Open Chat
</a>
</div>
</div>
</li>
@endforeach
</ul>
@endif
</div>
</div>
</div>
@section('scripts')
<script>
// -------------------------------
// WAIT FOR ECHO READY (DEFINE IT)
// -------------------------------
function waitForEcho(callback, retries = 40) {
if (window.Echo) {
console.log('%c[ECHO] Ready (Admin List)', 'color: green; font-weight: bold;');
callback();
return;
}
if (retries <= 0) {
console.error('[ECHO] Failed to initialize');
return;
}
setTimeout(() => waitForEcho(callback, retries - 1), 200);
}
// -------------------------------
// LISTEN FOR REALTIME MESSAGES
// -------------------------------
waitForEcho(() => {
console.log('[ADMIN LIST] Listening for new messages...');
window.Echo.private('admin.chat')
.listen('.NewChatMessage', (event) => {
// only USER → ADMIN messages
if (event.sender_type !== 'App\\Models\\User') return;
const badge = document.getElementById(`badge-${event.ticket_id}`);
if (!badge) return;
let count = parseInt(badge.innerText || 0);
badge.innerText = count + 1;
badge.classList.remove('d-none');
console.log('[ADMIN LIST] Badge updated for ticket', event.ticket_id);
});
});
</script>
@endsection
@endsection

View File

@@ -0,0 +1,268 @@
@extends('admin.layouts.app')
@section('page-title', 'Chat With ' . ($ticket->user->customer_name ?? $ticket->user->name))
@section('content')
<style>
.chat-box {
height: 70vh;
overflow-y: auto;
background: #f5f6fa;
border-radius: 8px;
padding: 15px;
}
.message {
max-width: 65%;
padding: 10px 14px;
border-radius: 15px;
margin-bottom: 10px;
font-size: 0.9rem;
line-height: 1.4;
}
.message.admin {
background: #007bff;
color: white;
margin-left: auto;
border-bottom-right-radius: 0;
}
.message.user {
background: #ffffff;
border: 1px solid #ddd;
margin-right: auto;
border-bottom-left-radius: 0;
}
.chat-input {
position: fixed;
bottom: 15px;
left: 250px;
right: 20px;
}
</style>
<div class="container py-4">
<div class="d-flex align-items-center mb-3">
<h4 class="fw-bold mb-0">
Chat With: {{ $ticket->user->customer_name ?? $ticket->user->name }}
</h4>
<span class="badge ms-3 {{ $ticket->status === 'open' ? 'bg-success' : 'bg-danger' }}">
{{ ucfirst($ticket->status) }}
</span>
</div>
<div id="chatBox" class="chat-box border shadow-sm">
@foreach($messages as $msg)
<div class="message {{ $msg->sender_type === 'App\\Models\\Admin' ? 'admin' : 'user' }}">
{{-- TEXT --}}
@if($msg->message)
<div>{{ $msg->message }}</div>
@endif
{{-- FILE --}}
@if($msg->file_path)
@php
$isImage = Str::startsWith($msg->file_type, 'image');
$isVideo = Str::startsWith($msg->file_type, 'video');
@endphp
{{-- IMAGE --}}
@if($isImage)
<img
src="{{ asset('storage/'.$msg->file_path) }}"
style="max-width:150px;"
class="rounded"
>
{{-- VIDEO --}}
@elseif($isVideo)
<video
src="{{ asset('storage/'.$msg->file_path) }}"
controls
style="max-width:200px; border-radius:8px;"
>
Your browser does not support the video tag.
</video>
{{-- PDF / EXCEL / OTHER --}}
@else
<a href="{{ asset('storage/'.$msg->file_path) }}" target="_blank">
📎 View Attachment
</a>
@endif
@endif
<small class="text-muted d-block mt-1">
{{ $msg->created_at->format('d M h:i A') }}
</small>
</div>
@endforeach
</div>
<div class="chat-input">
<div class="card shadow-sm">
<div class="card-body d-flex align-items-center gap-2">
<input type="text" id="messageInput" class="form-control" placeholder="Type your message...">
<input type="file" id="fileInput" class="form-control" style="max-width:200px;">
<button class="btn btn-primary" id="sendBtn">Send</button>
</div>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
// ✅ Make current admin ID available to JS
const CURRENT_ADMIN_ID = {{ auth('admin')->id() }};
console.log("CHAT WINDOW: script loaded");
// -------------------------------
// WAIT FOR ECHO READY
// -------------------------------
function waitForEcho(callback, retries = 40) {
if (window.Echo) {
console.log("%c[ECHO] Ready!", "color: green; font-weight: bold;", window.Echo);
return callback();
}
console.warn("[ECHO] Not ready. Retrying...");
if (retries <= 0) {
console.error("[ECHO] FAILED to initialize after retry limit");
return;
}
setTimeout(() => waitForEcho(callback, retries - 1), 200);
}
// Scroll chat down
function scrollToBottom() {
const el = document.getElementById("chatBox");
if (el) el.scrollTop = el.scrollHeight;
}
scrollToBottom();
// -------------------------------
// SEND MESSAGE (WORKING PART FROM SCRIPT #1)
// -------------------------------
document.getElementById("sendBtn").addEventListener("click", function () {
console.log("[SEND] Attempting to send message...");
let msg = document.getElementById("messageInput").value;
let file = document.getElementById("fileInput").files[0];
if (!msg.trim() && !file) {
alert("Please type something or upload a file.");
return;
}
let formData = new FormData();
formData.append("message", msg);
if (file) formData.append("file", file);
fetch("{{ route('admin.chat.send', $ticket->id) }}", {
method: "POST",
headers: { "X-CSRF-TOKEN": "{{ csrf_token() }}" },
body: formData
})
.then(res => res.json())
.then((response) => {
console.log("[SEND] Message sent:", response);
document.getElementById("messageInput").value = "";
document.getElementById("fileInput").value = "";
})
.catch(err => console.error("[SEND] Error:", err));
});
// -------------------------------
// LISTEN FOR REALTIME MESSAGE (WORKING PART FROM SCRIPT #2)
// ----------------------------
waitForEcho(() => {
const ticketId = "{{ $ticket->id }}";
console.log("[ECHO] Subscribing to PRIVATE channel:", `ticket.${ticketId}`);
window.Echo.private(`ticket.${ticketId}`)
.listen(".NewChatMessage", (event) => {
console.log("%c[REALTIME RECEIVED]", "color: blue; font-weight: bold;", event);
const msg = event; // ✅ flat payload
// ✅ CHECK IF THIS MESSAGE IS SENT BY CURRENT ADMIN
const isMine =
msg.sender_type === 'App\\Models\\Admin' &&
msg.sender_id === CURRENT_ADMIN_ID;
let html = `
<div class="message ${isMine ? 'admin' : 'user'}">
${msg.message ?? ''}
`;
if (msg.file_path) {
const fileUrl = `/storage/${msg.file_path}`;
if (msg.file_type?.startsWith("image")) {
html += `
<img
src="${fileUrl}"
class="rounded mt-2"
style="max-width:150px;"
>
`;
} else if (msg.file_type?.startsWith("video")) {
html += `
<video
src="${fileUrl}"
controls
class="mt-2"
style="max-width:200px; border-radius:8px;"
></video>
`;
} else {
html += `
<a href="${fileUrl}" target="_blank" class="mt-2 d-block">
📎 View Attachment
</a>
`;
}
}
html += `
<small class="text-muted d-block mt-1">Just now</small>
</div>
`;
document
.getElementById("chatBox")
.insertAdjacentHTML("beforeend", html);
scrollToBottom();
});
});
</script>
@endsection

View File

@@ -1,7 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Admin Panel</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet" />
@@ -220,6 +222,7 @@
font-weight: 500;
}
</style>
</head>
<body>
<div class="sidebar">
@@ -346,6 +349,8 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
@vite(['resources/js/app.js'])
@yield('scripts') <!-- REQUIRED FOR CHAT TO WORK -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const headerToggle = document.getElementById('headerToggle');

File diff suppressed because it is too large Load Diff

View File

@@ -11,60 +11,64 @@
</style>
</head>
<body>
<h3>Orders Report</h3>
@if(!empty($filters))
<p>
@if($filters['search']) Search: <strong>{{ $filters['search'] }}</strong> @endif
@if($filters['status']) &nbsp; | Status: <strong>{{ ucfirst($filters['status']) }}</strong> @endif
@if($filters['shipment']) &nbsp; | Shipment: <strong>{{ ucfirst($filters['shipment']) }}</strong> @endif
</p>
@endif
<h3>Orders Report</h3>
<table>
<thead>
@if(!empty($filters))
<p>
@if(!empty($filters['search'])) Search: <strong>{{ $filters['search'] }}</strong> @endif
@if(!empty($filters['status'])) | Status: <strong>{{ ucfirst($filters['status']) }}</strong> @endif
@if(!empty($filters['shipment'])) | Shipment: <strong>{{ ucfirst($filters['shipment']) }}</strong> @endif
</p>
@endif
<table>
<thead>
<tr>
<th>Order ID</th>
<th>Shipment ID</th>
<th>Customer ID</th>
<th>Company</th>
<th>Origin</th>
<th>Destination</th>
<th>Order Date</th>
<th>Invoice No</th>
<th>Invoice Date</th>
<th>Amount</th>
<th>Amount + GST</th>
<th>Invoice Status</th>
<th>Shipment Status</th>
</tr>
</thead>
<tbody>
@forelse($orders as $order)
@php
$mark = $order->markList;
$invoice = $order->invoice;
$shipment = $order->shipments->first();
@endphp
<tr>
<th>Order ID</th>
<th>Shipment ID</th>
<th>Customer ID</th>
<th>Company</th>
<th>Origin</th>
<th>Destination</th>
<th>Order Date</th>
<th>Invoice No</th>
<th>Invoice Date</th>
<th>Amount</th>
<th>Amount + GST</th>
<th>Invoice Status</th>
<th>Shipment Status</th>
<td>{{ $order->order_id }}</td>
<td>{{ $shipment?->shipment_id ?? '-' }}</td>
<td>{{ $mark?->customer_id ?? '-' }}</td>
<td>{{ $mark?->company_name ?? '-' }}</td>
<td>{{ $mark?->origin ?? $order->origin ?? '-' }}</td>
<td>{{ $mark?->destination ?? $order->destination ?? '-' }}</td>
<td>{{ $order->created_at?->format('d-m-Y') ?? '-' }}</td>
<td>{{ $invoice?->invoice_number ?? '-' }}</td>
<td>{{ $invoice?->invoice_date ? \Carbon\Carbon::parse($invoice->invoice_date)->format('d-m-Y') : '-' }}</td>
<td>{{ $invoice?->final_amount !== null ? number_format($invoice->final_amount, 2) : '-' }}</td>
<td>{{ $invoice?->final_amount_with_gst !== null ? number_format($invoice->final_amount_with_gst, 2) : '-' }}</td>
<td>{{ ucfirst($invoice?->status ?? 'Pending') }}</td>
<td>{{ ucfirst(str_replace('_',' ', $shipment?->status ?? 'Pending')) }}</td>
</tr>
</thead>
<tbody>
@forelse($orders as $order)
@php
$mark = $order->markList ?? null;
$invoice = $order->invoice ?? null;
$shipment = $order->shipments->first() ?? null;
@endphp
<tr>
<td>{{ $order->order_id }}</td>
<td>{{ $shipment->shipment_id ?? '-' }}</td>
<td>{{ $mark->customer_id ?? '-' }}</td>
<td>{{ $mark->company_name ?? '-' }}</td>
<td>{{ $mark->origin ?? $order->origin ?? '-' }}</td>
<td>{{ $mark->destination ?? $order->destination ?? '-' }}</td>
<td>{{ $order->created_at ? $order->created_at->format('d-m-Y') : '-' }}</td>
<td>{{ $invoice->invoice_number ?? '-' }}</td>
<td>{{ $invoice?->invoice_date ? \Carbon\Carbon::parse($invoice->invoice_date)->format('d-m-Y') : '-' }}</td>
<td>{{ $invoice?->final_amount ? number_format($invoice->final_amount, 2) : '-' }}</td>
<td>{{ $invoice?->final_amount_with_gst ? number_format($invoice->final_amount_with_gst, 2) : '-' }}</td>
<td>{{ $invoice->status ? ucfirst($invoice->status) : 'Pending' }}</td>
<td>{{ $shipment?->status ? ucfirst(str_replace('_',' ',$shipment->status)) : 'Pending' }}</td>
</tr>
@empty
<tr><td colspan="13" style="text-align:center">No orders found</td></tr>
@endforelse
</tbody>
</table>
@empty
<tr>
<td colspan="13" style="text-align:center">No orders found</td>
</tr>
@endforelse
</tbody>
</table>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,17 @@
<?php
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\RequestController;
use App\Http\Controllers\UserAuthController;
use App\Http\Controllers\MarkListController;
use App\Http\Controllers\User\UserOrderController;
use App\Http\Controllers\User\UserProfileController;
use App\Http\Controllers\User\ChatController;
//user send request
Route::post('/signup-request', [RequestController::class, 'usersignup']);
@@ -15,9 +20,10 @@ Route::post('/signup-request', [RequestController::class, 'usersignup']);
Route::post('/user/login', [UserAuthController::class, 'login']);
Route::post('/auth/refresh', [UserAuthController::class, 'refreshToken']);
Route::middleware(['auth:api'])->group(function () {
//Route::post('/user/refresh', [UserAuthController::class, 'refreshToken']);
Route::post('/user/logout', [UserAuthController::class, 'logout']);
@@ -32,9 +38,10 @@ Route::middleware(['auth:api'])->group(function () {
Route::get('/user/order/{order_id}/shipment', [UserOrderController::class, 'orderShipment']);
Route::get('/user/order/{order_id}/invoice', [UserOrderController::class, 'orderInvoice']);
Route::get('/user/order/{order_id}/track', [UserOrderController::class, 'trackOrder']);
Route::get('/user/invoice/{invoice_id}/details', [UserOrderController::class, 'invoiceDetails']);
// Invoice List
Route::get('/user/invoice/{invoice_id}/details', [UserOrderController::class, 'invoiceDetails']);
Route::get('/user/invoices', [UserOrderController::class, 'allInvoices']);
Route::get('/user/invoice/{invoice_id}/installments', [UserOrderController::class, 'invoiceInstallmentsById']);
@@ -46,4 +53,40 @@ Route::middleware(['auth:api'])->group(function () {
Route::post('/user/profile-update-request', [UserProfileController::class, 'updateProfileRequest']);
// Route::post('/user/profile/update', [UserProfileController::class, 'updateProfile']);
// ===========================
// CHAT SUPPORT ROUTES
// ===========================
Route::get('/user/chat/start', [ChatController::class, 'startChat']);
Route::get('/user/chat/messages/{ticketId}', [ChatController::class, 'getMessages']);
Route::post('/user/chat/send/{ticketId}', [ChatController::class, 'sendMessage']);
});
Route::post('/broadcasting/auth', function (Request $request) {
$user = auth('api')->user(); // JWT user (Flutter)
if (! $user) {
\Log::warning('BROADCAST AUTH FAILED - NO USER');
return response()->json(['message' => 'Unauthorized'], 401);
}
\Log::info('BROADCAST AUTH OK', [
'user_id' => $user->id,
'channel' => $request->channel_name,
]);
return Broadcast::auth(
$request->setUserResolver(fn () => $user)
);
});

78
routes/channels.php Normal file
View File

@@ -0,0 +1,78 @@
<?php
use Illuminate\Support\Facades\Broadcast;
use App\Models\SupportTicket;
use App\Models\Admin;
use Illuminate\Support\Facades\Log;
file_put_contents(storage_path('logs/broadcast_debug.log'), now()." CHANNELS LOADED\n", FILE_APPEND);
Broadcast::routes([
'middleware' => ['web', 'auth:admin'],
]);
Broadcast::channel('ticket.{ticketId}', function ($user, $ticketId) {
try {
// Very explicit logging to see what arrives here
Log::info("CHANNEL AUTH CHECK (ENTER)", [
'user_present' => $user !== null,
'user_type' => is_object($user) ? get_class($user) : gettype($user),
'user_id' => $user->id ?? null,
'ticketId' => $ticketId,
]);
// Find ticket and log
$ticket = SupportTicket::find($ticketId);
Log::info("CHANNEL AUTH: found ticket", [
'ticket_exists' => $ticket ? true : false,
'ticket_id' => $ticket?->id,
'ticket_user_id' => $ticket?->user_id,
]);
if (! $ticket) {
Log::warning("CHANNEL AUTH: ticket not found", ['ticketId' => $ticketId]);
return false;
}
// If admin, allow
if ($user instanceof Admin) {
Log::info("CHANNEL AUTH: admin allowed", ['admin_id' => $user->id]);
return true;
}
// If normal user, check ownership
if (is_object($user) && isset($user->id)) {
$allowed = $ticket->user_id === $user->id;
Log::info("CHANNEL AUTH: user allowed check", [
'ticket_user_id' => $ticket->user_id,
'current_user_id' => $user->id,
'allowed' => $allowed
]);
return $allowed;
}
Log::warning("CHANNEL AUTH: default deny");
return false;
} catch (\Throwable $e) {
Log::error("CHANNEL AUTH ERROR", [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return false;
}
});
Broadcast::channel('admin.chat', function ($admin) {
return auth('admin')->check();
});
// Broadcast::channel('ticket.{ticketId}', function ($admin, $ticketId) {
// \Log::info('CHANNEL AUTH OK', [
// 'admin_id' => $admin->id,
// 'ticketId' => $ticketId,
// ]);
// return true;
// });

View File

@@ -11,6 +11,11 @@ use App\Http\Controllers\Admin\AdminCustomerController;
use App\Http\Controllers\Admin\AdminAccountController;
use App\Http\Controllers\Admin\AdminReportController;
use App\Http\Controllers\Admin\AdminStaffController;
use App\Http\Controllers\Admin\AdminChatController;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Http\Request;
// ---------------------------
// Public Front Page
@@ -23,18 +28,25 @@ Route::get('/', function () {
// ADMIN LOGIN ROUTES
// ---------------------------
// login routes (public)
Route::prefix('admin')->group(function () {
Route::prefix('admin')->middleware('web')->group(function () {
Route::get('/login', [AdminAuthController::class, 'showLoginForm'])->name('admin.login');
Route::post('/login', [AdminAuthController::class, 'login'])->name('admin.login.submit');
Route::post('/logout', [AdminAuthController::class, 'logout'])->name('admin.logout');
});
Route::get('/login', function () {
return redirect()->route('admin.login');
})->name('login');
// ==========================================
// PROTECTED ADMIN ROUTES (session protected)
// ==========================================
Route::prefix('admin')
->middleware('auth:admin')
->middleware(['web', 'auth:admin'])
->group(function () {
// Dashboard
@@ -120,7 +132,11 @@ Route::prefix('admin')
Route::get('/orders/view/{id}', [AdminOrderController::class, 'popup'])
->name('admin.orders.popup');
// Route::get('/orders/{id}', [AdminOrderController::class, 'view'])
// ->name('admin.orders.view');
Route::get('/orders/{order:order_id}/see', [AdminOrderController::class, 'see'])
->name('admin.orders.see');
// ---------------------------
// ORDERS (FIXED ROUTES)
// ---------------------------
@@ -199,8 +215,8 @@ Route::prefix('admin')
->name('admin.invoice.installment.delete');
//Add New Invoice
Route::get('/admin/invoices/create', [InvoiceController::class, 'create'])->name('admin.invoices.create');
// //Add New Invoice
Route::get('/admin/invoices/create', [InvoiceController::class, 'create'])->name('admin.invoices.create');
// ---------------------------
@@ -220,13 +236,26 @@ Route::prefix('admin')
Route::post('/customers/{id}/status', [AdminCustomerController::class, 'toggleStatus'])
->name('admin.customers.status');
// Chat list page
Route::get('/chat-support', [AdminChatController::class, 'index'])
->name('admin.chat_support');
// Chat window (open specific user's chat)
Route::get('/chat-support/{ticketId}', [AdminChatController::class, 'openChat'])
->name('admin.chat.open');
// Admin sending message
Route::post('/chat-support/{ticketId}/send', [AdminChatController::class, 'sendMessage'])
->name('admin.chat.send');
});
// ==========================================
// ADMIN ACCOUNT (AJAX) ROUTES
// ==========================================
Route::prefix('admin/account')
->middleware('auth:admin')
->middleware(['web', 'auth:admin'])
->name('admin.account.')
->group(function () {
@@ -285,7 +314,7 @@ Route::prefix('admin')
->name('admin.orders.download.excel');
Route::prefix('admin/account')->middleware('auth:admin')->name('admin.account.')->group(function () {
Route::prefix('admin/account')->middleware(['web', 'auth:admin'])->name('admin.account.')->group(function () {
Route::post('/toggle-payment', [AdminAccountController::class, 'togglePayment'])->name('toggle');
});
@@ -293,7 +322,7 @@ Route::prefix('admin')
//Edit Button Route
//---------------------------
// protected admin routes
Route::middleware(['auth:admin'])
Route::middleware(['web', 'auth:admin'])
->prefix('admin')
->name('admin.')
->group(function () {

View File

@@ -5,7 +5,12 @@ import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
input: [
"resources/css/app.css",
"resources/js/app.js",
"resources/js/echo.js",
],
refresh: true,
}),
tailwindcss(),