diff --git a/app/Http/Controllers/Admin/TicketController.php b/app/Http/Controllers/Admin/TicketController.php new file mode 100644 index 0000000..0345028 --- /dev/null +++ b/app/Http/Controllers/Admin/TicketController.php @@ -0,0 +1,78 @@ +with('user'); + + if ($request->filled('q')) { + $q = $request->q; + $query->where(function ($qry) use ($q) { + $qry->where('subject', 'like', "%{$q}%") + ->orWhereHas('user', fn ($u) => $u->where('mobile', 'like', "%{$q}%") + ->orWhere('name', 'like', "%{$q}%")); + }); + } + + // Optional: Filter by status (open, answered, user_reply, closed) + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $perPage = min(max((int) $request->input('per_page', 15), 10), 100); + // Order by updated_at so tickets with new replies jump to the top + $tickets = $query->latest('updated_at')->paginate($perPage)->withQueryString(); + + return view('admin.tickets.index', compact('tickets')); + } + + public function show(Ticket $ticket) + { + // Load messages and the user belonging to the ticket + $ticket->load(['messages.user', 'user']); + + return view('admin.tickets.show', compact('ticket')); + } + + public function reply(Request $request, Ticket $ticket) + { + $request->validate([ + 'message' => 'required|string', + 'attachment' => 'nullable|file|mimes:jpg,jpeg,png,pdf|max:5120', + ]); + + $attachmentPath = null; + if ($request->hasFile('attachment')) { + $attachmentPath = $request->file('attachment')->store('tickets', 'public'); + } + + TicketMessage::create([ + 'ticket_id' => $ticket->id, + 'user_id' => auth()->id(), // This will be the Admin's ID + 'message' => $request->message, + 'attachment_path' => $attachmentPath, + ]); + + $ticket->update(['status' => 'answered']); + + return redirect()->route('tickets.show', $ticket->id) + ->with('success', 'پاسخ شما با موفقیت ارسال شد.'); + } + + public function close(Ticket $ticket) + { + $ticket->update(['status' => 'closed']); + + return redirect()->route('tickets.index') + ->with('success', 'تیکت با موفقیت بسته شد.'); + } +} diff --git a/app/Http/Controllers/api/TicketApiController.php b/app/Http/Controllers/api/TicketApiController.php new file mode 100644 index 0000000..b8fde4a --- /dev/null +++ b/app/Http/Controllers/api/TicketApiController.php @@ -0,0 +1,125 @@ +id()) + ->orderBy('updated_at', 'desc') + ->get() + ->map(function ($ticket) { + return [ + 'id' => $ticket->id, + 'subject' => $ticket->subject, + 'status' => $ticket->status, + 'priority' => $ticket->priority, + 'created_at' => $ticket->created_at, + 'updated_at' => $ticket->updated_at, + ]; + }); + + return $this->success($tickets, 'موفق', 'لیست تیکت‌ها با موفقیت دریافت شد'); + } + + public function show($id) + { + $ticket = Ticket::with(['messages' => function ($query) { + $query->orderBy('created_at', 'asc'); + }])->where('user_id', auth()->id())->find($id); + + if (!$ticket) { + return $this->failed([], 'یافت نشد', 'تیکت مورد نظر یافت نشد.', 404); + } + + $formattedTicket = [ + 'id' => $ticket->id, + 'subject' => $ticket->subject, + 'status' => $ticket->status, + 'priority' => $ticket->priority, + 'created_at' => $ticket->created_at, + 'messages' => $ticket->messages->map(function ($msg) { + return [ + 'id' => $msg->id, + // If user_id matches the logged in user, it's their message. Otherwise, it's admin's reply. + 'is_me' => $msg->user_id === auth()->id(), + 'message' => $msg->message, + 'attachment_url' => $msg->attachment_path ? asset('storage/' . $msg->attachment_path) : null, + 'created_at' => $msg->created_at, + ]; + }), + ]; + + return $this->success($formattedTicket, 'موفق', 'جزئیات تیکت دریافت شد'); + } + + public function store(Request $request) + { + $validated = $request->validate([ + 'subject' => 'required|string|max:255', + 'priority' => 'nullable|in:low,medium,high', + 'message' => 'required|string', + 'attachment' => 'nullable|file|mimes:jpg,jpeg,png,pdf|max:5120', // Max 5MB + ]); + + $ticket = Ticket::create([ + 'user_id' => auth()->id(), + 'subject' => $validated['subject'], + 'status' => 'open', + 'priority' => $validated['priority'] ?? 'medium', + ]); + + $attachmentPath = null; + if ($request->hasFile('attachment')) { + $attachmentPath = $request->file('attachment')->store('tickets', 'public'); + } + + TicketMessage::create([ + 'ticket_id' => $ticket->id, + 'user_id' => auth()->id(), + 'message' => $validated['message'], + 'attachment_path' => $attachmentPath, + ]); + + return $this->success(['ticket_id' => $ticket->id], 'موفق', 'تیکت شما با موفقیت ثبت شد'); + } + + public function reply(Request $request, $id) + { + $validated = $request->validate([ + 'message' => 'required|string', + 'attachment' => 'nullable|file|mimes:jpg,jpeg,png,pdf|max:5120', + ]); + + $ticket = Ticket::where('user_id', auth()->id())->find($id); + + if (!$ticket) { + return $this->failed([], 'خطا', 'تیکت یافت نشد', 404); + } + + $attachmentPath = null; + if ($request->hasFile('attachment')) { + $attachmentPath = $request->file('attachment')->store('tickets', 'public'); + } + + TicketMessage::create([ + 'ticket_id' => $ticket->id, + 'user_id' => auth()->id(), + 'message' => $validated['message'], + 'attachment_path' => $attachmentPath, + ]); + + $ticket->update(['status' => 'user_reply']); + + return $this->success([], 'موفق', 'پاسخ شما با موفقیت ثبت شد'); + } +} diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php new file mode 100644 index 0000000..ca1f114 --- /dev/null +++ b/app/Models/Ticket.php @@ -0,0 +1,25 @@ +hasMany(TicketMessage::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/TicketMessage.php b/app/Models/TicketMessage.php new file mode 100644 index 0000000..9138800 --- /dev/null +++ b/app/Models/TicketMessage.php @@ -0,0 +1,20 @@ +belongsTo(Ticket::class); + } +} diff --git a/resources/views/admin/tickets/index.blade.php b/resources/views/admin/tickets/index.blade.php new file mode 100644 index 0000000..55c1b50 --- /dev/null +++ b/resources/views/admin/tickets/index.blade.php @@ -0,0 +1,76 @@ +@extends('admin.layouts.app') +@section('content') +
+
+
+
+ + @include('admin.partial.list-filter', ['filterRoute' => 'tickets.index']) + + +
+
+
+
+@endsection diff --git a/resources/views/admin/tickets/show.blade.php b/resources/views/admin/tickets/show.blade.php new file mode 100644 index 0000000..8ae88cc --- /dev/null +++ b/resources/views/admin/tickets/show.blade.php @@ -0,0 +1,69 @@ +@extends('admin.layouts.app') +@section('content') +
+ @include('admin.layouts.errors') +
+ +
+
+

