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:
Claude Dev
2026-02-09 16:20:12 -05:00
parent 9603803928
commit 6f39c32270
23 changed files with 2343 additions and 76 deletions

View File

@@ -61,7 +61,16 @@
- [x] Create 9 marketing pages (Home, Products, VPS, Dedicated, Web, Game, Pricing, About, Contact) - [x] Create 9 marketing pages (Home, Products, VPS, Dedicated, Web, Game, Pricing, About, Contact)
- [x] Create navigation configs (account.ts, admin.ts, marketing.ts) - [x] Create navigation configs (account.ts, admin.ts, marketing.ts)
- [x] Set purple primary color (#7367F0) matching Vuexy demo - [x] Set purple primary color (#7367F0) matching Vuexy demo
- [x] All 53 tests passing, build clean - [x] All 114 tests passing, build clean (Phase 1-5 + Frontend + Notifications)
## Notifications System ✅
- [x] Create notification classes (PaymentSucceeded, PaymentFailed, SubscriptionCreated, SubscriptionCancelled, ServiceProvisioned, InvoiceGenerated)
- [x] Configure mail and database notification channels
- [x] Wire notifications to relevant events (PaymentSucceeded, PaymentFailed, SubscriptionCreated, SubscriptionCancelled, ServiceProvisioned, InvoiceGenerated)
- [x] Build NotificationBell component in Account and Admin layouts
- [x] Implement NotificationController (index, markAsRead, markAllAsRead)
- [x] FlashMessages supports info alerts
- [x] Inertia shared data includes impersonation state
## Phase 3: Provisioning Automation ## Phase 3: Provisioning Automation
- [x] Create `ProvisioningServiceInterface` abstraction - [x] Create `ProvisioningServiceInterface` abstraction
@@ -91,6 +100,22 @@
- [ ] Send credentials email on successful provisioning - [ ] Send credentials email on successful provisioning
- [x] Log all provisioning actions to `provisioning_logs` table - [x] Log all provisioning actions to `provisioning_logs` table
## Support Ticket System (Standalone) ✅
- [x] TicketReply model with relationships
- [x] Updated SupportTicket model with replies() relationship and department field
- [x] Migration: ticket_replies table + department column on support_tickets
- [x] Customer TicketController (index, create, store, show, reply, close)
- [x] Admin TicketController (index with filters, show, reply, updateStatus)
- [x] SupportTicketFactory with open/closed/urgent states
- [x] TicketReplyFactory with staffReply state
- [x] Customer Vue pages: Tickets/Index, Tickets/Create, Tickets/Show
- [x] Admin Vue pages: Admin/Tickets/Index, Admin/Tickets/Show
- [x] Ticket status/priority color resolvers
- [x] TypeScript interfaces for SupportTicket and TicketReply
- [x] Navigation items for both account and admin sidebars
- [x] Routes for both account and admin subdomains
- [x] 30 Pest tests (144 total, 775 assertions)
## Phase 4: Customer Dashboard (account.ezscale.cloud) ## Phase 4: Customer Dashboard (account.ezscale.cloud)
- [x] Build service overview dashboard: - [x] Build service overview dashboard:
- [x] Active services list with status indicators - [x] Active services list with status indicators
@@ -109,7 +134,7 @@
- [ ] Payment history - [ ] Payment history
- [ ] Manage payment methods (add/remove cards, set default) - [ ] Manage payment methods (add/remove cards, set default)
- [ ] Upcoming renewals - [ ] Upcoming renewals
- [ ] Plan upgrade/downgrade flow (self-service with proration) - [x] Plan upgrade/downgrade flow (self-service with proration)
- [ ] Subscription cancellation flow (with optional survey) - [ ] Subscription cancellation flow (with optional survey)
- [x] Profile and account settings: - [x] Profile and account settings:
- [x] Contact information - [x] Contact information
@@ -136,7 +161,7 @@
- [x] Customer list (searchable, filterable) - [x] Customer list (searchable, filterable)
- [x] Customer detail view (profile, services, billing history, notes) - [x] Customer detail view (profile, services, billing history, notes)
- [ ] Edit customer information - [ ] Edit customer information
- [ ] Impersonate customer (with audit logging) - [x] Impersonate customer (with audit logging)
- [ ] Add admin notes to customer account - [ ] Add admin notes to customer account
- [ ] View customer audit log - [ ] View customer audit log
- [x] Service management: - [x] Service management:
@@ -146,10 +171,10 @@
- [x] Terminate service - [x] Terminate service
- [ ] Modify service (change plan, extend expiry) - [ ] Modify service (change plan, extend expiry)
- [x] View provisioning logs - [x] View provisioning logs
- [ ] Order management: - [x] Order management:
- [ ] Pending orders list - [x] Pending orders list
- [ ] Approve/reject orders (for semi-automated provisioning) - [x] Approve/reject orders (for semi-automated provisioning)
- [ ] View order details - [x] View order details
- [x] Invoice management: - [x] Invoice management:
- [x] All invoices list (filter by status, date, customer) - [x] All invoices list (filter by status, date, customer)
- [ ] Create manual invoice - [ ] Create manual invoice
@@ -241,7 +266,7 @@
- [ ] Coupon code application - [ ] Coupon code application
- [ ] Add to cart / checkout flow - [ ] Add to cart / checkout flow
- [x] About page - [x] About page
- [x] Contact page - [x] Contact page with form submission backend
- [ ] Blog/news section (optional, or use WordPress?) - [ ] Blog/news section (optional, or use WordPress?)
- [ ] Knowledge base / FAQ: - [ ] Knowledge base / FAQ:
- [ ] Getting started guides - [ ] Getting started guides
@@ -253,6 +278,7 @@
- [x] Privacy Policy - [x] Privacy Policy
- [x] Acceptable Use Policy - [x] Acceptable Use Policy
- [x] SLA (Service Level Agreement) - [x] SLA (Service Level Agreement)
- [x] Footer links to legal pages
- [ ] Signup flow: - [ ] Signup flow:
- [ ] Plan selection - [ ] Plan selection
- [ ] Account creation - [ ] Account creation

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Account; namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\SupportTicket;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -42,6 +43,11 @@ class DashboardController extends Controller
->orderBy('current_period_end') ->orderBy('current_period_end')
->value('current_period_end'); ->value('current_period_end');
$openTicketsCount = SupportTicket::query()
->where('user_id', $user->id)
->whereIn('status', ['open', 'in_progress', 'waiting'])
->count();
return Inertia::render('Dashboard', [ return Inertia::render('Dashboard', [
'activeServicesCount' => $activeServicesCount, 'activeServicesCount' => $activeServicesCount,
'activeSubscriptionsCount' => $activeSubscriptionsCount, 'activeSubscriptionsCount' => $activeSubscriptionsCount,
@@ -49,6 +55,7 @@ class DashboardController extends Controller
'latestInvoices' => $latestInvoices, 'latestInvoices' => $latestInvoices,
'pendingInvoicesAmount' => number_format((float) $pendingInvoicesAmount, 2, '.', ''), 'pendingInvoicesAmount' => number_format((float) $pendingInvoicesAmount, 2, '.', ''),
'nextRenewalDate' => $nextRenewalDate, 'nextRenewalDate' => $nextRenewalDate,
'openTicketsCount' => $openTicketsCount,
]); ]);
} }
} }

