Add plan upgrade/downgrade, order management, impersonation, and contact form
- Plan upgrade/downgrade flow: UpgradeController with price difference calculations, Upgrade.vue with feature comparison and confirmation dialog - Admin order management: Order model/migration/factory, OrderController with process/complete/cancel/notes, Index and Show pages with filters - Admin impersonation: start/stop endpoints, session-based tracking, impersonation banner in AccountLayout, audit logging - Contact form: ContactRequest validation, ContactController with email, marketing route for form submission - FlashMessages now supports info alerts - Inertia shared data includes impersonation state - 114 tests passing (623 assertions) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
138
website/app/Http/Controllers/Account/UpgradeController.php
Normal file
138
website/app/Http/Controllers/Account/UpgradeController.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Account;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Plan;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class UpgradeController extends Controller
|
||||
{
|
||||
public function show(Request $request, Service $service): Response
|
||||
{
|
||||
abort_unless($service->user_id === $request->user()->id, 403);
|
||||
abort_unless($service->isActive(), 403, 'Only active services can be upgraded or downgraded.');
|
||||
|
||||
$service->load('plan');
|
||||
|
||||
$currentPlan = $service->plan;
|
||||
|
||||
$availablePlans = Plan::query()
|
||||
->where('service_type', $currentPlan->service_type)
|
||||
->where('status', 'active')
|
||||
->where('id', '!=', $currentPlan->id)
|
||||
->where(function ($query) {
|
||||
$query->whereNull('stock_quantity')
|
||||
->orWhere('stock_quantity', '>', 0);
|
||||
})
|
||||
->orderBy('price')
|
||||
->get()
|
||||
->map(function (Plan $plan) use ($currentPlan): array {
|
||||
$currentPrice = (float) $currentPlan->price;
|
||||
$newPrice = (float) $plan->price;
|
||||
$priceDifference = round($newPrice - $currentPrice, 2);
|
||||
|
||||
return [
|
||||
...$plan->toArray(),
|
||||
'price_difference' => $priceDifference,
|
||||
'is_upgrade' => $priceDifference > 0,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Services/Upgrade', [
|
||||
'service' => $service,
|
||||
'currentPlan' => $currentPlan,
|
||||
'availablePlans' => $availablePlans,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Service $service): RedirectResponse
|
||||
{
|
||||
abort_unless($service->user_id === $request->user()->id, 403);
|
||||
abort_unless($service->isActive(), 403, 'Only active services can be upgraded or downgraded.');
|
||||
|
||||
$validated = $request->validate([
|
||||
'plan_id' => ['required', 'integer', 'exists:plans,id'],
|
||||
]);
|
||||
|
||||
$service->load('plan');
|
||||
$currentPlan = $service->plan;
|
||||
$newPlan = Plan::findOrFail($validated['plan_id']);
|
||||
|
||||
abort_unless($newPlan->service_type === $currentPlan->service_type, 422, 'Cannot switch to a plan of a different service type.');
|
||||
abort_unless($newPlan->isAvailable(), 422, 'The selected plan is not available.');
|
||||
abort_if($newPlan->id === $currentPlan->id, 422, 'You are already on this plan.');
|
||||
|
||||
$currentPrice = (float) $currentPlan->price;
|
||||
$newPrice = (float) $newPlan->price;
|
||||
$priceDifference = round($newPrice - $currentPrice, 2);
|
||||
$isUpgrade = $priceDifference > 0;
|
||||
|
||||
DB::transaction(function () use ($service, $currentPlan, $newPlan, $priceDifference, $isUpgrade, $request): void {
|
||||
$oldPlanId = $service->plan_id;
|
||||
|
||||
$service->update([
|
||||
'plan_id' => $newPlan->id,
|
||||
]);
|
||||
|
||||
if ($priceDifference !== 0.0) {
|
||||
$invoiceNumber = 'INV-'.strtoupper(uniqid());
|
||||
$invoiceStatus = $isUpgrade ? 'pending' : 'paid';
|
||||
$invoiceTotal = abs($priceDifference);
|
||||
|
||||
$description = $isUpgrade
|
||||
? "Upgrade from {$currentPlan->name} to {$newPlan->name}"
|
||||
: "Credit: Downgrade from {$currentPlan->name} to {$newPlan->name}";
|
||||
|
||||
$invoice = Invoice::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'subscription_id' => $service->subscription_id,
|
||||
'gateway' => 'internal',
|
||||
'number' => $invoiceNumber,
|
||||
'total' => $isUpgrade ? $invoiceTotal : -$invoiceTotal,
|
||||
'tax' => 0,
|
||||
'currency' => 'USD',
|
||||
'status' => $invoiceStatus,
|
||||
'due_date' => $isUpgrade ? now()->addDays(7) : null,
|
||||
'paid_at' => $isUpgrade ? null : now(),
|
||||
]);
|
||||
|
||||
$invoice->items()->create([
|
||||
'description' => $description,
|
||||
'amount' => $isUpgrade ? $invoiceTotal : -$invoiceTotal,
|
||||
'quantity' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
AuditLog::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'action' => $isUpgrade ? 'service.upgrade' : 'service.downgrade',
|
||||
'resource_type' => 'service',
|
||||
'resource_id' => $service->id,
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'changes' => [
|
||||
'old_plan_id' => $oldPlanId,
|
||||
'new_plan_id' => $newPlan->id,
|
||||
'old_plan_name' => $currentPlan->name,
|
||||
'new_plan_name' => $newPlan->name,
|
||||
'price_difference' => $priceDifference,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
$actionLabel = $isUpgrade ? 'upgraded' : 'downgraded';
|
||||
|
||||
return redirect()->route('account.services.show', $service)
|
||||
->with('success', "Service successfully {$actionLabel} to {$newPlan->name}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?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 Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ImpersonationController extends Controller
|
||||
{
|
||||
public function start(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
if ($user->isAdmin()) {
|
||||
return redirect()
|
||||
->back()
|
||||
->with('error', 'Cannot impersonate admin users.');
|
||||
}
|
||||
|
||||
AuditLog::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'admin_id' => $request->user()->id,
|
||||
'action' => 'impersonate_start',
|
||||
'resource_type' => 'User',
|
||||
'resource_id' => $user->id,
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'changes' => [
|
||||
'admin' => $request->user()->name,
|
||||
'customer' => $user->name,
|
||||
],
|
||||
]);
|
||||
|
||||
$request->session()->put('impersonator_id', $request->user()->id);
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect('https://'.config('app.domains.account').'/dashboard')
|
||||
->with('info', "You are now impersonating {$user->name}.");
|
||||
}
|
||||
|
||||
public function stop(Request $request): RedirectResponse
|
||||
{
|
||||
$impersonatorId = $request->session()->get('impersonator_id');
|
||||
|
||||
if (! $impersonatorId) {
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
$admin = User::find($impersonatorId);
|
||||
|
||||
if (! $admin) {
|
||||
return redirect()->back()->with('error', 'Original admin user not found.');
|
||||
}
|
||||
|
||||
$request->session()->forget('impersonator_id');
|
||||
|
||||
Auth::login($admin);
|
||||
|
||||
return redirect('https://'.config('app.domains.admin').'/dashboard')
|
||||
->with('success', 'Impersonation ended.');
|
||||
}
|
||||
}
|
||||
140
website/app/Http/Controllers/Admin/OrderController.php
Normal file
140
website/app/Http/Controllers/Admin/OrderController.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Order;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$query = Order::query()
|
||||
->with(['user:id,name,email', 'plan:id,name,service_type,price,billing_cycle']);
|
||||
|
||||
// Search by order number or customer name/email
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search): void {
|
||||
$q->where('order_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);
|
||||
}
|
||||
|
||||
$orders = $query->latest()->paginate(25)->withQueryString();
|
||||
|
||||
return Inertia::render('Admin/Orders/Index', [
|
||||
'orders' => $orders,
|
||||
'filters' => [
|
||||
'search' => $request->input('search', ''),
|
||||
'status' => $request->input('status', ''),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Order $order): Response
|
||||
{
|
||||
$order->load([
|
||||
'user:id,name,email,status',
|
||||
'plan:id,name,service_type,price,billing_cycle',
|
||||
'invoice:id,number,total,status',
|
||||
'service:id,hostname,status,ipv4_address',
|
||||
]);
|
||||
|
||||
return Inertia::render('Admin/Orders/Show', [
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
|
||||
public function process(Order $order): RedirectResponse
|
||||
{
|
||||
$order->update(['status' => 'processing']);
|
||||
|
||||
AuditLog::create([
|
||||
'user_id' => $order->user_id,
|
||||
'admin_id' => auth()->id(),
|
||||
'action' => 'process_order',
|
||||
'resource_type' => 'order',
|
||||
'resource_id' => $order->id,
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', "Order {$order->order_number} is now processing.");
|
||||
}
|
||||
|
||||
public function complete(Order $order): RedirectResponse
|
||||
{
|
||||
$order->update([
|
||||
'status' => 'completed',
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
AuditLog::create([
|
||||
'user_id' => $order->user_id,
|
||||
'admin_id' => auth()->id(),
|
||||
'action' => 'complete_order',
|
||||
'resource_type' => 'order',
|
||||
'resource_id' => $order->id,
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', "Order {$order->order_number} has been completed.");
|
||||
}
|
||||
|
||||
public function cancel(Order $order): RedirectResponse
|
||||
{
|
||||
$order->update([
|
||||
'status' => 'cancelled',
|
||||
'cancelled_at' => now(),
|
||||
]);
|
||||
|
||||
AuditLog::create([
|
||||
'user_id' => $order->user_id,
|
||||
'admin_id' => auth()->id(),
|
||||
'action' => 'cancel_order',
|
||||
'resource_type' => 'order',
|
||||
'resource_id' => $order->id,
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', "Order {$order->order_number} has been cancelled.");
|
||||
}
|
||||
|
||||
public function updateNotes(Request $request, Order $order): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'admin_notes' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$order->update(['admin_notes' => $validated['admin_notes']]);
|
||||
|
||||
AuditLog::create([
|
||||
'user_id' => $order->user_id,
|
||||
'admin_id' => auth()->id(),
|
||||
'action' => 'update_order_notes',
|
||||
'resource_type' => 'order',
|
||||
'resource_id' => $order->id,
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', 'Admin notes updated.');
|
||||
}
|
||||
}
|
||||
31
website/app/Http/Controllers/Marketing/ContactController.php
Normal file
31
website/app/Http/Controllers/Marketing/ContactController.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Marketing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ContactRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class ContactController extends Controller
|
||||
{
|
||||
public function store(ContactRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
Mail::raw(
|
||||
"Name: {$data['name']}\nEmail: {$data['email']}\nSubject: {$data['subject']}\n\n{$data['message']}",
|
||||
function ($message) use ($data): void {
|
||||
$message->to(config('mail.from.address'))
|
||||
->replyTo($data['email'], $data['name'])
|
||||
->subject("[EZSCALE Contact] {$data['subject']}");
|
||||
}
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
->with('success', 'Thank you for your message! We\'ll get back to you shortly.');
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,9 @@ class HandleInertiaRequests extends Middleware
|
||||
'flash' => fn () => [
|
||||
'success' => $request->session()->get('success'),
|
||||
'error' => $request->session()->get('error'),
|
||||
'info' => $request->session()->get('info'),
|
||||
],
|
||||
'impersonating' => fn () => $request->session()->has('impersonator_id'),
|
||||
'domains' => fn () => [
|
||||
'marketing' => config('app.domains.marketing'),
|
||||
'account' => config('app.domains.account'),
|
||||
|
||||
22
website/app/Http/Middleware/ImpersonationMiddleware.php
Normal file
22
website/app/Http/Middleware/ImpersonationMiddleware.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ImpersonationMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($request->session()->has('impersonator_id')) {
|
||||
$request->merge(['is_impersonating' => true]);
|
||||
$request->merge(['impersonator_id' => $request->session()->get('impersonator_id')]);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
26
website/app/Http/Requests/ContactRequest.php
Normal file
26
website/app/Http/Requests/ContactRequest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ContactRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, array<int, string>> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'subject' => ['required', 'string', 'max:255'],
|
||||
'message' => ['required', 'string', 'min:10', 'max:5000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
60
website/app/Models/Order.php
Normal file
60
website/app/Models/Order.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Order extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'plan_id',
|
||||
'invoice_id',
|
||||
'service_id',
|
||||
'order_number',
|
||||
'status',
|
||||
'total',
|
||||
'currency',
|
||||
'payment_gateway',
|
||||
'configuration',
|
||||
'admin_notes',
|
||||
'completed_at',
|
||||
'cancelled_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'total' => 'decimal:2',
|
||||
'configuration' => 'json',
|
||||
'completed_at' => 'datetime',
|
||||
'cancelled_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function plan(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Plan::class);
|
||||
}
|
||||
|
||||
public function invoice(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Invoice::class);
|
||||
}
|
||||
|
||||
public function service(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Service::class);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,11 @@ class Plan extends Model
|
||||
];
|
||||
}
|
||||
|
||||
public function orders(): HasMany
|
||||
{
|
||||
return $this->hasMany(Order::class);
|
||||
}
|
||||
|
||||
public function services(): HasMany
|
||||
{
|
||||
return $this->hasMany(Service::class);
|
||||
|
||||
@@ -79,6 +79,11 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
return $this->hasMany(SupportTicket::class);
|
||||
}
|
||||
|
||||
public function orders(): HasMany
|
||||
{
|
||||
return $this->hasMany(Order::class);
|
||||
}
|
||||
|
||||
public function couponRedemptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CouponRedemption::class);
|
||||
|
||||
48
website/database/factories/OrderFactory.php
Normal file
48
website/database/factories/OrderFactory.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/** @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Order> */
|
||||
class OrderFactory extends Factory
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'plan_id' => Plan::factory(),
|
||||
'order_number' => 'ORD-'.strtoupper(fake()->unique()->bothify('########')),
|
||||
'status' => fake()->randomElement(['pending', 'processing', 'completed', 'cancelled']),
|
||||
'total' => fake()->randomFloat(2, 5, 200),
|
||||
'currency' => 'USD',
|
||||
'payment_gateway' => fake()->randomElement(['stripe', 'paypal']),
|
||||
'configuration' => ['hostname' => fake()->domainWord().'.ezscale.cloud'],
|
||||
];
|
||||
}
|
||||
|
||||
public function pending(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => 'pending']);
|
||||
}
|
||||
|
||||
public function processing(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => 'processing']);
|
||||
}
|
||||
|
||||
public function completed(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => 'completed', 'completed_at' => now()]);
|
||||
}
|
||||
|
||||
public function cancelled(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => 'cancelled', 'cancelled_at' => now()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('orders', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('plan_id')->constrained();
|
||||
$table->foreignId('invoice_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('service_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('order_number')->unique();
|
||||
$table->string('status')->default('pending'); // pending, processing, completed, cancelled, failed
|
||||
$table->decimal('total', 10, 2);
|
||||
$table->string('currency', 3)->default('USD');
|
||||
$table->string('payment_gateway')->nullable(); // stripe, paypal
|
||||
$table->json('configuration')->nullable(); // hostname, OS, location, etc.
|
||||
$table->text('admin_notes')->nullable();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamp('cancelled_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('orders');
|
||||
}
|
||||
};
|
||||
@@ -13,4 +13,7 @@ const flash = computed(() => (page.props as Record<string, unknown>).flash as Re
|
||||
<VAlert v-if="flash.error" type="error" variant="tonal" closable class="mb-4">
|
||||
{{ flash.error }}
|
||||
</VAlert>
|
||||
<VAlert v-if="flash.info" type="info" variant="tonal" closable class="mb-4">
|
||||
{{ flash.info }}
|
||||
</VAlert>
|
||||
</template>
|
||||
|
||||
@@ -19,11 +19,14 @@ interface AuthUser {
|
||||
interface PageProps {
|
||||
auth: { user: AuthUser | null }
|
||||
domains: { marketing: string; account: string; admin: string }
|
||||
impersonating: boolean
|
||||
}
|
||||
|
||||
const page = usePage()
|
||||
const props = computed(() => page.props as unknown as PageProps)
|
||||
const user = computed(() => props.value.auth?.user)
|
||||
const isImpersonating = computed(() => props.value.impersonating)
|
||||
const adminUrl = computed(() => `https://${props.value.domains?.admin}`)
|
||||
const currentUrl = computed(() => page.url)
|
||||
|
||||
function isActive(matchPrefix: string): boolean {
|
||||
@@ -90,6 +93,31 @@ function isActive(matchPrefix: string): boolean {
|
||||
</VContainer>
|
||||
</VAppBar>
|
||||
|
||||
<!-- Impersonation Banner -->
|
||||
<VBanner
|
||||
v-if="isImpersonating"
|
||||
color="warning"
|
||||
icon="tabler-user-shield"
|
||||
sticky
|
||||
class="impersonation-banner"
|
||||
>
|
||||
<template #text>
|
||||
You are impersonating <strong>{{ user?.name }}</strong>. Actions will be attributed to this user.
|
||||
</template>
|
||||
<template #actions>
|
||||
<Link
|
||||
:href="adminUrl + '/impersonate/stop'"
|
||||
method="post"
|
||||
as="button"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<VBtn color="warning" variant="tonal" size="small">
|
||||
Stop Impersonating
|
||||
</VBtn>
|
||||
</Link>
|
||||
</template>
|
||||
</VBanner>
|
||||
|
||||
<VMain>
|
||||
<VContainer>
|
||||
<FlashMessages />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link, useForm } from '@inertiajs/vue3'
|
||||
import { Link, router, useForm } from '@inertiajs/vue3'
|
||||
import { ref } from 'vue'
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import { resolveInvoiceStatusColor, resolveSubscriptionStatusColor } from '@/utils/resolvers'
|
||||
@@ -230,6 +230,15 @@ function formatBillingAddress(profile: CustomerProfile | null): string {
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center ga-2">
|
||||
<VBtn
|
||||
color="info"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
@click="router.post(`/impersonate/${customer.id}`)"
|
||||
>
|
||||
<VIcon icon="tabler-user-shield" start />
|
||||
Impersonate
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="customer.status !== 'suspended'"
|
||||
color="warning"
|
||||
|
||||
225
website/resources/ts/Pages/Admin/Orders/Index.vue
Normal file
225
website/resources/ts/Pages/Admin/Orders/Index.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link, router } from '@inertiajs/vue3'
|
||||
import { ref, watch } from 'vue'
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import { formatPrice } from '@/utils/resolvers'
|
||||
import type { PaginatedResponse, StatusColor } from '@/types'
|
||||
|
||||
interface OrderUser {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
interface OrderPlan {
|
||||
id: number
|
||||
name: string
|
||||
service_type: string
|
||||
price: string
|
||||
billing_cycle: string
|
||||
}
|
||||
|
||||
interface OrderItem {
|
||||
id: number
|
||||
user_id: number
|
||||
plan_id: number
|
||||
order_number: string
|
||||
status: string
|
||||
total: string
|
||||
currency: string
|
||||
payment_gateway: string | null
|
||||
created_at: string
|
||||
user: OrderUser | null
|
||||
plan: OrderPlan | null
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
search: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
orders: PaginatedResponse<OrderItem>
|
||||
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: 'Pending', value: 'pending' },
|
||||
{ title: 'Processing', value: 'processing' },
|
||||
{ title: 'Completed', value: 'completed' },
|
||||
{ title: 'Cancelled', value: 'cancelled' },
|
||||
{ title: 'Failed', value: 'failed' },
|
||||
]
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function applyFilters(): void {
|
||||
router.get('/orders', {
|
||||
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 resolveOrderStatusColor(statusVal: string): StatusColor {
|
||||
const map: Record<string, StatusColor> = {
|
||||
pending: 'warning',
|
||||
processing: 'info',
|
||||
completed: 'success',
|
||||
cancelled: 'error',
|
||||
failed: 'error',
|
||||
}
|
||||
return map[statusVal] ?? '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>
|
||||
<div class="d-flex align-center justify-space-between mb-6">
|
||||
<div>
|
||||
<div class="text-h4 font-weight-bold">
|
||||
Orders
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Manage all customer orders
|
||||
</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 order 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>
|
||||
|
||||
<!-- Orders Table -->
|
||||
<VCard>
|
||||
<VCardText v-if="orders.data.length === 0" class="text-center py-12">
|
||||
<VIcon icon="tabler-shopping-cart-off" size="48" color="disabled" class="mb-2" />
|
||||
<div class="text-medium-emphasis">
|
||||
No orders found.
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VTable v-else density="comfortable" hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order #</th>
|
||||
<th>Customer</th>
|
||||
<th>Plan</th>
|
||||
<th class="text-end">
|
||||
Total
|
||||
</th>
|
||||
<th>Status</th>
|
||||
<th>Gateway</th>
|
||||
<th>Created</th>
|
||||
<th class="text-center">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="order in orders.data" :key="order.id">
|
||||
<td class="text-body-2 font-weight-medium">
|
||||
{{ order.order_number }}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="text-body-2 font-weight-medium">{{ order.user?.name ?? 'Unknown' }}</span>
|
||||
<span class="text-caption text-medium-emphasis">{{ order.user?.email ?? '' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-body-2">
|
||||
{{ order.plan?.name ?? 'N/A' }}
|
||||
</td>
|
||||
<td class="text-end text-body-2 font-weight-medium">
|
||||
{{ formatPrice(order.total) }}
|
||||
</td>
|
||||
<td>
|
||||
<VChip
|
||||
:color="resolveOrderStatusColor(order.status)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ order.status }}
|
||||
</VChip>
|
||||
</td>
|
||||
<td class="text-body-2 text-capitalize">
|
||||
{{ order.payment_gateway ?? '---' }}
|
||||
</td>
|
||||
<td class="text-body-2">
|
||||
{{ formatDate(order.created_at) }}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<Link :href="`/orders/${order.id}`">
|
||||
<VBtn variant="text" size="small" color="primary">
|
||||
<VIcon icon="tabler-eye" size="18" />
|
||||
</VBtn>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
|
||||
<!-- Pagination -->
|
||||
<VCardText v-if="orders.last_page > 1" class="d-flex align-center justify-center pt-2">
|
||||
<VPagination
|
||||
:model-value="orders.data.length > 0 ? Math.ceil((orders.from ?? 1) / 25) : 1"
|
||||
:length="orders.last_page"
|
||||
:total-visible="7"
|
||||
@update:model-value="(page: number) => router.get('/orders', { ...props.filters, page }, { preserveState: true, preserveScroll: true })"
|
||||
/>
|
||||
</VCardText>
|
||||
|
||||
<VCardText v-if="orders.total > 0" class="text-center text-caption text-medium-emphasis">
|
||||
Showing {{ orders.from }} to {{ orders.to }} of {{ orders.total }} orders
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
520
website/resources/ts/Pages/Admin/Orders/Show.vue
Normal file
520
website/resources/ts/Pages/Admin/Orders/Show.vue
Normal file
@@ -0,0 +1,520 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link, useForm } from '@inertiajs/vue3'
|
||||
import { computed, ref } from 'vue'
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import AppTextarea from '@/Components/app-form-elements/AppTextarea.vue'
|
||||
import { formatPrice } from '@/utils/resolvers'
|
||||
import type { StatusColor } from '@/types'
|
||||
|
||||
interface OrderUser {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface OrderPlan {
|
||||
id: number
|
||||
name: string
|
||||
service_type: string
|
||||
price: string
|
||||
billing_cycle: string
|
||||
}
|
||||
|
||||
interface OrderInvoice {
|
||||
id: number
|
||||
number: string
|
||||
total: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface OrderService {
|
||||
id: number
|
||||
hostname: string | null
|
||||
status: string
|
||||
ipv4_address: string | null
|
||||
}
|
||||
|
||||
interface OrderDetail {
|
||||
id: number
|
||||
user_id: number
|
||||
plan_id: number
|
||||
invoice_id: number | null
|
||||
service_id: number | null
|
||||
order_number: string
|
||||
status: string
|
||||
total: string
|
||||
currency: string
|
||||
payment_gateway: string | null
|
||||
configuration: Record<string, string> | null
|
||||
admin_notes: string | null
|
||||
completed_at: string | null
|
||||
cancelled_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
user: OrderUser | null
|
||||
plan: OrderPlan | null
|
||||
invoice: OrderInvoice | null
|
||||
service: OrderService | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
order: OrderDetail
|
||||
}
|
||||
|
||||
defineOptions({ layout: AdminLayout })
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const confirmDialog = ref<boolean>(false)
|
||||
const confirmAction = ref<'process' | 'complete' | 'cancel'>('process')
|
||||
const confirmTitle = ref<string>('')
|
||||
const confirmMessage = ref<string>('')
|
||||
const confirmColor = ref<string>('info')
|
||||
|
||||
const processForm = useForm({})
|
||||
const completeForm = useForm({})
|
||||
const cancelForm = useForm({})
|
||||
|
||||
const notesForm = useForm({
|
||||
admin_notes: props.order.admin_notes ?? '',
|
||||
})
|
||||
|
||||
const isProcessing = computed<boolean>(() =>
|
||||
processForm.processing || completeForm.processing || cancelForm.processing,
|
||||
)
|
||||
|
||||
function openConfirmDialog(action: 'process' | 'complete' | 'cancel'): void {
|
||||
confirmAction.value = action
|
||||
|
||||
if (action === 'process') {
|
||||
confirmTitle.value = 'Process Order'
|
||||
confirmMessage.value = `Are you sure you want to mark order ${props.order.order_number} as processing?`
|
||||
confirmColor.value = 'info'
|
||||
}
|
||||
else if (action === 'complete') {
|
||||
confirmTitle.value = 'Complete Order'
|
||||
confirmMessage.value = `Are you sure you want to mark order ${props.order.order_number} as completed?`
|
||||
confirmColor.value = 'success'
|
||||
}
|
||||
else {
|
||||
confirmTitle.value = 'Cancel Order'
|
||||
confirmMessage.value = `Are you sure you want to cancel order ${props.order.order_number}? This action cannot be undone.`
|
||||
confirmColor.value = 'error'
|
||||
}
|
||||
|
||||
confirmDialog.value = true
|
||||
}
|
||||
|
||||
function executeAction(): void {
|
||||
const action = confirmAction.value
|
||||
|
||||
if (action === 'process') {
|
||||
processForm.post(`/orders/${props.order.id}/process`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { confirmDialog.value = false },
|
||||
})
|
||||
}
|
||||
else if (action === 'complete') {
|
||||
completeForm.post(`/orders/${props.order.id}/complete`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { confirmDialog.value = false },
|
||||
})
|
||||
}
|
||||
else {
|
||||
cancelForm.post(`/orders/${props.order.id}/cancel`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { confirmDialog.value = false },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function saveNotes(): void {
|
||||
notesForm.put(`/orders/${props.order.id}/notes`, {
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
|
||||
function resolveOrderStatusColor(statusVal: string): StatusColor {
|
||||
const map: Record<string, StatusColor> = {
|
||||
pending: 'warning',
|
||||
processing: 'info',
|
||||
completed: 'success',
|
||||
cancelled: 'error',
|
||||
failed: 'error',
|
||||
}
|
||||
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 formatServiceType(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
vps: 'VPS',
|
||||
dedicated: 'Dedicated',
|
||||
web_hosting: 'Web Hosting',
|
||||
hosting: 'Web Hosting',
|
||||
game: 'Game Hosting',
|
||||
game_server: 'Game Hosting',
|
||||
}
|
||||
return map[type] ?? type
|
||||
}
|
||||
|
||||
function configurationEntries(): Array<{ key: string; value: string }> {
|
||||
if (!props.order.configuration) return []
|
||||
return Object.entries(props.order.configuration).map(([key, value]) => ({
|
||||
key: key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
|
||||
value: String(value),
|
||||
}))
|
||||
}
|
||||
</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="/orders">
|
||||
<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">Order {{ order.order_number }}</span>
|
||||
<VChip
|
||||
:color="resolveOrderStatusColor(order.status)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ order.status }}
|
||||
</VChip>
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">
|
||||
{{ order.user?.name ?? 'Unknown Customer' }} · {{ order.user?.email ?? '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<VBtn
|
||||
v-if="order.status === 'pending'"
|
||||
color="info"
|
||||
variant="tonal"
|
||||
:disabled="isProcessing"
|
||||
@click="openConfirmDialog('process')"
|
||||
>
|
||||
<VIcon icon="tabler-player-play" start />
|
||||
Process
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
v-if="order.status === 'processing'"
|
||||
color="success"
|
||||
variant="tonal"
|
||||
:disabled="isProcessing"
|
||||
@click="openConfirmDialog('complete')"
|
||||
>
|
||||
<VIcon icon="tabler-check" start />
|
||||
Complete
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
v-if="order.status === 'pending' || order.status === 'processing'"
|
||||
color="error"
|
||||
variant="tonal"
|
||||
:disabled="isProcessing"
|
||||
@click="openConfirmDialog('cancel')"
|
||||
>
|
||||
<VIcon icon="tabler-x" start />
|
||||
Cancel
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<!-- Order Details -->
|
||||
<VCol cols="12" lg="6">
|
||||
<VCard>
|
||||
<VCardTitle class="d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-shopping-cart" size="22" />
|
||||
<span>Order 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;">Order Number</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2 font-weight-medium">
|
||||
{{ order.order_number }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Total</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2 font-weight-medium">
|
||||
{{ formatPrice(order.total) }}
|
||||
<span class="text-uppercase text-medium-emphasis ms-1">{{ order.currency }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Gateway</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2 text-capitalize">
|
||||
{{ order.payment_gateway ?? 'N/A' }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Created</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2">
|
||||
{{ formatDateTime(order.created_at) }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem v-if="order.completed_at">
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Completed</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2 text-success">
|
||||
{{ formatDateTime(order.completed_at) }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem v-if="order.cancelled_at">
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Cancelled</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2 text-error">
|
||||
{{ formatDateTime(order.cancelled_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="order.user">
|
||||
<div class="d-flex align-center gap-3">
|
||||
<VAvatar color="primary" variant="tonal" size="40">
|
||||
<span class="text-body-1 font-weight-semibold">
|
||||
{{ order.user.name.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-body-1 font-weight-medium">
|
||||
{{ order.user.name }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
{{ order.user.email }}
|
||||
</div>
|
||||
</div>
|
||||
<VSpacer />
|
||||
<Link :href="`/customers/${order.user.id}`">
|
||||
<VBtn variant="tonal" size="small" color="primary">
|
||||
View Customer
|
||||
</VBtn>
|
||||
</Link>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Related Resources -->
|
||||
<VCard v-if="order.invoice || order.service" class="mt-4">
|
||||
<VCardTitle class="d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-link" size="22" />
|
||||
<span>Related Resources</span>
|
||||
</VCardTitle>
|
||||
|
||||
<VCardText>
|
||||
<VList density="compact" class="pa-0">
|
||||
<VListItem v-if="order.invoice">
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Invoice</span>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<Link :href="`/invoices/${order.invoice.id}`" class="text-body-2 font-weight-medium text-primary text-decoration-none">
|
||||
{{ order.invoice.number }}
|
||||
</Link>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem v-if="order.service">
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Service</span>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<Link :href="`/services/${order.service.id}`" class="text-body-2 font-weight-medium text-primary text-decoration-none">
|
||||
Service #{{ order.service.id }}
|
||||
</Link>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
<!-- Plan & Configuration -->
|
||||
<VCol cols="12" lg="6">
|
||||
<!-- Plan Info -->
|
||||
<VCard>
|
||||
<VCardTitle class="d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-package" size="22" />
|
||||
<span>Plan</span>
|
||||
</VCardTitle>
|
||||
|
||||
<VCardText v-if="order.plan">
|
||||
<VList density="compact" class="pa-0">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Name</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2 font-weight-medium">
|
||||
{{ order.plan.name }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Service Type</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2">
|
||||
{{ formatServiceType(order.plan.service_type) }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Price</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2 font-weight-medium">
|
||||
{{ formatPrice(order.plan.price, order.plan.billing_cycle) }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
|
||||
<VCardText v-else class="text-center py-6">
|
||||
<div class="text-medium-emphasis">
|
||||
Plan information unavailable.
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Configuration -->
|
||||
<VCard class="mt-4">
|
||||
<VCardTitle class="d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-settings" size="22" />
|
||||
<span>Configuration</span>
|
||||
</VCardTitle>
|
||||
|
||||
<VCardText v-if="configurationEntries().length > 0">
|
||||
<VList density="compact" class="pa-0">
|
||||
<VListItem v-for="entry in configurationEntries()" :key="entry.key">
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">{{ entry.key }}</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2">
|
||||
{{ entry.value }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
|
||||
<VCardText v-else class="text-center py-6">
|
||||
<VIcon icon="tabler-inbox" size="36" color="disabled" class="mb-2" />
|
||||
<div class="text-medium-emphasis">
|
||||
No configuration data.
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Admin Notes -->
|
||||
<VCard class="mt-4">
|
||||
<VCardTitle class="d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-notes" size="22" />
|
||||
<span>Admin Notes</span>
|
||||
</VCardTitle>
|
||||
|
||||
<VCardText>
|
||||
<AppTextarea
|
||||
v-model="notesForm.admin_notes"
|
||||
placeholder="Add internal notes about this order..."
|
||||
rows="4"
|
||||
:error-messages="notesForm.errors.admin_notes"
|
||||
counter="1000"
|
||||
maxlength="1000"
|
||||
/>
|
||||
|
||||
<div class="d-flex justify-end mt-3">
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
:loading="notesForm.processing"
|
||||
:disabled="notesForm.processing"
|
||||
@click="saveNotes"
|
||||
>
|
||||
<VIcon icon="tabler-device-floppy" start />
|
||||
Save Notes
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<!-- 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>
|
||||
@@ -83,6 +83,23 @@ const platformLabel = computed<string>(() => {
|
||||
Managed by {{ platformLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex ga-3">
|
||||
<Link
|
||||
v-if="service.status === 'active' && service.plan"
|
||||
:href="`/services/${service.id}/upgrade`"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-arrows-exchange"
|
||||
start
|
||||
/>
|
||||
Upgrade / Downgrade
|
||||
</VBtn>
|
||||
</Link>
|
||||
<VBtn
|
||||
v-if="controlPanelUrl && !isTerminated"
|
||||
:href="controlPanelUrl"
|
||||
@@ -96,6 +113,7 @@ const platformLabel = computed<string>(() => {
|
||||
Open Control Panel
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Suspended Notice -->
|
||||
<VAlert
|
||||
@@ -334,6 +352,24 @@ const platformLabel = computed<string>(() => {
|
||||
<VCardTitle>Quick Actions</VCardTitle>
|
||||
<VCardText>
|
||||
<div class="d-flex flex-column ga-3">
|
||||
<Link
|
||||
v-if="service.status === 'active' && service.plan"
|
||||
:href="`/services/${service.id}/upgrade`"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<VBtn
|
||||
block
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-arrows-exchange"
|
||||
start
|
||||
/>
|
||||
Upgrade / Downgrade
|
||||
</VBtn>
|
||||
</Link>
|
||||
|
||||
<VBtn
|
||||
v-if="controlPanelUrl"
|
||||
:href="controlPanelUrl"
|
||||
|
||||
468
website/resources/ts/Pages/Services/Upgrade.vue
Normal file
468
website/resources/ts/Pages/Services/Upgrade.vue
Normal file
@@ -0,0 +1,468 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { Link, useForm } from '@inertiajs/vue3'
|
||||
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||
import { formatPrice } from '@/utils/resolvers'
|
||||
import type { Plan } from '@/types'
|
||||
|
||||
interface AvailablePlan extends Plan {
|
||||
price_difference: number
|
||||
is_upgrade: boolean
|
||||
}
|
||||
|
||||
interface ServiceData {
|
||||
id: number
|
||||
hostname: string | null
|
||||
ipv4_address: string | null
|
||||
domain: string | null
|
||||
status: string
|
||||
service_type: string
|
||||
plan: Plan
|
||||
}
|
||||
|
||||
interface Props {
|
||||
service: ServiceData
|
||||
currentPlan: Plan
|
||||
availablePlans: AvailablePlan[]
|
||||
}
|
||||
|
||||
defineOptions({ layout: AccountLayout })
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const showConfirmDialog = ref<boolean>(false)
|
||||
const selectedPlan = ref<AvailablePlan | null>(null)
|
||||
|
||||
const form = useForm({
|
||||
plan_id: 0,
|
||||
})
|
||||
|
||||
const upgradePlans = computed<AvailablePlan[]>(() =>
|
||||
props.availablePlans.filter(plan => plan.is_upgrade),
|
||||
)
|
||||
|
||||
const downgradePlans = computed<AvailablePlan[]>(() =>
|
||||
props.availablePlans.filter(plan => !plan.is_upgrade),
|
||||
)
|
||||
|
||||
const serviceLabel = computed<string>(() =>
|
||||
props.service.hostname || props.service.domain || `Service #${props.service.id}`,
|
||||
)
|
||||
|
||||
const confirmActionLabel = computed<string>(() => {
|
||||
if (!selectedPlan.value) return ''
|
||||
return selectedPlan.value.is_upgrade ? 'Upgrade' : 'Downgrade'
|
||||
})
|
||||
|
||||
const confirmActionColor = computed<string>(() => {
|
||||
if (!selectedPlan.value) return 'primary'
|
||||
return selectedPlan.value.is_upgrade ? 'success' : 'warning'
|
||||
})
|
||||
|
||||
function formatPriceDifference(difference: number): string {
|
||||
const abs = Math.abs(difference).toFixed(2)
|
||||
if (difference > 0) return `+$${abs}`
|
||||
if (difference < 0) return `-$${abs}`
|
||||
return '$0.00'
|
||||
}
|
||||
|
||||
function openConfirmDialog(plan: AvailablePlan): void {
|
||||
selectedPlan.value = plan
|
||||
form.plan_id = plan.id
|
||||
showConfirmDialog.value = true
|
||||
}
|
||||
|
||||
function submitUpgrade(): void {
|
||||
form.post(`/services/${props.service.id}/upgrade`, {
|
||||
onSuccess: () => {
|
||||
showConfirmDialog.value = false
|
||||
selectedPlan.value = null
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<Link
|
||||
:href="`/services/${service.id}`"
|
||||
class="text-primary text-body-2 text-decoration-none"
|
||||
>
|
||||
← Back to Service
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="d-flex align-center justify-space-between mb-6">
|
||||
<div>
|
||||
<div class="text-h4 font-weight-bold">
|
||||
Upgrade / Downgrade
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">
|
||||
{{ serviceLabel }} — Currently on <strong>{{ currentPlan.name }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Plan -->
|
||||
<VCard class="mb-6">
|
||||
<VCardTitle class="d-flex align-center ga-2">
|
||||
<VIcon
|
||||
icon="tabler-star"
|
||||
color="primary"
|
||||
size="20"
|
||||
/>
|
||||
Current Plan
|
||||
</VCardTitle>
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="4"
|
||||
>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Plan
|
||||
</div>
|
||||
<div class="text-h6 font-weight-bold mt-1">
|
||||
{{ currentPlan.name }}
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="4"
|
||||
>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Price
|
||||
</div>
|
||||
<div class="text-h6 font-weight-bold mt-1">
|
||||
{{ formatPrice(currentPlan.price, currentPlan.billing_cycle) }}
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="4"
|
||||
>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Service Type
|
||||
</div>
|
||||
<div class="text-body-1 text-capitalize mt-1">
|
||||
{{ currentPlan.service_type }}
|
||||
</div>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<div
|
||||
v-if="currentPlan.features && Object.keys(currentPlan.features).length > 0"
|
||||
class="mt-4"
|
||||
>
|
||||
<div class="text-body-2 text-medium-emphasis mb-2">
|
||||
Features
|
||||
</div>
|
||||
<VList density="compact">
|
||||
<VListItem
|
||||
v-for="(value, key) in currentPlan.features"
|
||||
:key="String(key)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
icon="tabler-check"
|
||||
color="success"
|
||||
size="18"
|
||||
/>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2">
|
||||
<span class="font-weight-medium text-capitalize">{{ String(key).replace(/_/g, ' ') }}:</span>
|
||||
{{ value }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- No Plans Available -->
|
||||
<VCard v-if="availablePlans.length === 0">
|
||||
<VCardText class="text-center py-12">
|
||||
<VIcon
|
||||
icon="tabler-arrows-exchange"
|
||||
size="48"
|
||||
class="text-medium-emphasis mb-4"
|
||||
/>
|
||||
<div class="text-h6 text-medium-emphasis mb-2">
|
||||
No alternative plans available
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
There are no other plans available for this service type.
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Upgrade Plans -->
|
||||
<div v-if="upgradePlans.length > 0">
|
||||
<div class="text-h5 font-weight-bold mb-4 d-flex align-center ga-2">
|
||||
<VIcon
|
||||
icon="tabler-arrow-up"
|
||||
color="success"
|
||||
size="24"
|
||||
/>
|
||||
Upgrade Options
|
||||
</div>
|
||||
<VRow class="mb-6">
|
||||
<VCol
|
||||
v-for="plan in upgradePlans"
|
||||
:key="plan.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
lg="4"
|
||||
>
|
||||
<VCard
|
||||
class="h-100"
|
||||
border
|
||||
>
|
||||
<VCardText>
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<div class="text-h6 font-weight-bold">
|
||||
{{ plan.name }}
|
||||
</div>
|
||||
<VChip
|
||||
color="success"
|
||||
size="small"
|
||||
>
|
||||
{{ formatPriceDifference(plan.price_difference) }}/{{ currentPlan.billing_cycle }}
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="text-h5 font-weight-bold mb-4">
|
||||
{{ formatPrice(plan.price, plan.billing_cycle) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="plan.description"
|
||||
class="text-body-2 text-medium-emphasis mb-4"
|
||||
>
|
||||
{{ plan.description }}
|
||||
</div>
|
||||
|
||||
<!-- Feature Comparison -->
|
||||
<div
|
||||
v-if="plan.features && Object.keys(plan.features).length > 0"
|
||||
class="mb-4"
|
||||
>
|
||||
<VList density="compact">
|
||||
<VListItem
|
||||
v-for="(value, key) in plan.features"
|
||||
:key="String(key)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
icon="tabler-check"
|
||||
color="success"
|
||||
size="16"
|
||||
/>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2">
|
||||
<span class="font-weight-medium text-capitalize">{{ String(key).replace(/_/g, ' ') }}:</span>
|
||||
{{ value }}
|
||||
<template v-if="currentPlan.features && currentPlan.features[String(key)] && currentPlan.features[String(key)] !== value">
|
||||
<span class="text-medium-emphasis text-caption ml-1">(currently: {{ currentPlan.features[String(key)] }})</span>
|
||||
</template>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
color="success"
|
||||
block
|
||||
@click="openConfirmDialog(plan)"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-arrow-up"
|
||||
start
|
||||
/>
|
||||
Upgrade
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
|
||||
<!-- Downgrade Plans -->
|
||||
<div v-if="downgradePlans.length > 0">
|
||||
<div class="text-h5 font-weight-bold mb-4 d-flex align-center ga-2">
|
||||
<VIcon
|
||||
icon="tabler-arrow-down"
|
||||
color="warning"
|
||||
size="24"
|
||||
/>
|
||||
Downgrade Options
|
||||
</div>
|
||||
<VRow class="mb-6">
|
||||
<VCol
|
||||
v-for="plan in downgradePlans"
|
||||
:key="plan.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
lg="4"
|
||||
>
|
||||
<VCard
|
||||
class="h-100"
|
||||
border
|
||||
>
|
||||
<VCardText>
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<div class="text-h6 font-weight-bold">
|
||||
{{ plan.name }}
|
||||
</div>
|
||||
<VChip
|
||||
color="warning"
|
||||
size="small"
|
||||
>
|
||||
{{ formatPriceDifference(plan.price_difference) }}/{{ currentPlan.billing_cycle }}
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="text-h5 font-weight-bold mb-4">
|
||||
{{ formatPrice(plan.price, plan.billing_cycle) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="plan.description"
|
||||
class="text-body-2 text-medium-emphasis mb-4"
|
||||
>
|
||||
{{ plan.description }}
|
||||
</div>
|
||||
|
||||
<!-- Feature Comparison -->
|
||||
<div
|
||||
v-if="plan.features && Object.keys(plan.features).length > 0"
|
||||
class="mb-4"
|
||||
>
|
||||
<VList density="compact">
|
||||
<VListItem
|
||||
v-for="(value, key) in plan.features"
|
||||
:key="String(key)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
icon="tabler-check"
|
||||
:color="currentPlan.features && currentPlan.features[String(key)] && currentPlan.features[String(key)] !== value ? 'warning' : 'success'"
|
||||
size="16"
|
||||
/>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2">
|
||||
<span class="font-weight-medium text-capitalize">{{ String(key).replace(/_/g, ' ') }}:</span>
|
||||
{{ value }}
|
||||
<template v-if="currentPlan.features && currentPlan.features[String(key)] && currentPlan.features[String(key)] !== value">
|
||||
<span class="text-medium-emphasis text-caption ml-1">(currently: {{ currentPlan.features[String(key)] }})</span>
|
||||
</template>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
color="warning"
|
||||
block
|
||||
@click="openConfirmDialog(plan)"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-arrow-down"
|
||||
start
|
||||
/>
|
||||
Downgrade
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<VDialog
|
||||
v-model="showConfirmDialog"
|
||||
max-width="520"
|
||||
>
|
||||
<VCard v-if="selectedPlan">
|
||||
<VCardTitle class="text-h5 pa-6 pb-2">
|
||||
Confirm {{ confirmActionLabel }}
|
||||
</VCardTitle>
|
||||
<VCardText class="pa-6 pt-2">
|
||||
<p class="text-body-1 mb-4">
|
||||
Are you sure you want to {{ confirmActionLabel.toLowerCase() }} your service from
|
||||
<strong>{{ currentPlan.name }}</strong> to <strong>{{ selectedPlan.name }}</strong>?
|
||||
</p>
|
||||
|
||||
<VCard
|
||||
variant="tonal"
|
||||
:color="confirmActionColor"
|
||||
class="mb-4"
|
||||
>
|
||||
<VCardText>
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Price Change
|
||||
</div>
|
||||
<div class="text-h6 font-weight-bold">
|
||||
{{ formatPriceDifference(selectedPlan.price_difference) }}/{{ currentPlan.billing_cycle }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
New Price
|
||||
</div>
|
||||
<div class="text-h6 font-weight-bold">
|
||||
{{ formatPrice(selectedPlan.price, selectedPlan.billing_cycle) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VAlert
|
||||
v-if="selectedPlan.is_upgrade"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-0"
|
||||
>
|
||||
An invoice for the price difference will be generated.
|
||||
</VAlert>
|
||||
<VAlert
|
||||
v-else
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-0"
|
||||
>
|
||||
A credit memo for the price difference will be applied to your account.
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
<VCardActions class="pa-6 pt-0">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
:disabled="form.processing"
|
||||
@click="showConfirmDialog = false"
|
||||
>
|
||||
Cancel
|
||||
</VBtn>
|
||||
<VBtn
|
||||
:color="confirmActionColor"
|
||||
:loading="form.processing"
|
||||
@click="submitUpgrade"
|
||||
>
|
||||
<VIcon
|
||||
:icon="selectedPlan.is_upgrade ? 'tabler-arrow-up' : 'tabler-arrow-down'"
|
||||
start
|
||||
/>
|
||||
Confirm {{ confirmActionLabel }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,6 +5,7 @@ export const adminNavItems: NavItem[] = [
|
||||
{ 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: 'Orders', href: '/orders', icon: 'tabler-shopping-cart', matchPrefix: '/orders' },
|
||||
{ title: 'Invoices', href: '/invoices', icon: 'tabler-file-invoice', matchPrefix: '/invoices' },
|
||||
{ title: 'Coupons', href: '/coupons', icon: 'tabler-discount-2', matchPrefix: '/coupons' },
|
||||
{ title: 'Audit Logs', href: '/audit-logs', icon: 'tabler-clipboard-list', matchPrefix: '/audit-logs' },
|
||||
|
||||
@@ -31,6 +31,17 @@ export function resolveTransactionStatusColor(status: string): StatusColor {
|
||||
return map[status] ?? 'secondary'
|
||||
}
|
||||
|
||||
export function resolveOrderStatusColor(status: string): StatusColor {
|
||||
const map: Record<string, StatusColor> = {
|
||||
pending: 'warning',
|
||||
processing: 'info',
|
||||
completed: 'success',
|
||||
cancelled: 'error',
|
||||
failed: 'error',
|
||||
}
|
||||
return map[status] ?? 'secondary'
|
||||
}
|
||||
|
||||
export function resolveServiceStatusColor(status: string): StatusColor {
|
||||
const map: Record<string, StatusColor> = {
|
||||
active: 'success',
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Http\Controllers\Account\PlanController;
|
||||
use App\Http\Controllers\Account\ProfileController;
|
||||
use App\Http\Controllers\Account\ServiceController;
|
||||
use App\Http\Controllers\Account\SubscriptionController;
|
||||
use App\Http\Controllers\Account\UpgradeController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/dashboard', [DashboardController::class, 'index'])->name('account.dashboard');
|
||||
@@ -32,6 +33,8 @@ Route::post('/checkout/{plan}', [CheckoutController::class, 'store'])->name('acc
|
||||
|
||||
// Services
|
||||
Route::resource('services', ServiceController::class)->only(['index', 'show'])->names('account.services');
|
||||
Route::get('/services/{service}/upgrade', [UpgradeController::class, 'show'])->name('account.services.upgrade');
|
||||
Route::post('/services/{service}/upgrade', [UpgradeController::class, 'store'])->name('account.services.upgrade.store');
|
||||
|
||||
// Subscriptions
|
||||
Route::get('/subscriptions', [SubscriptionController::class, 'index'])->name('account.subscriptions.index');
|
||||
|
||||
@@ -6,7 +6,9 @@ use App\Http\Controllers\Admin\AuditLogController;
|
||||
use App\Http\Controllers\Admin\CouponController;
|
||||
use App\Http\Controllers\Admin\CustomerController;
|
||||
use App\Http\Controllers\Admin\DashboardController;
|
||||
use App\Http\Controllers\Admin\ImpersonationController;
|
||||
use App\Http\Controllers\Admin\InvoiceController;
|
||||
use App\Http\Controllers\Admin\OrderController;
|
||||
use App\Http\Controllers\Admin\PlanController;
|
||||
use App\Http\Controllers\Admin\ServiceController;
|
||||
use App\Http\Controllers\Admin\SettingsController;
|
||||
@@ -44,7 +46,17 @@ Route::resource('coupons', CouponController::class)->names([
|
||||
'destroy' => 'admin.coupons.destroy',
|
||||
])->except(['show']);
|
||||
|
||||
Route::resource('orders', OrderController::class)->only(['index', 'show']);
|
||||
Route::post('orders/{order}/process', [OrderController::class, 'process'])->name('orders.process');
|
||||
Route::post('orders/{order}/complete', [OrderController::class, 'complete'])->name('orders.complete');
|
||||
Route::post('orders/{order}/cancel', [OrderController::class, 'cancel'])->name('orders.cancel');
|
||||
Route::put('orders/{order}/notes', [OrderController::class, 'updateNotes'])->name('orders.notes');
|
||||
|
||||
Route::get('audit-logs', [AuditLogController::class, 'index'])->name('audit-logs.index');
|
||||
|
||||
Route::get('settings', [SettingsController::class, 'index'])->name('admin.settings.index');
|
||||
Route::put('settings', [SettingsController::class, 'update'])->name('admin.settings.update');
|
||||
|
||||
// Impersonation
|
||||
Route::post('impersonate/{user}', [ImpersonationController::class, 'start'])->name('impersonate.start');
|
||||
Route::post('impersonate/stop', [ImpersonationController::class, 'stop'])->name('impersonate.stop');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\Marketing\ContactController;
|
||||
use App\Models\Plan;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
@@ -25,6 +26,7 @@ Route::get('/pricing', function () {
|
||||
})->name('pricing');
|
||||
Route::get('/about', fn () => Inertia::render('Marketing/About'))->name('about');
|
||||
Route::get('/contact', fn () => Inertia::render('Marketing/Contact'))->name('contact');
|
||||
Route::post('/contact', [ContactController::class, 'store'])->name('contact.store');
|
||||
|
||||
// Legal pages
|
||||
Route::get('/terms-of-service', fn () => Inertia::render('Marketing/TermsOfService'))->name('terms');
|
||||
|
||||
Reference in New Issue
Block a user