Add standalone support ticket system with customer and admin interfaces
Replaces planned SupportPal integration with a built-in ticket system. Customer side: create tickets, reply, close. Admin side: manage all tickets with search/filters, staff replies, status updates. Includes 30 Pest tests (144 total, 775 assertions). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
42
TASKS.md
42
TASKS.md
@@ -61,7 +61,16 @@
|
|||||||
- [x] Create 9 marketing pages (Home, Products, VPS, Dedicated, Web, Game, Pricing, About, Contact)
|
- [x] Create 9 marketing pages (Home, Products, VPS, Dedicated, Web, Game, Pricing, About, Contact)
|
||||||
- [x] Create navigation configs (account.ts, admin.ts, marketing.ts)
|
- [x] Create navigation configs (account.ts, admin.ts, marketing.ts)
|
||||||
- [x] Set purple primary color (#7367F0) matching Vuexy demo
|
- [x] Set purple primary color (#7367F0) matching Vuexy demo
|
||||||
- [x] All 53 tests passing, build clean
|
- [x] All 114 tests passing, build clean (Phase 1-5 + Frontend + Notifications)
|
||||||
|
|
||||||
|
## Notifications System ✅
|
||||||
|
- [x] Create notification classes (PaymentSucceeded, PaymentFailed, SubscriptionCreated, SubscriptionCancelled, ServiceProvisioned, InvoiceGenerated)
|
||||||
|
- [x] Configure mail and database notification channels
|
||||||
|
- [x] Wire notifications to relevant events (PaymentSucceeded, PaymentFailed, SubscriptionCreated, SubscriptionCancelled, ServiceProvisioned, InvoiceGenerated)
|
||||||
|
- [x] Build NotificationBell component in Account and Admin layouts
|
||||||
|
- [x] Implement NotificationController (index, markAsRead, markAllAsRead)
|
||||||
|
- [x] FlashMessages supports info alerts
|
||||||
|
- [x] Inertia shared data includes impersonation state
|
||||||
|
|
||||||
## Phase 3: Provisioning Automation
|
## Phase 3: Provisioning Automation
|
||||||
- [x] Create `ProvisioningServiceInterface` abstraction
|
- [x] Create `ProvisioningServiceInterface` abstraction
|
||||||
@@ -91,6 +100,22 @@
|
|||||||
- [ ] Send credentials email on successful provisioning
|
- [ ] Send credentials email on successful provisioning
|
||||||
- [x] Log all provisioning actions to `provisioning_logs` table
|
- [x] Log all provisioning actions to `provisioning_logs` table
|
||||||
|
|
||||||
|
## Support Ticket System (Standalone) ✅
|
||||||
|
- [x] TicketReply model with relationships
|
||||||
|
- [x] Updated SupportTicket model with replies() relationship and department field
|
||||||
|
- [x] Migration: ticket_replies table + department column on support_tickets
|
||||||
|
- [x] Customer TicketController (index, create, store, show, reply, close)
|
||||||
|
- [x] Admin TicketController (index with filters, show, reply, updateStatus)
|
||||||
|
- [x] SupportTicketFactory with open/closed/urgent states
|
||||||
|
- [x] TicketReplyFactory with staffReply state
|
||||||
|
- [x] Customer Vue pages: Tickets/Index, Tickets/Create, Tickets/Show
|
||||||
|
- [x] Admin Vue pages: Admin/Tickets/Index, Admin/Tickets/Show
|
||||||
|
- [x] Ticket status/priority color resolvers
|
||||||
|
- [x] TypeScript interfaces for SupportTicket and TicketReply
|
||||||
|
- [x] Navigation items for both account and admin sidebars
|
||||||
|
- [x] Routes for both account and admin subdomains
|
||||||
|
- [x] 30 Pest tests (144 total, 775 assertions)
|
||||||
|
|
||||||
## Phase 4: Customer Dashboard (account.ezscale.cloud)
|
## Phase 4: Customer Dashboard (account.ezscale.cloud)
|
||||||
- [x] Build service overview dashboard:
|
- [x] Build service overview dashboard:
|
||||||
- [x] Active services list with status indicators
|
- [x] Active services list with status indicators
|
||||||
@@ -109,7 +134,7 @@
|
|||||||
- [ ] Payment history
|
- [ ] Payment history
|
||||||
- [ ] Manage payment methods (add/remove cards, set default)
|
- [ ] Manage payment methods (add/remove cards, set default)
|
||||||
- [ ] Upcoming renewals
|
- [ ] Upcoming renewals
|
||||||
- [ ] Plan upgrade/downgrade flow (self-service with proration)
|
- [x] Plan upgrade/downgrade flow (self-service with proration)
|
||||||
- [ ] Subscription cancellation flow (with optional survey)
|
- [ ] Subscription cancellation flow (with optional survey)
|
||||||
- [x] Profile and account settings:
|
- [x] Profile and account settings:
|
||||||
- [x] Contact information
|
- [x] Contact information
|
||||||
@@ -136,7 +161,7 @@
|
|||||||
- [x] Customer list (searchable, filterable)
|
- [x] Customer list (searchable, filterable)
|
||||||
- [x] Customer detail view (profile, services, billing history, notes)
|
- [x] Customer detail view (profile, services, billing history, notes)
|
||||||
- [ ] Edit customer information
|
- [ ] Edit customer information
|
||||||
- [ ] Impersonate customer (with audit logging)
|
- [x] Impersonate customer (with audit logging)
|
||||||
- [ ] Add admin notes to customer account
|
- [ ] Add admin notes to customer account
|
||||||
- [ ] View customer audit log
|
- [ ] View customer audit log
|
||||||
- [x] Service management:
|
- [x] Service management:
|
||||||
@@ -146,10 +171,10 @@
|
|||||||
- [x] Terminate service
|
- [x] Terminate service
|
||||||
- [ ] Modify service (change plan, extend expiry)
|
- [ ] Modify service (change plan, extend expiry)
|
||||||
- [x] View provisioning logs
|
- [x] View provisioning logs
|
||||||
- [ ] Order management:
|
- [x] Order management:
|
||||||
- [ ] Pending orders list
|
- [x] Pending orders list
|
||||||
- [ ] Approve/reject orders (for semi-automated provisioning)
|
- [x] Approve/reject orders (for semi-automated provisioning)
|
||||||
- [ ] View order details
|
- [x] View order details
|
||||||
- [x] Invoice management:
|
- [x] Invoice management:
|
||||||
- [x] All invoices list (filter by status, date, customer)
|
- [x] All invoices list (filter by status, date, customer)
|
||||||
- [ ] Create manual invoice
|
- [ ] Create manual invoice
|
||||||
@@ -241,7 +266,7 @@
|
|||||||
- [ ] Coupon code application
|
- [ ] Coupon code application
|
||||||
- [ ] Add to cart / checkout flow
|
- [ ] Add to cart / checkout flow
|
||||||
- [x] About page
|
- [x] About page
|
||||||
- [x] Contact page
|
- [x] Contact page with form submission backend
|
||||||
- [ ] Blog/news section (optional, or use WordPress?)
|
- [ ] Blog/news section (optional, or use WordPress?)
|
||||||
- [ ] Knowledge base / FAQ:
|
- [ ] Knowledge base / FAQ:
|
||||||
- [ ] Getting started guides
|
- [ ] Getting started guides
|
||||||
@@ -253,6 +278,7 @@
|
|||||||
- [x] Privacy Policy
|
- [x] Privacy Policy
|
||||||
- [x] Acceptable Use Policy
|
- [x] Acceptable Use Policy
|
||||||
- [x] SLA (Service Level Agreement)
|
- [x] SLA (Service Level Agreement)
|
||||||
|
- [x] Footer links to legal pages
|
||||||
- [ ] Signup flow:
|
- [ ] Signup flow:
|
||||||
- [ ] Plan selection
|
- [ ] Plan selection
|
||||||
- [ ] Account creation
|
- [ ] Account creation
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Account;
|
namespace App\Http\Controllers\Account;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\SupportTicket;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@@ -42,6 +43,11 @@ class DashboardController extends Controller
|
|||||||
->orderBy('current_period_end')
|
->orderBy('current_period_end')
|
||||||
->value('current_period_end');
|
->value('current_period_end');
|
||||||
|
|
||||||
|
$openTicketsCount = SupportTicket::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->whereIn('status', ['open', 'in_progress', 'waiting'])
|
||||||
|
->count();
|
||||||
|
|
||||||
return Inertia::render('Dashboard', [
|
return Inertia::render('Dashboard', [
|
||||||
'activeServicesCount' => $activeServicesCount,
|
'activeServicesCount' => $activeServicesCount,
|
||||||
'activeSubscriptionsCount' => $activeSubscriptionsCount,
|
'activeSubscriptionsCount' => $activeSubscriptionsCount,
|
||||||
@@ -49,6 +55,7 @@ class DashboardController extends Controller
|
|||||||
'latestInvoices' => $latestInvoices,
|
'latestInvoices' => $latestInvoices,
|
||||||
'pendingInvoicesAmount' => number_format((float) $pendingInvoicesAmount, 2, '.', ''),
|
'pendingInvoicesAmount' => number_format((float) $pendingInvoicesAmount, 2, '.', ''),
|
||||||
'nextRenewalDate' => $nextRenewalDate,
|
'nextRenewalDate' => $nextRenewalDate,
|
||||||
|
'openTicketsCount' => $openTicketsCount,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
104
website/app/Http/Controllers/Account/TicketController.php
Normal file
104
website/app/Http/Controllers/Account/TicketController.php
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Account;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\SupportTicket;
|
||||||
|
use App\Models\TicketReply;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class TicketController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$tickets = SupportTicket::query()
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->latest('updated_at')
|
||||||
|
->paginate(15)
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return Inertia::render('Tickets/Index', [
|
||||||
|
'tickets' => $tickets,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Tickets/Create');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'subject' => ['required', 'string', 'max:255'],
|
||||||
|
'message' => ['required', 'string', 'min:10', 'max:5000'],
|
||||||
|
'priority' => ['required', 'in:low,medium,high,urgent'],
|
||||||
|
'department' => ['required', 'in:billing,technical,sales,general'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ticket = SupportTicket::query()->create([
|
||||||
|
'user_id' => $request->user()->id,
|
||||||
|
'subject' => $validated['subject'],
|
||||||
|
'status' => 'open',
|
||||||
|
'priority' => $validated['priority'],
|
||||||
|
'department' => $validated['department'],
|
||||||
|
'last_reply_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
TicketReply::query()->create([
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'user_id' => $request->user()->id,
|
||||||
|
'body' => $validated['message'],
|
||||||
|
'is_staff_reply' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('account.tickets.show', $ticket)
|
||||||
|
->with('success', 'Support ticket created successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, SupportTicket $ticket): Response
|
||||||
|
{
|
||||||
|
abort_unless($ticket->user_id === $request->user()->id, 403);
|
||||||
|
|
||||||
|
$ticket->load(['replies.user:id,name,email', 'user:id,name,email']);
|
||||||
|
|
||||||
|
return Inertia::render('Tickets/Show', [
|
||||||
|
'ticket' => $ticket,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reply(Request $request, SupportTicket $ticket): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($ticket->user_id === $request->user()->id, 403);
|
||||||
|
abort_if($ticket->status === 'closed', 403, 'Cannot reply to a closed ticket.');
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'body' => ['required', 'string', 'max:5000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
TicketReply::query()->create([
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'user_id' => $request->user()->id,
|
||||||
|
'body' => $validated['body'],
|
||||||
|
'is_staff_reply' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ticket->update(['last_reply_at' => now()]);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', 'Reply added successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function close(Request $request, SupportTicket $ticket): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($ticket->user_id === $request->user()->id, 403);
|
||||||
|
|
||||||
|
$ticket->update(['status' => 'closed']);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', 'Ticket has been closed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
128
website/app/Http/Controllers/Admin/TicketController.php
Normal file
128
website/app/Http/Controllers/Admin/TicketController.php
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\SupportTicket;
|
||||||
|
use App\Models\TicketReply;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class TicketController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$query = SupportTicket::query()
|
||||||
|
->with('user:id,name,email');
|
||||||
|
|
||||||
|
if ($search = $request->input('search')) {
|
||||||
|
$query->where(function ($q) use ($search): void {
|
||||||
|
$q->where('subject', 'like', "%{$search}%")
|
||||||
|
->orWhereHas('user', function ($uq) use ($search): void {
|
||||||
|
$uq->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('email', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status = $request->input('status')) {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($priority = $request->input('priority')) {
|
||||||
|
$query->where('priority', $priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($department = $request->input('department')) {
|
||||||
|
$query->where('department', $department);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tickets = $query->latest('updated_at')
|
||||||
|
->paginate(25)
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Tickets/Index', [
|
||||||
|
'tickets' => $tickets,
|
||||||
|
'filters' => [
|
||||||
|
'search' => $request->input('search', ''),
|
||||||
|
'status' => $request->input('status', ''),
|
||||||
|
'priority' => $request->input('priority', ''),
|
||||||
|
'department' => $request->input('department', ''),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(SupportTicket $ticket): Response
|
||||||
|
{
|
||||||
|
$ticket->load([
|
||||||
|
'replies.user:id,name,email',
|
||||||
|
'user:id,name,email,status,company',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Tickets/Show', [
|
||||||
|
'ticket' => $ticket,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reply(Request $request, SupportTicket $ticket): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'body' => ['required', 'string', 'max:5000'],
|
||||||
|
'status' => ['nullable', 'in:open,in_progress,waiting,closed'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
TicketReply::query()->create([
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'user_id' => $request->user()->id,
|
||||||
|
'body' => $validated['body'],
|
||||||
|
'is_staff_reply' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$updateData = ['last_reply_at' => now()];
|
||||||
|
|
||||||
|
if (! empty($validated['status'])) {
|
||||||
|
$updateData['status'] = $validated['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket->update($updateData);
|
||||||
|
|
||||||
|
AuditLog::query()->create([
|
||||||
|
'user_id' => $ticket->user_id,
|
||||||
|
'admin_id' => $request->user()->id,
|
||||||
|
'action' => 'reply_ticket',
|
||||||
|
'resource_type' => 'support_ticket',
|
||||||
|
'resource_id' => $ticket->id,
|
||||||
|
'ip_address' => $request->ip(),
|
||||||
|
'user_agent' => $request->userAgent(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', 'Reply sent successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateStatus(Request $request, SupportTicket $ticket): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'status' => ['required', 'in:open,in_progress,waiting,closed'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ticket->update(['status' => $validated['status']]);
|
||||||
|
|
||||||
|
AuditLog::query()->create([
|
||||||
|
'user_id' => $ticket->user_id,
|
||||||
|
'admin_id' => $request->user()->id,
|
||||||
|
'action' => 'update_ticket_status',
|
||||||
|
'resource_type' => 'support_ticket',
|
||||||
|
'resource_id' => $ticket->id,
|
||||||
|
'ip_address' => $request->ip(),
|
||||||
|
'user_agent' => $request->userAgent(),
|
||||||
|
'details' => json_encode(['status' => $validated['status']]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', 'Ticket status updated.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ namespace App\Models;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class SupportTicket extends Model
|
class SupportTicket extends Model
|
||||||
{
|
{
|
||||||
@@ -18,9 +19,11 @@ class SupportTicket extends Model
|
|||||||
'subject',
|
'subject',
|
||||||
'status',
|
'status',
|
||||||
'priority',
|
'priority',
|
||||||
|
'department',
|
||||||
'last_reply_at',
|
'last_reply_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -33,4 +36,9 @@ class SupportTicket extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function replies(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TicketReply::class, 'ticket_id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
website/app/Models/TicketReply.php
Normal file
39
website/app/Models/TicketReply.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class TicketReply extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'ticket_id',
|
||||||
|
'user_id',
|
||||||
|
'body',
|
||||||
|
'is_staff_reply',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_staff_reply' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticket(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(SupportTicket::class, 'ticket_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
website/database/factories/SupportTicketFactory.php
Normal file
51
website/database/factories/SupportTicketFactory.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\SupportTicket;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\SupportTicket>
|
||||||
|
*/
|
||||||
|
class SupportTicketFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = SupportTicket::class;
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_id' => User::factory(),
|
||||||
|
'subject' => fake()->sentence(),
|
||||||
|
'status' => fake()->randomElement(['open', 'in_progress', 'waiting', 'closed']),
|
||||||
|
'priority' => fake()->randomElement(['low', 'medium', 'high', 'urgent']),
|
||||||
|
'department' => fake()->randomElement(['billing', 'technical', 'sales', 'general']),
|
||||||
|
'last_reply_at' => fake()->optional(0.7)->dateTimeBetween('-30 days', 'now'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function open(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'status' => 'open',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closed(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'status' => 'closed',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function urgent(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'priority' => 'urgent',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
website/database/factories/TicketReplyFactory.php
Normal file
36
website/database/factories/TicketReplyFactory.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\SupportTicket;
|
||||||
|
use App\Models\TicketReply;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\TicketReply>
|
||||||
|
*/
|
||||||
|
class TicketReplyFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = TicketReply::class;
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ticket_id' => SupportTicket::factory(),
|
||||||
|
'user_id' => User::factory(),
|
||||||
|
'body' => fake()->paragraphs(2, true),
|
||||||
|
'is_staff_reply' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function staffReply(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'is_staff_reply' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// Add department column to support_tickets if missing
|
||||||
|
if (! Schema::hasColumn('support_tickets', 'department')) {
|
||||||
|
Schema::table('support_tickets', function (Blueprint $table): void {
|
||||||
|
$table->string('department')->default('general')->after('priority');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::create('ticket_replies', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('ticket_id')->constrained('support_tickets')->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->text('body');
|
||||||
|
$table->boolean('is_staff_reply')->default(false);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('ticket_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('ticket_replies');
|
||||||
|
|
||||||
|
if (Schema::hasColumn('support_tickets', 'department')) {
|
||||||
|
Schema::table('support_tickets', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('department');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
233
website/resources/ts/Pages/Admin/Tickets/Index.vue
Normal file
233
website/resources/ts/Pages/Admin/Tickets/Index.vue
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { Link, router } from '@inertiajs/vue3'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||||
|
import { resolveTicketStatusColor, resolveTicketPriorityColor } from '@/utils/resolvers'
|
||||||
|
import type { PaginatedResponse, SupportTicket } from '@/types'
|
||||||
|
|
||||||
|
interface Filters {
|
||||||
|
search: string
|
||||||
|
status: string
|
||||||
|
priority: string
|
||||||
|
department: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tickets: PaginatedResponse<SupportTicket>
|
||||||
|
filters: Filters
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({ layout: AdminLayout })
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const search = ref<string>(props.filters.search)
|
||||||
|
const status = ref<string>(props.filters.status)
|
||||||
|
const priority = ref<string>(props.filters.priority)
|
||||||
|
const department = ref<string>(props.filters.department)
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ title: 'All Statuses', value: '' },
|
||||||
|
{ title: 'Open', value: 'open' },
|
||||||
|
{ title: 'In Progress', value: 'in_progress' },
|
||||||
|
{ title: 'Waiting', value: 'waiting' },
|
||||||
|
{ title: 'Closed', value: 'closed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const priorityOptions = [
|
||||||
|
{ title: 'All Priorities', value: '' },
|
||||||
|
{ title: 'Low', value: 'low' },
|
||||||
|
{ title: 'Medium', value: 'medium' },
|
||||||
|
{ title: 'High', value: 'high' },
|
||||||
|
{ title: 'Urgent', value: 'urgent' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const departmentOptions = [
|
||||||
|
{ title: 'All Departments', value: '' },
|
||||||
|
{ title: 'General', value: 'general' },
|
||||||
|
{ title: 'Billing', value: 'billing' },
|
||||||
|
{ title: 'Technical', value: 'technical' },
|
||||||
|
{ title: 'Sales', value: 'sales' },
|
||||||
|
]
|
||||||
|
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function applyFilters(): void {
|
||||||
|
router.get('/tickets', {
|
||||||
|
search: search.value || undefined,
|
||||||
|
status: status.value || undefined,
|
||||||
|
priority: priority.value || undefined,
|
||||||
|
department: department.value || undefined,
|
||||||
|
}, {
|
||||||
|
preserveState: true,
|
||||||
|
preserveScroll: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(search, () => {
|
||||||
|
if (searchTimeout) clearTimeout(searchTimeout)
|
||||||
|
searchTimeout = setTimeout(applyFilters, 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([status, priority, department], () => {
|
||||||
|
applyFilters()
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatStatus(statusVal: string): string {
|
||||||
|
return statusVal.replace(/_/g, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-center justify-space-between mb-6">
|
||||||
|
<div>
|
||||||
|
<div class="text-h4 font-weight-bold">
|
||||||
|
Support Tickets
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
Manage all customer support tickets
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<VCard class="mb-6">
|
||||||
|
<VCardText>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12" md="4">
|
||||||
|
<VTextField
|
||||||
|
v-model="search"
|
||||||
|
prepend-inner-icon="tabler-search"
|
||||||
|
placeholder="Search by subject, customer name, or email..."
|
||||||
|
density="compact"
|
||||||
|
clearable
|
||||||
|
hide-details
|
||||||
|
@click:clear="search = ''"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="3">
|
||||||
|
<VSelect
|
||||||
|
v-model="status"
|
||||||
|
:items="statusOptions"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
label="Status"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="3">
|
||||||
|
<VSelect
|
||||||
|
v-model="priority"
|
||||||
|
:items="priorityOptions"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
label="Priority"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="2">
|
||||||
|
<VSelect
|
||||||
|
v-model="department"
|
||||||
|
:items="departmentOptions"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
label="Department"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Tickets Table -->
|
||||||
|
<VCard>
|
||||||
|
<VCardText v-if="tickets.data.length === 0" class="text-center py-12">
|
||||||
|
<VIcon icon="tabler-ticket-off" size="48" color="disabled" class="mb-2" />
|
||||||
|
<div class="text-medium-emphasis">
|
||||||
|
No tickets found.
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VTable v-else density="comfortable" hover>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Subject</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Priority</th>
|
||||||
|
<th>Department</th>
|
||||||
|
<th>Last Updated</th>
|
||||||
|
<th class="text-center">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="ticket in tickets.data" :key="ticket.id">
|
||||||
|
<td class="text-body-2 text-medium-emphasis">
|
||||||
|
{{ ticket.id }}
|
||||||
|
</td>
|
||||||
|
<td class="text-body-2 font-weight-medium">
|
||||||
|
{{ ticket.subject }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<span class="text-body-2 font-weight-medium">{{ ticket.user?.name ?? 'Unknown' }}</span>
|
||||||
|
<span class="text-caption text-medium-emphasis">{{ ticket.user?.email ?? '' }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<VChip
|
||||||
|
:color="resolveTicketStatusColor(ticket.status)"
|
||||||
|
size="small"
|
||||||
|
class="text-capitalize"
|
||||||
|
>
|
||||||
|
{{ formatStatus(ticket.status) }}
|
||||||
|
</VChip>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<VChip
|
||||||
|
:color="resolveTicketPriorityColor(ticket.priority)"
|
||||||
|
size="small"
|
||||||
|
class="text-capitalize"
|
||||||
|
>
|
||||||
|
{{ ticket.priority }}
|
||||||
|
</VChip>
|
||||||
|
</td>
|
||||||
|
<td class="text-body-2 text-capitalize">
|
||||||
|
{{ ticket.department }}
|
||||||
|
</td>
|
||||||
|
<td class="text-body-2">
|
||||||
|
{{ formatDate(ticket.updated_at) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<Link :href="`/tickets/${ticket.id}`">
|
||||||
|
<VBtn variant="text" size="small" color="primary">
|
||||||
|
<VIcon icon="tabler-eye" size="18" />
|
||||||
|
</VBtn>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</VTable>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<VCardText v-if="tickets.last_page > 1" class="d-flex align-center justify-center pt-2">
|
||||||
|
<VPagination
|
||||||
|
:model-value="tickets.data.length > 0 ? Math.ceil((tickets.from ?? 1) / 25) : 1"
|
||||||
|
:length="tickets.last_page"
|
||||||
|
:total-visible="7"
|
||||||
|
@update:model-value="(page: number) => router.get('/tickets', { ...props.filters, page }, { preserveState: true, preserveScroll: true })"
|
||||||
|
/>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VCardText v-if="tickets.total > 0" class="text-center text-caption text-medium-emphasis">
|
||||||
|
Showing {{ tickets.from }} to {{ tickets.to }} of {{ tickets.total }} tickets
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
397
website/resources/ts/Pages/Admin/Tickets/Show.vue
Normal file
397
website/resources/ts/Pages/Admin/Tickets/Show.vue
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { useForm, Link } from '@inertiajs/vue3'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||||
|
import AppTextarea from '@/Components/app-form-elements/AppTextarea.vue'
|
||||||
|
import AppSelect from '@/Components/app-form-elements/AppSelect.vue'
|
||||||
|
import { resolveTicketStatusColor, resolveTicketPriorityColor } from '@/utils/resolvers'
|
||||||
|
import type { SupportTicket } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ticket: SupportTicket
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({ layout: AdminLayout })
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const replyForm = useForm({
|
||||||
|
body: '',
|
||||||
|
status: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusForm = useForm({
|
||||||
|
status: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ title: 'No Change', value: '' },
|
||||||
|
{ title: 'Open', value: 'open' },
|
||||||
|
{ title: 'In Progress', value: 'in_progress' },
|
||||||
|
{ title: 'Waiting on Customer', value: 'waiting' },
|
||||||
|
{ title: 'Closed', value: 'closed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const quickStatusOptions = [
|
||||||
|
{ title: 'Open', value: 'open', color: 'info' },
|
||||||
|
{ title: 'In Progress', value: 'in_progress', color: 'success' },
|
||||||
|
{ title: 'Waiting', value: 'waiting', color: 'warning' },
|
||||||
|
{ title: 'Closed', value: 'closed', color: 'secondary' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusDialog = ref<boolean>(false)
|
||||||
|
const pendingStatus = ref<string>('')
|
||||||
|
|
||||||
|
function submitReply(): void {
|
||||||
|
replyForm.post(`/tickets/${props.ticket.id}/reply`, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
replyForm.reset('body', 'status')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStatusDialog(newStatus: string): void {
|
||||||
|
pendingStatus.value = newStatus
|
||||||
|
statusDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmStatusUpdate(): void {
|
||||||
|
statusForm.status = pendingStatus.value
|
||||||
|
statusForm.put(`/tickets/${props.ticket.id}/status`, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
statusDialog.value = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatus(status: string): string {
|
||||||
|
return status.replace(/_/g, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserInitial(name: string): string {
|
||||||
|
return name.charAt(0).toUpperCase()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex align-center justify-space-between mb-6">
|
||||||
|
<div class="d-flex align-center gap-4">
|
||||||
|
<Link href="/tickets">
|
||||||
|
<VBtn variant="text" icon="tabler-arrow-left" size="small" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-center gap-2">
|
||||||
|
<span class="text-h4 font-weight-bold">Ticket #{{ ticket.id }}</span>
|
||||||
|
<VChip
|
||||||
|
:color="resolveTicketStatusColor(ticket.status)"
|
||||||
|
size="small"
|
||||||
|
class="text-capitalize"
|
||||||
|
>
|
||||||
|
{{ formatStatus(ticket.status) }}
|
||||||
|
</VChip>
|
||||||
|
<VChip
|
||||||
|
:color="resolveTicketPriorityColor(ticket.priority)"
|
||||||
|
size="small"
|
||||||
|
class="text-capitalize"
|
||||||
|
>
|
||||||
|
{{ ticket.priority }}
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis mt-1">
|
||||||
|
{{ ticket.user?.name ?? 'Unknown Customer' }} · {{ ticket.user?.email ?? '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VRow>
|
||||||
|
<!-- Conversation Thread -->
|
||||||
|
<VCol cols="12" lg="8">
|
||||||
|
<!-- Ticket Subject Card -->
|
||||||
|
<VCard class="mb-4">
|
||||||
|
<VCardText>
|
||||||
|
<div class="text-h6 font-weight-bold mb-2">
|
||||||
|
{{ ticket.subject }}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-center ga-4 text-body-2 text-medium-emphasis">
|
||||||
|
<span>
|
||||||
|
<VIcon icon="tabler-building" size="16" class="me-1" />
|
||||||
|
<span class="text-capitalize">{{ ticket.department }}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<VIcon icon="tabler-calendar" size="16" class="me-1" />
|
||||||
|
{{ formatDateTime(ticket.created_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Replies -->
|
||||||
|
<div class="d-flex flex-column ga-4 mb-6">
|
||||||
|
<VCard
|
||||||
|
v-for="reply in ticket.replies"
|
||||||
|
:key="reply.id"
|
||||||
|
:class="reply.is_staff_reply ? 'border-s-4 border-primary' : ''"
|
||||||
|
:variant="reply.is_staff_reply ? 'tonal' : 'elevated'"
|
||||||
|
>
|
||||||
|
<VCardText>
|
||||||
|
<div class="d-flex align-center ga-3 mb-3">
|
||||||
|
<VAvatar
|
||||||
|
:color="reply.is_staff_reply ? 'primary' : 'secondary'"
|
||||||
|
variant="tonal"
|
||||||
|
size="36"
|
||||||
|
>
|
||||||
|
<span class="text-body-2 font-weight-semibold">
|
||||||
|
{{ getUserInitial(reply.user?.name ?? 'U') }}
|
||||||
|
</span>
|
||||||
|
</VAvatar>
|
||||||
|
<div>
|
||||||
|
<div class="text-body-1 font-weight-medium">
|
||||||
|
{{ reply.user?.name ?? 'Unknown' }}
|
||||||
|
<VChip
|
||||||
|
v-if="reply.is_staff_reply"
|
||||||
|
size="x-small"
|
||||||
|
color="primary"
|
||||||
|
class="ms-2"
|
||||||
|
>
|
||||||
|
Staff
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-medium-emphasis">
|
||||||
|
{{ formatDateTime(reply.created_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2" style="white-space: pre-wrap;">{{ reply.body }}</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reply Form -->
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="d-flex align-center gap-2">
|
||||||
|
<VIcon icon="tabler-message" size="22" />
|
||||||
|
<span>Staff Reply</span>
|
||||||
|
</VCardTitle>
|
||||||
|
<VCardText>
|
||||||
|
<VForm @submit.prevent="submitReply">
|
||||||
|
<AppTextarea
|
||||||
|
v-model="replyForm.body"
|
||||||
|
placeholder="Type your reply to the customer..."
|
||||||
|
rows="5"
|
||||||
|
:error-messages="replyForm.errors.body"
|
||||||
|
counter="5000"
|
||||||
|
maxlength="5000"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="d-flex align-center justify-space-between flex-wrap ga-3">
|
||||||
|
<div style="min-width: 200px;">
|
||||||
|
<AppSelect
|
||||||
|
v-model="replyForm.status"
|
||||||
|
:items="statusOptions"
|
||||||
|
label="Update Status"
|
||||||
|
density="compact"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VBtn
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
:loading="replyForm.processing"
|
||||||
|
:disabled="replyForm.processing || !replyForm.body.trim()"
|
||||||
|
>
|
||||||
|
<VIcon icon="tabler-send" start />
|
||||||
|
Send Reply
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</VForm>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<VCol cols="12" lg="4">
|
||||||
|
<!-- Customer Info -->
|
||||||
|
<VCard class="mb-4">
|
||||||
|
<VCardTitle class="d-flex align-center gap-2">
|
||||||
|
<VIcon icon="tabler-user" size="22" />
|
||||||
|
<span>Customer</span>
|
||||||
|
</VCardTitle>
|
||||||
|
<VCardText v-if="ticket.user">
|
||||||
|
<div class="d-flex align-center gap-3 mb-4">
|
||||||
|
<VAvatar color="primary" variant="tonal" size="40">
|
||||||
|
<span class="text-body-1 font-weight-semibold">
|
||||||
|
{{ getUserInitial(ticket.user.name) }}
|
||||||
|
</span>
|
||||||
|
</VAvatar>
|
||||||
|
<div>
|
||||||
|
<div class="text-body-1 font-weight-medium">
|
||||||
|
{{ ticket.user.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
{{ ticket.user.email }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VList density="compact" class="pa-0">
|
||||||
|
<VListItem v-if="ticket.user.status">
|
||||||
|
<template #prepend>
|
||||||
|
<span class="text-body-2 text-medium-emphasis" style="min-width: 80px;">Status</span>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle class="text-body-2 text-capitalize">
|
||||||
|
{{ ticket.user.status }}
|
||||||
|
</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
<VListItem v-if="ticket.user.company">
|
||||||
|
<template #prepend>
|
||||||
|
<span class="text-body-2 text-medium-emphasis" style="min-width: 80px;">Company</span>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle class="text-body-2">
|
||||||
|
{{ ticket.user.company }}
|
||||||
|
</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<Link :href="`/customers/${ticket.user.id}`">
|
||||||
|
<VBtn variant="tonal" size="small" color="primary" block>
|
||||||
|
View Customer
|
||||||
|
</VBtn>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Ticket Details -->
|
||||||
|
<VCard class="mb-4">
|
||||||
|
<VCardTitle class="d-flex align-center gap-2">
|
||||||
|
<VIcon icon="tabler-info-circle" size="22" />
|
||||||
|
<span>Ticket Details</span>
|
||||||
|
</VCardTitle>
|
||||||
|
<VCardText>
|
||||||
|
<VList density="compact" class="pa-0">
|
||||||
|
<VListItem>
|
||||||
|
<template #prepend>
|
||||||
|
<span class="text-body-2 text-medium-emphasis" style="min-width: 100px;">Status</span>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle>
|
||||||
|
<VChip
|
||||||
|
:color="resolveTicketStatusColor(ticket.status)"
|
||||||
|
size="small"
|
||||||
|
class="text-capitalize"
|
||||||
|
>
|
||||||
|
{{ formatStatus(ticket.status) }}
|
||||||
|
</VChip>
|
||||||
|
</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
<VListItem>
|
||||||
|
<template #prepend>
|
||||||
|
<span class="text-body-2 text-medium-emphasis" style="min-width: 100px;">Priority</span>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle>
|
||||||
|
<VChip
|
||||||
|
:color="resolveTicketPriorityColor(ticket.priority)"
|
||||||
|
size="small"
|
||||||
|
class="text-capitalize"
|
||||||
|
>
|
||||||
|
{{ ticket.priority }}
|
||||||
|
</VChip>
|
||||||
|
</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
<VListItem>
|
||||||
|
<template #prepend>
|
||||||
|
<span class="text-body-2 text-medium-emphasis" style="min-width: 100px;">Department</span>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle class="text-body-2 text-capitalize">
|
||||||
|
{{ ticket.department }}
|
||||||
|
</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
<VListItem>
|
||||||
|
<template #prepend>
|
||||||
|
<span class="text-body-2 text-medium-emphasis" style="min-width: 100px;">Created</span>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle class="text-body-2">
|
||||||
|
{{ formatDateTime(ticket.created_at) }}
|
||||||
|
</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
<VListItem v-if="ticket.last_reply_at">
|
||||||
|
<template #prepend>
|
||||||
|
<span class="text-body-2 text-medium-emphasis" style="min-width: 100px;">Last Reply</span>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle class="text-body-2">
|
||||||
|
{{ formatDateTime(ticket.last_reply_at) }}
|
||||||
|
</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Quick Status Update -->
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="d-flex align-center gap-2">
|
||||||
|
<VIcon icon="tabler-toggle-left" size="22" />
|
||||||
|
<span>Update Status</span>
|
||||||
|
</VCardTitle>
|
||||||
|
<VCardText>
|
||||||
|
<div class="d-flex flex-column ga-2">
|
||||||
|
<VBtn
|
||||||
|
v-for="option in quickStatusOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:color="option.color"
|
||||||
|
variant="tonal"
|
||||||
|
block
|
||||||
|
:disabled="ticket.status === option.value"
|
||||||
|
@click="openStatusDialog(option.value)"
|
||||||
|
>
|
||||||
|
{{ option.title }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<!-- Status Confirmation Dialog -->
|
||||||
|
<VDialog v-model="statusDialog" max-width="500" persistent>
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="text-h5 pa-5">
|
||||||
|
Update Ticket Status
|
||||||
|
</VCardTitle>
|
||||||
|
<VCardText class="px-5 pb-2">
|
||||||
|
Are you sure you want to change the ticket status to <strong class="text-capitalize">{{ formatStatus(pendingStatus) }}</strong>?
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions class="pa-5">
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn variant="text" :disabled="statusForm.processing" @click="statusDialog = false">
|
||||||
|
Cancel
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
:loading="statusForm.processing"
|
||||||
|
@click="confirmStatusUpdate"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -13,6 +13,7 @@ interface Props {
|
|||||||
latestInvoices: Invoice[]
|
latestInvoices: Invoice[]
|
||||||
pendingInvoicesAmount: string
|
pendingInvoicesAmount: string
|
||||||
nextRenewalDate: string | null
|
nextRenewalDate: string | null
|
||||||
|
openTicketsCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
defineOptions({ layout: AccountLayout })
|
defineOptions({ layout: AccountLayout })
|
||||||
@@ -378,10 +379,8 @@ const unpaidInvoices = computed<Invoice[]>(() => {
|
|||||||
</VBtn>
|
</VBtn>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<a
|
<Link
|
||||||
href="https://ezscale.support"
|
href="/tickets/create"
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-decoration-none"
|
class="text-decoration-none"
|
||||||
>
|
>
|
||||||
<VBtn
|
<VBtn
|
||||||
@@ -392,14 +391,16 @@ const unpaidInvoices = computed<Invoice[]>(() => {
|
|||||||
icon="tabler-headset"
|
icon="tabler-headset"
|
||||||
start
|
start
|
||||||
/>
|
/>
|
||||||
Get Support
|
Open Support Ticket
|
||||||
<VIcon
|
<VBadge
|
||||||
icon="tabler-external-link"
|
v-if="openTicketsCount > 0"
|
||||||
end
|
:content="openTicketsCount"
|
||||||
size="14"
|
color="error"
|
||||||
|
inline
|
||||||
|
class="ms-1"
|
||||||
/>
|
/>
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useForm, Link } from '@inertiajs/vue3'
|
import { useForm, Link } from '@inertiajs/vue3'
|
||||||
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||||
import { resolveSubscriptionStatusColor, formatPrice } from '@/utils/resolvers'
|
import { resolveSubscriptionStatusColor, formatPrice } from '@/utils/resolvers'
|
||||||
|
import AppTextarea from '@/Components/app-form-elements/AppTextarea.vue'
|
||||||
|
import AppSelect from '@/Components/app-form-elements/AppSelect.vue'
|
||||||
import type { Subscription, Plan } from '@/types'
|
import type { Subscription, Plan } from '@/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -14,47 +16,82 @@ defineOptions({ layout: AccountLayout })
|
|||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
const cancelImmediately = ref(false)
|
const showCancelDialog = ref<boolean>(false)
|
||||||
|
const cancelImmediately = ref<boolean>(false)
|
||||||
|
const cancelReason = ref<string>('')
|
||||||
|
|
||||||
|
const cancelReasons = [
|
||||||
|
'Too expensive',
|
||||||
|
'No longer needed',
|
||||||
|
'Switching to competitor',
|
||||||
|
'Poor performance',
|
||||||
|
'Missing features',
|
||||||
|
'Technical issues',
|
||||||
|
'Other',
|
||||||
|
]
|
||||||
|
|
||||||
const cancelForm = useForm({
|
const cancelForm = useForm({
|
||||||
immediately: false,
|
immediately: false,
|
||||||
|
reason: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const swapForm = useForm({
|
const swapForm = useForm({
|
||||||
plan_id: '',
|
plan_id: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const cancelSubscription = (): void => {
|
function openCancelDialog(): void {
|
||||||
cancelForm.immediately = cancelImmediately.value
|
showCancelDialog.value = true
|
||||||
cancelForm.post(`/subscriptions/${props.subscription.id}/cancel`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resumeSubscription = (): void => {
|
function confirmCancel(): void {
|
||||||
|
cancelForm.immediately = cancelImmediately.value
|
||||||
|
cancelForm.reason = cancelReason.value
|
||||||
|
cancelForm.post(`/subscriptions/${props.subscription.id}/cancel`, {
|
||||||
|
onSuccess: () => {
|
||||||
|
showCancelDialog.value = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumeSubscription(): void {
|
||||||
useForm({}).post(`/subscriptions/${props.subscription.id}/resume`)
|
useForm({}).post(`/subscriptions/${props.subscription.id}/resume`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const swapPlan = (): void => {
|
function swapPlan(): void {
|
||||||
swapForm.post(`/subscriptions/${props.subscription.id}/swap`)
|
swapForm.post(`/subscriptions/${props.subscription.id}/swap`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPlan = computed(() => props.subscription.plan)
|
||||||
|
|
||||||
|
const isActive = computed<boolean>(() => props.subscription.stripe_status === 'active')
|
||||||
|
const isCancelling = computed<boolean>(() => !!props.subscription.ends_at && props.subscription.stripe_status !== 'canceled')
|
||||||
|
const isCanceled = computed<boolean>(() => props.subscription.stripe_status === 'canceled')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-4">
|
<!-- Breadcrumb -->
|
||||||
<Link href="/subscriptions" class="text-primary text-body-2 text-decoration-none">← Back to Subscriptions</Link>
|
<div class="d-flex align-center ga-2 mb-4">
|
||||||
|
<Link href="/subscriptions" class="text-decoration-none">
|
||||||
|
<VBtn variant="text" size="small" color="primary">
|
||||||
|
<VIcon icon="tabler-arrow-left" start />
|
||||||
|
Subscriptions
|
||||||
|
</VBtn>
|
||||||
|
</Link>
|
||||||
|
<VIcon icon="tabler-chevron-right" size="16" color="disabled" />
|
||||||
|
<span class="text-body-2 text-medium-emphasis">{{ currentPlan?.name || subscription.type }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-h4 font-weight-bold mb-6">Subscription Details</div>
|
<div class="d-flex align-center justify-space-between mb-6">
|
||||||
|
<div class="text-h4 font-weight-bold">Subscription Details</div>
|
||||||
<VRow>
|
|
||||||
<!-- Subscription Info -->
|
|
||||||
<VCol cols="12" lg="8">
|
|
||||||
<VCard class="mb-6">
|
|
||||||
<VCardText>
|
|
||||||
<div class="d-flex align-center justify-space-between mb-4">
|
|
||||||
<div class="text-h6 font-weight-bold">
|
|
||||||
{{ subscription.plan?.name || subscription.type }}
|
|
||||||
</div>
|
|
||||||
<VChip
|
<VChip
|
||||||
:color="resolveSubscriptionStatusColor(subscription.stripe_status)"
|
:color="resolveSubscriptionStatusColor(subscription.stripe_status)"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -65,38 +102,96 @@ const swapPlan = (): void => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VRow>
|
<VRow>
|
||||||
<VCol cols="6">
|
<!-- Subscription Info -->
|
||||||
|
<VCol cols="12" lg="8">
|
||||||
|
<VCard class="mb-6">
|
||||||
|
<VCardTitle>
|
||||||
|
{{ currentPlan?.name || subscription.type }}
|
||||||
|
</VCardTitle>
|
||||||
|
<VCardText>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="6" sm="4">
|
||||||
<div class="text-body-2 text-medium-emphasis">Gateway</div>
|
<div class="text-body-2 text-medium-emphasis">Gateway</div>
|
||||||
<div class="text-body-1 text-capitalize mt-1">{{ subscription.gateway || 'stripe' }}</div>
|
<div class="text-body-1 text-capitalize mt-1">{{ subscription.gateway || 'stripe' }}</div>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol v-if="subscription.plan" cols="6">
|
<VCol v-if="currentPlan" cols="6" sm="4">
|
||||||
<div class="text-body-2 text-medium-emphasis">Price</div>
|
<div class="text-body-2 text-medium-emphasis">Price</div>
|
||||||
<div class="text-body-1 mt-1">{{ formatPrice(subscription.plan.price, subscription.plan.billing_cycle) }}</div>
|
<div class="text-body-1 font-weight-medium mt-1">{{ formatPrice(currentPlan.price, currentPlan.billing_cycle) }}</div>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol v-if="subscription.current_period_start" cols="6">
|
<VCol v-if="currentPlan" cols="6" sm="4">
|
||||||
<div class="text-body-2 text-medium-emphasis">Current Period Start</div>
|
<div class="text-body-2 text-medium-emphasis">Billing Cycle</div>
|
||||||
<div class="text-body-1 mt-1">{{ new Date(subscription.current_period_start).toLocaleDateString() }}</div>
|
<div class="text-body-1 text-capitalize mt-1">{{ currentPlan.billing_cycle }}</div>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol v-if="subscription.current_period_end" cols="6">
|
<VCol v-if="subscription.current_period_start" cols="6" sm="4">
|
||||||
<div class="text-body-2 text-medium-emphasis">Current Period End</div>
|
<div class="text-body-2 text-medium-emphasis">Period Start</div>
|
||||||
<div class="text-body-1 mt-1">{{ new Date(subscription.current_period_end).toLocaleDateString() }}</div>
|
<div class="text-body-1 mt-1">{{ formatDate(subscription.current_period_start) }}</div>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol v-if="subscription.ends_at" cols="6">
|
<VCol v-if="subscription.current_period_end" cols="6" sm="4">
|
||||||
<div class="text-body-2 text-medium-emphasis">Cancels On</div>
|
<div class="text-body-2 text-medium-emphasis">Period End</div>
|
||||||
<div class="text-body-1 text-error mt-1">{{ new Date(subscription.ends_at).toLocaleDateString() }}</div>
|
<div class="text-body-1 mt-1">{{ formatDate(subscription.current_period_end) }}</div>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="6">
|
<VCol cols="6" sm="4">
|
||||||
<div class="text-body-2 text-medium-emphasis">Created</div>
|
<div class="text-body-2 text-medium-emphasis">Created</div>
|
||||||
<div class="text-body-1 mt-1">{{ new Date(subscription.created_at).toLocaleDateString() }}</div>
|
<div class="text-body-1 mt-1">{{ formatDate(subscription.created_at) }}</div>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
|
|
||||||
|
<!-- Cancellation Notice -->
|
||||||
|
<VAlert
|
||||||
|
v-if="isCancelling"
|
||||||
|
type="warning"
|
||||||
|
variant="tonal"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<div class="font-weight-medium">Subscription cancelling</div>
|
||||||
|
<div class="text-body-2">
|
||||||
|
Your subscription will end on {{ formatDate(subscription.ends_at!) }}.
|
||||||
|
You can resume it before then to keep your service.
|
||||||
|
</div>
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<VAlert
|
||||||
|
v-if="isCanceled"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<div class="font-weight-medium">Subscription cancelled</div>
|
||||||
|
<div class="text-body-2">
|
||||||
|
This subscription has been cancelled and is no longer active.
|
||||||
|
</div>
|
||||||
|
</VAlert>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Plan Features -->
|
||||||
|
<VCard v-if="currentPlan?.features" class="mb-6">
|
||||||
|
<VCardTitle>Plan Features</VCardTitle>
|
||||||
|
<VCardText>
|
||||||
|
<VList density="compact">
|
||||||
|
<VListItem
|
||||||
|
v-for="(value, key) in currentPlan.features"
|
||||||
|
:key="String(key)"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon icon="tabler-check" color="success" size="18" />
|
||||||
|
</template>
|
||||||
|
<VListItemTitle class="text-body-2">
|
||||||
|
<span class="text-capitalize">{{ String(key).replace(/_/g, ' ') }}</span>:
|
||||||
|
<span class="font-weight-medium">{{ value }}</span>
|
||||||
|
</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
|
|
||||||
<!-- Change Plan -->
|
<!-- Change Plan -->
|
||||||
<VCard v-if="availablePlans.length > 0 && subscription.stripe_status === 'active'">
|
<VCard v-if="availablePlans.length > 0 && isActive && !isCancelling">
|
||||||
<VCardTitle>Change Plan</VCardTitle>
|
<VCardTitle>Change Plan</VCardTitle>
|
||||||
<VCardText>
|
<VCardText>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||||
|
Switch to a different plan. Your billing will be adjusted automatically.
|
||||||
|
</p>
|
||||||
<VForm @submit.prevent="swapPlan">
|
<VForm @submit.prevent="swapPlan">
|
||||||
<VRadioGroup v-model="swapForm.plan_id" class="mb-4">
|
<VRadioGroup v-model="swapForm.plan_id" class="mb-4">
|
||||||
<VRadio
|
<VRadio
|
||||||
@@ -105,9 +200,27 @@ const swapPlan = (): void => {
|
|||||||
:value="String(plan.id)"
|
:value="String(plan.id)"
|
||||||
>
|
>
|
||||||
<template #label>
|
<template #label>
|
||||||
<div class="d-flex justify-space-between w-100">
|
<div class="d-flex justify-space-between align-center w-100">
|
||||||
<span>{{ plan.name }}</span>
|
<span>{{ plan.name }}</span>
|
||||||
|
<div class="d-flex align-center ga-2">
|
||||||
<span class="text-medium-emphasis">{{ formatPrice(plan.price, plan.billing_cycle) }}</span>
|
<span class="text-medium-emphasis">{{ formatPrice(plan.price, plan.billing_cycle) }}</span>
|
||||||
|
<VChip
|
||||||
|
v-if="currentPlan && parseFloat(plan.price) > parseFloat(currentPlan.price)"
|
||||||
|
color="info"
|
||||||
|
size="x-small"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
Upgrade
|
||||||
|
</VChip>
|
||||||
|
<VChip
|
||||||
|
v-else-if="currentPlan"
|
||||||
|
color="warning"
|
||||||
|
size="x-small"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
Downgrade
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</VRadio>
|
</VRadio>
|
||||||
@@ -115,10 +228,12 @@ const swapPlan = (): void => {
|
|||||||
|
|
||||||
<VBtn
|
<VBtn
|
||||||
type="submit"
|
type="submit"
|
||||||
|
color="primary"
|
||||||
:loading="swapForm.processing"
|
:loading="swapForm.processing"
|
||||||
:disabled="!swapForm.plan_id || swapForm.processing"
|
:disabled="!swapForm.plan_id || swapForm.processing"
|
||||||
|
prepend-icon="tabler-switch-horizontal"
|
||||||
>
|
>
|
||||||
{{ swapForm.processing ? 'Changing...' : 'Change Plan' }}
|
Change Plan
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VForm>
|
</VForm>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
@@ -128,46 +243,137 @@ const swapPlan = (): void => {
|
|||||||
<!-- Actions Sidebar -->
|
<!-- Actions Sidebar -->
|
||||||
<VCol cols="12" lg="4">
|
<VCol cols="12" lg="4">
|
||||||
<!-- Cancel -->
|
<!-- Cancel -->
|
||||||
<VCard v-if="subscription.stripe_status === 'active' && !subscription.ends_at" class="mb-6">
|
<VCard v-if="isActive && !isCancelling" class="mb-6">
|
||||||
<VCardTitle>Cancel Subscription</VCardTitle>
|
<VCardTitle>Cancel Subscription</VCardTitle>
|
||||||
<VCardText>
|
<VCardText>
|
||||||
<VCheckbox
|
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||||
v-model="cancelImmediately"
|
If you cancel, your service will remain active until the end of your current billing period.
|
||||||
label="Cancel immediately (no grace period)"
|
</p>
|
||||||
hide-details
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<VBtn
|
<VBtn
|
||||||
color="error"
|
color="error"
|
||||||
|
variant="tonal"
|
||||||
block
|
block
|
||||||
:loading="cancelForm.processing"
|
prepend-icon="tabler-x"
|
||||||
:disabled="cancelForm.processing"
|
@click="openCancelDialog"
|
||||||
@click="cancelSubscription"
|
|
||||||
>
|
>
|
||||||
{{ cancelForm.processing ? 'Cancelling...' : 'Cancel Subscription' }}
|
Cancel Subscription
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
|
|
||||||
<!-- Resume -->
|
<!-- Resume -->
|
||||||
<VCard v-if="subscription.ends_at && subscription.stripe_status !== 'canceled'">
|
<VCard v-if="isCancelling" class="mb-6">
|
||||||
<VCardTitle>Resume Subscription</VCardTitle>
|
<VCardTitle>Resume Subscription</VCardTitle>
|
||||||
<VCardText>
|
<VCardText>
|
||||||
<div class="text-body-2 text-medium-emphasis mb-3">
|
<p class="text-body-2 text-medium-emphasis mb-3">
|
||||||
Your subscription is set to cancel. You can resume it before it expires.
|
Your subscription is set to cancel on {{ formatDate(subscription.ends_at!) }}.
|
||||||
</div>
|
Resume to keep your service running.
|
||||||
|
</p>
|
||||||
<VBtn
|
<VBtn
|
||||||
color="success"
|
color="success"
|
||||||
block
|
block
|
||||||
|
prepend-icon="tabler-player-play"
|
||||||
@click="resumeSubscription"
|
@click="resumeSubscription"
|
||||||
>
|
>
|
||||||
Resume Subscription
|
Resume Subscription
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Quick Info -->
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle>Quick Info</VCardTitle>
|
||||||
|
<VCardText>
|
||||||
|
<div class="d-flex flex-column ga-3">
|
||||||
|
<div class="d-flex justify-space-between align-center">
|
||||||
|
<span class="text-body-2 text-medium-emphasis">Status</span>
|
||||||
|
<VChip
|
||||||
|
:color="resolveSubscriptionStatusColor(subscription.stripe_status)"
|
||||||
|
size="small"
|
||||||
|
class="text-capitalize"
|
||||||
|
>
|
||||||
|
{{ subscription.stripe_status }}
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
<VDivider />
|
||||||
|
<div v-if="currentPlan" class="d-flex justify-space-between">
|
||||||
|
<span class="text-body-2 text-medium-emphasis">Plan</span>
|
||||||
|
<span class="text-body-2 font-weight-medium">{{ currentPlan.name }}</span>
|
||||||
|
</div>
|
||||||
|
<VDivider />
|
||||||
|
<div v-if="currentPlan" class="d-flex justify-space-between">
|
||||||
|
<span class="text-body-2 text-medium-emphasis">Price</span>
|
||||||
|
<span class="text-body-2 font-weight-medium">{{ formatPrice(currentPlan.price, currentPlan.billing_cycle) }}</span>
|
||||||
|
</div>
|
||||||
|
<VDivider />
|
||||||
|
<div v-if="subscription.current_period_end" class="d-flex justify-space-between">
|
||||||
|
<span class="text-body-2 text-medium-emphasis">Next Renewal</span>
|
||||||
|
<span class="text-body-2">{{ formatDate(subscription.current_period_end) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
|
|
||||||
|
<!-- Cancel Confirmation Dialog -->
|
||||||
|
<VDialog v-model="showCancelDialog" max-width="500">
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="d-flex align-center ga-2">
|
||||||
|
<VIcon icon="tabler-alert-triangle" color="error" />
|
||||||
|
Cancel Subscription
|
||||||
|
</VCardTitle>
|
||||||
|
<VCardText>
|
||||||
|
<p class="text-body-1 mb-4">
|
||||||
|
Are you sure you want to cancel your subscription?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<VAlert type="info" variant="tonal" class="mb-4">
|
||||||
|
<div class="text-body-2">
|
||||||
|
By default, your service will remain active until the end of your current billing period.
|
||||||
|
</div>
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<AppSelect
|
||||||
|
v-model="cancelReason"
|
||||||
|
:items="cancelReasons"
|
||||||
|
label="Reason for cancelling (optional)"
|
||||||
|
placeholder="Select a reason"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VCheckbox
|
||||||
|
v-model="cancelImmediately"
|
||||||
|
hide-details
|
||||||
|
class="mb-2"
|
||||||
|
>
|
||||||
|
<template #label>
|
||||||
|
<div>
|
||||||
|
<div class="text-body-2 font-weight-medium">Cancel immediately</div>
|
||||||
|
<div class="text-caption text-medium-emphasis">
|
||||||
|
Your service will be terminated right away with no refund.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VCheckbox>
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions class="pa-4 pt-0">
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
variant="outlined"
|
||||||
|
@click="showCancelDialog = false"
|
||||||
|
>
|
||||||
|
Keep Subscription
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
color="error"
|
||||||
|
:loading="cancelForm.processing"
|
||||||
|
@click="confirmCancel"
|
||||||
|
>
|
||||||
|
Confirm Cancellation
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
114
website/resources/ts/Pages/Tickets/Create.vue
Normal file
114
website/resources/ts/Pages/Tickets/Create.vue
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { useForm, Link } from '@inertiajs/vue3'
|
||||||
|
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||||
|
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
|
||||||
|
import AppSelect from '@/Components/app-form-elements/AppSelect.vue'
|
||||||
|
import AppTextarea from '@/Components/app-form-elements/AppTextarea.vue'
|
||||||
|
|
||||||
|
defineOptions({ layout: AccountLayout })
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
subject: '',
|
||||||
|
department: 'general',
|
||||||
|
priority: 'medium',
|
||||||
|
message: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const departmentOptions = [
|
||||||
|
{ title: 'General', value: 'general' },
|
||||||
|
{ title: 'Billing', value: 'billing' },
|
||||||
|
{ title: 'Technical', value: 'technical' },
|
||||||
|
{ title: 'Sales', value: 'sales' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const priorityOptions = [
|
||||||
|
{ title: 'Low', value: 'low' },
|
||||||
|
{ title: 'Medium', value: 'medium' },
|
||||||
|
{ title: 'High', value: 'high' },
|
||||||
|
{ title: 'Urgent', value: 'urgent' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function submitTicket(): void {
|
||||||
|
form.post('/tickets', {
|
||||||
|
preserveScroll: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<Link href="/tickets" class="text-primary text-body-2 text-decoration-none">
|
||||||
|
← Back to Tickets
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-h4 font-weight-bold mb-6">
|
||||||
|
Create Support Ticket
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VCard>
|
||||||
|
<VCardText>
|
||||||
|
<VForm @submit.prevent="submitTicket">
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12">
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.subject"
|
||||||
|
label="Subject"
|
||||||
|
placeholder="Brief description of your issue"
|
||||||
|
:error-messages="form.errors.subject"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppSelect
|
||||||
|
v-model="form.department"
|
||||||
|
label="Department"
|
||||||
|
:items="departmentOptions"
|
||||||
|
:error-messages="form.errors.department"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppSelect
|
||||||
|
v-model="form.priority"
|
||||||
|
label="Priority"
|
||||||
|
:items="priorityOptions"
|
||||||
|
:error-messages="form.errors.priority"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12">
|
||||||
|
<AppTextarea
|
||||||
|
v-model="form.message"
|
||||||
|
label="Message"
|
||||||
|
placeholder="Please describe your issue in detail (minimum 10 characters)..."
|
||||||
|
rows="8"
|
||||||
|
:error-messages="form.errors.message"
|
||||||
|
counter="5000"
|
||||||
|
maxlength="5000"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12" class="d-flex justify-end ga-3">
|
||||||
|
<Link href="/tickets" class="text-decoration-none">
|
||||||
|
<VBtn variant="tonal" color="secondary">
|
||||||
|
Cancel
|
||||||
|
</VBtn>
|
||||||
|
</Link>
|
||||||
|
<VBtn
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
:loading="form.processing"
|
||||||
|
:disabled="form.processing"
|
||||||
|
>
|
||||||
|
<VIcon icon="tabler-send" start />
|
||||||
|
Submit Ticket
|
||||||
|
</VBtn>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VForm>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
127
website/resources/ts/Pages/Tickets/Index.vue
Normal file
127
website/resources/ts/Pages/Tickets/Index.vue
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { Link, router } from '@inertiajs/vue3'
|
||||||
|
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||||
|
import { resolveTicketStatusColor, resolveTicketPriorityColor } from '@/utils/resolvers'
|
||||||
|
import type { PaginatedResponse, SupportTicket } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tickets: PaginatedResponse<SupportTicket>
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({ layout: AccountLayout })
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatus(status: string): string {
|
||||||
|
return status.replace(/_/g, ' ')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-center justify-space-between mb-6">
|
||||||
|
<div>
|
||||||
|
<div class="text-h4 font-weight-bold">
|
||||||
|
Support Tickets
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
View and manage your support requests
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href="/tickets/create" class="text-decoration-none">
|
||||||
|
<VBtn color="primary">
|
||||||
|
<VIcon icon="tabler-plus" start />
|
||||||
|
Create Ticket
|
||||||
|
</VBtn>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VCard v-if="tickets.data.length === 0">
|
||||||
|
<VCardText class="text-center py-12">
|
||||||
|
<VIcon icon="tabler-ticket-off" size="48" color="disabled" class="mb-2" />
|
||||||
|
<div class="text-medium-emphasis mb-4">
|
||||||
|
You don't have any support tickets yet.
|
||||||
|
</div>
|
||||||
|
<Link href="/tickets/create" class="text-decoration-none">
|
||||||
|
<VBtn color="primary" variant="tonal">
|
||||||
|
Create Your First Ticket
|
||||||
|
</VBtn>
|
||||||
|
</Link>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<VCard v-else>
|
||||||
|
<VTable density="comfortable" hover>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Subject</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Priority</th>
|
||||||
|
<th>Department</th>
|
||||||
|
<th>Last Updated</th>
|
||||||
|
<th class="text-center">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="ticket in tickets.data" :key="ticket.id">
|
||||||
|
<td class="text-body-2 font-weight-medium">
|
||||||
|
{{ ticket.subject }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<VChip
|
||||||
|
:color="resolveTicketStatusColor(ticket.status)"
|
||||||
|
size="small"
|
||||||
|
class="text-capitalize"
|
||||||
|
>
|
||||||
|
{{ formatStatus(ticket.status) }}
|
||||||
|
</VChip>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<VChip
|
||||||
|
:color="resolveTicketPriorityColor(ticket.priority)"
|
||||||
|
size="small"
|
||||||
|
class="text-capitalize"
|
||||||
|
>
|
||||||
|
{{ ticket.priority }}
|
||||||
|
</VChip>
|
||||||
|
</td>
|
||||||
|
<td class="text-body-2 text-capitalize">
|
||||||
|
{{ ticket.department }}
|
||||||
|
</td>
|
||||||
|
<td class="text-body-2">
|
||||||
|
{{ formatDate(ticket.updated_at) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<Link :href="`/tickets/${ticket.id}`">
|
||||||
|
<VBtn variant="text" size="small" color="primary">
|
||||||
|
<VIcon icon="tabler-eye" size="18" />
|
||||||
|
</VBtn>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</VTable>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<VCardText v-if="tickets.last_page > 1" class="d-flex align-center justify-center pt-2">
|
||||||
|
<VPagination
|
||||||
|
:model-value="tickets.data.length > 0 ? Math.ceil((tickets.from ?? 1) / 15) : 1"
|
||||||
|
:length="tickets.last_page"
|
||||||
|
:total-visible="7"
|
||||||
|
@update:model-value="(page: number) => router.get('/tickets', { page }, { preserveState: true, preserveScroll: true })"
|
||||||
|
/>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VCardText v-if="tickets.total > 0" class="text-center text-caption text-medium-emphasis">
|
||||||
|
Showing {{ tickets.from }} to {{ tickets.to }} of {{ tickets.total }} tickets
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
225
website/resources/ts/Pages/Tickets/Show.vue
Normal file
225
website/resources/ts/Pages/Tickets/Show.vue
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { useForm, Link } from '@inertiajs/vue3'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||||
|
import AppTextarea from '@/Components/app-form-elements/AppTextarea.vue'
|
||||||
|
import { resolveTicketStatusColor, resolveTicketPriorityColor } from '@/utils/resolvers'
|
||||||
|
import type { SupportTicket } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ticket: SupportTicket
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({ layout: AccountLayout })
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const closeDialog = ref<boolean>(false)
|
||||||
|
|
||||||
|
const replyForm = useForm({
|
||||||
|
body: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const closeForm = useForm({})
|
||||||
|
|
||||||
|
function submitReply(): void {
|
||||||
|
replyForm.post(`/tickets/${props.ticket.id}/reply`, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
replyForm.reset('body')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTicket(): void {
|
||||||
|
closeForm.post(`/tickets/${props.ticket.id}/close`, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
closeDialog.value = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatus(status: string): string {
|
||||||
|
return status.replace(/_/g, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserInitial(name: string): string {
|
||||||
|
return name.charAt(0).toUpperCase()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Back link -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<Link href="/tickets" class="text-primary text-body-2 text-decoration-none">
|
||||||
|
← Back to Tickets
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ticket Header -->
|
||||||
|
<VCard class="mb-6">
|
||||||
|
<VCardText>
|
||||||
|
<div class="d-flex align-center justify-space-between flex-wrap ga-4">
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-center ga-3 mb-2">
|
||||||
|
<span class="text-h5 font-weight-bold">{{ ticket.subject }}</span>
|
||||||
|
<VChip
|
||||||
|
:color="resolveTicketStatusColor(ticket.status)"
|
||||||
|
size="small"
|
||||||
|
class="text-capitalize"
|
||||||
|
>
|
||||||
|
{{ formatStatus(ticket.status) }}
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-center ga-4 text-body-2 text-medium-emphasis">
|
||||||
|
<span>
|
||||||
|
<VIcon icon="tabler-tag" size="16" class="me-1" />
|
||||||
|
<span class="text-capitalize">{{ ticket.priority }}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<VIcon icon="tabler-building" size="16" class="me-1" />
|
||||||
|
<span class="text-capitalize">{{ ticket.department }}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<VIcon icon="tabler-calendar" size="16" class="me-1" />
|
||||||
|
{{ formatDateTime(ticket.created_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VBtn
|
||||||
|
v-if="ticket.status !== 'closed'"
|
||||||
|
color="error"
|
||||||
|
variant="tonal"
|
||||||
|
@click="closeDialog = true"
|
||||||
|
>
|
||||||
|
<VIcon icon="tabler-x" start />
|
||||||
|
Close Ticket
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Conversation Thread -->
|
||||||
|
<div class="d-flex flex-column ga-4 mb-6">
|
||||||
|
<VCard
|
||||||
|
v-for="reply in ticket.replies"
|
||||||
|
:key="reply.id"
|
||||||
|
:class="reply.is_staff_reply ? 'border-s-4 border-primary' : ''"
|
||||||
|
:variant="reply.is_staff_reply ? 'tonal' : 'elevated'"
|
||||||
|
>
|
||||||
|
<VCardText>
|
||||||
|
<div class="d-flex align-center ga-3 mb-3">
|
||||||
|
<VAvatar
|
||||||
|
:color="reply.is_staff_reply ? 'primary' : 'secondary'"
|
||||||
|
variant="tonal"
|
||||||
|
size="36"
|
||||||
|
>
|
||||||
|
<span class="text-body-2 font-weight-semibold">
|
||||||
|
{{ getUserInitial(reply.user?.name ?? 'U') }}
|
||||||
|
</span>
|
||||||
|
</VAvatar>
|
||||||
|
<div>
|
||||||
|
<div class="text-body-1 font-weight-medium">
|
||||||
|
{{ reply.user?.name ?? 'Unknown' }}
|
||||||
|
<VChip
|
||||||
|
v-if="reply.is_staff_reply"
|
||||||
|
size="x-small"
|
||||||
|
color="primary"
|
||||||
|
class="ms-2"
|
||||||
|
>
|
||||||
|
Staff
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-medium-emphasis">
|
||||||
|
{{ formatDateTime(reply.created_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2" style="white-space: pre-wrap;">{{ reply.body }}</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reply Form -->
|
||||||
|
<VCard v-if="ticket.status !== 'closed'">
|
||||||
|
<VCardTitle class="d-flex align-center gap-2">
|
||||||
|
<VIcon icon="tabler-message" size="22" />
|
||||||
|
<span>Add Reply</span>
|
||||||
|
</VCardTitle>
|
||||||
|
<VCardText>
|
||||||
|
<VForm @submit.prevent="submitReply">
|
||||||
|
<AppTextarea
|
||||||
|
v-model="replyForm.body"
|
||||||
|
placeholder="Type your reply..."
|
||||||
|
rows="5"
|
||||||
|
:error-messages="replyForm.errors.body"
|
||||||
|
counter="5000"
|
||||||
|
maxlength="5000"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<div class="d-flex justify-end">
|
||||||
|
<VBtn
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
:loading="replyForm.processing"
|
||||||
|
:disabled="replyForm.processing || !replyForm.body.trim()"
|
||||||
|
>
|
||||||
|
<VIcon icon="tabler-send" start />
|
||||||
|
Send Reply
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</VForm>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Closed Notice -->
|
||||||
|
<VAlert
|
||||||
|
v-else
|
||||||
|
type="info"
|
||||||
|
variant="tonal"
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
This ticket has been closed. If you need further assistance, please create a new ticket.
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<!-- Close Confirmation Dialog -->
|
||||||
|
<VDialog v-model="closeDialog" max-width="500" persistent>
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="text-h5 pa-5">
|
||||||
|
Close Ticket
|
||||||
|
</VCardTitle>
|
||||||
|
<VCardText class="px-5 pb-2">
|
||||||
|
Are you sure you want to close this ticket? You will not be able to reply after closing.
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions class="pa-5">
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn variant="text" :disabled="closeForm.processing" @click="closeDialog = false">
|
||||||
|
Cancel
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
color="error"
|
||||||
|
variant="flat"
|
||||||
|
:loading="closeForm.processing"
|
||||||
|
@click="closeTicket"
|
||||||
|
>
|
||||||
|
Close Ticket
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -11,5 +11,6 @@ export const accountNavItems: NavItem[] = [
|
|||||||
{ title: 'Subscriptions', href: '/subscriptions', icon: 'tabler-receipt', matchPrefix: '/subscriptions' },
|
{ title: 'Subscriptions', href: '/subscriptions', icon: 'tabler-receipt', matchPrefix: '/subscriptions' },
|
||||||
{ title: 'Billing', href: '/billing', icon: 'tabler-credit-card', matchPrefix: '/billing' },
|
{ title: 'Billing', href: '/billing', icon: 'tabler-credit-card', matchPrefix: '/billing' },
|
||||||
{ title: 'Plans', href: '/plans', icon: 'tabler-package', matchPrefix: '/plans' },
|
{ title: 'Plans', href: '/plans', icon: 'tabler-package', matchPrefix: '/plans' },
|
||||||
|
{ title: 'Support', href: '/tickets', icon: 'tabler-headset', matchPrefix: '/tickets' },
|
||||||
{ title: 'Settings', href: '/profile', icon: 'tabler-settings', matchPrefix: '/profile' },
|
{ title: 'Settings', href: '/profile', icon: 'tabler-settings', matchPrefix: '/profile' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const adminNavItems: NavItem[] = [
|
|||||||
{ title: 'Orders', href: '/orders', icon: 'tabler-shopping-cart', matchPrefix: '/orders' },
|
{ title: 'Orders', href: '/orders', icon: 'tabler-shopping-cart', matchPrefix: '/orders' },
|
||||||
{ title: 'Invoices', href: '/invoices', icon: 'tabler-file-invoice', matchPrefix: '/invoices' },
|
{ title: 'Invoices', href: '/invoices', icon: 'tabler-file-invoice', matchPrefix: '/invoices' },
|
||||||
{ title: 'Coupons', href: '/coupons', icon: 'tabler-discount-2', matchPrefix: '/coupons' },
|
{ title: 'Coupons', href: '/coupons', icon: 'tabler-discount-2', matchPrefix: '/coupons' },
|
||||||
|
{ title: 'Tickets', href: '/tickets', icon: 'tabler-message-circle', matchPrefix: '/tickets' },
|
||||||
{ title: 'Audit Logs', href: '/audit-logs', icon: 'tabler-clipboard-list', matchPrefix: '/audit-logs' },
|
{ title: 'Audit Logs', href: '/audit-logs', icon: 'tabler-clipboard-list', matchPrefix: '/audit-logs' },
|
||||||
{ title: 'Settings', href: '/settings', icon: 'tabler-settings', matchPrefix: '/settings' },
|
{ title: 'Settings', href: '/settings', icon: 'tabler-settings', matchPrefix: '/settings' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -163,4 +163,39 @@ export interface CouponRedemption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SupportTicket {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
subject: string
|
||||||
|
status: 'open' | 'in_progress' | 'waiting' | 'closed'
|
||||||
|
priority: 'low' | 'medium' | 'high' | 'urgent'
|
||||||
|
department: 'billing' | 'technical' | 'sales' | 'general'
|
||||||
|
last_reply_at: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
user?: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
status?: string
|
||||||
|
company?: string | null
|
||||||
|
}
|
||||||
|
replies?: TicketReply[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketReply {
|
||||||
|
id: number
|
||||||
|
ticket_id: number
|
||||||
|
user_id: number
|
||||||
|
body: string
|
||||||
|
is_staff_reply: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
user?: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type StatusColor = 'success' | 'error' | 'warning' | 'info' | 'secondary'
|
export type StatusColor = 'success' | 'error' | 'warning' | 'info' | 'secondary'
|
||||||
|
|||||||
@@ -74,6 +74,26 @@ export function resolvePlatformUrl(platform: string, platformServiceId: string |
|
|||||||
return urls[platform] ?? null
|
return urls[platform] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveTicketStatusColor(status: string): StatusColor {
|
||||||
|
const map: Record<string, StatusColor> = {
|
||||||
|
open: 'info',
|
||||||
|
in_progress: 'success',
|
||||||
|
waiting: 'warning',
|
||||||
|
closed: 'secondary',
|
||||||
|
}
|
||||||
|
return map[status] ?? 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTicketPriorityColor(priority: string): StatusColor {
|
||||||
|
const map: Record<string, StatusColor> = {
|
||||||
|
low: 'success',
|
||||||
|
medium: 'info',
|
||||||
|
high: 'warning',
|
||||||
|
urgent: 'error',
|
||||||
|
}
|
||||||
|
return map[priority] ?? 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
export function formatPrice(price: string | number, cycle?: string): string {
|
export function formatPrice(price: string | number, cycle?: string): string {
|
||||||
const amount = parseFloat(String(price)).toFixed(2)
|
const amount = parseFloat(String(price)).toFixed(2)
|
||||||
return cycle ? `$${amount}/${cycle}` : `$${amount}`
|
return cycle ? `$${amount}/${cycle}` : `$${amount}`
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Http\Controllers\Account\PlanController;
|
|||||||
use App\Http\Controllers\Account\ProfileController;
|
use App\Http\Controllers\Account\ProfileController;
|
||||||
use App\Http\Controllers\Account\ServiceController;
|
use App\Http\Controllers\Account\ServiceController;
|
||||||
use App\Http\Controllers\Account\SubscriptionController;
|
use App\Http\Controllers\Account\SubscriptionController;
|
||||||
|
use App\Http\Controllers\Account\TicketController;
|
||||||
use App\Http\Controllers\Account\UpgradeController;
|
use App\Http\Controllers\Account\UpgradeController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -53,6 +54,11 @@ Route::get('/billing/invoices/{invoice}/download', [BillingController::class, 'd
|
|||||||
Route::get('/billing/transactions', [BillingController::class, 'transactions'])->name('account.billing.transactions');
|
Route::get('/billing/transactions', [BillingController::class, 'transactions'])->name('account.billing.transactions');
|
||||||
Route::post('/billing/setup-intent', [BillingController::class, 'setupIntent'])->name('account.billing.setup-intent');
|
Route::post('/billing/setup-intent', [BillingController::class, 'setupIntent'])->name('account.billing.setup-intent');
|
||||||
|
|
||||||
|
// Support Tickets
|
||||||
|
Route::resource('tickets', TicketController::class)->only(['index', 'create', 'store', 'show'])->names('account.tickets');
|
||||||
|
Route::post('/tickets/{ticket}/reply', [TicketController::class, 'reply'])->name('account.tickets.reply');
|
||||||
|
Route::post('/tickets/{ticket}/close', [TicketController::class, 'close'])->name('account.tickets.close');
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
Route::get('/notifications', [NotificationController::class, 'index'])->name('account.notifications.index');
|
Route::get('/notifications', [NotificationController::class, 'index'])->name('account.notifications.index');
|
||||||
Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('account.notifications.read');
|
Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('account.notifications.read');
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Http\Controllers\Admin\OrderController;
|
|||||||
use App\Http\Controllers\Admin\PlanController;
|
use App\Http\Controllers\Admin\PlanController;
|
||||||
use App\Http\Controllers\Admin\ServiceController;
|
use App\Http\Controllers\Admin\ServiceController;
|
||||||
use App\Http\Controllers\Admin\SettingsController;
|
use App\Http\Controllers\Admin\SettingsController;
|
||||||
|
use App\Http\Controllers\Admin\TicketController as AdminTicketController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/dashboard', [DashboardController::class, 'index'])->name('admin.dashboard');
|
Route::get('/dashboard', [DashboardController::class, 'index'])->name('admin.dashboard');
|
||||||
@@ -57,6 +58,14 @@ Route::get('audit-logs', [AuditLogController::class, 'index'])->name('audit-logs
|
|||||||
Route::get('settings', [SettingsController::class, 'index'])->name('admin.settings.index');
|
Route::get('settings', [SettingsController::class, 'index'])->name('admin.settings.index');
|
||||||
Route::put('settings', [SettingsController::class, 'update'])->name('admin.settings.update');
|
Route::put('settings', [SettingsController::class, 'update'])->name('admin.settings.update');
|
||||||
|
|
||||||
|
// Support Tickets
|
||||||
|
Route::resource('tickets', AdminTicketController::class)->only(['index', 'show'])->names([
|
||||||
|
'index' => 'admin.tickets.index',
|
||||||
|
'show' => 'admin.tickets.show',
|
||||||
|
]);
|
||||||
|
Route::post('tickets/{ticket}/reply', [AdminTicketController::class, 'reply'])->name('admin.tickets.reply');
|
||||||
|
Route::put('tickets/{ticket}/status', [AdminTicketController::class, 'updateStatus'])->name('admin.tickets.status');
|
||||||
|
|
||||||
// Impersonation
|
// Impersonation
|
||||||
Route::post('impersonate/{user}', [ImpersonationController::class, 'start'])->name('impersonate.start');
|
Route::post('impersonate/{user}', [ImpersonationController::class, 'start'])->name('impersonate.start');
|
||||||
Route::post('impersonate/stop', [ImpersonationController::class, 'stop'])->name('impersonate.stop');
|
Route::post('impersonate/stop', [ImpersonationController::class, 'stop'])->name('impersonate.stop');
|
||||||
|
|||||||
451
website/tests/Feature/SupportTicketTest.php
Normal file
451
website/tests/Feature/SupportTicketTest.php
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\SupportTicket;
|
||||||
|
use App\Models\TicketReply;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RoleAndPermissionSeeder;
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->seed(RoleAndPermissionSeeder::class);
|
||||||
|
$this->accountUrl = 'http://'.config('app.domains.account');
|
||||||
|
$this->adminUrl = 'http://'.config('app.domains.admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Customer Ticket Management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Customer Ticket List', function (): void {
|
||||||
|
it('allows a customer to view their ticket list', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
SupportTicket::factory()->count(3)->create(['user_id' => $customer->id]);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->accountUrl.'/tickets')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Tickets/Index')
|
||||||
|
->has('tickets.data', 3)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('customer cannot see other users tickets', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$otherUser = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
SupportTicket::factory()->count(2)->create(['user_id' => $customer->id]);
|
||||||
|
SupportTicket::factory()->count(3)->create(['user_id' => $otherUser->id]);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->accountUrl.'/tickets')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Tickets/Index')
|
||||||
|
->has('tickets.data', 2)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects guests to login when accessing ticket list', function (): void {
|
||||||
|
$this->get($this->accountUrl.'/tickets')
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Customer Ticket Creation', function (): void {
|
||||||
|
it('displays the create ticket form', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->accountUrl.'/tickets/create')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Tickets/Create')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a customer to create a ticket', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->post($this->accountUrl.'/tickets', [
|
||||||
|
'subject' => 'Unable to access VPS',
|
||||||
|
'department' => 'technical',
|
||||||
|
'priority' => 'high',
|
||||||
|
'message' => 'I cannot SSH into my VPS server. Please help.',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('support_tickets', [
|
||||||
|
'user_id' => $customer->id,
|
||||||
|
'subject' => 'Unable to access VPS',
|
||||||
|
'department' => 'technical',
|
||||||
|
'priority' => 'high',
|
||||||
|
'status' => 'open',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ticket = SupportTicket::where('user_id', $customer->id)->first();
|
||||||
|
$this->assertDatabaseHas('ticket_replies', [
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'user_id' => $customer->id,
|
||||||
|
'body' => 'I cannot SSH into my VPS server. Please help.',
|
||||||
|
'is_staff_reply' => false,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates required fields when creating a ticket', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->post($this->accountUrl.'/tickets', [])
|
||||||
|
->assertSessionHasErrors(['subject', 'department', 'priority', 'message']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires authentication to create a ticket', function (): void {
|
||||||
|
$this->get($this->accountUrl.'/tickets/create')
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Customer Ticket Detail', function (): void {
|
||||||
|
it('allows a customer to view their own ticket detail', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$ticket = SupportTicket::factory()->create(['user_id' => $customer->id]);
|
||||||
|
TicketReply::factory()->count(2)->create([
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'user_id' => $customer->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->accountUrl.'/tickets/'.$ticket->id)
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Tickets/Show')
|
||||||
|
->has('ticket')
|
||||||
|
->has('ticket.replies', 2)
|
||||||
|
->where('ticket.id', $ticket->id)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids a customer from viewing another users ticket', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$otherUser = User::factory()->customer()->create();
|
||||||
|
$ticket = SupportTicket::factory()->create(['user_id' => $otherUser->id]);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->accountUrl.'/tickets/'.$ticket->id)
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Customer Ticket Reply', function (): void {
|
||||||
|
it('allows a customer to reply to their open ticket', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->post($this->accountUrl.'/tickets/'.$ticket->id.'/reply', [
|
||||||
|
'body' => 'Here is additional information about my issue.',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('ticket_replies', [
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'user_id' => $customer->id,
|
||||||
|
'body' => 'Here is additional information about my issue.',
|
||||||
|
'is_staff_reply' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($ticket->fresh()->last_reply_at)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('customer cannot reply to a closed ticket', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$ticket = SupportTicket::factory()->closed()->create(['user_id' => $customer->id]);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->post($this->accountUrl.'/tickets/'.$ticket->id.'/reply', [
|
||||||
|
'body' => 'Trying to reply to closed ticket.',
|
||||||
|
])
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('customer cannot reply to another users ticket', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$otherUser = User::factory()->customer()->create();
|
||||||
|
$ticket = SupportTicket::factory()->open()->create(['user_id' => $otherUser->id]);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->post($this->accountUrl.'/tickets/'.$ticket->id.'/reply', [
|
||||||
|
'body' => 'Unauthorized reply attempt.',
|
||||||
|
])
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates reply body is required', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->post($this->accountUrl.'/tickets/'.$ticket->id.'/reply', [])
|
||||||
|
->assertSessionHasErrors(['body']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Customer Ticket Close', function (): void {
|
||||||
|
it('allows a customer to close their ticket', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->post($this->accountUrl.'/tickets/'.$ticket->id.'/close')
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect($ticket->fresh()->status)->toBe('closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('customer cannot close another users ticket', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$otherUser = User::factory()->customer()->create();
|
||||||
|
$ticket = SupportTicket::factory()->open()->create(['user_id' => $otherUser->id]);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->post($this->accountUrl.'/tickets/'.$ticket->id.'/close')
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Admin Ticket Management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Admin Ticket List', function (): void {
|
||||||
|
it('allows admin to view all tickets', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$customer1 = User::factory()->customer()->create();
|
||||||
|
$customer2 = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
SupportTicket::factory()->count(2)->create(['user_id' => $customer1->id]);
|
||||||
|
SupportTicket::factory()->count(3)->create(['user_id' => $customer2->id]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/tickets')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Tickets/Index')
|
||||||
|
->has('tickets.data', 5)
|
||||||
|
->has('filters')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows admin to filter tickets by status', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
SupportTicket::factory()->count(2)->create([
|
||||||
|
'user_id' => $customer->id,
|
||||||
|
'status' => 'open',
|
||||||
|
]);
|
||||||
|
SupportTicket::factory()->count(3)->create([
|
||||||
|
'user_id' => $customer->id,
|
||||||
|
'status' => 'closed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/tickets?status=open')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Tickets/Index')
|
||||||
|
->has('tickets.data', 2)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows admin to filter tickets by priority', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
SupportTicket::factory()->count(1)->urgent()->create(['user_id' => $customer->id]);
|
||||||
|
SupportTicket::factory()->count(2)->create([
|
||||||
|
'user_id' => $customer->id,
|
||||||
|
'priority' => 'low',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/tickets?priority=urgent')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Tickets/Index')
|
||||||
|
->has('tickets.data', 1)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows admin to filter tickets by department', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
SupportTicket::factory()->count(2)->create([
|
||||||
|
'user_id' => $customer->id,
|
||||||
|
'department' => 'billing',
|
||||||
|
]);
|
||||||
|
SupportTicket::factory()->count(3)->create([
|
||||||
|
'user_id' => $customer->id,
|
||||||
|
'department' => 'technical',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/tickets?department=billing')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Tickets/Index')
|
||||||
|
->has('tickets.data', 2)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies customer access to admin ticket list', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($this->adminUrl.'/tickets')
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects guest to login when accessing admin ticket list', function (): void {
|
||||||
|
$this->get($this->adminUrl.'/tickets')
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Admin Ticket Detail', function (): void {
|
||||||
|
it('allows admin to view any ticket', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$ticket = SupportTicket::factory()->create(['user_id' => $customer->id]);
|
||||||
|
TicketReply::factory()->count(3)->create(['ticket_id' => $ticket->id]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get($this->adminUrl.'/tickets/'.$ticket->id)
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('Admin/Tickets/Show')
|
||||||
|
->has('ticket')
|
||||||
|
->has('ticket.replies', 3)
|
||||||
|
->where('ticket.id', $ticket->id)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Admin Ticket Reply', function (): void {
|
||||||
|
it('allows admin to reply to a ticket as staff', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->post($this->adminUrl.'/tickets/'.$ticket->id.'/reply', [
|
||||||
|
'body' => 'Thank you for your inquiry. We are looking into this.',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('ticket_replies', [
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'user_id' => $admin->id,
|
||||||
|
'body' => 'Thank you for your inquiry. We are looking into this.',
|
||||||
|
'is_staff_reply' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($ticket->fresh()->last_reply_at)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('admin can reply to a closed ticket', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$ticket = SupportTicket::factory()->closed()->create(['user_id' => $customer->id]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->post($this->adminUrl.'/tickets/'.$ticket->id.'/reply', [
|
||||||
|
'body' => 'Additional information regarding this closed ticket.',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('ticket_replies', [
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'user_id' => $admin->id,
|
||||||
|
'body' => 'Additional information regarding this closed ticket.',
|
||||||
|
'is_staff_reply' => true,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates reply body is required for admin', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->post($this->adminUrl.'/tickets/'.$ticket->id.'/reply', [])
|
||||||
|
->assertSessionHasErrors(['body']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Admin Ticket Status Update', function (): void {
|
||||||
|
it('allows admin to update ticket status', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->put($this->adminUrl.'/tickets/'.$ticket->id.'/status', [
|
||||||
|
'status' => 'in_progress',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect($ticket->fresh()->status)->toBe('in_progress');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows admin to change ticket to closed status', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->put($this->adminUrl.'/tickets/'.$ticket->id.'/status', [
|
||||||
|
'status' => 'closed',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect($ticket->fresh()->status)->toBe('closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows admin to reopen a closed ticket', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$ticket = SupportTicket::factory()->closed()->create(['user_id' => $customer->id]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->put($this->adminUrl.'/tickets/'.$ticket->id.'/status', [
|
||||||
|
'status' => 'open',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect($ticket->fresh()->status)->toBe('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates status is required', function (): void {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->put($this->adminUrl.'/tickets/'.$ticket->id.'/status', [])
|
||||||
|
->assertSessionHasErrors(['status']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies customer access to status update endpoint', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->put($this->adminUrl.'/tickets/'.$ticket->id.'/status', [
|
||||||
|
'status' => 'closed',
|
||||||
|
])
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user