diff --git a/website/app/Http/Controllers/Admin/CustomerController.php b/website/app/Http/Controllers/Admin/CustomerController.php new file mode 100644 index 0000000..bff5390 --- /dev/null +++ b/website/app/Http/Controllers/Admin/CustomerController.php @@ -0,0 +1,145 @@ +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."); + } +} diff --git a/website/app/Http/Controllers/Admin/InvoiceController.php b/website/app/Http/Controllers/Admin/InvoiceController.php new file mode 100644 index 0000000..a8fb7dd --- /dev/null +++ b/website/app/Http/Controllers/Admin/InvoiceController.php @@ -0,0 +1,78 @@ +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."); + } +} diff --git a/website/app/Http/Controllers/Admin/PlanController.php b/website/app/Http/Controllers/Admin/PlanController.php new file mode 100644 index 0000000..d9716b8 --- /dev/null +++ b/website/app/Http/Controllers/Admin/PlanController.php @@ -0,0 +1,116 @@ +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.'); + } +} diff --git a/website/app/Http/Controllers/Admin/ServiceController.php b/website/app/Http/Controllers/Admin/ServiceController.php new file mode 100644 index 0000000..bb494f8 --- /dev/null +++ b/website/app/Http/Controllers/Admin/ServiceController.php @@ -0,0 +1,126 @@ +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.'); + } +} diff --git a/website/app/Http/Requests/StorePlanRequest.php b/website/app/Http/Requests/StorePlanRequest.php new file mode 100644 index 0000000..cb8c6ac --- /dev/null +++ b/website/app/Http/Requests/StorePlanRequest.php @@ -0,0 +1,79 @@ +> */ + 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 */ + 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|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; + } +} diff --git a/website/resources/ts/Pages/Admin/Customers/Index.vue b/website/resources/ts/Pages/Admin/Customers/Index.vue new file mode 100644 index 0000000..1cb77ba --- /dev/null +++ b/website/resources/ts/Pages/Admin/Customers/Index.vue @@ -0,0 +1,235 @@ + + + diff --git a/website/resources/ts/Pages/Admin/Customers/Show.vue b/website/resources/ts/Pages/Admin/Customers/Show.vue new file mode 100644 index 0000000..8044c24 --- /dev/null +++ b/website/resources/ts/Pages/Admin/Customers/Show.vue @@ -0,0 +1,680 @@ + + + diff --git a/website/resources/ts/Pages/Admin/Invoices/Index.vue b/website/resources/ts/Pages/Admin/Invoices/Index.vue new file mode 100644 index 0000000..949ab63 --- /dev/null +++ b/website/resources/ts/Pages/Admin/Invoices/Index.vue @@ -0,0 +1,203 @@ + + + diff --git a/website/resources/ts/Pages/Admin/Invoices/Show.vue b/website/resources/ts/Pages/Admin/Invoices/Show.vue new file mode 100644 index 0000000..05c404b --- /dev/null +++ b/website/resources/ts/Pages/Admin/Invoices/Show.vue @@ -0,0 +1,409 @@ + + + diff --git a/website/resources/ts/Pages/Admin/Plans/Create.vue b/website/resources/ts/Pages/Admin/Plans/Create.vue new file mode 100644 index 0000000..7384384 --- /dev/null +++ b/website/resources/ts/Pages/Admin/Plans/Create.vue @@ -0,0 +1,276 @@ + + + diff --git a/website/resources/ts/Pages/Admin/Plans/Edit.vue b/website/resources/ts/Pages/Admin/Plans/Edit.vue new file mode 100644 index 0000000..3031af5 --- /dev/null +++ b/website/resources/ts/Pages/Admin/Plans/Edit.vue @@ -0,0 +1,325 @@ + + + diff --git a/website/resources/ts/Pages/Admin/Plans/Index.vue b/website/resources/ts/Pages/Admin/Plans/Index.vue new file mode 100644 index 0000000..0497118 --- /dev/null +++ b/website/resources/ts/Pages/Admin/Plans/Index.vue @@ -0,0 +1,305 @@ + + + diff --git a/website/resources/ts/Pages/Admin/Services/Index.vue b/website/resources/ts/Pages/Admin/Services/Index.vue new file mode 100644 index 0000000..9e7bd21 --- /dev/null +++ b/website/resources/ts/Pages/Admin/Services/Index.vue @@ -0,0 +1,267 @@ + + + diff --git a/website/resources/ts/Pages/Admin/Services/Show.vue b/website/resources/ts/Pages/Admin/Services/Show.vue new file mode 100644 index 0000000..7757cbf --- /dev/null +++ b/website/resources/ts/Pages/Admin/Services/Show.vue @@ -0,0 +1,507 @@ + + + diff --git a/website/resources/ts/navigation/admin.ts b/website/resources/ts/navigation/admin.ts index e1bcb66..6322932 100644 --- a/website/resources/ts/navigation/admin.ts +++ b/website/resources/ts/navigation/admin.ts @@ -2,4 +2,8 @@ import type { NavItem } from './account' export const adminNavItems: NavItem[] = [ { title: 'Dashboard', href: '/dashboard', icon: 'tabler-smart-home', matchPrefix: '/dashboard' }, + { title: 'Plans', href: '/plans', icon: 'tabler-package', matchPrefix: '/plans' }, + { title: 'Customers', href: '/customers', icon: 'tabler-users', matchPrefix: '/customers' }, + { title: 'Services', href: '/services', icon: 'tabler-server', matchPrefix: '/services' }, + { title: 'Invoices', href: '/invoices', icon: 'tabler-file-invoice', matchPrefix: '/invoices' }, ] diff --git a/website/resources/ts/utils/resolvers.ts b/website/resources/ts/utils/resolvers.ts index bb1b189..fde98e7 100644 --- a/website/resources/ts/utils/resolvers.ts +++ b/website/resources/ts/utils/resolvers.ts @@ -16,6 +16,7 @@ export function resolveInvoiceStatusColor(status: string): StatusColor { paid: 'success', pending: 'warning', overdue: 'error', + void: 'secondary', } return map[status] ?? 'secondary' } diff --git a/website/routes/admin.php b/website/routes/admin.php index b057b9b..633e456 100644 --- a/website/routes/admin.php +++ b/website/routes/admin.php @@ -2,7 +2,32 @@ declare(strict_types=1); +use App\Http\Controllers\Admin\CustomerController; use App\Http\Controllers\Admin\DashboardController; +use App\Http\Controllers\Admin\InvoiceController; +use App\Http\Controllers\Admin\PlanController; +use App\Http\Controllers\Admin\ServiceController; use Illuminate\Support\Facades\Route; Route::get('/dashboard', [DashboardController::class, 'index'])->name('admin.dashboard'); + +Route::resource('customers', CustomerController::class)->only(['index', 'show']); +Route::post('customers/{user}/suspend', [CustomerController::class, 'suspend'])->name('customers.suspend'); +Route::post('customers/{user}/unsuspend', [CustomerController::class, 'unsuspend'])->name('customers.unsuspend'); + +Route::resource('plans', PlanController::class)->names([ + 'index' => 'admin.plans.index', + 'create' => 'admin.plans.create', + 'store' => 'admin.plans.store', + 'edit' => 'admin.plans.edit', + 'update' => 'admin.plans.update', + 'destroy' => 'admin.plans.destroy', +])->except(['show']); + +Route::resource('services', ServiceController::class)->only(['index', 'show']); +Route::post('services/{service}/suspend', [ServiceController::class, 'suspend'])->name('services.suspend'); +Route::post('services/{service}/unsuspend', [ServiceController::class, 'unsuspend'])->name('services.unsuspend'); +Route::post('services/{service}/terminate', [ServiceController::class, 'terminate'])->name('services.terminate'); + +Route::resource('invoices', InvoiceController::class)->only(['index', 'show']); +Route::post('invoices/{invoice}/void', [InvoiceController::class, 'void'])->name('invoices.void');