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 navigation configs (account.ts, admin.ts, marketing.ts)
|
||||
- [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
|
||||
- [x] Create `ProvisioningServiceInterface` abstraction
|
||||
@@ -91,6 +100,22 @@
|
||||
- [ ] Send credentials email on successful provisioning
|
||||
- [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)
|
||||
- [x] Build service overview dashboard:
|
||||
- [x] Active services list with status indicators
|
||||
@@ -109,7 +134,7 @@
|
||||
- [ ] Payment history
|
||||
- [ ] Manage payment methods (add/remove cards, set default)
|
||||
- [ ] 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)
|
||||
- [x] Profile and account settings:
|
||||
- [x] Contact information
|
||||
@@ -136,7 +161,7 @@
|
||||
- [x] Customer list (searchable, filterable)
|
||||
- [x] Customer detail view (profile, services, billing history, notes)
|
||||
- [ ] Edit customer information
|
||||
- [ ] Impersonate customer (with audit logging)
|
||||
- [x] Impersonate customer (with audit logging)
|
||||
- [ ] Add admin notes to customer account
|
||||
- [ ] View customer audit log
|
||||
- [x] Service management:
|
||||
@@ -146,10 +171,10 @@
|
||||
- [x] Terminate service
|
||||
- [ ] Modify service (change plan, extend expiry)
|
||||
- [x] View provisioning logs
|
||||
- [ ] Order management:
|
||||
- [ ] Pending orders list
|
||||
- [ ] Approve/reject orders (for semi-automated provisioning)
|
||||
- [ ] View order details
|
||||
- [x] Order management:
|
||||
- [x] Pending orders list
|
||||
- [x] Approve/reject orders (for semi-automated provisioning)
|
||||
- [x] View order details
|
||||
- [x] Invoice management:
|
||||
- [x] All invoices list (filter by status, date, customer)
|
||||
- [ ] Create manual invoice
|
||||
@@ -241,7 +266,7 @@
|
||||
- [ ] Coupon code application
|
||||
- [ ] Add to cart / checkout flow
|
||||
- [x] About page
|
||||
- [x] Contact page
|
||||
- [x] Contact page with form submission backend
|
||||
- [ ] Blog/news section (optional, or use WordPress?)
|
||||
- [ ] Knowledge base / FAQ:
|
||||
- [ ] Getting started guides
|
||||
@@ -253,6 +278,7 @@
|
||||
- [x] Privacy Policy
|
||||
- [x] Acceptable Use Policy
|
||||
- [x] SLA (Service Level Agreement)
|
||||
- [x] Footer links to legal pages
|
||||
- [ ] Signup flow:
|
||||
- [ ] Plan selection
|
||||
- [ ] Account creation
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Account;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SupportTicket;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@@ -42,6 +43,11 @@ class DashboardController extends Controller
|
||||
->orderBy('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', [
|
||||
'activeServicesCount' => $activeServicesCount,
|
||||
'activeSubscriptionsCount' => $activeSubscriptionsCount,
|
||||
@@ -49,6 +55,7 @@ class DashboardController extends Controller
|
||||
'latestInvoices' => $latestInvoices,
|
||||
'pendingInvoicesAmount' => number_format((float) $pendingInvoicesAmount, 2, '.', ''),
|
||||
'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\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class SupportTicket extends Model
|
||||
{
|
||||
@@ -18,9 +19,11 @@ class SupportTicket extends Model
|
||||
'subject',
|
||||
'status',
|
||||
'priority',
|
||||
'department',
|
||||
'last_reply_at',
|
||||
];
|
||||
|
||||
/** @return array<string, string> */
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
@@ -33,4 +36,9 @@ class SupportTicket extends Model
|
||||
{
|
||||
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[]
|
||||
pendingInvoicesAmount: string
|
||||
nextRenewalDate: string | null
|
||||
openTicketsCount: number
|
||||
}
|
||||
|
||||
defineOptions({ layout: AccountLayout })
|
||||
@@ -378,10 +379,8 @@ const unpaidInvoices = computed<Invoice[]>(() => {
|
||||
</VBtn>
|
||||
</Link>
|
||||
|
||||
<a
|
||||
href="https://ezscale.support"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<Link
|
||||
href="/tickets/create"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<VBtn
|
||||
@@ -392,14 +391,16 @@ const unpaidInvoices = computed<Invoice[]>(() => {
|
||||
icon="tabler-headset"
|
||||
start
|
||||
/>
|
||||
Get Support
|
||||
<VIcon
|
||||
icon="tabler-external-link"
|
||||
end
|
||||
size="14"
|
||||
Open Support Ticket
|
||||
<VBadge
|
||||
v-if="openTicketsCount > 0"
|
||||
:content="openTicketsCount"
|
||||
color="error"
|
||||
inline
|
||||
class="ms-1"
|
||||
/>
|
||||
</VBtn>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useForm, Link } from '@inertiajs/vue3'
|
||||
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||
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'
|
||||
|
||||
interface Props {
|
||||
@@ -14,47 +16,82 @@ defineOptions({ layout: AccountLayout })
|
||||
|
||||
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({
|
||||
immediately: false,
|
||||
reason: '',
|
||||
})
|
||||
|
||||
const swapForm = useForm({
|
||||
plan_id: '',
|
||||
})
|
||||
|
||||
const cancelSubscription = (): void => {
|
||||
cancelForm.immediately = cancelImmediately.value
|
||||
cancelForm.post(`/subscriptions/${props.subscription.id}/cancel`)
|
||||
function openCancelDialog(): void {
|
||||
showCancelDialog.value = true
|
||||
}
|
||||
|
||||
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`)
|
||||
}
|
||||
|
||||
const swapPlan = (): void => {
|
||||
function swapPlan(): void {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<Link href="/subscriptions" class="text-primary text-body-2 text-decoration-none">← Back to Subscriptions</Link>
|
||||
<!-- Breadcrumb -->
|
||||
<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 class="text-h4 font-weight-bold mb-6">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>
|
||||
<div class="d-flex align-center justify-space-between mb-6">
|
||||
<div class="text-h4 font-weight-bold">Subscription Details</div>
|
||||
<VChip
|
||||
:color="resolveSubscriptionStatusColor(subscription.stripe_status)"
|
||||
size="small"
|
||||
@@ -65,38 +102,96 @@ const swapPlan = (): void => {
|
||||
</div>
|
||||
|
||||
<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-1 text-capitalize mt-1">{{ subscription.gateway || 'stripe' }}</div>
|
||||
</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-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 v-if="subscription.current_period_start" cols="6">
|
||||
<div class="text-body-2 text-medium-emphasis">Current Period Start</div>
|
||||
<div class="text-body-1 mt-1">{{ new Date(subscription.current_period_start).toLocaleDateString() }}</div>
|
||||
<VCol v-if="currentPlan" cols="6" sm="4">
|
||||
<div class="text-body-2 text-medium-emphasis">Billing Cycle</div>
|
||||
<div class="text-body-1 text-capitalize mt-1">{{ currentPlan.billing_cycle }}</div>
|
||||
</VCol>
|
||||
<VCol v-if="subscription.current_period_end" cols="6">
|
||||
<div class="text-body-2 text-medium-emphasis">Current Period End</div>
|
||||
<div class="text-body-1 mt-1">{{ new Date(subscription.current_period_end).toLocaleDateString() }}</div>
|
||||
<VCol v-if="subscription.current_period_start" cols="6" sm="4">
|
||||
<div class="text-body-2 text-medium-emphasis">Period Start</div>
|
||||
<div class="text-body-1 mt-1">{{ formatDate(subscription.current_period_start) }}</div>
|
||||
</VCol>
|
||||
<VCol v-if="subscription.ends_at" cols="6">
|
||||
<div class="text-body-2 text-medium-emphasis">Cancels On</div>
|
||||
<div class="text-body-1 text-error mt-1">{{ new Date(subscription.ends_at).toLocaleDateString() }}</div>
|
||||
<VCol v-if="subscription.current_period_end" cols="6" sm="4">
|
||||
<div class="text-body-2 text-medium-emphasis">Period End</div>
|
||||
<div class="text-body-1 mt-1">{{ formatDate(subscription.current_period_end) }}</div>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VCol cols="6" sm="4">
|
||||
<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>
|
||||
</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>
|
||||
</VCard>
|
||||
|
||||
<!-- Change Plan -->
|
||||
<VCard v-if="availablePlans.length > 0 && subscription.stripe_status === 'active'">
|
||||
<VCard v-if="availablePlans.length > 0 && isActive && !isCancelling">
|
||||
<VCardTitle>Change Plan</VCardTitle>
|
||||
<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">
|
||||
<VRadioGroup v-model="swapForm.plan_id" class="mb-4">
|
||||
<VRadio
|
||||
@@ -105,9 +200,27 @@ const swapPlan = (): void => {
|
||||
:value="String(plan.id)"
|
||||
>
|
||||
<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>
|
||||
<div class="d-flex align-center ga-2">
|
||||
<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>
|
||||
</template>
|
||||
</VRadio>
|
||||
@@ -115,10 +228,12 @@ const swapPlan = (): void => {
|
||||
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="swapForm.processing"
|
||||
:disabled="!swapForm.plan_id || swapForm.processing"
|
||||
prepend-icon="tabler-switch-horizontal"
|
||||
>
|
||||
{{ swapForm.processing ? 'Changing...' : 'Change Plan' }}
|
||||
Change Plan
|
||||
</VBtn>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
@@ -128,46 +243,137 @@ const swapPlan = (): void => {
|
||||
<!-- Actions Sidebar -->
|
||||
<VCol cols="12" lg="4">
|
||||
<!-- 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>
|
||||
<VCardText>
|
||||
<VCheckbox
|
||||
v-model="cancelImmediately"
|
||||
label="Cancel immediately (no grace period)"
|
||||
hide-details
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||
If you cancel, your service will remain active until the end of your current billing period.
|
||||
</p>
|
||||
<VBtn
|
||||
color="error"
|
||||
variant="tonal"
|
||||
block
|
||||
:loading="cancelForm.processing"
|
||||
:disabled="cancelForm.processing"
|
||||
@click="cancelSubscription"
|
||||
prepend-icon="tabler-x"
|
||||
@click="openCancelDialog"
|
||||
>
|
||||
{{ cancelForm.processing ? 'Cancelling...' : 'Cancel Subscription' }}
|
||||
Cancel Subscription
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Resume -->
|
||||
<VCard v-if="subscription.ends_at && subscription.stripe_status !== 'canceled'">
|
||||
<VCard v-if="isCancelling" class="mb-6">
|
||||
<VCardTitle>Resume Subscription</VCardTitle>
|
||||
<VCardText>
|
||||
<div class="text-body-2 text-medium-emphasis mb-3">
|
||||
Your subscription is set to cancel. You can resume it before it expires.
|
||||
</div>
|
||||
|
||||
<p class="text-body-2 text-medium-emphasis mb-3">
|
||||
Your subscription is set to cancel on {{ formatDate(subscription.ends_at!) }}.
|
||||
Resume to keep your service running.
|
||||
</p>
|
||||
<VBtn
|
||||
color="success"
|
||||
block
|
||||
prepend-icon="tabler-player-play"
|
||||
@click="resumeSubscription"
|
||||
>
|
||||
Resume Subscription
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</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>
|
||||
</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>
|
||||
</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: 'Billing', href: '/billing', icon: 'tabler-credit-card', matchPrefix: '/billing' },
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
@@ -8,6 +8,7 @@ export const adminNavItems: NavItem[] = [
|
||||
{ title: 'Orders', href: '/orders', icon: 'tabler-shopping-cart', matchPrefix: '/orders' },
|
||||
{ title: 'Invoices', href: '/invoices', icon: 'tabler-file-invoice', matchPrefix: '/invoices' },
|
||||
{ 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: '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'
|
||||
|
||||
@@ -74,6 +74,26 @@ export function resolvePlatformUrl(platform: string, platformServiceId: string |
|
||||
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 {
|
||||
const amount = parseFloat(String(price)).toFixed(2)
|
||||
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\ServiceController;
|
||||
use App\Http\Controllers\Account\SubscriptionController;
|
||||
use App\Http\Controllers\Account\TicketController;
|
||||
use App\Http\Controllers\Account\UpgradeController;
|
||||
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::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
|
||||
Route::get('/notifications', [NotificationController::class, 'index'])->name('account.notifications.index');
|
||||
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\ServiceController;
|
||||
use App\Http\Controllers\Admin\SettingsController;
|
||||
use App\Http\Controllers\Admin\TicketController as AdminTicketController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
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::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
|
||||
Route::post('impersonate/{user}', [ImpersonationController::class, 'start'])->name('impersonate.start');
|
||||
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