Added ticketing feature
This commit is contained in:
78
app/Http/Controllers/Admin/TicketController.php
Normal file
78
app/Http/Controllers/Admin/TicketController.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketMessage;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TicketController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
// Eager load the user who created the ticket
|
||||
$query = Ticket::query()->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', 'تیکت با موفقیت بسته شد.');
|
||||
}
|
||||
}
|
||||
125
app/Http/Controllers/api/TicketApiController.php
Normal file
125
app/Http/Controllers/api/TicketApiController.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketMessage;
|
||||
use App\Traits\BaseApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TicketApiController extends Controller
|
||||
{
|
||||
use BaseApiResponse;
|
||||
|
||||
public function index()
|
||||
{
|
||||
$tickets = Ticket::where('user_id', auth()->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([], 'موفق', 'پاسخ شما با موفقیت ثبت شد');
|
||||
}
|
||||
}
|
||||
25
app/Models/Ticket.php
Normal file
25
app/Models/Ticket.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Ticket extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'subject',
|
||||
'status', // 'open', 'answered', 'user_reply', 'closed'
|
||||
'priority', // 'low', 'medium', 'high'
|
||||
];
|
||||
|
||||
public function messages()
|
||||
{
|
||||
return $this->hasMany(TicketMessage::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
20
app/Models/TicketMessage.php
Normal file
20
app/Models/TicketMessage.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TicketMessage extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'ticket_id',
|
||||
'user_id',
|
||||
'message',
|
||||
'attachment_path',
|
||||
];
|
||||
|
||||
public function ticket()
|
||||
{
|
||||
return $this->belongsTo(Ticket::class);
|
||||
}
|
||||
}
|
||||
76
resources/views/admin/tickets/index.blade.php
Normal file
76
resources/views/admin/tickets/index.blade.php
Normal file
@@ -0,0 +1,76 @@
|
||||
@extends('admin.layouts.app')
|
||||
@section('content')
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<!-- Reuses your existing search component -->
|
||||
@include('admin.partial.list-filter', ['filterRoute' => 'tickets.index'])
|
||||
|
||||
<div id="datatable_wrapper" class="dataTables_wrapper dt-bootstrap4 no-footer">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<table id="datatable" class="table table-bordered dt-responsive nowrap dataTable no-footer dtr-inline" style="border-collapse: collapse; border-spacing: 0px; width: 100%;" role="grid">
|
||||
<thead>
|
||||
<tr role="row">
|
||||
<th>شناسه</th>
|
||||
<th>کاربر (موبایل)</th>
|
||||
<th>موضوع</th>
|
||||
<th>وضعیت</th>
|
||||
<th>اولویت</th>
|
||||
<th>تاریخ بروزرسانی</th>
|
||||
<th>عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($tickets as $ticket)
|
||||
<tr>
|
||||
<td>#{{ $ticket->id }}</td>
|
||||
<td>{{ optional($ticket->user)->mobile ?? 'کاربر ناشناس' }}</td>
|
||||
<td>{{ Str::limit($ticket->subject, 50) }}</td>
|
||||
<td>
|
||||
@if($ticket->status === 'open')
|
||||
<span class="badge bg-primary">باز (جدید)</span>
|
||||
@elseif($ticket->status === 'user_reply')
|
||||
<span class="badge bg-warning text-dark">پاسخ کاربر</span>
|
||||
@elseif($ticket->status === 'answered')
|
||||
<span class="badge bg-success">پاسخ داده شده</span>
|
||||
@else
|
||||
<span class="badge bg-secondary">بسته شده</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
@if($ticket->priority === 'high')
|
||||
<span class="badge bg-danger">مهم</span>
|
||||
@elseif($ticket->priority === 'medium')
|
||||
<span class="badge bg-info">متوسط</span>
|
||||
@else
|
||||
<span class="badge bg-light text-dark">کم</span>
|
||||
@endif
|
||||
</td>
|
||||
<td dir="ltr">{{ $ticket->updated_at->format('Y/m/d H:i') }}</td>
|
||||
<td>
|
||||
<a href="{{ route('tickets.show', $ticket->id) }}" class="btn btn-primary waves-effect waves-light btn-sm">مشاهده و پاسخ</a>
|
||||
|
||||
@if($ticket->status !== 'closed')
|
||||
<form action="{{ route('tickets.close', $ticket->id) }}" method="POST" style="display: inline;">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-secondary waves-effect waves-light btn-sm" onclick="return confirm('آیا از بستن این تیکت اطمینان دارید؟')">بستن</button>
|
||||
</form>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center mt-3">
|
||||
{{ $tickets->withQueryString()->links() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
69
resources/views/admin/tickets/show.blade.php
Normal file
69
resources/views/admin/tickets/show.blade.php
Normal file
@@ -0,0 +1,69 @@
|
||||
@extends('admin.layouts.app')
|
||||
@section('content')
|
||||
<div class="row">
|
||||
@include('admin.layouts.errors')
|
||||
<div class="col-lg-8 offset-lg-2">
|
||||
<!-- Ticket Info Header -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body bg-light">
|
||||
<h4 class="card-title">موضوع: {{ $ticket->subject }}</h4>
|
||||
<p class="mb-0 text-muted">تیکت #{{ $ticket->id }} | ایجاد کننده: {{ optional($ticket->user)->mobile }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat History -->
|
||||
@foreach($ticket->messages as $msg)
|
||||
<div class="card mb-3 shadow-sm {{ $msg->user_id === $ticket->user_id ? 'border-primary' : 'border-success' }}">
|
||||
<div class="card-header {{ $msg->user_id === $ticket->user_id ? 'bg-primary text-white' : 'bg-success text-white' }}">
|
||||
<strong>{{ $msg->user_id === $ticket->user_id ? 'کاربر' : 'پشتیبانی (شما)' }}</strong>
|
||||
<span class="float-end" dir="ltr" style="font-size: 0.85rem;">{{ $msg->created_at->format('Y/m/d H:i') }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p style="white-space: pre-wrap;">{{ $msg->message }}</p>
|
||||
|
||||
@if($msg->attachment_path)
|
||||
<hr>
|
||||
<a href="{{ asset('storage/' . $msg->attachment_path) }}" target="_blank" class="btn btn-sm btn-info waves-effect waves-light">
|
||||
<i class="fas fa-paperclip"></i> مشاهده فایل ضمیمه
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
<!-- Reply Form -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
@if($ticket->status !== 'closed')
|
||||
<h4 class="card-title mb-3">ارسال پاسخ</h4>
|
||||
<!-- Notice the enctype="multipart/form-data" which is required for attachments -->
|
||||
<form action="{{ route('tickets.reply', $ticket->id) }}" method="post" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<div class="mb-4">
|
||||
<label for="message">متن پاسخ</label>
|
||||
<textarea class="form-control" name="message" id="message" rows="5" placeholder="پاسخ خود را بنویسید..." required></textarea>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="attachment">فایل ضمیمه (اختیاری)</label>
|
||||
<input class="form-control" name="attachment" id="attachment" type="file" accept=".jpg,.jpeg,.png,.pdf">
|
||||
<small class="text-muted">فرمتهای مجاز: jpg, png, pdf (حداکثر 5 مگابایت)</small>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<button type="submit" class="btn btn-success waves-effect waves-light">ارسال پاسخ</button>
|
||||
<a href="{{ route('tickets.index') }}" class="btn btn-outline-secondary">بازگشت به لیست</a>
|
||||
</div>
|
||||
</form>
|
||||
@else
|
||||
<div class="alert alert-secondary text-center mb-0">
|
||||
این تیکت بسته شده است و امکان ارسال پاسخ جدید وجود ندارد.
|
||||
<br>
|
||||
<a href="{{ route('tickets.index') }}" class="btn btn-secondary btn-sm mt-2">بازگشت به لیست</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user