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[] = [
|
export const adminNavItems: NavItem[] = [
|
||||||
{ title: 'Dashboard', href: '/dashboard', icon: 'tabler-smart-home', matchPrefix: '/dashboard' },
|
{ 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',
|
paid: 'success',
|
||||||
pending: 'warning',
|
pending: 'warning',
|
||||||
overdue: 'error',
|
overdue: 'error',
|
||||||
|
void: 'secondary',
|
||||||
}
|
}
|
||||||
return map[status] ?? 'secondary'
|
return map[status] ?? 'secondary'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,32 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Http\Controllers\Admin\CustomerController;
|
||||||
use App\Http\Controllers\Admin\DashboardController;
|
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;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/dashboard', [DashboardController::class, 'index'])->name('admin.dashboard');
|
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