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:
145
website/app/Http/Controllers/Admin/CustomerController.php
Normal file
145
website/app/Http/Controllers/Admin/CustomerController.php
Normal 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.");
|
||||
}
|
||||
}
|
||||
78
website/app/Http/Controllers/Admin/InvoiceController.php
Normal file
78
website/app/Http/Controllers/Admin/InvoiceController.php
Normal 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.");
|
||||
}
|
||||
}
|
||||
116
website/app/Http/Controllers/Admin/PlanController.php
Normal file
116
website/app/Http/Controllers/Admin/PlanController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
126
website/app/Http/Controllers/Admin/ServiceController.php
Normal file
126
website/app/Http/Controllers/Admin/ServiceController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
79
website/app/Http/Requests/StorePlanRequest.php
Normal file
79
website/app/Http/Requests/StorePlanRequest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
235
website/resources/ts/Pages/Admin/Customers/Index.vue
Normal file
235
website/resources/ts/Pages/Admin/Customers/Index.vue
Normal 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>
|
||||
680
website/resources/ts/Pages/Admin/Customers/Show.vue
Normal file
680
website/resources/ts/Pages/Admin/Customers/Show.vue
Normal 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">—</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">—</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>
|
||||
203
website/resources/ts/Pages/Admin/Invoices/Index.vue
Normal file
203
website/resources/ts/Pages/Admin/Invoices/Index.vue
Normal 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>
|
||||
409
website/resources/ts/Pages/Admin/Invoices/Show.vue
Normal file
409
website/resources/ts/Pages/Admin/Invoices/Show.vue
Normal 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' }} · {{ 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>
|
||||
276
website/resources/ts/Pages/Admin/Plans/Create.vue
Normal file
276
website/resources/ts/Pages/Admin/Plans/Create.vue
Normal 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>
|
||||
325
website/resources/ts/Pages/Admin/Plans/Edit.vue
Normal file
325
website/resources/ts/Pages/Admin/Plans/Edit.vue
Normal 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>
|
||||
305
website/resources/ts/Pages/Admin/Plans/Index.vue
Normal file
305
website/resources/ts/Pages/Admin/Plans/Index.vue
Normal 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>
|
||||
267
website/resources/ts/Pages/Admin/Services/Index.vue
Normal file
267
website/resources/ts/Pages/Admin/Services/Index.vue
Normal 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>
|
||||
507
website/resources/ts/Pages/Admin/Services/Show.vue
Normal file
507
website/resources/ts/Pages/Admin/Services/Show.vue
Normal 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' }} · {{ 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">
|
||||
· {{ 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>
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -16,6 +16,7 @@ export function resolveInvoiceStatusColor(status: string): StatusColor {
|
||||
paid: 'success',
|
||||
pending: 'warning',
|
||||
overdue: 'error',
|
||||
void: 'secondary',
|
||||
}
|
||||
return map[status] ?? 'secondary'
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user