Build admin panel CRUD: customers, plans, services, invoices

Phase 5 (Admin Panel):
- Customer management: searchable/filterable list, detail view with
  tabs (overview/services/billing), suspend/unsuspend actions with
  audit logging
- Plan management: full CRUD with create/edit/archive, dynamic
  feature key-value editor, service type filters, subscriber counts
- Service management: list with type/status filters, detail view
  with provisioning logs, suspend/unsuspend/terminate with
  confirmation dialogs
- Invoice management: list with status filters, detail view with
  line items and payment transactions, void invoice action
- Admin nav: added Customers, Plans, Services, Invoices links
- 52 tests passing, build clean

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-02-09 10:30:26 -05:00
parent dc998b4d7c
commit 2061b1f3e3
17 changed files with 3781 additions and 0 deletions

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class CustomerController extends Controller
{
public function index(Request $request): Response
{
$query = User::role('customer')
->withCount(['services', 'invoices'])
->with('subscriptions');
// Search by name or email
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search): void {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
// Filter by status
if ($status = $request->input('status')) {
$query->where('status', $status);
}
// Sort
$sortBy = $request->input('sort', 'created_at');
$sortDir = $request->input('direction', 'desc');
$allowedSorts = ['name', 'email', 'created_at', 'status'];
if (in_array($sortBy, $allowedSorts, true)) {
$query->orderBy($sortBy, $sortDir === 'asc' ? 'asc' : 'desc');
}
$customers = $query->paginate(15)->withQueryString();
// Add subscriptions_count manually since it's a Cashier relationship
$customers->getCollection()->transform(function (User $user): User {
$user->setAttribute('subscriptions_count', $user->subscriptions->count());
unset($user->subscriptions);
return $user;
});
return Inertia::render('Admin/Customers/Index', [
'customers' => $customers,
'filters' => [
'search' => $request->input('search', ''),
'status' => $request->input('status', ''),
'sort' => $sortBy,
'direction' => $sortDir,
],
]);
}
public function show(User $user): Response
{
$user->load(['profile', 'services.plan']);
// Load subscriptions with plan info via join
$subscriptions = $user->subscriptions()
->select([
'subscriptions.id',
'subscriptions.user_id',
'subscriptions.plan_id',
'subscriptions.type',
'subscriptions.stripe_status',
'subscriptions.gateway',
'subscriptions.current_period_start',
'subscriptions.current_period_end',
'subscriptions.ends_at',
'subscriptions.created_at',
])
->leftJoin('plans', 'subscriptions.plan_id', '=', 'plans.id')
->addSelect([
'plans.name as plan_name',
'plans.price as plan_price',
'plans.billing_cycle as plan_billing_cycle',
])
->orderByDesc('subscriptions.created_at')
->get();
$recentInvoices = $user->invoices()
->latest()
->limit(10)
->get(['id', 'user_id', 'number', 'total', 'status', 'gateway', 'created_at']);
$auditLogs = AuditLog::query()
->where('user_id', $user->id)
->latest()
->limit(20)
->get(['id', 'action', 'resource_type', 'resource_id', 'ip_address', 'created_at']);
return Inertia::render('Admin/Customers/Show', [
'customer' => $user,
'subscriptions' => $subscriptions,
'recentInvoices' => $recentInvoices,
'auditLogs' => $auditLogs,
]);
}
public function suspend(User $user): RedirectResponse
{
$user->update(['status' => 'suspended']);
AuditLog::create([
'user_id' => $user->id,
'admin_id' => auth()->id(),
'action' => 'suspend_account',
'resource_type' => 'user',
'resource_id' => $user->id,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
return redirect()->back()->with('success', "Customer {$user->name} has been suspended.");
}
public function unsuspend(User $user): RedirectResponse
{
$user->update(['status' => 'active']);
AuditLog::create([
'user_id' => $user->id,
'admin_id' => auth()->id(),
'action' => 'unsuspend_account',
'resource_type' => 'user',
'resource_id' => $user->id,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
return redirect()->back()->with('success', "Customer {$user->name} has been unsuspended.");
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\Invoice;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class InvoiceController extends Controller
{
public function index(Request $request): Response
{
$query = Invoice::query()
->with('user:id,name,email');
// Search by invoice number or customer name
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search): void {
$q->where('number', 'like', "%{$search}%")
->orWhereHas('user', function ($uq) use ($search): void {
$uq->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
});
}
// Filter by status
if ($status = $request->input('status')) {
$query->where('status', $status);
}
$invoices = $query->latest()->paginate(15)->withQueryString();
return Inertia::render('Admin/Invoices/Index', [
'invoices' => $invoices,
'filters' => [
'search' => $request->input('search', ''),
'status' => $request->input('status', ''),
],
]);
}
public function show(Invoice $invoice): Response
{
$invoice->load([
'user:id,name,email,status',
'items',
'paymentTransactions',
]);
return Inertia::render('Admin/Invoices/Show', [
'invoice' => $invoice,
]);
}
public function void(Invoice $invoice): RedirectResponse
{
$invoice->update(['status' => 'void']);
AuditLog::create([
'user_id' => $invoice->user_id,
'admin_id' => auth()->id(),
'action' => 'void_invoice',
'resource_type' => 'invoice',
'resource_id' => $invoice->id,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
return redirect()->back()->with('success', "Invoice {$invoice->number} has been voided.");
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StorePlanRequest;
use App\Models\Plan;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Cashier\Subscription;
class PlanController extends Controller
{
public function index(Request $request): Response
{
$query = Plan::query();
// Filter by service type
if ($request->filled('service_type') && $request->input('service_type') !== 'all') {
$query->where('service_type', $request->input('service_type'));
}
// Search by name
if ($request->filled('search')) {
$query->where('name', 'like', '%'.$request->input('search').'%');
}
$plans = $query
->withCount(['services as subscribers_count' => function ($q): void {
$q->where('status', 'active');
}])
->orderBy('sort_order')
->orderBy('name')
->get();
return Inertia::render('Admin/Plans/Index', [
'plans' => $plans,
'filters' => [
'service_type' => $request->input('service_type', 'all'),
'search' => $request->input('search', ''),
],
]);
}
public function create(): Response
{
return Inertia::render('Admin/Plans/Create');
}
public function store(StorePlanRequest $request): RedirectResponse
{
Plan::query()->create([
'name' => $request->validated('name'),
'slug' => $request->validated('slug'),
'description' => $request->validated('description'),
'service_type' => $request->validated('service_type'),
'price' => $request->validated('price'),
'currency' => 'USD',
'billing_cycle' => $request->validated('billing_cycle'),
'features' => $request->featuresForStorage(),
'stock_quantity' => $request->validated('stock_quantity'),
'sort_order' => $request->validated('sort_order', 0),
'status' => 'active',
]);
return redirect()
->route('admin.plans.index')
->with('success', 'Plan created successfully.');
}
public function edit(Plan $plan): Response
{
$subscribersCount = Subscription::query()
->where('plan_id', $plan->id)
->where('stripe_status', 'active')
->count();
return Inertia::render('Admin/Plans/Edit', [
'plan' => $plan,
'subscribersCount' => $subscribersCount,
]);
}
public function update(StorePlanRequest $request, Plan $plan): RedirectResponse
{
$plan->update([
'name' => $request->validated('name'),
'slug' => $request->validated('slug'),
'description' => $request->validated('description'),
'service_type' => $request->validated('service_type'),
'price' => $request->validated('price'),
'billing_cycle' => $request->validated('billing_cycle'),
'features' => $request->featuresForStorage(),
'stock_quantity' => $request->validated('stock_quantity'),
'sort_order' => $request->validated('sort_order', 0),
]);
return redirect()
->route('admin.plans.index')
->with('success', 'Plan updated successfully.');
}
public function destroy(Plan $plan): RedirectResponse
{
// Soft-delete by setting status to inactive
$plan->update(['status' => 'inactive']);
return redirect()
->route('admin.plans.index')
->with('success', 'Plan archived successfully.');
}
}

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\Service;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ServiceController extends Controller
{
public function index(Request $request): Response
{
$query = Service::query()
->with(['user:id,name,email', 'plan:id,name,service_type,price,billing_cycle']);
// Search by customer name or email
if ($search = $request->input('search')) {
$query->whereHas('user', function ($q) use ($search): void {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
// Filter by service type
if ($serviceType = $request->input('service_type')) {
$query->where('service_type', $serviceType);
}
// Filter by status
if ($status = $request->input('status')) {
$query->where('status', $status);
}
$services = $query->latest()->paginate(15)->withQueryString();
return Inertia::render('Admin/Services/Index', [
'services' => $services,
'filters' => [
'search' => $request->input('search', ''),
'service_type' => $request->input('service_type', ''),
'status' => $request->input('status', ''),
],
]);
}
public function show(Service $service): Response
{
$service->load([
'user:id,name,email,status',
'plan:id,name,service_type,price,billing_cycle',
'provisioningLogs' => function ($query): void {
$query->latest()->limit(50);
},
]);
return Inertia::render('Admin/Services/Show', [
'service' => $service,
]);
}
public function suspend(Service $service): RedirectResponse
{
$service->update([
'status' => 'suspended',
'suspended_at' => now(),
]);
AuditLog::create([
'user_id' => $service->user_id,
'admin_id' => auth()->id(),
'action' => 'suspend_service',
'resource_type' => 'service',
'resource_id' => $service->id,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
return redirect()->back()->with('success', 'Service has been suspended.');
}
public function unsuspend(Service $service): RedirectResponse
{
$service->update([
'status' => 'active',
'suspended_at' => null,
]);
AuditLog::create([
'user_id' => $service->user_id,
'admin_id' => auth()->id(),
'action' => 'unsuspend_service',
'resource_type' => 'service',
'resource_id' => $service->id,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
return redirect()->back()->with('success', 'Service has been unsuspended.');
}
public function terminate(Service $service): RedirectResponse
{
$service->update([
'status' => 'terminated',
'terminated_at' => now(),
]);
AuditLog::create([
'user_id' => $service->user_id,
'admin_id' => auth()->id(),
'action' => 'terminate_service',
'resource_type' => 'service',
'resource_id' => $service->id,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
return redirect()->back()->with('success', 'Service has been terminated.');
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePlanRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
$uniqueSlugRule = Rule::unique('plans', 'slug');
if ($this->route('plan')) {
$uniqueSlugRule->ignore($this->route('plan'));
}
return [
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', $uniqueSlugRule],
'description' => ['nullable', 'string'],
'service_type' => ['required', Rule::in(['vps', 'dedicated', 'hosting', 'mysql', 'game_server'])],
'price' => ['required', 'numeric', 'min:0'],
'billing_cycle' => ['required', Rule::in(['monthly', 'quarterly', 'semi_annual', 'annual'])],
'features' => ['nullable', 'array'],
'features.*.key' => ['required_with:features', 'string', 'max:255'],
'features.*.value' => ['required_with:features', 'string', 'max:255'],
'stock_quantity' => ['nullable', 'integer', 'min:0'],
'sort_order' => ['integer', 'min:0'],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'name.required' => 'Plan name is required.',
'slug.required' => 'Slug is required.',
'slug.unique' => 'This slug is already in use.',
'service_type.required' => 'Service type is required.',
'service_type.in' => 'Invalid service type selected.',
'price.required' => 'Price is required.',
'price.min' => 'Price must be at least 0.',
'billing_cycle.required' => 'Billing cycle is required.',
'billing_cycle.in' => 'Invalid billing cycle selected.',
];
}
/**
* Get the features as a key-value array suitable for storing.
*
* @return array<string, string>|null
*/
public function featuresForStorage(): ?array
{
$features = $this->input('features');
if (empty($features) || ! is_array($features)) {
return null;
}
$result = [];
foreach ($features as $feature) {
if (! empty($feature['key']) && isset($feature['value'])) {
$result[$feature['key']] = $feature['value'];
}
}
return empty($result) ? null : $result;
}
}

View File

@@ -0,0 +1,235 @@
<script lang="ts" setup>
import { Link, router } from '@inertiajs/vue3'
import { ref, watch } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
import type { PaginatedResponse } from '@/types'
interface Customer {
id: number
name: string
email: string
phone: string | null
company: string | null
status: string
services_count: number
subscriptions_count: number
created_at: string
}
interface Filters {
search: string
status: string
sort: string
direction: string
}
interface Props {
customers: PaginatedResponse<Customer>
filters: Filters
}
defineOptions({ layout: AdminLayout })
const props = defineProps<Props>()
const search = ref<string>(props.filters.search)
const statusFilter = ref<string>(props.filters.status)
const statusOptions = [
{ title: 'All', value: '' },
{ title: 'Active', value: 'active' },
{ title: 'Suspended', value: 'suspended' },
{ title: 'Banned', value: 'banned' },
]
let searchTimeout: ReturnType<typeof setTimeout> | null = null
watch(search, (value: string) => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => {
applyFilters({ search: value })
}, 400)
})
watch(statusFilter, (value: string) => {
applyFilters({ status: value })
})
function applyFilters(overrides: Partial<Filters> = {}): void {
router.get('/customers', {
search: overrides.search ?? search.value,
status: overrides.status ?? statusFilter.value,
sort: props.filters.sort,
direction: props.filters.direction,
}, {
preserveState: true,
replace: true,
})
}
function resolveUserStatusColor(status: string): string {
const map: Record<string, string> = {
active: 'success',
suspended: 'warning',
banned: 'error',
}
return map[status] ?? 'secondary'
}
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>
<!-- Page Header -->
<div class="d-flex align-center justify-space-between mb-6">
<div>
<div class="text-h4 font-weight-bold">
Customers
</div>
<div class="text-body-2 text-medium-emphasis">
Manage customer accounts and view their details
</div>
</div>
<VChip color="primary" variant="tonal" size="small">
{{ customers.total }} total
</VChip>
</div>
<!-- Filters -->
<VCard class="mb-6">
<VCardText>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="search"
prepend-inner-icon="tabler-search"
placeholder="Search by name or email..."
variant="outlined"
density="compact"
clearable
hide-details
/>
</VCol>
<VCol cols="12" md="4">
<div class="d-flex align-center ga-2">
<VChip
v-for="option in statusOptions"
:key="option.value"
:color="statusFilter === option.value ? 'primary' : undefined"
:variant="statusFilter === option.value ? 'flat' : 'tonal'"
class="cursor-pointer"
@click="statusFilter = option.value"
>
{{ option.title }}
</VChip>
</div>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- Customers Table -->
<VCard>
<VCardText v-if="customers.data.length === 0" class="text-center py-12">
<VIcon icon="tabler-users-minus" size="48" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">
No customers found.
</div>
</VCardText>
<VTable v-else density="comfortable" hover>
<thead>
<tr>
<th>Customer</th>
<th>Status</th>
<th class="text-center">
Services
</th>
<th class="text-center">
Subscriptions
</th>
<th>Created</th>
<th class="text-end">
Actions
</th>
</tr>
</thead>
<tbody>
<tr v-for="customer in customers.data" :key="customer.id">
<td>
<div class="d-flex align-center gap-3 py-2">
<VAvatar color="primary" variant="tonal" size="38">
<span class="text-body-2 font-weight-medium">
{{ customer.name.charAt(0).toUpperCase() }}
</span>
</VAvatar>
<div>
<div class="text-body-2 font-weight-medium">
{{ customer.name }}
</div>
<div class="text-caption text-medium-emphasis">
{{ customer.email }}
</div>
</div>
</div>
</td>
<td>
<VChip
:color="resolveUserStatusColor(customer.status)"
size="small"
class="text-capitalize"
>
{{ customer.status }}
</VChip>
</td>
<td class="text-center">
<VChip size="small" variant="tonal" color="info">
{{ customer.services_count }}
</VChip>
</td>
<td class="text-center">
<VChip size="small" variant="tonal" color="primary">
{{ customer.subscriptions_count }}
</VChip>
</td>
<td class="text-body-2">
{{ formatDate(customer.created_at) }}
</td>
<td class="text-end">
<Link
:href="`/customers/${customer.id}`"
class="text-decoration-none"
>
<VBtn
variant="text"
color="primary"
size="small"
>
<VIcon icon="tabler-eye" start />
View
</VBtn>
</Link>
</td>
</tr>
</tbody>
</VTable>
<!-- Pagination -->
<VCardText v-if="customers.last_page > 1" class="d-flex align-center justify-center pt-4">
<VPagination
:model-value="Math.ceil((customers.from || 1) / 15)"
:length="customers.last_page"
:total-visible="7"
rounded
@update:model-value="(page: number) => router.get('/customers', { ...props.filters, page }, { preserveState: true })"
/>
</VCardText>
</VCard>
</div>
</template>

View File

@@ -0,0 +1,680 @@
<script lang="ts" setup>
import { Link, useForm } from '@inertiajs/vue3'
import { ref } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
import { resolveInvoiceStatusColor, resolveSubscriptionStatusColor } from '@/utils/resolvers'
interface CustomerProfile {
billing_address_line1: string | null
billing_address_line2: string | null
billing_city: string | null
billing_state: string | null
billing_zip: string | null
billing_country: string | null
tax_id: string | null
tax_exempt: boolean
company_name: string | null
company_vat: string | null
}
interface Customer {
id: number
name: string
email: string
phone: string | null
company: string | null
status: string
created_at: string
email_verified_at: string | null
profile: CustomerProfile | null
services: CustomerService[]
}
interface CustomerService {
id: number
service_type: string
platform: string | null
hostname: string | null
domain: string | null
status: string
ipv4_address: string | null
created_at: string
plan: {
id: number
name: string
price: string
billing_cycle: string
} | null
}
interface CustomerSubscription {
id: number
type: string
stripe_status: string
gateway: string
current_period_start: string | null
current_period_end: string | null
ends_at: string | null
created_at: string
plan_name: string | null
plan_price: string | null
plan_billing_cycle: string | null
}
interface CustomerInvoice {
id: number
number: string
total: string
status: string
gateway: string
created_at: string
}
interface CustomerAuditLog {
id: number
action: string
resource_type: string
resource_id: number | null
ip_address: string | null
created_at: string
}
interface Props {
customer: Customer
subscriptions: CustomerSubscription[]
recentInvoices: CustomerInvoice[]
auditLogs: CustomerAuditLog[]
}
defineOptions({ layout: AdminLayout })
const props = defineProps<Props>()
const activeTab = ref<string>('overview')
const suspendForm = useForm({})
const unsuspendForm = useForm({})
function handleSuspend(): void {
suspendForm.post(`/customers/${props.customer.id}/suspend`, {
preserveScroll: true,
})
}
function handleUnsuspend(): void {
unsuspendForm.post(`/customers/${props.customer.id}/unsuspend`, {
preserveScroll: true,
})
}
function resolveUserStatusColor(status: string): string {
const map: Record<string, string> = {
active: 'success',
suspended: 'warning',
banned: 'error',
}
return map[status] ?? 'secondary'
}
function resolveServiceStatusColor(status: string): string {
const map: Record<string, string> = {
active: 'success',
suspended: 'warning',
pending: 'info',
terminated: 'error',
}
return map[status] ?? 'secondary'
}
function formatCurrency(value: number | string): string {
const num = typeof value === 'string' ? parseFloat(value) : value
return `$${num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
}
function formatAction(action: string): string {
return action
.replace(/_/g, ' ')
.replace(/\b\w/g, (c: string) => c.toUpperCase())
}
function customerInitials(name: string): string {
return name
.split(' ')
.map((n: string) => n.charAt(0))
.join('')
.toUpperCase()
.slice(0, 2)
}
function formatBillingAddress(profile: CustomerProfile | null): string {
if (!profile) {
return 'No billing address on file'
}
const parts = [
profile.billing_address_line1,
profile.billing_address_line2,
[profile.billing_city, profile.billing_state, profile.billing_zip].filter(Boolean).join(', '),
profile.billing_country,
].filter(Boolean)
return parts.length > 0 ? parts.join('\n') : 'No billing address on file'
}
</script>
<template>
<div>
<!-- Breadcrumb -->
<div class="d-flex align-center ga-2 mb-4">
<Link href="/customers" class="text-decoration-none">
<VBtn variant="text" size="small" color="primary">
<VIcon icon="tabler-arrow-left" start />
Customers
</VBtn>
</Link>
<VIcon icon="tabler-chevron-right" size="16" color="disabled" />
<span class="text-body-2 text-medium-emphasis">{{ customer.name }}</span>
</div>
<!-- Customer Header Card -->
<VCard class="mb-6">
<VCardText>
<div class="d-flex align-center justify-space-between flex-wrap gap-4">
<div class="d-flex align-center gap-4">
<VAvatar color="primary" variant="tonal" size="56">
<span class="text-h6 font-weight-medium">
{{ customerInitials(customer.name) }}
</span>
</VAvatar>
<div>
<div class="text-h5 font-weight-bold">
{{ customer.name }}
</div>
<div class="text-body-2 text-medium-emphasis">
{{ customer.email }}
</div>
<div class="d-flex align-center ga-2 mt-1">
<VChip
:color="resolveUserStatusColor(customer.status)"
size="small"
class="text-capitalize"
>
{{ customer.status }}
</VChip>
<VChip
v-if="customer.email_verified_at"
color="success"
size="small"
variant="tonal"
>
<VIcon icon="tabler-mail-check" start size="14" />
Verified
</VChip>
<VChip
v-else
color="warning"
size="small"
variant="tonal"
>
<VIcon icon="tabler-mail-x" start size="14" />
Unverified
</VChip>
<span class="text-caption text-medium-emphasis">
Customer since {{ formatDate(customer.created_at) }}
</span>
</div>
</div>
</div>
<div class="d-flex align-center ga-2">
<VBtn
v-if="customer.status !== 'suspended'"
color="warning"
variant="tonal"
size="small"
:loading="suspendForm.processing"
@click="handleSuspend"
>
<VIcon icon="tabler-ban" start />
Suspend
</VBtn>
<VBtn
v-else
color="success"
variant="tonal"
size="small"
:loading="unsuspendForm.processing"
@click="handleUnsuspend"
>
<VIcon icon="tabler-circle-check" start />
Unsuspend
</VBtn>
</div>
</div>
</VCardText>
</VCard>
<!-- Tabs -->
<VTabs v-model="activeTab" class="mb-6">
<VTab value="overview">
<VIcon icon="tabler-user" start />
Overview
</VTab>
<VTab value="services">
<VIcon icon="tabler-server" start />
Services
<VChip size="x-small" color="primary" variant="tonal" class="ms-2">
{{ customer.services.length }}
</VChip>
</VTab>
<VTab value="billing">
<VIcon icon="tabler-credit-card" start />
Billing
</VTab>
</VTabs>
<!-- Tab Content -->
<VWindow v-model="activeTab">
<!-- Overview Tab -->
<VWindowItem value="overview">
<VRow>
<!-- User Info -->
<VCol cols="12" md="6">
<VCard>
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-info-circle" size="22" />
<span>Customer Information</span>
</VCardTitle>
<VCardText>
<VList density="compact" class="pa-0">
<VListItem>
<template #prepend>
<VIcon icon="tabler-user" size="20" class="me-3" />
</template>
<VListItemTitle class="text-caption text-medium-emphasis">
Name
</VListItemTitle>
<VListItemSubtitle class="text-body-2 font-weight-medium text-high-emphasis">
{{ customer.name }}
</VListItemSubtitle>
</VListItem>
<VListItem>
<template #prepend>
<VIcon icon="tabler-mail" size="20" class="me-3" />
</template>
<VListItemTitle class="text-caption text-medium-emphasis">
Email
</VListItemTitle>
<VListItemSubtitle class="text-body-2 font-weight-medium text-high-emphasis">
{{ customer.email }}
</VListItemSubtitle>
</VListItem>
<VListItem v-if="customer.phone">
<template #prepend>
<VIcon icon="tabler-phone" size="20" class="me-3" />
</template>
<VListItemTitle class="text-caption text-medium-emphasis">
Phone
</VListItemTitle>
<VListItemSubtitle class="text-body-2 font-weight-medium text-high-emphasis">
{{ customer.phone }}
</VListItemSubtitle>
</VListItem>
<VListItem v-if="customer.company">
<template #prepend>
<VIcon icon="tabler-building" size="20" class="me-3" />
</template>
<VListItemTitle class="text-caption text-medium-emphasis">
Company
</VListItemTitle>
<VListItemSubtitle class="text-body-2 font-weight-medium text-high-emphasis">
{{ customer.company }}
</VListItemSubtitle>
</VListItem>
<VListItem>
<template #prepend>
<VIcon icon="tabler-calendar" size="20" class="me-3" />
</template>
<VListItemTitle class="text-caption text-medium-emphasis">
Member Since
</VListItemTitle>
<VListItemSubtitle class="text-body-2 font-weight-medium text-high-emphasis">
{{ formatDate(customer.created_at) }}
</VListItemSubtitle>
</VListItem>
</VList>
</VCardText>
</VCard>
</VCol>
<!-- Billing Address -->
<VCol cols="12" md="6">
<VCard class="mb-4">
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-map-pin" size="22" />
<span>Billing Address</span>
</VCardTitle>
<VCardText>
<div class="text-body-2" style="white-space: pre-line;">
{{ formatBillingAddress(customer.profile) }}
</div>
<div v-if="customer.profile?.tax_id" class="mt-3">
<span class="text-caption text-medium-emphasis">Tax ID:</span>
<span class="text-body-2 ms-1">{{ customer.profile.tax_id }}</span>
</div>
<VChip
v-if="customer.profile?.tax_exempt"
color="info"
size="small"
variant="tonal"
class="mt-2"
>
Tax Exempt
</VChip>
</VCardText>
</VCard>
<!-- Quick Stats -->
<VCard>
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-chart-bar" size="22" />
<span>Quick Stats</span>
</VCardTitle>
<VCardText>
<VRow>
<VCol cols="4" class="text-center">
<div class="text-h5 font-weight-bold text-primary">
{{ customer.services.length }}
</div>
<div class="text-caption text-medium-emphasis">
Services
</div>
</VCol>
<VCol cols="4" class="text-center">
<div class="text-h5 font-weight-bold text-info">
{{ subscriptions.length }}
</div>
<div class="text-caption text-medium-emphasis">
Subscriptions
</div>
</VCol>
<VCol cols="4" class="text-center">
<div class="text-h5 font-weight-bold text-success">
{{ recentInvoices.length }}
</div>
<div class="text-caption text-medium-emphasis">
Invoices
</div>
</VCol>
</VRow>
</VCardText>
</VCard>
</VCol>
<!-- Recent Activity -->
<VCol cols="12">
<VCard>
<VCardTitle class="d-flex align-center justify-space-between">
<div class="d-flex align-center gap-2">
<VIcon icon="tabler-activity" size="22" />
<span>Recent Activity</span>
</div>
<VChip size="small" color="primary" variant="tonal">
Last 20
</VChip>
</VCardTitle>
<VCardText v-if="auditLogs.length === 0" class="text-center py-8">
<VIcon icon="tabler-inbox" size="48" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">
No activity recorded yet.
</div>
</VCardText>
<VTable v-else density="comfortable" hover>
<thead>
<tr>
<th>Action</th>
<th>Resource</th>
<th>IP Address</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<tr v-for="log in auditLogs" :key="log.id">
<td class="text-body-2 font-weight-medium">
{{ formatAction(log.action) }}
</td>
<td class="text-body-2">
<span class="text-capitalize">{{ log.resource_type }}</span>
<span v-if="log.resource_id" class="text-medium-emphasis">
#{{ log.resource_id }}
</span>
</td>
<td class="text-body-2 text-medium-emphasis">
{{ log.ip_address ?? 'N/A' }}
</td>
<td class="text-body-2">
{{ formatDate(log.created_at) }}
</td>
</tr>
</tbody>
</VTable>
</VCard>
</VCol>
</VRow>
</VWindowItem>
<!-- Services Tab -->
<VWindowItem value="services">
<VCard>
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-server" size="22" />
<span>Services</span>
</VCardTitle>
<VCardText v-if="customer.services.length === 0" class="text-center py-12">
<VIcon icon="tabler-server-off" size="48" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">
This customer has no services.
</div>
</VCardText>
<VTable v-else density="comfortable" hover>
<thead>
<tr>
<th>Service</th>
<th>Plan</th>
<th>Type</th>
<th>Status</th>
<th>IP Address</th>
<th>Created</th>
</tr>
</thead>
<tbody>
<tr v-for="service in customer.services" :key="service.id">
<td>
<div class="text-body-2 font-weight-medium">
{{ service.hostname || service.domain || `Service #${service.id}` }}
</div>
<div v-if="service.platform" class="text-caption text-medium-emphasis text-capitalize">
{{ service.platform }}
</div>
</td>
<td class="text-body-2">
{{ service.plan?.name ?? 'N/A' }}
</td>
<td>
<VChip size="small" variant="tonal" class="text-capitalize">
{{ service.service_type }}
</VChip>
</td>
<td>
<VChip
:color="resolveServiceStatusColor(service.status)"
size="small"
class="text-capitalize"
>
{{ service.status }}
</VChip>
</td>
<td class="text-body-2 text-medium-emphasis">
{{ service.ipv4_address ?? 'N/A' }}
</td>
<td class="text-body-2">
{{ formatDate(service.created_at) }}
</td>
</tr>
</tbody>
</VTable>
</VCard>
</VWindowItem>
<!-- Billing Tab -->
<VWindowItem value="billing">
<VRow>
<!-- Subscriptions -->
<VCol cols="12">
<VCard class="mb-6">
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-receipt" size="22" />
<span>Subscriptions</span>
</VCardTitle>
<VCardText v-if="subscriptions.length === 0" class="text-center py-8">
<VIcon icon="tabler-receipt-off" size="48" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">
No subscriptions found.
</div>
</VCardText>
<VTable v-else density="comfortable" hover>
<thead>
<tr>
<th>Plan</th>
<th>Gateway</th>
<th>Status</th>
<th class="text-end">
Price
</th>
<th>Renewal</th>
<th>Created</th>
</tr>
</thead>
<tbody>
<tr v-for="sub in subscriptions" :key="sub.id">
<td class="text-body-2 font-weight-medium">
{{ sub.plan_name ?? sub.type }}
</td>
<td>
<VChip size="small" variant="tonal" class="text-capitalize">
{{ sub.gateway }}
</VChip>
</td>
<td>
<VChip
:color="resolveSubscriptionStatusColor(sub.stripe_status)"
size="small"
class="text-capitalize"
>
{{ sub.stripe_status }}
</VChip>
</td>
<td class="text-end text-body-2 font-weight-medium">
<template v-if="sub.plan_price">
{{ formatCurrency(sub.plan_price) }}/{{ sub.plan_billing_cycle ?? 'mo' }}
</template>
<span v-else class="text-medium-emphasis">&mdash;</span>
</td>
<td class="text-body-2">
<template v-if="sub.current_period_end">
{{ formatDate(sub.current_period_end) }}
</template>
<span v-else class="text-medium-emphasis">&mdash;</span>
<div v-if="sub.ends_at" class="text-caption text-error">
Cancels {{ formatDate(sub.ends_at) }}
</div>
</td>
<td class="text-body-2">
{{ formatDate(sub.created_at) }}
</td>
</tr>
</tbody>
</VTable>
</VCard>
</VCol>
<!-- Invoices -->
<VCol cols="12">
<VCard>
<VCardTitle class="d-flex align-center justify-space-between">
<div class="d-flex align-center gap-2">
<VIcon icon="tabler-file-invoice" size="22" />
<span>Recent Invoices</span>
</div>
<VChip size="small" color="info" variant="tonal">
Last 10
</VChip>
</VCardTitle>
<VCardText v-if="recentInvoices.length === 0" class="text-center py-8">
<VIcon icon="tabler-file-off" size="48" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">
No invoices found.
</div>
</VCardText>
<VTable v-else density="comfortable" hover>
<thead>
<tr>
<th>Invoice #</th>
<th>Gateway</th>
<th>Status</th>
<th class="text-end">
Amount
</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<tr v-for="invoice in recentInvoices" :key="invoice.id">
<td class="text-body-2 font-weight-medium">
{{ invoice.number }}
</td>
<td>
<VChip size="small" variant="tonal" class="text-capitalize">
{{ invoice.gateway }}
</VChip>
</td>
<td>
<VChip
:color="resolveInvoiceStatusColor(invoice.status)"
size="small"
class="text-capitalize"
>
{{ invoice.status }}
</VChip>
</td>
<td class="text-end text-body-2 font-weight-medium">
{{ formatCurrency(invoice.total) }}
</td>
<td class="text-body-2">
{{ formatDate(invoice.created_at) }}
</td>
</tr>
</tbody>
</VTable>
</VCard>
</VCol>
</VRow>
</VWindowItem>
</VWindow>
</div>
</template>

View File

@@ -0,0 +1,203 @@
<script lang="ts" setup>
import { Link, router } from '@inertiajs/vue3'
import { ref, watch } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
import { resolveInvoiceStatusColor, formatPrice } from '@/utils/resolvers'
import type { PaginatedResponse } from '@/types'
interface InvoiceUser {
id: number
name: string
email: string
}
interface InvoiceItem {
id: number
user_id: number
number: string
total: string
tax: string
currency: string
status: string
gateway: string | null
due_date: string | null
paid_at: string | null
created_at: string
user: InvoiceUser | null
}
interface Filters {
search: string
status: string
}
interface Props {
invoices: PaginatedResponse<InvoiceItem>
filters: Filters
}
defineOptions({ layout: AdminLayout })
const props = defineProps<Props>()
const search = ref<string>(props.filters.search)
const status = ref<string>(props.filters.status)
const statusOptions = [
{ title: 'All Statuses', value: '' },
{ title: 'Paid', value: 'paid' },
{ title: 'Pending', value: 'pending' },
{ title: 'Overdue', value: 'overdue' },
{ title: 'Void', value: 'void' },
]
let searchTimeout: ReturnType<typeof setTimeout> | null = null
function applyFilters(): void {
router.get('/invoices', {
search: search.value || undefined,
status: status.value || undefined,
}, {
preserveState: true,
preserveScroll: true,
})
}
watch(search, () => {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(applyFilters, 300)
})
watch(status, () => {
applyFilters()
})
function formatDate(dateStr: string | null): string {
if (!dateStr) return '---'
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">
Invoices
</div>
<div class="text-body-2 text-medium-emphasis">
Manage all customer invoices
</div>
</div>
</div>
<!-- Filters -->
<VCard class="mb-6">
<VCardText>
<VRow>
<VCol cols="12" md="8">
<VTextField
v-model="search"
prepend-inner-icon="tabler-search"
placeholder="Search by invoice number, customer name, or email..."
density="compact"
clearable
hide-details
@click:clear="search = ''"
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-model="status"
:items="statusOptions"
density="compact"
hide-details
label="Status"
/>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- Invoices Table -->
<VCard>
<VCardText v-if="invoices.data.length === 0" class="text-center py-12">
<VIcon icon="tabler-file-off" size="48" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">
No invoices found.
</div>
</VCardText>
<VTable v-else density="comfortable" hover>
<thead>
<tr>
<th>Invoice #</th>
<th>Customer</th>
<th class="text-end">
Amount
</th>
<th>Status</th>
<th>Due Date</th>
<th>Paid Date</th>
<th class="text-center">
Actions
</th>
</tr>
</thead>
<tbody>
<tr v-for="invoice in invoices.data" :key="invoice.id">
<td class="text-body-2 font-weight-medium">
{{ invoice.number }}
</td>
<td>
<div class="d-flex flex-column">
<span class="text-body-2 font-weight-medium">{{ invoice.user?.name ?? 'Unknown' }}</span>
<span class="text-caption text-medium-emphasis">{{ invoice.user?.email ?? '' }}</span>
</div>
</td>
<td class="text-end text-body-2 font-weight-medium">
{{ formatPrice(invoice.total) }}
</td>
<td>
<VChip
:color="resolveInvoiceStatusColor(invoice.status)"
size="small"
class="text-capitalize"
>
{{ invoice.status }}
</VChip>
</td>
<td class="text-body-2">
{{ formatDate(invoice.due_date) }}
</td>
<td class="text-body-2">
{{ formatDate(invoice.paid_at) }}
</td>
<td class="text-center">
<Link :href="`/invoices/${invoice.id}`">
<VBtn variant="text" size="small" color="primary">
<VIcon icon="tabler-eye" size="18" />
</VBtn>
</Link>
</td>
</tr>
</tbody>
</VTable>
<!-- Pagination -->
<VCardText v-if="invoices.last_page > 1" class="d-flex align-center justify-center pt-2">
<VPagination
:model-value="invoices.data.length > 0 ? Math.ceil((invoices.from ?? 1) / 15) : 1"
:length="invoices.last_page"
:total-visible="7"
@update:model-value="(page: number) => router.get('/invoices', { ...props.filters, page }, { preserveState: true, preserveScroll: true })"
/>
</VCardText>
<VCardText v-if="invoices.total > 0" class="text-center text-caption text-medium-emphasis">
Showing {{ invoices.from }} to {{ invoices.to }} of {{ invoices.total }} invoices
</VCardText>
</VCard>
</div>
</template>

View File

@@ -0,0 +1,409 @@
<script lang="ts" setup>
import { Link, useForm } from '@inertiajs/vue3'
import { computed, ref } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
import { resolveInvoiceStatusColor, resolveTransactionStatusColor, formatPrice } from '@/utils/resolvers'
interface InvoiceUser {
id: number
name: string
email: string
status: string
}
interface InvoiceLineItem {
id: number
description: string
amount: string
quantity: number
}
interface PaymentTransactionItem {
id: number
gateway: string
gateway_transaction_id: string | null
amount: string
currency: string
status: string
payment_method: string | null
description: string | null
created_at: string
}
interface InvoiceDetail {
id: number
user_id: number
number: string
total: string
tax: string
currency: string
status: string
gateway: string | null
gateway_invoice_id: string | null
invoice_pdf: string | null
due_date: string | null
paid_at: string | null
created_at: string
updated_at: string
user: InvoiceUser | null
items: InvoiceLineItem[]
payment_transactions: PaymentTransactionItem[]
}
interface Props {
invoice: InvoiceDetail
}
defineOptions({ layout: AdminLayout })
const props = defineProps<Props>()
const voidDialog = ref<boolean>(false)
const voidForm = useForm({})
const subtotal = computed<number>(() => {
return props.invoice.items.reduce((sum, item) => {
return sum + (parseFloat(item.amount) * item.quantity)
}, 0)
})
function submitVoid(): void {
voidForm.post(`/invoices/${props.invoice.id}/void`, {
preserveScroll: true,
onSuccess: () => { voidDialog.value = false },
})
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '---'
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
}
function formatDateTime(dateStr: string | null): string {
if (!dateStr) return '---'
const date = new Date(dateStr)
return date.toLocaleString('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
</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="/invoices">
<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">Invoice {{ invoice.number }}</span>
<VChip
:color="resolveInvoiceStatusColor(invoice.status)"
size="small"
class="text-capitalize"
>
{{ invoice.status }}
</VChip>
</div>
<div class="text-body-2 text-medium-emphasis mt-1">
{{ invoice.user?.name ?? 'Unknown Customer' }} &middot; {{ invoice.user?.email ?? '' }}
</div>
</div>
</div>
<div class="d-flex gap-2">
<VBtn
v-if="invoice.invoice_pdf"
color="info"
variant="tonal"
:href="invoice.invoice_pdf"
target="_blank"
>
<VIcon icon="tabler-download" start />
Download PDF
</VBtn>
<VBtn
v-if="invoice.status !== 'void' && invoice.status !== 'paid'"
color="error"
variant="tonal"
:disabled="voidForm.processing"
@click="voidDialog = true"
>
<VIcon icon="tabler-ban" start />
Void Invoice
</VBtn>
</div>
</div>
<VRow>
<!-- Invoice Info -->
<VCol cols="12" lg="4">
<VCard>
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-file-invoice" size="22" />
<span>Invoice Details</span>
</VCardTitle>
<VCardText>
<VList density="compact" class="pa-0">
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 120px;">Number</span>
</template>
<VListItemTitle class="text-body-2 font-weight-medium">
{{ invoice.number }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 120px;">Gateway</span>
</template>
<VListItemTitle class="text-body-2 text-capitalize">
{{ invoice.gateway ?? 'N/A' }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 120px;">Currency</span>
</template>
<VListItemTitle class="text-body-2 text-uppercase">
{{ invoice.currency }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 120px;">Created</span>
</template>
<VListItemTitle class="text-body-2">
{{ formatDate(invoice.created_at) }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 120px;">Due Date</span>
</template>
<VListItemTitle class="text-body-2">
{{ formatDate(invoice.due_date) }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 120px;">Paid Date</span>
</template>
<VListItemTitle class="text-body-2">
{{ formatDate(invoice.paid_at) }}
</VListItemTitle>
</VListItem>
</VList>
</VCardText>
</VCard>
<!-- Customer Card -->
<VCard class="mt-4">
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-user" size="22" />
<span>Customer</span>
</VCardTitle>
<VCardText v-if="invoice.user">
<div class="d-flex align-center gap-3">
<VAvatar color="primary" variant="tonal" size="40">
<span class="text-body-1 font-weight-semibold">
{{ invoice.user.name.charAt(0).toUpperCase() }}
</span>
</VAvatar>
<div>
<div class="text-body-1 font-weight-medium">
{{ invoice.user.name }}
</div>
<div class="text-body-2 text-medium-emphasis">
{{ invoice.user.email }}
</div>
</div>
</div>
<div class="mt-3">
<Link :href="`/customers/${invoice.user.id}`">
<VBtn variant="tonal" size="small" color="primary" block>
View Customer
</VBtn>
</Link>
</div>
</VCardText>
</VCard>
</VCol>
<!-- Invoice Items & Totals -->
<VCol cols="12" lg="8">
<VCard>
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-list" size="22" />
<span>Invoice Items</span>
</VCardTitle>
<VCardText v-if="invoice.items.length === 0" class="text-center py-8">
<VIcon icon="tabler-inbox" size="48" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">
No items on this invoice.
</div>
</VCardText>
<template v-else>
<VTable density="comfortable">
<thead>
<tr>
<th>Description</th>
<th class="text-center">
Qty
</th>
<th class="text-end">
Unit Price
</th>
<th class="text-end">
Total
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in invoice.items" :key="item.id">
<td class="text-body-2">
{{ item.description }}
</td>
<td class="text-center text-body-2">
{{ item.quantity }}
</td>
<td class="text-end text-body-2">
{{ formatPrice(item.amount) }}
</td>
<td class="text-end text-body-2 font-weight-medium">
{{ formatPrice(parseFloat(item.amount) * item.quantity) }}
</td>
</tr>
</tbody>
</VTable>
<!-- Totals -->
<VDivider />
<VCardText>
<div class="d-flex flex-column align-end ga-2">
<div class="d-flex align-center ga-6" style="min-width: 200px;">
<span class="text-body-2 text-medium-emphasis">Subtotal</span>
<VSpacer />
<span class="text-body-2">{{ formatPrice(subtotal) }}</span>
</div>
<div class="d-flex align-center ga-6" style="min-width: 200px;">
<span class="text-body-2 text-medium-emphasis">Tax</span>
<VSpacer />
<span class="text-body-2">{{ formatPrice(invoice.tax) }}</span>
</div>
<VDivider class="my-1" style="min-width: 200px;" />
<div class="d-flex align-center ga-6" style="min-width: 200px;">
<span class="text-body-1 font-weight-bold">Total</span>
<VSpacer />
<span class="text-body-1 font-weight-bold">{{ formatPrice(invoice.total) }}</span>
</div>
</div>
</VCardText>
</template>
</VCard>
<!-- Payment Transactions -->
<VCard class="mt-4">
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-credit-card" size="22" />
<span>Payment Transactions</span>
<VSpacer />
<VChip size="small" color="secondary" variant="tonal">
{{ invoice.payment_transactions.length }}
</VChip>
</VCardTitle>
<VCardText v-if="invoice.payment_transactions.length === 0" class="text-center py-8">
<VIcon icon="tabler-inbox" size="48" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">
No payment transactions recorded.
</div>
</VCardText>
<VTable v-else density="comfortable" hover>
<thead>
<tr>
<th>Gateway</th>
<th>Method</th>
<th>Status</th>
<th class="text-end">
Amount
</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<tr v-for="txn in invoice.payment_transactions" :key="txn.id">
<td class="text-body-2 text-capitalize">
{{ txn.gateway }}
</td>
<td class="text-body-2">
{{ txn.payment_method ?? '---' }}
</td>
<td>
<VChip
:color="resolveTransactionStatusColor(txn.status)"
size="small"
class="text-capitalize"
>
{{ txn.status }}
</VChip>
</td>
<td class="text-end text-body-2 font-weight-medium">
{{ formatPrice(txn.amount) }}
</td>
<td class="text-body-2">
{{ formatDateTime(txn.created_at) }}
</td>
</tr>
</tbody>
</VTable>
</VCard>
</VCol>
</VRow>
<!-- Void Confirmation Dialog -->
<VDialog v-model="voidDialog" max-width="500" persistent>
<VCard>
<VCardTitle class="text-h5 pa-5">
Void Invoice
</VCardTitle>
<VCardText class="px-5 pb-2">
Are you sure you want to void invoice <strong>{{ invoice.number }}</strong>?
This will mark the invoice as void and it can no longer be collected on.
</VCardText>
<VCardActions class="pa-5">
<VSpacer />
<VBtn variant="text" :disabled="voidForm.processing" @click="voidDialog = false">
Cancel
</VBtn>
<VBtn
color="error"
variant="flat"
:loading="voidForm.processing"
@click="submitVoid"
>
Void Invoice
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -0,0 +1,276 @@
<script lang="ts" setup>
import { Link, useForm } from '@inertiajs/vue3'
import { watch } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.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: AdminLayout })
interface FeatureRow {
key: string
value: string
}
const serviceTypeOptions = [
{ title: 'VPS', value: 'vps' },
{ title: 'Dedicated', value: 'dedicated' },
{ title: 'Hosting', value: 'hosting' },
{ title: 'MySQL', value: 'mysql' },
{ title: 'Game Server', value: 'game_server' },
]
const billingCycleOptions = [
{ title: 'Monthly', value: 'monthly' },
{ title: 'Quarterly', value: 'quarterly' },
{ title: 'Semi-Annual', value: 'semi_annual' },
{ title: 'Annual', value: 'annual' },
]
const form = useForm({
name: '',
slug: '',
description: '',
service_type: '' as string,
price: '' as string | number,
billing_cycle: '' as string,
features: [] as FeatureRow[],
stock_quantity: null as number | null,
sort_order: 0,
})
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
let slugManuallyEdited = false
watch(() => form.name, (newName: string) => {
if (!slugManuallyEdited) {
form.slug = slugify(newName)
}
})
function onSlugInput(): void {
slugManuallyEdited = true
}
function addFeature(): void {
form.features.push({ key: '', value: '' })
}
function removeFeature(index: number): void {
form.features.splice(index, 1)
}
function submit(): void {
form.post('/plans', {
preserveScroll: true,
})
}
</script>
<template>
<div>
<!-- Header -->
<div class="d-flex align-center justify-space-between mb-6">
<div>
<div class="d-flex align-center gap-2 mb-1">
<Link href="/plans" class="text-decoration-none">
<VBtn icon="tabler-arrow-left" variant="text" size="small" />
</Link>
<span class="text-h4 font-weight-bold">Create Plan</span>
</div>
<div class="text-body-2 text-medium-emphasis ms-10">
Add a new service plan to your catalog
</div>
</div>
</div>
<form @submit.prevent="submit">
<VRow>
<!-- Plan Details -->
<VCol cols="12" lg="8">
<VCard title="Plan Details" class="mb-6">
<VCardText>
<VRow>
<VCol cols="12" md="6">
<AppTextField
v-model="form.name"
label="Plan Name"
placeholder="e.g. Basic VPS"
:error-messages="form.errors.name"
/>
</VCol>
<VCol cols="12" md="6">
<AppTextField
v-model="form.slug"
label="Slug"
placeholder="e.g. basic-vps"
:error-messages="form.errors.slug"
@input="onSlugInput"
/>
</VCol>
<VCol cols="12">
<AppTextarea
v-model="form.description"
label="Description"
placeholder="Brief description of the plan..."
rows="3"
:error-messages="form.errors.description"
/>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- Pricing & Billing -->
<VCard title="Pricing & Billing" class="mb-6">
<VCardText>
<VRow>
<VCol cols="12" md="4">
<AppSelect
v-model="form.service_type"
label="Service Type"
:items="serviceTypeOptions"
placeholder="Select type"
:error-messages="form.errors.service_type"
/>
</VCol>
<VCol cols="12" md="4">
<AppTextField
v-model="form.price"
label="Price (USD)"
type="number"
step="0.01"
min="0"
placeholder="0.00"
:error-messages="form.errors.price"
/>
</VCol>
<VCol cols="12" md="4">
<AppSelect
v-model="form.billing_cycle"
label="Billing Cycle"
:items="billingCycleOptions"
placeholder="Select cycle"
:error-messages="form.errors.billing_cycle"
/>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- Features -->
<VCard title="Features" class="mb-6">
<VCardText>
<div v-if="form.features.length === 0" class="text-center py-4">
<VIcon icon="tabler-list" size="40" color="disabled" class="mb-2" />
<div class="text-medium-emphasis mb-3">
No features added yet
</div>
</div>
<div
v-for="(feature, index) in form.features"
:key="index"
class="mb-3"
>
<VRow align="center">
<VCol cols="12" md="5">
<AppTextField
v-model="feature.key"
placeholder="Feature name (e.g. cpu, ram)"
density="compact"
hide-details
/>
</VCol>
<VCol cols="12" md="5">
<AppTextField
v-model="feature.value"
placeholder="Value (e.g. 2 vCPU, 4 GB)"
density="compact"
hide-details
/>
</VCol>
<VCol cols="12" md="2">
<VBtn
icon="tabler-trash"
color="error"
variant="text"
size="small"
@click="removeFeature(index)"
/>
</VCol>
</VRow>
</div>
<VBtn
variant="tonal"
color="primary"
prepend-icon="tabler-plus"
size="small"
@click="addFeature"
>
Add Feature
</VBtn>
</VCardText>
</VCard>
</VCol>
<!-- Sidebar -->
<VCol cols="12" lg="4">
<VCard title="Inventory & Ordering" class="mb-6">
<VCardText>
<AppTextField
v-model="form.stock_quantity"
label="Stock Quantity"
type="number"
min="0"
placeholder="Leave empty for unlimited"
:error-messages="form.errors.stock_quantity"
class="mb-4"
/>
<AppTextField
v-model="form.sort_order"
label="Sort Order"
type="number"
min="0"
:error-messages="form.errors.sort_order"
/>
</VCardText>
</VCard>
<!-- Actions -->
<VCard>
<VCardText>
<VBtn
type="submit"
color="primary"
block
:loading="form.processing"
:disabled="form.processing"
prepend-icon="tabler-check"
class="mb-3"
>
Create Plan
</VBtn>
<Link href="/plans" class="text-decoration-none">
<VBtn
variant="outlined"
block
>
Cancel
</VBtn>
</Link>
</VCardText>
</VCard>
</VCol>
</VRow>
</form>
</div>
</template>

View File

@@ -0,0 +1,325 @@
<script lang="ts" setup>
import { Link, useForm } from '@inertiajs/vue3'
import { computed, watch } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.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'
import type { Plan } from '@/types'
defineOptions({ layout: AdminLayout })
interface FeatureRow {
key: string
value: string
}
interface Props {
plan: Plan & { created_at: string }
subscribersCount: number
}
const props = defineProps<Props>()
const serviceTypeOptions = [
{ title: 'VPS', value: 'vps' },
{ title: 'Dedicated', value: 'dedicated' },
{ title: 'Hosting', value: 'hosting' },
{ title: 'MySQL', value: 'mysql' },
{ title: 'Game Server', value: 'game_server' },
]
const billingCycleOptions = [
{ title: 'Monthly', value: 'monthly' },
{ title: 'Quarterly', value: 'quarterly' },
{ title: 'Semi-Annual', value: 'semi_annual' },
{ title: 'Annual', value: 'annual' },
]
function featuresFromPlan(features: Record<string, string> | null): FeatureRow[] {
if (!features) {
return []
}
return Object.entries(features).map(([key, value]) => ({ key, value }))
}
const form = useForm({
name: props.plan.name,
slug: props.plan.slug,
description: props.plan.description ?? '',
service_type: props.plan.service_type,
price: props.plan.price,
billing_cycle: props.plan.billing_cycle,
features: featuresFromPlan(props.plan.features),
stock_quantity: props.plan.stock_quantity,
sort_order: props.plan.sort_order,
})
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
let slugManuallyEdited = true // Start as true since slug is pre-populated
watch(() => form.name, (newName: string) => {
if (!slugManuallyEdited) {
form.slug = slugify(newName)
}
})
function onSlugInput(): void {
slugManuallyEdited = true
}
function addFeature(): void {
form.features.push({ key: '', value: '' })
}
function removeFeature(index: number): void {
form.features.splice(index, 1)
}
function submit(): void {
form.put(`/plans/${props.plan.id}`, {
preserveScroll: true,
})
}
const formattedCreatedAt = computed<string>(() => {
const date = new Date(props.plan.created_at)
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})
})
</script>
<template>
<div>
<!-- Header -->
<div class="d-flex align-center justify-space-between mb-6">
<div>
<div class="d-flex align-center gap-2 mb-1">
<Link href="/plans" class="text-decoration-none">
<VBtn icon="tabler-arrow-left" variant="text" size="small" />
</Link>
<span class="text-h4 font-weight-bold">Edit Plan</span>
</div>
<div class="text-body-2 text-medium-emphasis ms-10">
Update plan details for "{{ plan.name }}"
</div>
</div>
</div>
<form @submit.prevent="submit">
<VRow>
<!-- Plan Details -->
<VCol cols="12" lg="8">
<VCard title="Plan Details" class="mb-6">
<VCardText>
<VRow>
<VCol cols="12" md="6">
<AppTextField
v-model="form.name"
label="Plan Name"
placeholder="e.g. Basic VPS"
:error-messages="form.errors.name"
/>
</VCol>
<VCol cols="12" md="6">
<AppTextField
v-model="form.slug"
label="Slug"
placeholder="e.g. basic-vps"
:error-messages="form.errors.slug"
@input="onSlugInput"
/>
</VCol>
<VCol cols="12">
<AppTextarea
v-model="form.description"
label="Description"
placeholder="Brief description of the plan..."
rows="3"
:error-messages="form.errors.description"
/>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- Pricing & Billing -->
<VCard title="Pricing & Billing" class="mb-6">
<VCardText>
<VRow>
<VCol cols="12" md="4">
<AppSelect
v-model="form.service_type"
label="Service Type"
:items="serviceTypeOptions"
placeholder="Select type"
:error-messages="form.errors.service_type"
/>
</VCol>
<VCol cols="12" md="4">
<AppTextField
v-model="form.price"
label="Price (USD)"
type="number"
step="0.01"
min="0"
placeholder="0.00"
:error-messages="form.errors.price"
/>
</VCol>
<VCol cols="12" md="4">
<AppSelect
v-model="form.billing_cycle"
label="Billing Cycle"
:items="billingCycleOptions"
placeholder="Select cycle"
:error-messages="form.errors.billing_cycle"
/>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- Features -->
<VCard title="Features" class="mb-6">
<VCardText>
<div v-if="form.features.length === 0" class="text-center py-4">
<VIcon icon="tabler-list" size="40" color="disabled" class="mb-2" />
<div class="text-medium-emphasis mb-3">
No features added yet
</div>
</div>
<div
v-for="(feature, index) in form.features"
:key="index"
class="mb-3"
>
<VRow align="center">
<VCol cols="12" md="5">
<AppTextField
v-model="feature.key"
placeholder="Feature name (e.g. cpu, ram)"
density="compact"
hide-details
/>
</VCol>
<VCol cols="12" md="5">
<AppTextField
v-model="feature.value"
placeholder="Value (e.g. 2 vCPU, 4 GB)"
density="compact"
hide-details
/>
</VCol>
<VCol cols="12" md="2">
<VBtn
icon="tabler-trash"
color="error"
variant="text"
size="small"
@click="removeFeature(index)"
/>
</VCol>
</VRow>
</div>
<VBtn
variant="tonal"
color="primary"
prepend-icon="tabler-plus"
size="small"
@click="addFeature"
>
Add Feature
</VBtn>
</VCardText>
</VCard>
</VCol>
<!-- Sidebar -->
<VCol cols="12" lg="4">
<!-- Plan Info -->
<VCard title="Plan Info" class="mb-6">
<VCardText>
<div class="d-flex justify-space-between align-center mb-3">
<span class="text-body-2 text-medium-emphasis">Status</span>
<VChip
:color="plan.status === 'active' ? 'success' : 'error'"
size="small"
class="text-capitalize"
>
{{ plan.status }}
</VChip>
</div>
<div class="d-flex justify-space-between align-center mb-3">
<span class="text-body-2 text-medium-emphasis">Active Subscribers</span>
<span class="text-body-2 font-weight-medium">{{ subscribersCount }}</span>
</div>
<div class="d-flex justify-space-between align-center">
<span class="text-body-2 text-medium-emphasis">Created</span>
<span class="text-body-2">{{ formattedCreatedAt }}</span>
</div>
</VCardText>
</VCard>
<!-- Inventory & Ordering -->
<VCard title="Inventory & Ordering" class="mb-6">
<VCardText>
<AppTextField
v-model="form.stock_quantity"
label="Stock Quantity"
type="number"
min="0"
placeholder="Leave empty for unlimited"
:error-messages="form.errors.stock_quantity"
class="mb-4"
/>
<AppTextField
v-model="form.sort_order"
label="Sort Order"
type="number"
min="0"
:error-messages="form.errors.sort_order"
/>
</VCardText>
</VCard>
<!-- Actions -->
<VCard>
<VCardText>
<VBtn
type="submit"
color="primary"
block
:loading="form.processing"
:disabled="form.processing"
prepend-icon="tabler-check"
class="mb-3"
>
Update Plan
</VBtn>
<Link href="/plans" class="text-decoration-none">
<VBtn
variant="outlined"
block
>
Cancel
</VBtn>
</Link>
</VCardText>
</VCard>
</VCol>
</VRow>
</form>
</div>
</template>

View File

@@ -0,0 +1,305 @@
<script lang="ts" setup>
import { Link, router } from '@inertiajs/vue3'
import { computed, ref, watch } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
import type { Plan, StatusColor } from '@/types'
import { formatPrice } from '@/utils/resolvers'
interface PlanWithSubscribers extends Plan {
subscribers_count: number
created_at: string
}
interface Filters {
service_type: string
search: string
}
interface Props {
plans: PlanWithSubscribers[]
filters: Filters
}
defineOptions({ layout: AdminLayout })
const props = defineProps<Props>()
const search = ref<string>(props.filters.search)
const activeTab = ref<string>(props.filters.service_type)
const serviceTypeTabs: { title: string; value: string }[] = [
{ title: 'All', value: 'all' },
{ title: 'VPS', value: 'vps' },
{ title: 'Dedicated', value: 'dedicated' },
{ title: 'Hosting', value: 'hosting' },
{ title: 'MySQL', value: 'mysql' },
{ title: 'Game Server', value: 'game_server' },
]
function resolveServiceTypeColor(type: string): StatusColor {
const map: Record<string, StatusColor> = {
vps: 'success',
dedicated: 'info',
hosting: 'warning',
mysql: 'secondary',
game_server: 'error',
}
return map[type] ?? 'secondary'
}
function resolveServiceTypeLabel(type: string): string {
const map: Record<string, string> = {
vps: 'VPS',
dedicated: 'Dedicated',
hosting: 'Hosting',
mysql: 'MySQL',
game_server: 'Game Server',
}
return map[type] ?? type
}
function resolveBillingCycleLabel(cycle: string): string {
const map: Record<string, string> = {
monthly: 'Monthly',
quarterly: 'Quarterly',
semi_annual: 'Semi-Annual',
annual: 'Annual',
}
return map[cycle] ?? cycle
}
function resolvePlanStatusColor(status: string): StatusColor {
return status === 'active' ? 'success' : 'error'
}
const tableHeaders = computed(() => [
{ title: 'Plan Name', key: 'name', sortable: true },
{ title: 'Service Type', key: 'service_type', sortable: true },
{ title: 'Price', key: 'price', sortable: true, align: 'end' as const },
{ title: 'Billing Cycle', key: 'billing_cycle', sortable: true },
{ title: 'Stock', key: 'stock_quantity', sortable: true, align: 'center' as const },
{ title: 'Status', key: 'status', sortable: true, align: 'center' as const },
{ title: 'Subscribers', key: 'subscribers_count', sortable: true, align: 'center' as const },
{ title: 'Actions', key: 'actions', sortable: false, align: 'center' as const },
])
let searchTimeout: ReturnType<typeof setTimeout> | null = null
function applyFilters(): void {
router.get('/plans', {
service_type: activeTab.value,
search: search.value || undefined,
}, {
preserveState: true,
replace: true,
})
}
watch(activeTab, () => {
applyFilters()
})
watch(search, () => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => {
applyFilters()
}, 300)
})
function archivePlan(plan: PlanWithSubscribers): void {
if (confirm(`Are you sure you want to archive "${plan.name}"? It will be set to inactive.`)) {
router.delete(`/plans/${plan.id}`, {
preserveScroll: true,
})
}
}
function reactivatePlan(plan: PlanWithSubscribers): void {
router.put(`/plans/${plan.id}`, {
...plan,
status: 'active',
features: plan.features
? Object.entries(plan.features).map(([key, value]) => ({ key, value }))
: [],
}, {
preserveScroll: true,
})
}
</script>
<template>
<div>
<!-- Header -->
<div class="d-flex align-center justify-space-between mb-6">
<div>
<div class="text-h4 font-weight-bold">
Plans
</div>
<div class="text-body-2 text-medium-emphasis">
Manage your service plans and pricing
</div>
</div>
<Link href="/plans/create">
<VBtn color="primary" prepend-icon="tabler-plus">
Create Plan
</VBtn>
</Link>
</div>
<!-- Filters -->
<VCard class="mb-6">
<VCardText>
<VRow align="center">
<VCol cols="12" md="8">
<VTabs
v-model="activeTab"
density="comfortable"
>
<VTab
v-for="tab in serviceTypeTabs"
:key="tab.value"
:value="tab.value"
>
{{ tab.title }}
</VTab>
</VTabs>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="search"
placeholder="Search plans..."
prepend-inner-icon="tabler-search"
variant="outlined"
density="compact"
clearable
hide-details
/>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- Plans Table -->
<VCard>
<VDataTable
:headers="tableHeaders"
:items="plans"
:items-per-page="25"
hover
class="text-no-wrap"
>
<!-- Plan Name -->
<template #item.name="{ item }">
<div class="d-flex flex-column py-2">
<span class="text-body-1 font-weight-medium">{{ item.name }}</span>
<span
v-if="item.description"
class="text-caption text-medium-emphasis text-truncate"
style="max-width: 300px;"
>
{{ item.description }}
</span>
</div>
</template>
<!-- Service Type -->
<template #item.service_type="{ item }">
<VChip
:color="resolveServiceTypeColor(item.service_type)"
size="small"
variant="tonal"
>
{{ resolveServiceTypeLabel(item.service_type) }}
</VChip>
</template>
<!-- Price -->
<template #item.price="{ item }">
<span class="font-weight-medium">{{ formatPrice(item.price) }}</span>
</template>
<!-- Billing Cycle -->
<template #item.billing_cycle="{ item }">
{{ resolveBillingCycleLabel(item.billing_cycle) }}
</template>
<!-- Stock -->
<template #item.stock_quantity="{ item }">
<template v-if="item.stock_quantity !== null">
<VChip
:color="item.stock_quantity > 0 ? 'success' : 'error'"
size="small"
variant="tonal"
>
{{ item.stock_quantity }}
</VChip>
</template>
<span v-else class="text-medium-emphasis">Unlimited</span>
</template>
<!-- Status -->
<template #item.status="{ item }">
<VChip
:color="resolvePlanStatusColor(item.status)"
size="small"
class="text-capitalize"
>
{{ item.status }}
</VChip>
</template>
<!-- Subscribers -->
<template #item.subscribers_count="{ item }">
<span class="font-weight-medium">{{ item.subscribers_count }}</span>
</template>
<!-- Actions -->
<template #item.actions="{ item }">
<VMenu>
<template #activator="{ props: menuProps }">
<VBtn
icon="tabler-dots-vertical"
variant="text"
size="small"
v-bind="menuProps"
/>
</template>
<VList density="compact">
<Link :href="`/plans/${item.id}/edit`" class="text-decoration-none">
<VListItem prepend-icon="tabler-edit">
<VListItemTitle>Edit</VListItemTitle>
</VListItem>
</Link>
<VListItem
v-if="item.status === 'active'"
prepend-icon="tabler-archive"
@click="archivePlan(item)"
>
<VListItemTitle>Archive</VListItemTitle>
</VListItem>
<VListItem
v-else
prepend-icon="tabler-refresh"
@click="reactivatePlan(item)"
>
<VListItemTitle>Reactivate</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</template>
<!-- No data -->
<template #no-data>
<div class="text-center py-8">
<VIcon icon="tabler-package" size="48" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">
No plans found.
</div>
</div>
</template>
</VDataTable>
</VCard>
</div>
</template>

View File

@@ -0,0 +1,267 @@
<script lang="ts" setup>
import { Link, router } from '@inertiajs/vue3'
import { ref, watch } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
import type { PaginatedResponse, StatusColor } from '@/types'
interface ServiceUser {
id: number
name: string
email: string
}
interface ServicePlan {
id: number
name: string
service_type: string
price: string
billing_cycle: string
}
interface ServiceItem {
id: number
user_id: number
service_type: string
platform: string | null
platform_service_id: string | null
status: string
hostname: string | null
domain: string | null
ipv4_address: string | null
created_at: string
user: ServiceUser | null
plan: ServicePlan | null
}
interface Filters {
search: string
service_type: string
status: string
}
interface Props {
services: PaginatedResponse<ServiceItem>
filters: Filters
}
defineOptions({ layout: AdminLayout })
const props = defineProps<Props>()
const search = ref<string>(props.filters.search)
const serviceType = ref<string>(props.filters.service_type)
const status = ref<string>(props.filters.status)
const serviceTypeOptions = [
{ title: 'All Types', value: '' },
{ title: 'VPS', value: 'vps' },
{ title: 'Dedicated', value: 'dedicated' },
{ title: 'Web Hosting', value: 'web_hosting' },
{ title: 'Game Hosting', value: 'game' },
]
const statusOptions = [
{ title: 'All Statuses', value: '' },
{ title: 'Active', value: 'active' },
{ title: 'Suspended', value: 'suspended' },
{ title: 'Terminated', value: 'terminated' },
{ title: 'Pending', value: 'pending' },
]
let searchTimeout: ReturnType<typeof setTimeout> | null = null
function applyFilters(): void {
router.get('/services', {
search: search.value || undefined,
service_type: serviceType.value || undefined,
status: status.value || undefined,
}, {
preserveState: true,
preserveScroll: true,
})
}
watch(search, () => {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(applyFilters, 300)
})
watch([serviceType, status], () => {
applyFilters()
})
function resolveServiceStatusColor(statusVal: string): StatusColor {
const map: Record<string, StatusColor> = {
active: 'success',
suspended: 'warning',
terminated: 'error',
pending: 'info',
}
return map[statusVal] ?? 'secondary'
}
function resolveServiceTypeColor(type: string): string {
const map: Record<string, string> = {
vps: 'primary',
dedicated: 'info',
web_hosting: 'success',
game: 'warning',
}
return map[type] ?? 'secondary'
}
function formatServiceType(type: string): string {
const map: Record<string, string> = {
vps: 'VPS',
dedicated: 'Dedicated',
web_hosting: 'Web Hosting',
game: 'Game Hosting',
}
return map[type] ?? type
}
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">
Services
</div>
<div class="text-body-2 text-medium-emphasis">
Manage all customer services
</div>
</div>
</div>
<!-- Filters -->
<VCard class="mb-6">
<VCardText>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="search"
prepend-inner-icon="tabler-search"
placeholder="Search by customer name or email..."
density="compact"
clearable
hide-details
@click:clear="search = ''"
/>
</VCol>
<VCol cols="12" sm="6" md="3">
<VSelect
v-model="serviceType"
:items="serviceTypeOptions"
density="compact"
hide-details
label="Service Type"
/>
</VCol>
<VCol cols="12" sm="6" md="3">
<VSelect
v-model="status"
:items="statusOptions"
density="compact"
hide-details
label="Status"
/>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- Services Table -->
<VCard>
<VCardText v-if="services.data.length === 0" class="text-center py-12">
<VIcon icon="tabler-server-off" size="48" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">
No services found.
</div>
</VCardText>
<VTable v-else density="comfortable" hover>
<thead>
<tr>
<th>ID</th>
<th>Customer</th>
<th>Plan</th>
<th>Type</th>
<th>Status</th>
<th>Platform ID</th>
<th>Created</th>
<th class="text-center">
Actions
</th>
</tr>
</thead>
<tbody>
<tr v-for="service in services.data" :key="service.id">
<td class="text-body-2 font-weight-medium">
#{{ service.id }}
</td>
<td>
<div class="d-flex flex-column">
<span class="text-body-2 font-weight-medium">{{ service.user?.name ?? 'Unknown' }}</span>
<span class="text-caption text-medium-emphasis">{{ service.user?.email ?? '' }}</span>
</div>
</td>
<td class="text-body-2">
{{ service.plan?.name ?? 'N/A' }}
</td>
<td>
<VChip
:color="resolveServiceTypeColor(service.service_type)"
size="small"
variant="tonal"
>
{{ formatServiceType(service.service_type) }}
</VChip>
</td>
<td>
<VChip
:color="resolveServiceStatusColor(service.status)"
size="small"
class="text-capitalize"
>
{{ service.status }}
</VChip>
</td>
<td class="text-body-2 text-medium-emphasis">
{{ service.platform_service_id ?? '---' }}
</td>
<td class="text-body-2">
{{ formatDate(service.created_at) }}
</td>
<td class="text-center">
<Link :href="`/services/${service.id}`">
<VBtn variant="text" size="small" color="primary">
<VIcon icon="tabler-eye" size="18" />
</VBtn>
</Link>
</td>
</tr>
</tbody>
</VTable>
<!-- Pagination -->
<VCardText v-if="services.last_page > 1" class="d-flex align-center justify-center pt-2">
<VPagination
:model-value="services.data.length > 0 ? Math.ceil((services.from ?? 1) / 15) : 1"
:length="services.last_page"
:total-visible="7"
@update:model-value="(page: number) => router.get('/services', { ...props.filters, page }, { preserveState: true, preserveScroll: true })"
/>
</VCardText>
<VCardText v-if="services.total > 0" class="text-center text-caption text-medium-emphasis">
Showing {{ services.from }} to {{ services.to }} of {{ services.total }} services
</VCardText>
</VCard>
</div>
</template>

View File

@@ -0,0 +1,507 @@
<script lang="ts" setup>
import { Link, useForm } from '@inertiajs/vue3'
import { computed, ref } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
import type { StatusColor } from '@/types'
interface ServiceUser {
id: number
name: string
email: string
status: string
}
interface ServicePlan {
id: number
name: string
service_type: string
price: string
billing_cycle: string
}
interface ProvisioningLogItem {
id: number
action: string
platform: string | null
platform_response: Record<string, unknown> | null
status: string
error_message: string | null
created_at: string
}
interface ServiceDetail {
id: number
user_id: number
service_type: string
platform: string | null
platform_service_id: string | null
status: string
hostname: string | null
domain: string | null
ipv4_address: string | null
ipv6_address: string | null
auto_renew: boolean
provisioned_at: string | null
suspended_at: string | null
terminated_at: string | null
created_at: string
updated_at: string
user: ServiceUser | null
plan: ServicePlan | null
provisioning_logs: ProvisioningLogItem[]
}
interface Props {
service: ServiceDetail
}
defineOptions({ layout: AdminLayout })
const props = defineProps<Props>()
const confirmDialog = ref<boolean>(false)
const confirmAction = ref<'suspend' | 'unsuspend' | 'terminate'>('suspend')
const confirmTitle = ref<string>('')
const confirmMessage = ref<string>('')
const confirmColor = ref<string>('warning')
const suspendForm = useForm({})
const unsuspendForm = useForm({})
const terminateForm = useForm({})
const isProcessing = computed<boolean>(() =>
suspendForm.processing || unsuspendForm.processing || terminateForm.processing,
)
function openConfirmDialog(action: 'suspend' | 'unsuspend' | 'terminate'): void {
confirmAction.value = action
if (action === 'suspend') {
confirmTitle.value = 'Suspend Service'
confirmMessage.value = `Are you sure you want to suspend service #${props.service.id}? The customer will lose access to their service.`
confirmColor.value = 'warning'
} else if (action === 'unsuspend') {
confirmTitle.value = 'Unsuspend Service'
confirmMessage.value = `Are you sure you want to unsuspend service #${props.service.id}? The customer will regain access to their service.`
confirmColor.value = 'success'
} else {
confirmTitle.value = 'Terminate Service'
confirmMessage.value = `Are you sure you want to terminate service #${props.service.id}? This action may be irreversible. The service will be permanently deactivated.`
confirmColor.value = 'error'
}
confirmDialog.value = true
}
function executeAction(): void {
const action = confirmAction.value
if (action === 'suspend') {
suspendForm.post(`/services/${props.service.id}/suspend`, {
preserveScroll: true,
onSuccess: () => { confirmDialog.value = false },
})
} else if (action === 'unsuspend') {
unsuspendForm.post(`/services/${props.service.id}/unsuspend`, {
preserveScroll: true,
onSuccess: () => { confirmDialog.value = false },
})
} else {
terminateForm.post(`/services/${props.service.id}/terminate`, {
preserveScroll: true,
onSuccess: () => { confirmDialog.value = false },
})
}
}
function resolveServiceStatusColor(statusVal: string): StatusColor {
const map: Record<string, StatusColor> = {
active: 'success',
suspended: 'warning',
terminated: 'error',
pending: 'info',
}
return map[statusVal] ?? 'secondary'
}
function resolveServiceTypeColor(type: string): string {
const map: Record<string, string> = {
vps: 'primary',
dedicated: 'info',
web_hosting: 'success',
game: 'warning',
}
return map[type] ?? 'secondary'
}
function formatServiceType(type: string): string {
const map: Record<string, string> = {
vps: 'VPS',
dedicated: 'Dedicated',
web_hosting: 'Web Hosting',
game: 'Game Hosting',
}
return map[type] ?? type
}
function resolveLogStatusColor(statusVal: string): StatusColor {
const map: Record<string, StatusColor> = {
success: 'success',
failed: 'error',
pending: 'warning',
in_progress: 'info',
}
return map[statusVal] ?? 'secondary'
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '---'
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
}
function formatDateTime(dateStr: string | null): string {
if (!dateStr) return '---'
const date = new Date(dateStr)
return date.toLocaleString('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function formatPrice(price: string | number, cycle?: string): string {
const amount = parseFloat(String(price)).toFixed(2)
return cycle ? `$${amount}/${cycle}` : `$${amount}`
}
</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="/services">
<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">Service #{{ service.id }}</span>
<VChip
:color="resolveServiceStatusColor(service.status)"
size="small"
class="text-capitalize"
>
{{ service.status }}
</VChip>
<VChip
:color="resolveServiceTypeColor(service.service_type)"
size="small"
variant="tonal"
>
{{ formatServiceType(service.service_type) }}
</VChip>
</div>
<div class="text-body-2 text-medium-emphasis mt-1">
{{ service.user?.name ?? 'Unknown Customer' }} &middot; {{ service.user?.email ?? '' }}
</div>
</div>
</div>
<div class="d-flex gap-2">
<VBtn
v-if="service.status === 'active'"
color="warning"
variant="tonal"
:disabled="isProcessing"
@click="openConfirmDialog('suspend')"
>
<VIcon icon="tabler-player-pause" start />
Suspend
</VBtn>
<VBtn
v-if="service.status === 'suspended'"
color="success"
variant="tonal"
:disabled="isProcessing"
@click="openConfirmDialog('unsuspend')"
>
<VIcon icon="tabler-player-play" start />
Unsuspend
</VBtn>
<VBtn
v-if="service.status !== 'terminated'"
color="error"
variant="tonal"
:disabled="isProcessing"
@click="openConfirmDialog('terminate')"
>
<VIcon icon="tabler-trash" start />
Terminate
</VBtn>
</div>
</div>
<VRow>
<!-- Service Details -->
<VCol cols="12" lg="6">
<VCard>
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-server" size="22" />
<span>Service Details</span>
</VCardTitle>
<VCardText>
<VList density="compact" class="pa-0">
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Plan</span>
</template>
<VListItemTitle class="text-body-2 font-weight-medium">
{{ service.plan?.name ?? 'N/A' }}
<template v-if="service.plan">
&middot; {{ formatPrice(service.plan.price, service.plan.billing_cycle) }}
</template>
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Platform</span>
</template>
<VListItemTitle class="text-body-2">
{{ service.platform ?? 'Not assigned' }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Platform ID</span>
</template>
<VListItemTitle class="text-body-2">
{{ service.platform_service_id ?? '---' }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Hostname</span>
</template>
<VListItemTitle class="text-body-2">
{{ service.hostname ?? '---' }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Domain</span>
</template>
<VListItemTitle class="text-body-2">
{{ service.domain ?? '---' }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">IPv4 Address</span>
</template>
<VListItemTitle class="text-body-2">
{{ service.ipv4_address ?? '---' }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">IPv6 Address</span>
</template>
<VListItemTitle class="text-body-2">
{{ service.ipv6_address ?? '---' }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Auto Renew</span>
</template>
<VListItemTitle>
<VChip :color="service.auto_renew ? 'success' : 'secondary'" size="small">
{{ service.auto_renew ? 'Yes' : 'No' }}
</VChip>
</VListItemTitle>
</VListItem>
</VList>
</VCardText>
</VCard>
</VCol>
<!-- Dates & Customer -->
<VCol cols="12" lg="6">
<VCard class="mb-4">
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-calendar" size="22" />
<span>Dates</span>
</VCardTitle>
<VCardText>
<VList density="compact" class="pa-0">
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Created</span>
</template>
<VListItemTitle class="text-body-2">
{{ formatDateTime(service.created_at) }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Provisioned</span>
</template>
<VListItemTitle class="text-body-2">
{{ formatDateTime(service.provisioned_at) }}
</VListItemTitle>
</VListItem>
<VListItem v-if="service.suspended_at">
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Suspended</span>
</template>
<VListItemTitle class="text-body-2 text-warning">
{{ formatDateTime(service.suspended_at) }}
</VListItemTitle>
</VListItem>
<VListItem v-if="service.terminated_at">
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Terminated</span>
</template>
<VListItemTitle class="text-body-2 text-error">
{{ formatDateTime(service.terminated_at) }}
</VListItemTitle>
</VListItem>
</VList>
</VCardText>
</VCard>
<VCard>
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-user" size="22" />
<span>Customer</span>
</VCardTitle>
<VCardText v-if="service.user">
<div class="d-flex align-center gap-3">
<VAvatar color="primary" variant="tonal" size="40">
<span class="text-body-1 font-weight-semibold">
{{ service.user.name.charAt(0).toUpperCase() }}
</span>
</VAvatar>
<div>
<div class="text-body-1 font-weight-medium">
{{ service.user.name }}
</div>
<div class="text-body-2 text-medium-emphasis">
{{ service.user.email }}
</div>
</div>
<VSpacer />
<Link :href="`/customers/${service.user.id}`">
<VBtn variant="tonal" size="small" color="primary">
View Customer
</VBtn>
</Link>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- Provisioning Logs -->
<VCard class="mt-6">
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-list-details" size="22" />
<span>Provisioning Logs</span>
<VSpacer />
<VChip size="small" color="secondary" variant="tonal">
{{ service.provisioning_logs.length }} {{ service.provisioning_logs.length === 1 ? 'entry' : 'entries' }}
</VChip>
</VCardTitle>
<VCardText v-if="service.provisioning_logs.length === 0" class="text-center py-8">
<VIcon icon="tabler-inbox" size="48" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">
No provisioning logs recorded for this service.
</div>
</VCardText>
<VTable v-else density="comfortable" hover>
<thead>
<tr>
<th>Action</th>
<th>Status</th>
<th>Platform</th>
<th>Message</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
<tr v-for="log in service.provisioning_logs" :key="log.id">
<td class="text-body-2 font-weight-medium text-capitalize">
{{ log.action }}
</td>
<td>
<VChip
:color="resolveLogStatusColor(log.status)"
size="small"
class="text-capitalize"
>
{{ log.status }}
</VChip>
</td>
<td class="text-body-2">
{{ log.platform ?? '---' }}
</td>
<td class="text-body-2 text-medium-emphasis" style="max-width: 400px;">
<span class="d-inline-block text-truncate" style="max-width: 400px;">
{{ log.error_message ?? 'OK' }}
</span>
</td>
<td class="text-body-2">
{{ formatDateTime(log.created_at) }}
</td>
</tr>
</tbody>
</VTable>
</VCard>
<!-- Confirmation Dialog -->
<VDialog v-model="confirmDialog" max-width="500" persistent>
<VCard>
<VCardTitle class="text-h5 pa-5">
{{ confirmTitle }}
</VCardTitle>
<VCardText class="px-5 pb-2">
{{ confirmMessage }}
</VCardText>
<VCardActions class="pa-5">
<VSpacer />
<VBtn variant="text" :disabled="isProcessing" @click="confirmDialog = false">
Cancel
</VBtn>
<VBtn
:color="confirmColor"
variant="flat"
:loading="isProcessing"
@click="executeAction"
>
Confirm
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -2,4 +2,8 @@ import type { NavItem } from './account'
export const adminNavItems: NavItem[] = [
{ title: 'Dashboard', href: '/dashboard', icon: 'tabler-smart-home', matchPrefix: '/dashboard' },
{ title: 'Plans', href: '/plans', icon: 'tabler-package', matchPrefix: '/plans' },
{ title: 'Customers', href: '/customers', icon: 'tabler-users', matchPrefix: '/customers' },
{ title: 'Services', href: '/services', icon: 'tabler-server', matchPrefix: '/services' },
{ title: 'Invoices', href: '/invoices', icon: 'tabler-file-invoice', matchPrefix: '/invoices' },
]

View File

@@ -16,6 +16,7 @@ export function resolveInvoiceStatusColor(status: string): StatusColor {
paid: 'success',
pending: 'warning',
overdue: 'error',
void: 'secondary',
}
return map[status] ?? 'secondary'
}

View File

@@ -2,7 +2,32 @@
declare(strict_types=1);
use App\Http\Controllers\Admin\CustomerController;
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\InvoiceController;
use App\Http\Controllers\Admin\PlanController;
use App\Http\Controllers\Admin\ServiceController;
use Illuminate\Support\Facades\Route;
Route::get('/dashboard', [DashboardController::class, 'index'])->name('admin.dashboard');
Route::resource('customers', CustomerController::class)->only(['index', 'show']);
Route::post('customers/{user}/suspend', [CustomerController::class, 'suspend'])->name('customers.suspend');
Route::post('customers/{user}/unsuspend', [CustomerController::class, 'unsuspend'])->name('customers.unsuspend');
Route::resource('plans', PlanController::class)->names([
'index' => 'admin.plans.index',
'create' => 'admin.plans.create',
'store' => 'admin.plans.store',
'edit' => 'admin.plans.edit',
'update' => 'admin.plans.update',
'destroy' => 'admin.plans.destroy',
])->except(['show']);
Route::resource('services', ServiceController::class)->only(['index', 'show']);
Route::post('services/{service}/suspend', [ServiceController::class, 'suspend'])->name('services.suspend');
Route::post('services/{service}/unsuspend', [ServiceController::class, 'unsuspend'])->name('services.unsuspend');
Route::post('services/{service}/terminate', [ServiceController::class, 'terminate'])->name('services.terminate');
Route::resource('invoices', InvoiceController::class)->only(['index', 'show']);
Route::post('invoices/{invoice}/void', [InvoiceController::class, 'void'])->name('invoices.void');