موضوع: {{ $ticket->subject }}

+

تیکت #{{ $ticket->id }} | ایجاد کننده: {{ optional($ticket->user)->mobile }}

+
+
+ + + @foreach($ticket->messages as $msg) +
+
+ {{ $msg->user_id === $ticket->user_id ? 'کاربر' : 'پشتیبانی (شما)' }} + {{ $msg->created_at->format('Y/m/d H:i') }} +
+
+

{{ $msg->message }}

+ + @if($msg->attachment_path) +
+ + مشاهده فایل ضمیمه + + @endif +
+
+ @endforeach + + +
+
+ @if($ticket->status !== 'closed') +

ارسال پاسخ

+ +
+ @csrf +
+ + +
+
+ + + فرمت‌های مجاز: jpg, png, pdf (حداکثر 5 مگابایت) +
+ +
+ + بازگشت به لیست +
+
+ @else +
+ این تیکت بسته شده است و امکان ارسال پاسخ جدید وجود ندارد. +
+ بازگشت به لیست +
+ @endif +
+
+ +
+
+@endsection diff --git a/routes/api.php b/routes/api.php index bd7abab..13d493e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -139,5 +139,13 @@ Route::prefix('v1')->middleware('auth:sanctum')->group(function () { Route::delete('/opinion/{id}', [\App\Http\Controllers\api\LegalOpinionHighlightController::class, 'destroy']); }); + // --- TICKETING SYSTEM --- + Route::prefix('tickets')->group(function () { + Route::get('/', [\App\Http\Controllers\api\TicketApiController::class, 'index']); + Route::get('/{id}', [\App\Http\Controllers\api\TicketApiController::class, 'show']); + Route::post('/', [\App\Http\Controllers\api\TicketApiController::class, 'store']); + Route::post('/{id}/reply', [\App\Http\Controllers\api\TicketApiController::class, 'reply']); + }); + }); diff --git a/routes/web.php b/routes/web.php index af93bab..8bc70c0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -59,4 +59,9 @@ Route::middleware(['auth', config('jetstream.auth_session')])->group(function () Route::resource('bylaw-node', BylawNodeController::class); Route::resource('bylaw-codification-link', BylawCodificationLinkController::class); Route::get('suggestions', [SuggestionController::class, 'index'])->name('suggestions.index'); + + Route::get('tickets', [TicketController::class, 'index'])->name('tickets.index'); + Route::get('tickets/{ticket}', [TicketController::class, 'show'])->name('tickets.show'); + Route::post('tickets/{ticket}/reply', [TicketController::class, 'reply'])->name('tickets.reply'); + Route::post('tickets/{ticket}/close', [TicketController::class, 'close'])->name('tickets.close'); });