View 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.');
}
}

View 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.');
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class SupportTicket extends Model class SupportTicket extends Model
{ {
@@ -18,9 +19,11 @@ class SupportTicket extends Model
'subject', 'subject',
'status', 'status',
'priority', 'priority',
'department',
'last_reply_at', 'last_reply_at',
]; ];
/** @return array<string, string> */
protected function casts(): array protected function casts(): array
{ {
return [ return [
@@ -33,4 +36,9 @@ class SupportTicket extends Model
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function replies(): HasMany
{
return $this->hasMany(TicketReply::class, 'ticket_id');
}
} }

View 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);
}
}

View 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',
]);
}
}

View 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,
]);
}
}

View File

@@ -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');
});
}
}
};

View 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>

View 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' }} &middot; {{ 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>

View File

@@ -13,6 +13,7 @@ interface Props {
latestInvoices: Invoice[] latestInvoices: Invoice[]
pendingInvoicesAmount: string pendingInvoicesAmount: string
nextRenewalDate: string | null nextRenewalDate: string | null
openTicketsCount: number
} }
defineOptions({ layout: AccountLayout }) defineOptions({ layout: AccountLayout })
@@ -378,10 +379,8 @@ const unpaidInvoices = computed<Invoice[]>(() => {
</VBtn> </VBtn>
</Link> </Link>
<a <Link
href="https://ezscale.support" href="/tickets/create"
target="_blank"
rel="noopener noreferrer"
class="text-decoration-none" class="text-decoration-none"
> >
<VBtn <VBtn
@@ -392,14 +391,16 @@ const unpaidInvoices = computed<Invoice[]>(() => {
icon="tabler-headset" icon="tabler-headset"
start start
/> />
Get Support Open Support Ticket
<VIcon <VBadge
icon="tabler-external-link" v-if="openTicketsCount > 0"
end :content="openTicketsCount"
size="14" color="error"
inline
class="ms-1"
/> />
</VBtn> </VBtn>
</a> </Link>
</div> </div>
</VCardText> </VCardText>
</VCard> </VCard>

View File

@@ -1,8 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue' import { ref, computed } from 'vue'
import { useForm, Link } from '@inertiajs/vue3' import { useForm, Link } from '@inertiajs/vue3'
import AccountLayout from '@/Layouts/AccountLayout.vue' import AccountLayout from '@/Layouts/AccountLayout.vue'
import { resolveSubscriptionStatusColor, formatPrice } from '@/utils/resolvers' import { resolveSubscriptionStatusColor, formatPrice } from '@/utils/resolvers'
import AppTextarea from '@/Components/app-form-elements/AppTextarea.vue'
import AppSelect from '@/Components/app-form-elements/AppSelect.vue'
import type { Subscription, Plan } from '@/types' import type { Subscription, Plan } from '@/types'
interface Props { interface Props {
@@ -14,47 +16,82 @@ defineOptions({ layout: AccountLayout })
const props = defineProps<Props>() const props = defineProps<Props>()
const cancelImmediately = ref(false) const showCancelDialog = ref<boolean>(false)
const cancelImmediately = ref<boolean>(false)
const cancelReason = ref<string>('')
const cancelReasons = [
'Too expensive',
'No longer needed',
'Switching to competitor',
'Poor performance',
'Missing features',
'Technical issues',
'Other',
]
const cancelForm = useForm({ const cancelForm = useForm({
immediately: false, immediately: false,
reason: '',
}) })
const swapForm = useForm({ const swapForm = useForm({
plan_id: '', plan_id: '',
}) })
const cancelSubscription = (): void => { function openCancelDialog(): void {
cancelForm.immediately = cancelImmediately.value showCancelDialog.value = true
cancelForm.post(`/subscriptions/${props.subscription.id}/cancel`)
} }
const resumeSubscription = (): void => { function confirmCancel(): void {
cancelForm.immediately = cancelImmediately.value
cancelForm.reason = cancelReason.value
cancelForm.post(`/subscriptions/${props.subscription.id}/cancel`, {
onSuccess: () => {
showCancelDialog.value = false
},
})
}
function resumeSubscription(): void {
useForm({}).post(`/subscriptions/${props.subscription.id}/resume`) useForm({}).post(`/subscriptions/${props.subscription.id}/resume`)
} }
const swapPlan = (): void => { function swapPlan(): void {
swapForm.post(`/subscriptions/${props.subscription.id}/swap`) swapForm.post(`/subscriptions/${props.subscription.id}/swap`)
} }
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
const currentPlan = computed(() => props.subscription.plan)
const isActive = computed<boolean>(() => props.subscription.stripe_status === 'active')
const isCancelling = computed<boolean>(() => !!props.subscription.ends_at && props.subscription.stripe_status !== 'canceled')
const isCanceled = computed<boolean>(() => props.subscription.stripe_status === 'canceled')
</script> </script>
<template> <template>
<div> <div>
<div class="mb-4"> <!-- Breadcrumb -->
<Link href="/subscriptions" class="text-primary text-body-2 text-decoration-none">&larr; Back to Subscriptions</Link> <div class="d-flex align-center ga-2 mb-4">
<Link href="/subscriptions" class="text-decoration-none">
<VBtn variant="text" size="small" color="primary">
<VIcon icon="tabler-arrow-left" start />
Subscriptions
</VBtn>
</Link>
<VIcon icon="tabler-chevron-right" size="16" color="disabled" />
<span class="text-body-2 text-medium-emphasis">{{ currentPlan?.name || subscription.type }}</span>
</div> </div>
<div class="text-h4 font-weight-bold mb-6">Subscription Details</div> <div class="d-flex align-center justify-space-between mb-6">
<div class="text-h4 font-weight-bold">Subscription Details</div>
<VRow>
<!-- Subscription Info -->
<VCol cols="12" lg="8">
<VCard class="mb-6">
<VCardText>
<div class="d-flex align-center justify-space-between mb-4">
<div class="text-h6 font-weight-bold">
{{ subscription.plan?.name || subscription.type }}
</div>
<VChip <VChip
:color="resolveSubscriptionStatusColor(subscription.stripe_status)" :color="resolveSubscriptionStatusColor(subscription.stripe_status)"
size="small" size="small"
@@ -65,38 +102,96 @@ const swapPlan = (): void => {
</div> </div>
<VRow> <VRow>
<VCol cols="6"> <!-- Subscription Info -->
<VCol cols="12" lg="8">
<VCard class="mb-6">
<VCardTitle>
{{ currentPlan?.name || subscription.type }}
</VCardTitle>
<VCardText>
<VRow>
<VCol cols="6" sm="4">
<div class="text-body-2 text-medium-emphasis">Gateway</div> <div class="text-body-2 text-medium-emphasis">Gateway</div>
<div class="text-body-1 text-capitalize mt-1">{{ subscription.gateway || 'stripe' }}</div> <div class="text-body-1 text-capitalize mt-1">{{ subscription.gateway || 'stripe' }}</div>
</VCol> </VCol>
<VCol v-if="subscription.plan" cols="6"> <VCol v-if="currentPlan" cols="6" sm="4">
<div class="text-body-2 text-medium-emphasis">Price</div> <div class="text-body-2 text-medium-emphasis">Price</div>
<div class="text-body-1 mt-1">{{ formatPrice(subscription.plan.price, subscription.plan.billing_cycle) }}</div> <div class="text-body-1 font-weight-medium mt-1">{{ formatPrice(currentPlan.price, currentPlan.billing_cycle) }}</div>
</VCol> </VCol>
<VCol v-if="subscription.current_period_start" cols="6"> <VCol v-if="currentPlan" cols="6" sm="4">
<div class="text-body-2 text-medium-emphasis">Current Period Start</div> <div class="text-body-2 text-medium-emphasis">Billing Cycle</div>
<div class="text-body-1 mt-1">{{ new Date(subscription.current_period_start).toLocaleDateString() }}</div> <div class="text-body-1 text-capitalize mt-1">{{ currentPlan.billing_cycle }}</div>
</VCol> </VCol>
<VCol v-if="subscription.current_period_end" cols="6"> <VCol v-if="subscription.current_period_start" cols="6" sm="4">
<div class="text-body-2 text-medium-emphasis">Current Period End</div> <div class="text-body-2 text-medium-emphasis">Period Start</div>
<div class="text-body-1 mt-1">{{ new Date(subscription.current_period_end).toLocaleDateString() }}</div> <div class="text-body-1 mt-1">{{ formatDate(subscription.current_period_start) }}</div>
</VCol> </VCol>
<VCol v-if="subscription.ends_at" cols="6"> <VCol v-if="subscription.current_period_end" cols="6" sm="4">
<div class="text-body-2 text-medium-emphasis">Cancels On</div> <div class="text-body-2 text-medium-emphasis">Period End</div>
<div class="text-body-1 text-error mt-1">{{ new Date(subscription.ends_at).toLocaleDateString() }}</div> <div class="text-body-1 mt-1">{{ formatDate(subscription.current_period_end) }}</div>
</VCol> </VCol>
<VCol cols="6"> <VCol cols="6" sm="4">
<div class="text-body-2 text-medium-emphasis">Created</div> <div class="text-body-2 text-medium-emphasis">Created</div>
<div class="text-body-1 mt-1">{{ new Date(subscription.created_at).toLocaleDateString() }}</div> <div class="text-body-1 mt-1">{{ formatDate(subscription.created_at) }}</div>
</VCol> </VCol>
</VRow> </VRow>
<!-- Cancellation Notice -->
<VAlert
v-if="isCancelling"
type="warning"
variant="tonal"
class="mt-4"
>
<div class="font-weight-medium">Subscription cancelling</div>
<div class="text-body-2">
Your subscription will end on {{ formatDate(subscription.ends_at!) }}.
You can resume it before then to keep your service.
</div>
</VAlert>
<VAlert
v-if="isCanceled"
type="error"
variant="tonal"
class="mt-4"
>
<div class="font-weight-medium">Subscription cancelled</div>
<div class="text-body-2">
This subscription has been cancelled and is no longer active.
</div>
</VAlert>
</VCardText>
</VCard>
<!-- Plan Features -->
<VCard v-if="currentPlan?.features" class="mb-6">
<VCardTitle>Plan Features</VCardTitle>
<VCardText>
<VList density="compact">
<VListItem
v-for="(value, key) in currentPlan.features"
:key="String(key)"
>
<template #prepend>
<VIcon icon="tabler-check" color="success" size="18" />
</template>
<VListItemTitle class="text-body-2">
<span class="text-capitalize">{{ String(key).replace(/_/g, ' ') }}</span>:
<span class="font-weight-medium">{{ value }}</span>
</VListItemTitle>
</VListItem>
</VList>
</VCardText> </VCardText>
</VCard> </VCard>
<!-- Change Plan --> <!-- Change Plan -->
<VCard v-if="availablePlans.length > 0 && subscription.stripe_status === 'active'"> <VCard v-if="availablePlans.length > 0 && isActive && !isCancelling">
<VCardTitle>Change Plan</VCardTitle> <VCardTitle>Change Plan</VCardTitle>
<VCardText> <VCardText>
<p class="text-body-2 text-medium-emphasis mb-4">
Switch to a different plan. Your billing will be adjusted automatically.
</p>
<VForm @submit.prevent="swapPlan"> <VForm @submit.prevent="swapPlan">
<VRadioGroup v-model="swapForm.plan_id" class="mb-4"> <VRadioGroup v-model="swapForm.plan_id" class="mb-4">
<VRadio <VRadio
@@ -105,9 +200,27 @@ const swapPlan = (): void => {
:value="String(plan.id)" :value="String(plan.id)"
> >
<template #label> <template #label>
<div class="d-flex justify-space-between w-100"> <div class="d-flex justify-space-between align-center w-100">
<span>{{ plan.name }}</span> <span>{{ plan.name }}</span>
<div class="d-flex align-center ga-2">
<span class="text-medium-emphasis">{{ formatPrice(plan.price, plan.billing_cycle) }}</span> <span class="text-medium-emphasis">{{ formatPrice(plan.price, plan.billing_cycle) }}</span>
<VChip
v-if="currentPlan && parseFloat(plan.price) > parseFloat(currentPlan.price)"
color="info"
size="x-small"
variant="tonal"
>
Upgrade
</VChip>
<VChip
v-else-if="currentPlan"
color="warning"
size="x-small"
variant="tonal"
>
Downgrade
</VChip>
</div>
</div> </div>
</template> </template>
</VRadio> </VRadio>
@@ -115,10 +228,12 @@ const swapPlan = (): void => {
<VBtn <VBtn
type="submit" type="submit"
color="primary"
:loading="swapForm.processing" :loading="swapForm.processing"
:disabled="!swapForm.plan_id || swapForm.processing" :disabled="!swapForm.plan_id || swapForm.processing"
prepend-icon="tabler-switch-horizontal"
> >
{{ swapForm.processing ? 'Changing...' : 'Change Plan' }} Change Plan
</VBtn> </VBtn>
</VForm> </VForm>
</VCardText> </VCardText>
@@ -128,46 +243,137 @@ const swapPlan = (): void => {
<!-- Actions Sidebar --> <!-- Actions Sidebar -->
<VCol cols="12" lg="4"> <VCol cols="12" lg="4">
<!-- Cancel --> <!-- Cancel -->
<VCard v-if="subscription.stripe_status === 'active' && !subscription.ends_at" class="mb-6"> <VCard v-if="isActive && !isCancelling" class="mb-6">
<VCardTitle>Cancel Subscription</VCardTitle> <VCardTitle>Cancel Subscription</VCardTitle>
<VCardText> <VCardText>
<VCheckbox <p class="text-body-2 text-medium-emphasis mb-4">
v-model="cancelImmediately" If you cancel, your service will remain active until the end of your current billing period.
label="Cancel immediately (no grace period)" </p>
hide-details
class="mb-4"
/>
<VBtn <VBtn
color="error" color="error"
variant="tonal"
block block
:loading="cancelForm.processing" prepend-icon="tabler-x"
:disabled="cancelForm.processing" @click="openCancelDialog"
@click="cancelSubscription"
> >
{{ cancelForm.processing ? 'Cancelling...' : 'Cancel Subscription' }} Cancel Subscription
</VBtn> </VBtn>
</VCardText> </VCardText>
</VCard> </VCard>
<!-- Resume --> <!-- Resume -->
<VCard v-if="subscription.ends_at && subscription.stripe_status !== 'canceled'"> <VCard v-if="isCancelling" class="mb-6">
<VCardTitle>Resume Subscription</VCardTitle> <VCardTitle>Resume Subscription</VCardTitle>
<VCardText> <VCardText>
<div class="text-body-2 text-medium-emphasis mb-3"> <p class="text-body-2 text-medium-emphasis mb-3">
Your subscription is set to cancel. You can resume it before it expires. Your subscription is set to cancel on {{ formatDate(subscription.ends_at!) }}.
</div> Resume to keep your service running.
</p>
<VBtn <VBtn
color="success" color="success"
block block
prepend-icon="tabler-player-play"
@click="resumeSubscription" @click="resumeSubscription"
> >
Resume Subscription Resume Subscription
</VBtn> </VBtn>
</VCardText> </VCardText>
</VCard> </VCard>
<!-- Quick Info -->
<VCard>
<VCardTitle>Quick Info</VCardTitle>
<VCardText>
<div class="d-flex flex-column ga-3">
<div class="d-flex justify-space-between align-center">
<span class="text-body-2 text-medium-emphasis">Status</span>
<VChip
:color="resolveSubscriptionStatusColor(subscription.stripe_status)"
size="small"
class="text-capitalize"
>
{{ subscription.stripe_status }}
</VChip>
</div>
<VDivider />
<div v-if="currentPlan" class="d-flex justify-space-between">
<span class="text-body-2 text-medium-emphasis">Plan</span>
<span class="text-body-2 font-weight-medium">{{ currentPlan.name }}</span>
</div>
<VDivider />
<div v-if="currentPlan" class="d-flex justify-space-between">
<span class="text-body-2 text-medium-emphasis">Price</span>
<span class="text-body-2 font-weight-medium">{{ formatPrice(currentPlan.price, currentPlan.billing_cycle) }}</span>
</div>
<VDivider />
<div v-if="subscription.current_period_end" class="d-flex justify-space-between">
<span class="text-body-2 text-medium-emphasis">Next Renewal</span>
<span class="text-body-2">{{ formatDate(subscription.current_period_end) }}</span>
</div>
</div>
</VCardText>
</VCard>
</VCol> </VCol>
</VRow> </VRow>
<!-- Cancel Confirmation Dialog -->
<VDialog v-model="showCancelDialog" max-width="500">
<VCard>
<VCardTitle class="d-flex align-center ga-2">
<VIcon icon="tabler-alert-triangle" color="error" />
Cancel Subscription
</VCardTitle>
<VCardText>
<p class="text-body-1 mb-4">
Are you sure you want to cancel your subscription?
</p>
<VAlert type="info" variant="tonal" class="mb-4">
<div class="text-body-2">
By default, your service will remain active until the end of your current billing period.
</div>
</VAlert>
<AppSelect
v-model="cancelReason"
:items="cancelReasons"
label="Reason for cancelling (optional)"
placeholder="Select a reason"
class="mb-4"
/>
<VCheckbox
v-model="cancelImmediately"
hide-details
class="mb-2"
>
<template #label>
<div>
<div class="text-body-2 font-weight-medium">Cancel immediately</div>
<div class="text-caption text-medium-emphasis">
Your service will be terminated right away with no refund.
</div>
</div>
</template>
</VCheckbox>
</VCardText>
<VCardActions class="pa-4 pt-0">
<VSpacer />
<VBtn
variant="outlined"
@click="showCancelDialog = false"
>
Keep Subscription
</VBtn>
<VBtn
color="error"
:loading="cancelForm.processing"
@click="confirmCancel"
>
Confirm Cancellation
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div> </div>
</template> </template>

View 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">
&larr; 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>

View 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>

View 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">
&larr; 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>

View File

@@ -11,5 +11,6 @@ export const accountNavItems: NavItem[] = [
{ title: 'Subscriptions', href: '/subscriptions', icon: 'tabler-receipt', matchPrefix: '/subscriptions' }, { title: 'Subscriptions', href: '/subscriptions', icon: 'tabler-receipt', matchPrefix: '/subscriptions' },
{ title: 'Billing', href: '/billing', icon: 'tabler-credit-card', matchPrefix: '/billing' }, { title: 'Billing', href: '/billing', icon: 'tabler-credit-card', matchPrefix: '/billing' },
{ title: 'Plans', href: '/plans', icon: 'tabler-package', matchPrefix: '/plans' }, { title: 'Plans', href: '/plans', icon: 'tabler-package', matchPrefix: '/plans' },
{ title: 'Support', href: '/tickets', icon: 'tabler-headset', matchPrefix: '/tickets' },
{ title: 'Settings', href: '/profile', icon: 'tabler-settings', matchPrefix: '/profile' }, { title: 'Settings', href: '/profile', icon: 'tabler-settings', matchPrefix: '/profile' },
] ]

View File

@@ -8,6 +8,7 @@ export const adminNavItems: NavItem[] = [
{ title: 'Orders', href: '/orders', icon: 'tabler-shopping-cart', matchPrefix: '/orders' }, { title: 'Orders', href: '/orders', icon: 'tabler-shopping-cart', matchPrefix: '/orders' },
{ title: 'Invoices', href: '/invoices', icon: 'tabler-file-invoice', matchPrefix: '/invoices' }, { title: 'Invoices', href: '/invoices', icon: 'tabler-file-invoice', matchPrefix: '/invoices' },
{ title: 'Coupons', href: '/coupons', icon: 'tabler-discount-2', matchPrefix: '/coupons' }, { title: 'Coupons', href: '/coupons', icon: 'tabler-discount-2', matchPrefix: '/coupons' },
{ title: 'Tickets', href: '/tickets', icon: 'tabler-message-circle', matchPrefix: '/tickets' },
{ title: 'Audit Logs', href: '/audit-logs', icon: 'tabler-clipboard-list', matchPrefix: '/audit-logs' }, { title: 'Audit Logs', href: '/audit-logs', icon: 'tabler-clipboard-list', matchPrefix: '/audit-logs' },
{ title: 'Settings', href: '/settings', icon: 'tabler-settings', matchPrefix: '/settings' }, { title: 'Settings', href: '/settings', icon: 'tabler-settings', matchPrefix: '/settings' },
] ]

View File

@@ -163,4 +163,39 @@ export interface CouponRedemption {
} }
} }
export interface SupportTicket {
id: number
user_id: number
subject: string
status: 'open' | 'in_progress' | 'waiting' | 'closed'
priority: 'low' | 'medium' | 'high' | 'urgent'
department: 'billing' | 'technical' | 'sales' | 'general'
last_reply_at: string | null
created_at: string
updated_at: string
user?: {
id: number
name: string
email: string
status?: string
company?: string | null
}
replies?: TicketReply[]
}
export interface TicketReply {
id: number
ticket_id: number
user_id: number
body: string
is_staff_reply: boolean
created_at: string
updated_at: string
user?: {
id: number
name: string
email: string
}
}
export type StatusColor = 'success' | 'error' | 'warning' | 'info' | 'secondary' export type StatusColor = 'success' | 'error' | 'warning' | 'info' | 'secondary'

View File

@@ -74,6 +74,26 @@ export function resolvePlatformUrl(platform: string, platformServiceId: string |
return urls[platform] ?? null return urls[platform] ?? null
} }
export function resolveTicketStatusColor(status: string): StatusColor {
const map: Record<string, StatusColor> = {
open: 'info',
in_progress: 'success',
waiting: 'warning',
closed: 'secondary',
}
return map[status] ?? 'secondary'
}
export function resolveTicketPriorityColor(priority: string): StatusColor {
const map: Record<string, StatusColor> = {
low: 'success',
medium: 'info',
high: 'warning',
urgent: 'error',
}
return map[priority] ?? 'secondary'
}
export function formatPrice(price: string | number, cycle?: string): string { export function formatPrice(price: string | number, cycle?: string): string {
const amount = parseFloat(String(price)).toFixed(2) const amount = parseFloat(String(price)).toFixed(2)
return cycle ? `$${amount}/${cycle}` : `$${amount}` return cycle ? `$${amount}/${cycle}` : `$${amount}`

View File

@@ -10,6 +10,7 @@ use App\Http\Controllers\Account\PlanController;
use App\Http\Controllers\Account\ProfileController; use App\Http\Controllers\Account\ProfileController;
use App\Http\Controllers\Account\ServiceController; use App\Http\Controllers\Account\ServiceController;
use App\Http\Controllers\Account\SubscriptionController; use App\Http\Controllers\Account\SubscriptionController;
use App\Http\Controllers\Account\TicketController;
use App\Http\Controllers\Account\UpgradeController; use App\Http\Controllers\Account\UpgradeController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -53,6 +54,11 @@ Route::get('/billing/invoices/{invoice}/download', [BillingController::class, 'd
Route::get('/billing/transactions', [BillingController::class, 'transactions'])->name('account.billing.transactions'); Route::get('/billing/transactions', [BillingController::class, 'transactions'])->name('account.billing.transactions');
Route::post('/billing/setup-intent', [BillingController::class, 'setupIntent'])->name('account.billing.setup-intent'); Route::post('/billing/setup-intent', [BillingController::class, 'setupIntent'])->name('account.billing.setup-intent');
// Support Tickets
Route::resource('tickets', TicketController::class)->only(['index', 'create', 'store', 'show'])->names('account.tickets');
Route::post('/tickets/{ticket}/reply', [TicketController::class, 'reply'])->name('account.tickets.reply');
Route::post('/tickets/{ticket}/close', [TicketController::class, 'close'])->name('account.tickets.close');
// Notifications // Notifications
Route::get('/notifications', [NotificationController::class, 'index'])->name('account.notifications.index'); Route::get('/notifications', [NotificationController::class, 'index'])->name('account.notifications.index');
Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('account.notifications.read'); Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('account.notifications.read');

View File

@@ -12,6 +12,7 @@ use App\Http\Controllers\Admin\OrderController;
use App\Http\Controllers\Admin\PlanController; use App\Http\Controllers\Admin\PlanController;
use App\Http\Controllers\Admin\ServiceController; use App\Http\Controllers\Admin\ServiceController;
use App\Http\Controllers\Admin\SettingsController; use App\Http\Controllers\Admin\SettingsController;
use App\Http\Controllers\Admin\TicketController as AdminTicketController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/dashboard', [DashboardController::class, 'index'])->name('admin.dashboard'); Route::get('/dashboard', [DashboardController::class, 'index'])->name('admin.dashboard');
@@ -57,6 +58,14 @@ Route::get('audit-logs', [AuditLogController::class, 'index'])->name('audit-logs
Route::get('settings', [SettingsController::class, 'index'])->name('admin.settings.index'); Route::get('settings', [SettingsController::class, 'index'])->name('admin.settings.index');
Route::put('settings', [SettingsController::class, 'update'])->name('admin.settings.update'); Route::put('settings', [SettingsController::class, 'update'])->name('admin.settings.update');
// Support Tickets
Route::resource('tickets', AdminTicketController::class)->only(['index', 'show'])->names([
'index' => 'admin.tickets.index',
'show' => 'admin.tickets.show',
]);
Route::post('tickets/{ticket}/reply', [AdminTicketController::class, 'reply'])->name('admin.tickets.reply');
Route::put('tickets/{ticket}/status', [AdminTicketController::class, 'updateStatus'])->name('admin.tickets.status');
// Impersonation // Impersonation
Route::post('impersonate/{user}', [ImpersonationController::class, 'start'])->name('impersonate.start'); Route::post('impersonate/{user}', [ImpersonationController::class, 'start'])->name('impersonate.start');
Route::post('impersonate/stop', [ImpersonationController::class, 'stop'])->name('impersonate.stop'); Route::post('impersonate/stop', [ImpersonationController::class, 'stop'])->name('impersonate.stop');

View 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();
});
});