Add coupon management, customer services, and update TASKS.md
Phase 5 (Admin Panel): - Admin coupon management: full CRUD with create/edit/deactivate, auto-generate codes, plan restrictions multi-select, usage tracking, redemption history on edit page - Add active flag migration for coupons table Phase 4 (Customer Dashboard): - Customer services list page with plan info and status - Customer service detail page with network info, plan details, control panel links (VirtFusion/SynergyCP/Enhance), important dates - Service status/type resolver utilities Documentation: - Update TASKS.md with all completed Phase 3/4/5/8 items Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
108
TASKS.md
108
TASKS.md
@@ -64,40 +64,40 @@
|
||||
- [x] All 53 tests passing, build clean
|
||||
|
||||
## Phase 3: Provisioning Automation
|
||||
- [ ] Create `ProvisioningServiceInterface` abstraction
|
||||
- [ ] Build VirtFusion provisioning service:
|
||||
- [ ] Create VPS via API
|
||||
- [ ] Suspend/unsuspend VPS
|
||||
- [ ] Terminate VPS
|
||||
- [ ] Get status and resource usage
|
||||
- [ ] Credential generation and secure storage
|
||||
- [x] Create `ProvisioningServiceInterface` abstraction
|
||||
- [x] Build VirtFusion provisioning service:
|
||||
- [x] Create VPS via API
|
||||
- [x] Suspend/unsuspend VPS
|
||||
- [x] Terminate VPS
|
||||
- [x] Get status and resource usage
|
||||
- [x] Credential generation and secure storage
|
||||
- [ ] Build Pterodactyl provisioning service:
|
||||
- [ ] Create game server via API
|
||||
- [ ] Suspend/unsuspend server
|
||||
- [ ] Delete server
|
||||
- [ ] Get server status and resources
|
||||
- [ ] Build SynergyCP provisioning service:
|
||||
- [ ] Provision dedicated server
|
||||
- [ ] Suspend/unsuspend server
|
||||
- [ ] Terminate server
|
||||
- [ ] Get server details
|
||||
- [ ] Handle limited hardware inventory (waitlist/semi-auto)
|
||||
- [ ] Build Enhance provisioning service:
|
||||
- [ ] Create web hosting account
|
||||
- [ ] Suspend/delete account
|
||||
- [ ] Get account status
|
||||
- [ ] Implement event-driven provisioning (listen to `PaymentSucceeded` events)
|
||||
- [x] Build SynergyCP provisioning service:
|
||||
- [x] Provision dedicated server
|
||||
- [x] Suspend/unsuspend server
|
||||
- [x] Terminate server
|
||||
- [x] Get server details
|
||||
- [x] Handle limited hardware inventory (waitlist/semi-auto)
|
||||
- [x] Build Enhance provisioning service:
|
||||
- [x] Create web hosting account
|
||||
- [x] Suspend/delete account
|
||||
- [x] Get account status
|
||||
- [x] Implement event-driven provisioning (listen to `PaymentSucceeded` events)
|
||||
- [ ] Build provisioning failure handling and retry logic
|
||||
- [ ] Send credentials email on successful provisioning
|
||||
- [ ] Log all provisioning actions to `provisioning_logs` table
|
||||
- [x] Log all provisioning actions to `provisioning_logs` table
|
||||
|
||||
## Phase 4: Customer Dashboard (account.ezscale.cloud)
|
||||
- [ ] Build service overview dashboard:
|
||||
- [ ] Active services list with status indicators
|
||||
- [x] Build service overview dashboard:
|
||||
- [x] Active services list with status indicators
|
||||
- [ ] Resource usage widgets (CPU, RAM, disk, bandwidth)
|
||||
- [ ] Next invoice and payment due date
|
||||
- [x] Next invoice and payment due date
|
||||
- [ ] Recent support tickets
|
||||
- [ ] Quick actions (renew, upgrade, create ticket)
|
||||
- [x] Quick actions (renew, upgrade, create ticket)
|
||||
- [ ] Build service detail pages:
|
||||
- [ ] VPS details (IP, credentials, resource graphs, control buttons)
|
||||
- [ ] Game server details (connect info, resource usage, restart button)
|
||||
@@ -111,61 +111,61 @@
|
||||
- [ ] Upcoming renewals
|
||||
- [ ] Plan upgrade/downgrade flow (self-service with proration)
|
||||
- [ ] Subscription cancellation flow (with optional survey)
|
||||
- [ ] Profile and account settings:
|
||||
- [ ] Contact information
|
||||
- [ ] Billing/shipping addresses
|
||||
- [ ] Tax ID
|
||||
- [ ] Password change
|
||||
- [ ] 2FA setup (TOTP, passkeys)
|
||||
- [x] Profile and account settings:
|
||||
- [x] Contact information
|
||||
- [x] Billing/shipping addresses
|
||||
- [x] Tax ID
|
||||
- [x] Password change
|
||||
- [x] 2FA setup (TOTP, passkeys)
|
||||
- [ ] SupportPal integration:
|
||||
- [ ] SSO to SupportPal
|
||||
- [ ] View recent tickets widget
|
||||
- [ ] Create ticket button (opens SupportPal or API)
|
||||
|
||||
## Phase 5: Admin Panel (admin.ezscale.cloud)
|
||||
- [ ] Analytics dashboard:
|
||||
- [ ] MRR (Monthly Recurring Revenue) graph
|
||||
- [x] Analytics dashboard:
|
||||
- [x] MRR (Monthly Recurring Revenue) graph
|
||||
- [ ] ARR (Annual Recurring Revenue)
|
||||
- [ ] Churn rate calculation and graph
|
||||
- [ ] Customer growth chart
|
||||
- [ ] Revenue trends (daily, monthly, yearly)
|
||||
- [ ] Popular plans and conversion rates
|
||||
- [ ] Outstanding invoices total
|
||||
- [x] Popular plans and conversion rates
|
||||
- [x] Outstanding invoices total
|
||||
- [ ] Overdue accounts list
|
||||
- [ ] Customer management:
|
||||
- [ ] Customer list (searchable, filterable)
|
||||
- [ ] Customer detail view (profile, services, billing history, notes)
|
||||
- [x] Customer management:
|
||||
- [x] Customer list (searchable, filterable)
|
||||
- [x] Customer detail view (profile, services, billing history, notes)
|
||||
- [ ] Edit customer information
|
||||
- [ ] Impersonate customer (with audit logging)
|
||||
- [ ] Add admin notes to customer account
|
||||
- [ ] View customer audit log
|
||||
- [ ] Service management:
|
||||
- [ ] All services list (filter by type, status, platform)
|
||||
- [x] Service management:
|
||||
- [x] All services list (filter by type, status, platform)
|
||||
- [ ] Manually provision service
|
||||
- [ ] Suspend/unsuspend service
|
||||
- [ ] Terminate service
|
||||
- [x] Suspend/unsuspend service
|
||||
- [x] Terminate service
|
||||
- [ ] Modify service (change plan, extend expiry)
|
||||
- [ ] View provisioning logs
|
||||
- [x] View provisioning logs
|
||||
- [ ] Order management:
|
||||
- [ ] Pending orders list
|
||||
- [ ] Approve/reject orders (for semi-automated provisioning)
|
||||
- [ ] View order details
|
||||
- [ ] Invoice management:
|
||||
- [ ] All invoices list (filter by status, date, customer)
|
||||
- [x] Invoice management:
|
||||
- [x] All invoices list (filter by status, date, customer)
|
||||
- [ ] Create manual invoice
|
||||
- [ ] Edit invoice (before sending)
|
||||
- [ ] Void/refund invoice
|
||||
- [x] Void/refund invoice
|
||||
- [ ] Resend invoice email
|
||||
- [ ] Coupon management:
|
||||
- [ ] Create coupon (percentage, fixed, applies to plans)
|
||||
- [ ] Edit coupon details
|
||||
- [ ] View redemption history
|
||||
- [ ] Deactivate/delete coupon
|
||||
- [ ] Plan management:
|
||||
- [ ] Create new plan (set pricing, features, billing cycle)
|
||||
- [ ] Edit existing plan
|
||||
- [ ] Archive/hide plan
|
||||
- [ ] Set stock quantity (for limited dedicated servers)
|
||||
- [x] Plan management:
|
||||
- [x] Create new plan (set pricing, features, billing cycle)
|
||||
- [x] Edit existing plan
|
||||
- [x] Archive/hide plan
|
||||
- [x] Set stock quantity (for limited dedicated servers)
|
||||
- [ ] System configuration:
|
||||
- [ ] Email template editor
|
||||
- [ ] Tax rate configuration (by region)
|
||||
@@ -248,11 +248,11 @@
|
||||
- [ ] Tutorials
|
||||
- [ ] Troubleshooting
|
||||
- [ ] API documentation
|
||||
- [ ] Legal pages:
|
||||
- [ ] Terms of Service
|
||||
- [ ] Privacy Policy
|
||||
- [ ] Acceptable Use Policy
|
||||
- [ ] SLA (Service Level Agreement)
|
||||
- [x] Legal pages:
|
||||
- [x] Terms of Service
|
||||
- [x] Privacy Policy
|
||||
- [x] Acceptable Use Policy
|
||||
- [x] SLA (Service Level Agreement)
|
||||
- [ ] Signup flow:
|
||||
- [ ] Plan selection
|
||||
- [ ] Account creation
|
||||
|
||||
41
website/app/Http/Controllers/Account/ServiceController.php
Normal file
41
website/app/Http/Controllers/Account/ServiceController.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Account;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ServiceController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$services = $request->user()
|
||||
->services()
|
||||
->with('plan:id,name,service_type,price,billing_cycle')
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return Inertia::render('Services/Index', [
|
||||
'services' => $services,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, Service $service): Response
|
||||
{
|
||||
abort_unless($service->user_id === $request->user()->id, 403);
|
||||
|
||||
$service->load([
|
||||
'plan:id,name,slug,description,service_type,price,billing_cycle,features',
|
||||
'subscription',
|
||||
]);
|
||||
|
||||
return Inertia::render('Services/Show', [
|
||||
'service' => $service,
|
||||
]);
|
||||
}
|
||||
}
|
||||
102
website/app/Http/Controllers/Admin/CouponController.php
Normal file
102
website/app/Http/Controllers/Admin/CouponController.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreCouponRequest;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\Plan;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class CouponController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
$coupons = Coupon::query()
|
||||
->withCount('redemptions')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(25);
|
||||
|
||||
return Inertia::render('Admin/Coupons/Index', [
|
||||
'coupons' => $coupons,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
{
|
||||
$plans = Plan::query()
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'service_type']);
|
||||
|
||||
return Inertia::render('Admin/Coupons/Create', [
|
||||
'plans' => $plans,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreCouponRequest $request): RedirectResponse
|
||||
{
|
||||
Coupon::query()->create([
|
||||
'code' => strtoupper($request->validated('code')),
|
||||
'type' => $request->validated('type'),
|
||||
'value' => $request->validated('value'),
|
||||
'currency' => 'USD',
|
||||
'max_uses' => $request->validated('max_uses'),
|
||||
'applies_to' => $request->validated('applies_to'),
|
||||
'expires_at' => $request->validated('expires_at'),
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('admin.coupons.index')
|
||||
->with('success', 'Coupon created successfully.');
|
||||
}
|
||||
|
||||
public function edit(Coupon $coupon): Response
|
||||
{
|
||||
$plans = Plan::query()
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'service_type']);
|
||||
|
||||
$redemptions = $coupon->redemptions()
|
||||
->with('user:id,name,email')
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
return Inertia::render('Admin/Coupons/Edit', [
|
||||
'coupon' => $coupon,
|
||||
'plans' => $plans,
|
||||
'redemptions' => $redemptions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(StoreCouponRequest $request, Coupon $coupon): RedirectResponse
|
||||
{
|
||||
$coupon->update([
|
||||
'code' => strtoupper($request->validated('code')),
|
||||
'type' => $request->validated('type'),
|
||||
'value' => $request->validated('value'),
|
||||
'max_uses' => $request->validated('max_uses'),
|
||||
'applies_to' => $request->validated('applies_to'),
|
||||
'expires_at' => $request->validated('expires_at'),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('admin.coupons.index')
|
||||
->with('success', 'Coupon updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(Coupon $coupon): RedirectResponse
|
||||
{
|
||||
$coupon->update(['active' => false]);
|
||||
|
||||
return redirect()
|
||||
->route('admin.coupons.index')
|
||||
->with('success', 'Coupon deactivated successfully.');
|
||||
}
|
||||
}
|
||||
51
website/app/Http/Requests/StoreCouponRequest.php
Normal file
51
website/app/Http/Requests/StoreCouponRequest.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreCouponRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, array<int, mixed>> */
|
||||
public function rules(): array
|
||||
{
|
||||
$uniqueCodeRule = Rule::unique('coupons', 'code');
|
||||
|
||||
if ($this->route('coupon')) {
|
||||
$uniqueCodeRule->ignore($this->route('coupon'));
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => ['required', 'string', 'max:50', $uniqueCodeRule],
|
||||
'type' => ['required', Rule::in(['percentage', 'fixed'])],
|
||||
'value' => ['required', 'numeric', 'min:0.01'],
|
||||
'max_uses' => ['nullable', 'integer', 'min:1'],
|
||||
'expires_at' => ['nullable', 'date', 'after:now'],
|
||||
'applies_to' => ['nullable', 'array'],
|
||||
'applies_to.*' => ['integer', 'exists:plans,id'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'code.required' => 'Coupon code is required.',
|
||||
'code.unique' => 'This coupon code is already in use.',
|
||||
'type.required' => 'Discount type is required.',
|
||||
'type.in' => 'Discount type must be percentage or fixed.',
|
||||
'value.required' => 'Discount value is required.',
|
||||
'value.min' => 'Discount value must be at least 0.01.',
|
||||
'expires_at.after' => 'Expiry date must be in the future.',
|
||||
'applies_to.*.exists' => 'One or more selected plans do not exist.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ class Coupon extends Model
|
||||
'applies_to',
|
||||
'max_uses',
|
||||
'times_used',
|
||||
'active',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
@@ -30,6 +31,7 @@ class Coupon extends Model
|
||||
'applies_to' => 'array',
|
||||
'max_uses' => 'integer',
|
||||
'times_used' => 'integer',
|
||||
'active' => 'boolean',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
@@ -41,6 +43,10 @@ class Coupon extends Model
|
||||
|
||||
public function isValid(): bool
|
||||
{
|
||||
if (! $this->active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->expires_at && $this->expires_at->isPast()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ class CouponFactory extends Factory
|
||||
'value' => fake()->randomFloat(2, 5, 50),
|
||||
'max_uses' => fake()->optional()->numberBetween(10, 1000),
|
||||
'times_used' => 0,
|
||||
'active' => true,
|
||||
'expires_at' => fake()->optional()->dateTimeBetween('now', '+1 year'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?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::table('coupons', function (Blueprint $table): void {
|
||||
$table->boolean('active')->default(true)->after('times_used');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('coupons', function (Blueprint $table): void {
|
||||
$table->dropColumn('active');
|
||||
});
|
||||
}
|
||||
};
|
||||
210
website/resources/ts/Pages/Admin/Coupons/Create.vue
Normal file
210
website/resources/ts/Pages/Admin/Coupons/Create.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link, useForm } from '@inertiajs/vue3'
|
||||
import { computed } 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'
|
||||
|
||||
interface PlanOption {
|
||||
id: number
|
||||
name: string
|
||||
service_type: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
plans: PlanOption[]
|
||||
}
|
||||
|
||||
defineOptions({ layout: AdminLayout })
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const typeOptions = [
|
||||
{ title: 'Percentage', value: 'percentage' },
|
||||
{ title: 'Fixed Amount', value: 'fixed' },
|
||||
]
|
||||
|
||||
const planSelectItems = computed(() =>
|
||||
props.plans.map(plan => ({
|
||||
title: `${plan.name} (${plan.service_type})`,
|
||||
value: plan.id,
|
||||
})),
|
||||
)
|
||||
|
||||
const form = useForm({
|
||||
code: '',
|
||||
type: '' as string,
|
||||
value: '' as string | number,
|
||||
max_uses: null as number | null,
|
||||
expires_at: '' as string,
|
||||
applies_to: [] as number[],
|
||||
})
|
||||
|
||||
function generateCode(): void {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
let code = 'EZ-'
|
||||
for (let i = 0; i < 8; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
form.code = code
|
||||
}
|
||||
|
||||
const valueLabel = computed<string>(() => {
|
||||
if (form.type === 'percentage') {
|
||||
return 'Discount Percentage (%)'
|
||||
}
|
||||
return 'Discount Amount (USD)'
|
||||
})
|
||||
|
||||
const valuePlaceholder = computed<string>(() => {
|
||||
if (form.type === 'percentage') {
|
||||
return 'e.g. 15'
|
||||
}
|
||||
return 'e.g. 5.00'
|
||||
})
|
||||
|
||||
function submit(): void {
|
||||
form.post('/coupons', {
|
||||
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="/coupons" class="text-decoration-none">
|
||||
<VBtn icon="tabler-arrow-left" variant="text" size="small" />
|
||||
</Link>
|
||||
<span class="text-h4 font-weight-bold">Create Coupon</span>
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis ms-10">
|
||||
Add a new discount coupon
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<VRow>
|
||||
<!-- Coupon Details -->
|
||||
<VCol cols="12" lg="8">
|
||||
<VCard title="Coupon Details" class="mb-6">
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12" md="8">
|
||||
<AppTextField
|
||||
v-model="form.code"
|
||||
label="Coupon Code"
|
||||
placeholder="e.g. SAVE20"
|
||||
:error-messages="form.errors.code"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4" class="d-flex align-end">
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
prepend-icon="tabler-refresh"
|
||||
block
|
||||
@click="generateCode"
|
||||
>
|
||||
Auto-Generate
|
||||
</VBtn>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<AppSelect
|
||||
v-model="form.type"
|
||||
label="Discount Type"
|
||||
:items="typeOptions"
|
||||
placeholder="Select type"
|
||||
:error-messages="form.errors.type"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<AppTextField
|
||||
v-model="form.value"
|
||||
:label="valueLabel"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
:placeholder="valuePlaceholder"
|
||||
:error-messages="form.errors.value"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Plan Restrictions -->
|
||||
<VCard title="Plan Restrictions" class="mb-6">
|
||||
<VCardText>
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||
Optionally restrict this coupon to specific plans. Leave empty to apply to all plans.
|
||||
</p>
|
||||
<AppSelect
|
||||
v-model="form.applies_to"
|
||||
label="Applicable Plans"
|
||||
:items="planSelectItems"
|
||||
placeholder="All Plans (no restriction)"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
:error-messages="form.errors.applies_to"
|
||||
/>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<VCol cols="12" lg="4">
|
||||
<VCard title="Limits & Expiry" class="mb-6">
|
||||
<VCardText>
|
||||
<AppTextField
|
||||
v-model="form.max_uses"
|
||||
label="Max Uses"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Unlimited"
|
||||
:error-messages="form.errors.max_uses"
|
||||
class="mb-4"
|
||||
/>
|
||||
<AppTextField
|
||||
v-model="form.expires_at"
|
||||
label="Expiry Date"
|
||||
type="datetime-local"
|
||||
:error-messages="form.errors.expires_at"
|
||||
/>
|
||||
</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 Coupon
|
||||
</VBtn>
|
||||
<Link href="/coupons" class="text-decoration-none">
|
||||
<VBtn
|
||||
variant="outlined"
|
||||
block
|
||||
>
|
||||
Cancel
|
||||
</VBtn>
|
||||
</Link>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
300
website/resources/ts/Pages/Admin/Coupons/Edit.vue
Normal file
300
website/resources/ts/Pages/Admin/Coupons/Edit.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link, useForm } from '@inertiajs/vue3'
|
||||
import { computed } 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 type { Coupon, CouponRedemption, StatusColor } from '@/types'
|
||||
|
||||
interface PlanOption {
|
||||
id: number
|
||||
name: string
|
||||
service_type: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
coupon: Coupon
|
||||
plans: PlanOption[]
|
||||
redemptions: CouponRedemption[]
|
||||
}
|
||||
|
||||
defineOptions({ layout: AdminLayout })
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const typeOptions = [
|
||||
{ title: 'Percentage', value: 'percentage' },
|
||||
{ title: 'Fixed Amount', value: 'fixed' },
|
||||
]
|
||||
|
||||
const planSelectItems = computed(() =>
|
||||
props.plans.map(plan => ({
|
||||
title: `${plan.name} (${plan.service_type})`,
|
||||
value: plan.id,
|
||||
})),
|
||||
)
|
||||
|
||||
function formatExpiresAt(dateStr: string | null): string {
|
||||
if (!dateStr) {
|
||||
return ''
|
||||
}
|
||||
const date = new Date(dateStr)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||
}
|
||||
|
||||
const form = useForm({
|
||||
code: props.coupon.code,
|
||||
type: props.coupon.type,
|
||||
value: props.coupon.value,
|
||||
max_uses: props.coupon.max_uses,
|
||||
expires_at: formatExpiresAt(props.coupon.expires_at),
|
||||
applies_to: props.coupon.applies_to ?? [],
|
||||
})
|
||||
|
||||
const valueLabel = computed<string>(() => {
|
||||
if (form.type === 'percentage') {
|
||||
return 'Discount Percentage (%)'
|
||||
}
|
||||
return 'Discount Amount (USD)'
|
||||
})
|
||||
|
||||
const valuePlaceholder = computed<string>(() => {
|
||||
if (form.type === 'percentage') {
|
||||
return 'e.g. 15'
|
||||
}
|
||||
return 'e.g. 5.00'
|
||||
})
|
||||
|
||||
function resolveCouponStatus(): { label: string; color: StatusColor } {
|
||||
if (!props.coupon.active) {
|
||||
return { label: 'Inactive', color: 'error' }
|
||||
}
|
||||
if (props.coupon.expires_at && new Date(props.coupon.expires_at) < new Date()) {
|
||||
return { label: 'Expired', color: 'secondary' }
|
||||
}
|
||||
if (props.coupon.max_uses !== null && props.coupon.times_used >= props.coupon.max_uses) {
|
||||
return { label: 'Exhausted', color: 'warning' }
|
||||
}
|
||||
return { label: 'Active', color: 'success' }
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const formattedCreatedAt = computed<string>(() => formatDate(props.coupon.created_at))
|
||||
|
||||
const redemptionHeaders = computed(() => [
|
||||
{ title: 'Customer', key: 'user', sortable: false },
|
||||
{ title: 'Discount', key: 'discount_amount', sortable: true, align: 'end' as const },
|
||||
{ title: 'Redeemed', key: 'created_at', sortable: true },
|
||||
])
|
||||
|
||||
function submit(): void {
|
||||
form.put(`/coupons/${props.coupon.id}`, {
|
||||
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="/coupons" class="text-decoration-none">
|
||||
<VBtn icon="tabler-arrow-left" variant="text" size="small" />
|
||||
</Link>
|
||||
<span class="text-h4 font-weight-bold">Edit Coupon</span>
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis ms-10">
|
||||
Update coupon "{{ coupon.code }}"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<VRow>
|
||||
<!-- Coupon Details -->
|
||||
<VCol cols="12" lg="8">
|
||||
<VCard title="Coupon Details" class="mb-6">
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<AppTextField
|
||||
v-model="form.code"
|
||||
label="Coupon Code"
|
||||
placeholder="e.g. SAVE20"
|
||||
:error-messages="form.errors.code"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<AppSelect
|
||||
v-model="form.type"
|
||||
label="Discount Type"
|
||||
:items="typeOptions"
|
||||
placeholder="Select type"
|
||||
:error-messages="form.errors.type"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<AppTextField
|
||||
v-model="form.value"
|
||||
:label="valueLabel"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
:placeholder="valuePlaceholder"
|
||||
:error-messages="form.errors.value"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Plan Restrictions -->
|
||||
<VCard title="Plan Restrictions" class="mb-6">
|
||||
<VCardText>
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||
Optionally restrict this coupon to specific plans. Leave empty to apply to all plans.
|
||||
</p>
|
||||
<AppSelect
|
||||
v-model="form.applies_to"
|
||||
label="Applicable Plans"
|
||||
:items="planSelectItems"
|
||||
placeholder="All Plans (no restriction)"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
:error-messages="form.errors.applies_to"
|
||||
/>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Redemption History -->
|
||||
<VCard title="Redemption History" class="mb-6">
|
||||
<VDataTable
|
||||
:headers="redemptionHeaders"
|
||||
:items="redemptions"
|
||||
:items-per-page="10"
|
||||
hover
|
||||
class="text-no-wrap"
|
||||
>
|
||||
<!-- Customer -->
|
||||
<template #item.user="{ item }">
|
||||
<div v-if="item.user" class="d-flex flex-column py-2">
|
||||
<span class="text-body-2 font-weight-medium">{{ item.user.name }}</span>
|
||||
<span class="text-caption text-medium-emphasis">{{ item.user.email }}</span>
|
||||
</div>
|
||||
<span v-else class="text-medium-emphasis">Unknown</span>
|
||||
</template>
|
||||
|
||||
<!-- Discount Amount -->
|
||||
<template #item.discount_amount="{ item }">
|
||||
<span class="font-weight-medium">${{ parseFloat(item.discount_amount).toFixed(2) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Created At -->
|
||||
<template #item.created_at="{ item }">
|
||||
{{ formatDate(item.created_at) }}
|
||||
</template>
|
||||
|
||||
<!-- No data -->
|
||||
<template #no-data>
|
||||
<div class="text-center py-8">
|
||||
<VIcon icon="tabler-receipt-off" size="40" color="disabled" class="mb-2" />
|
||||
<div class="text-medium-emphasis">
|
||||
No redemptions yet.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VDataTable>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<VCol cols="12" lg="4">
|
||||
<!-- Coupon Info -->
|
||||
<VCard title="Coupon 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="resolveCouponStatus().color"
|
||||
size="small"
|
||||
>
|
||||
{{ resolveCouponStatus().label }}
|
||||
</VChip>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between align-center mb-3">
|
||||
<span class="text-body-2 text-medium-emphasis">Times Used</span>
|
||||
<span class="text-body-2 font-weight-medium">{{ coupon.times_used }}</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>
|
||||
|
||||
<!-- Limits & Expiry -->
|
||||
<VCard title="Limits & Expiry" class="mb-6">
|
||||
<VCardText>
|
||||
<AppTextField
|
||||
v-model="form.max_uses"
|
||||
label="Max Uses"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Unlimited"
|
||||
:error-messages="form.errors.max_uses"
|
||||
class="mb-4"
|
||||
/>
|
||||
<AppTextField
|
||||
v-model="form.expires_at"
|
||||
label="Expiry Date"
|
||||
type="datetime-local"
|
||||
:error-messages="form.errors.expires_at"
|
||||
/>
|
||||
</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 Coupon
|
||||
</VBtn>
|
||||
<Link href="/coupons" class="text-decoration-none">
|
||||
<VBtn
|
||||
variant="outlined"
|
||||
block
|
||||
>
|
||||
Cancel
|
||||
</VBtn>
|
||||
</Link>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
210
website/resources/ts/Pages/Admin/Coupons/Index.vue
Normal file
210
website/resources/ts/Pages/Admin/Coupons/Index.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link, router } from '@inertiajs/vue3'
|
||||
import { computed } from 'vue'
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import type { Coupon, PaginatedResponse, Plan, StatusColor } from '@/types'
|
||||
|
||||
interface CouponWithCount extends Coupon {
|
||||
redemptions_count: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
coupons: PaginatedResponse<CouponWithCount>
|
||||
}
|
||||
|
||||
defineOptions({ layout: AdminLayout })
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const tableHeaders = computed(() => [
|
||||
{ title: 'Code', key: 'code', sortable: true },
|
||||
{ title: 'Type', key: 'type', sortable: true },
|
||||
{ title: 'Value', key: 'value', sortable: true, align: 'end' as const },
|
||||
{ title: 'Plans', key: 'applies_to', sortable: false },
|
||||
{ title: 'Usage', key: 'usage', sortable: false, align: 'center' as const },
|
||||
{ title: 'Expires', key: 'expires_at', sortable: true },
|
||||
{ title: 'Status', key: 'status', sortable: false, align: 'center' as const },
|
||||
{ title: 'Actions', key: 'actions', sortable: false, align: 'center' as const },
|
||||
])
|
||||
|
||||
function resolveTypeColor(type: string): StatusColor {
|
||||
return type === 'percentage' ? 'info' : 'warning'
|
||||
}
|
||||
|
||||
function formatValue(coupon: CouponWithCount): string {
|
||||
if (coupon.type === 'percentage') {
|
||||
return `${parseFloat(coupon.value)}%`
|
||||
}
|
||||
return `$${parseFloat(coupon.value).toFixed(2)}`
|
||||
}
|
||||
|
||||
function formatDate(dateString: string | null): string {
|
||||
if (!dateString) {
|
||||
return 'Never'
|
||||
}
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function formatPlansApplicable(appliesTo: number[] | null): string {
|
||||
if (!appliesTo || appliesTo.length === 0) {
|
||||
return 'All Plans'
|
||||
}
|
||||
return `${appliesTo.length} plan${appliesTo.length > 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
function resolveCouponStatus(coupon: CouponWithCount): { label: string; color: StatusColor } {
|
||||
if (!coupon.active) {
|
||||
return { label: 'Inactive', color: 'error' }
|
||||
}
|
||||
if (coupon.expires_at && new Date(coupon.expires_at) < new Date()) {
|
||||
return { label: 'Expired', color: 'secondary' }
|
||||
}
|
||||
if (coupon.max_uses !== null && coupon.times_used >= coupon.max_uses) {
|
||||
return { label: 'Exhausted', color: 'warning' }
|
||||
}
|
||||
return { label: 'Active', color: 'success' }
|
||||
}
|
||||
|
||||
function deactivateCoupon(coupon: CouponWithCount): void {
|
||||
if (confirm(`Are you sure you want to deactivate coupon "${coupon.code}"?`)) {
|
||||
router.delete(`/coupons/${coupon.id}`, {
|
||||
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">
|
||||
Coupons
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Manage discount coupons and promotions
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/coupons/create">
|
||||
<VBtn color="primary" prepend-icon="tabler-plus">
|
||||
Create Coupon
|
||||
</VBtn>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<!-- Coupons Table -->
|
||||
<VCard>
|
||||
<VDataTable
|
||||
:headers="tableHeaders"
|
||||
:items="coupons.data"
|
||||
:items-per-page="25"
|
||||
hover
|
||||
class="text-no-wrap"
|
||||
>
|
||||
<!-- Code -->
|
||||
<template #item.code="{ item }">
|
||||
<span class="font-weight-medium font-monospace">{{ item.code }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Type -->
|
||||
<template #item.type="{ item }">
|
||||
<VChip
|
||||
:color="resolveTypeColor(item.type)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ item.type }}
|
||||
</VChip>
|
||||
</template>
|
||||
|
||||
<!-- Value -->
|
||||
<template #item.value="{ item }">
|
||||
<span class="font-weight-medium">{{ formatValue(item) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Plans Applicable -->
|
||||
<template #item.applies_to="{ item }">
|
||||
<VChip
|
||||
size="small"
|
||||
variant="tonal"
|
||||
:color="!item.applies_to || item.applies_to.length === 0 ? 'success' : 'secondary'"
|
||||
>
|
||||
{{ formatPlansApplicable(item.applies_to) }}
|
||||
</VChip>
|
||||
</template>
|
||||
|
||||
<!-- Usage -->
|
||||
<template #item.usage="{ item }">
|
||||
<span class="font-weight-medium">
|
||||
{{ item.redemptions_count }}
|
||||
</span>
|
||||
<span class="text-medium-emphasis">
|
||||
/ {{ item.max_uses ?? '∞' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Expires -->
|
||||
<template #item.expires_at="{ item }">
|
||||
<span :class="{ 'text-error': item.expires_at && new Date(item.expires_at) < new Date() }">
|
||||
{{ formatDate(item.expires_at) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Status -->
|
||||
<template #item.status="{ item }">
|
||||
<VChip
|
||||
:color="resolveCouponStatus(item).color"
|
||||
size="small"
|
||||
>
|
||||
{{ resolveCouponStatus(item).label }}
|
||||
</VChip>
|
||||
</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="`/coupons/${item.id}/edit`" class="text-decoration-none">
|
||||
<VListItem prepend-icon="tabler-edit">
|
||||
<VListItemTitle>Edit</VListItemTitle>
|
||||
</VListItem>
|
||||
</Link>
|
||||
<VListItem
|
||||
v-if="item.active"
|
||||
prepend-icon="tabler-ban"
|
||||
@click="deactivateCoupon(item)"
|
||||
>
|
||||
<VListItemTitle>Deactivate</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</template>
|
||||
|
||||
<!-- No data -->
|
||||
<template #no-data>
|
||||
<div class="text-center py-8">
|
||||
<VIcon icon="tabler-discount-2" size="48" color="disabled" class="mb-2" />
|
||||
<div class="text-medium-emphasis">
|
||||
No coupons found.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VDataTable>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
150
website/resources/ts/Pages/Services/Index.vue
Normal file
150
website/resources/ts/Pages/Services/Index.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||
import { resolveServiceStatusColor, resolveServiceTypeColor, formatPrice } from '@/utils/resolvers'
|
||||
import type { Service } from '@/types'
|
||||
|
||||
interface Props {
|
||||
services: Service[]
|
||||
}
|
||||
|
||||
defineOptions({ layout: AccountLayout })
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '--'
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between mb-6">
|
||||
<div class="text-h4 font-weight-bold">
|
||||
Services
|
||||
</div>
|
||||
<Link
|
||||
href="/plans"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<VBtn>
|
||||
<VIcon
|
||||
icon="tabler-plus"
|
||||
start
|
||||
/>
|
||||
Order New Service
|
||||
</VBtn>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<VCard v-if="services.length === 0">
|
||||
<VCardText class="text-center py-12">
|
||||
<VIcon
|
||||
icon="tabler-server-off"
|
||||
size="48"
|
||||
class="text-medium-emphasis mb-4"
|
||||
/>
|
||||
<div class="text-h6 text-medium-emphasis mb-2">
|
||||
No services yet
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis mb-4">
|
||||
You don't have any services. Browse our plans to get started.
|
||||
</div>
|
||||
<Link
|
||||
href="/plans"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<VBtn>Browse Plans</VBtn>
|
||||
</Link>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VCard v-else>
|
||||
<VTable hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Service</th>
|
||||
<th>Plan</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>IP Address</th>
|
||||
<th>Renewal Date</th>
|
||||
<th class="text-end">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="service in services"
|
||||
:key="service.id"
|
||||
>
|
||||
<td>
|
||||
<div class="font-weight-medium">
|
||||
{{ service.hostname || service.domain || `Service #${service.id}` }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
{{ service.platform }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ service.plan?.name || '--' }}
|
||||
<div
|
||||
v-if="service.plan"
|
||||
class="text-body-2 text-medium-emphasis"
|
||||
>
|
||||
{{ formatPrice(service.plan.price, service.plan.billing_cycle) }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<VChip
|
||||
:color="resolveServiceTypeColor(service.service_type)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ service.service_type }}
|
||||
</VChip>
|
||||
</td>
|
||||
<td>
|
||||
<VChip
|
||||
:color="resolveServiceStatusColor(service.status)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ service.status }}
|
||||
</VChip>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="service.ipv4_address">{{ service.ipv4_address }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-medium-emphasis"
|
||||
>--</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ service.subscription?.current_period_end ? formatDate(service.subscription.current_period_end) : formatDate(null) }}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<Link
|
||||
:href="`/services/${service.id}`"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
size="small"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-eye"
|
||||
start
|
||||
/>
|
||||
Manage
|
||||
</VBtn>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
390
website/resources/ts/Pages/Services/Show.vue
Normal file
390
website/resources/ts/Pages/Services/Show.vue
Normal file
@@ -0,0 +1,390 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||
import {
|
||||
resolveServiceStatusColor,
|
||||
resolveServiceTypeColor,
|
||||
resolvePlatformUrl,
|
||||
formatPrice,
|
||||
} from '@/utils/resolvers'
|
||||
import type { Service } from '@/types'
|
||||
|
||||
interface Props {
|
||||
service: Service
|
||||
}
|
||||
|
||||
defineOptions({ layout: AccountLayout })
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '--'
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string | null): string {
|
||||
if (!dateStr) return '--'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
const controlPanelUrl = computed<string | null>(() => {
|
||||
return resolvePlatformUrl(props.service.platform, props.service.platform_service_id)
|
||||
})
|
||||
|
||||
const isSuspended = computed<boolean>(() => props.service.status === 'suspended')
|
||||
const isTerminated = computed<boolean>(() => props.service.status === 'terminated')
|
||||
|
||||
const platformLabel = computed<string>(() => {
|
||||
const labels: Record<string, string> = {
|
||||
virtfusion: 'VirtFusion',
|
||||
synergycp: 'SynergyCP',
|
||||
enhance: 'Enhance',
|
||||
pterodactyl: 'Pterodactyl',
|
||||
}
|
||||
return labels[props.service.platform] ?? props.service.platform
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<Link
|
||||
href="/services"
|
||||
class="text-primary text-body-2 text-decoration-none"
|
||||
>
|
||||
← Back to Services
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<!-- Service Header -->
|
||||
<div class="d-flex align-center justify-space-between mb-6">
|
||||
<div>
|
||||
<div class="d-flex align-center ga-3">
|
||||
<div class="text-h4 font-weight-bold">
|
||||
{{ service.hostname || service.domain || `Service #${service.id}` }}
|
||||
</div>
|
||||
<VChip
|
||||
:color="resolveServiceStatusColor(service.status)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ service.status }}
|
||||
</VChip>
|
||||
<VChip
|
||||
:color="resolveServiceTypeColor(service.service_type)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ service.service_type }}
|
||||
</VChip>
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">
|
||||
Managed by {{ platformLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<VBtn
|
||||
v-if="controlPanelUrl && !isTerminated"
|
||||
:href="controlPanelUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-external-link"
|
||||
start
|
||||
/>
|
||||
Open Control Panel
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<!-- Suspended Notice -->
|
||||
<VAlert
|
||||
v-if="isSuspended"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="mb-6"
|
||||
>
|
||||
<VAlertTitle>Service Suspended</VAlertTitle>
|
||||
This service was suspended on {{ formatDateTime(service.suspended_at) }}.
|
||||
Please contact support or check your billing status to resolve this issue.
|
||||
</VAlert>
|
||||
|
||||
<!-- Terminated Notice -->
|
||||
<VAlert
|
||||
v-if="isTerminated"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
class="mb-6"
|
||||
>
|
||||
<VAlertTitle>Service Terminated</VAlertTitle>
|
||||
This service was terminated on {{ formatDateTime(service.terminated_at) }}.
|
||||
Data may no longer be recoverable.
|
||||
</VAlert>
|
||||
|
||||
<VRow>
|
||||
<!-- Service Details -->
|
||||
<VCol
|
||||
cols="12"
|
||||
lg="8"
|
||||
>
|
||||
<!-- Plan & Pricing -->
|
||||
<VCard class="mb-6">
|
||||
<VCardTitle>Plan Details</VCardTitle>
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="6">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Plan
|
||||
</div>
|
||||
<div class="text-body-1 font-weight-medium mt-1">
|
||||
{{ service.plan?.name || '--' }}
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Price
|
||||
</div>
|
||||
<div class="text-body-1 mt-1">
|
||||
{{ service.plan ? formatPrice(service.plan.price, service.plan.billing_cycle) : '--' }}
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Service Type
|
||||
</div>
|
||||
<div class="text-body-1 text-capitalize mt-1">
|
||||
{{ service.service_type }}
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Platform
|
||||
</div>
|
||||
<div class="text-body-1 mt-1">
|
||||
{{ platformLabel }}
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Billing Cycle
|
||||
</div>
|
||||
<div class="text-body-1 text-capitalize mt-1">
|
||||
{{ service.plan?.billing_cycle || '--' }}
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Auto Renew
|
||||
</div>
|
||||
<div class="text-body-1 mt-1">
|
||||
<VChip
|
||||
:color="service.auto_renew ? 'success' : 'secondary'"
|
||||
size="small"
|
||||
>
|
||||
{{ service.auto_renew ? 'Enabled' : 'Disabled' }}
|
||||
</VChip>
|
||||
</div>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<!-- Plan Features -->
|
||||
<div
|
||||
v-if="service.plan?.features && Object.keys(service.plan.features).length > 0"
|
||||
class="mt-6"
|
||||
>
|
||||
<div class="text-body-2 text-medium-emphasis mb-3">
|
||||
Plan Features
|
||||
</div>
|
||||
<VList density="compact">
|
||||
<VListItem
|
||||
v-for="(value, key) in service.plan.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>
|
||||
|
||||
<!-- Network Information -->
|
||||
<VCard class="mb-6">
|
||||
<VCardTitle>Network Information</VCardTitle>
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="6">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
IPv4 Address
|
||||
</div>
|
||||
<div class="text-body-1 mt-1">
|
||||
<code v-if="service.ipv4_address">{{ service.ipv4_address }}</code>
|
||||
<span
|
||||
v-else
|
||||
class="text-medium-emphasis"
|
||||
>Not assigned</span>
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
IPv6 Address
|
||||
</div>
|
||||
<div class="text-body-1 mt-1">
|
||||
<code v-if="service.ipv6_address">{{ service.ipv6_address }}</code>
|
||||
<span
|
||||
v-else
|
||||
class="text-medium-emphasis"
|
||||
>Not assigned</span>
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Hostname
|
||||
</div>
|
||||
<div class="text-body-1 mt-1">
|
||||
<code v-if="service.hostname">{{ service.hostname }}</code>
|
||||
<span
|
||||
v-else
|
||||
class="text-medium-emphasis"
|
||||
>Not set</span>
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Domain
|
||||
</div>
|
||||
<div class="text-body-1 mt-1">
|
||||
<code v-if="service.domain">{{ service.domain }}</code>
|
||||
<span
|
||||
v-else
|
||||
class="text-medium-emphasis"
|
||||
>Not set</span>
|
||||
</div>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<VCol
|
||||
cols="12"
|
||||
lg="4"
|
||||
>
|
||||
<!-- Important Dates -->
|
||||
<VCard class="mb-6">
|
||||
<VCardTitle>Important Dates</VCardTitle>
|
||||
<VCardText>
|
||||
<div class="d-flex flex-column ga-4">
|
||||
<div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Created
|
||||
</div>
|
||||
<div class="text-body-1 mt-1">
|
||||
{{ formatDate(service.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Provisioned
|
||||
</div>
|
||||
<div class="text-body-1 mt-1">
|
||||
{{ formatDate(service.provisioned_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="service.subscription?.current_period_end">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Next Renewal
|
||||
</div>
|
||||
<div class="text-body-1 mt-1">
|
||||
{{ formatDate(service.subscription.current_period_end) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="service.suspended_at">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Suspended
|
||||
</div>
|
||||
<div class="text-body-1 text-error mt-1">
|
||||
{{ formatDate(service.suspended_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="service.terminated_at">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Terminated
|
||||
</div>
|
||||
<div class="text-body-1 text-error mt-1">
|
||||
{{ formatDate(service.terminated_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<VCard v-if="!isTerminated">
|
||||
<VCardTitle>Quick Actions</VCardTitle>
|
||||
<VCardText>
|
||||
<div class="d-flex flex-column ga-3">
|
||||
<VBtn
|
||||
v-if="controlPanelUrl"
|
||||
:href="controlPanelUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
block
|
||||
variant="tonal"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-external-link"
|
||||
start
|
||||
/>
|
||||
Open Control Panel
|
||||
</VBtn>
|
||||
|
||||
<Link
|
||||
v-if="service.subscription_id"
|
||||
:href="`/subscriptions/${service.subscription_id}`"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<VBtn
|
||||
block
|
||||
variant="tonal"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-receipt"
|
||||
start
|
||||
/>
|
||||
View Subscription
|
||||
</VBtn>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/billing"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<VBtn
|
||||
block
|
||||
variant="tonal"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-credit-card"
|
||||
start
|
||||
/>
|
||||
Billing & Payments
|
||||
</VBtn>
|
||||
</Link>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,6 +7,7 @@ export interface NavItem {
|
||||
|
||||
export const accountNavItems: NavItem[] = [
|
||||
{ title: 'Dashboard', href: '/dashboard', icon: 'tabler-smart-home', matchPrefix: '/dashboard' },
|
||||
{ title: 'Services', href: '/services', icon: 'tabler-server', matchPrefix: '/services' },
|
||||
{ title: 'Subscriptions', href: '/subscriptions', icon: 'tabler-receipt', matchPrefix: '/subscriptions' },
|
||||
{ title: 'Billing', href: '/billing', icon: 'tabler-credit-card', matchPrefix: '/billing' },
|
||||
{ title: 'Plans', href: '/plans', icon: 'tabler-package', matchPrefix: '/plans' },
|
||||
|
||||
@@ -6,4 +6,5 @@ export const adminNavItems: NavItem[] = [
|
||||
{ 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' },
|
||||
{ title: 'Coupons', href: '/coupons', icon: 'tabler-discount-2', matchPrefix: '/coupons' },
|
||||
]
|
||||
|
||||
@@ -111,4 +111,56 @@ export interface PaginationLink {
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
id: number
|
||||
user_id: number
|
||||
subscription_id: number | null
|
||||
plan_id: number
|
||||
service_type: string
|
||||
platform: string
|
||||
platform_service_id: string | null
|
||||
status: 'pending' | 'active' | 'suspended' | 'terminated'
|
||||
ipv4_address: string | null
|
||||
ipv6_address: string | null
|
||||
hostname: string | null
|
||||
domain: string | null
|
||||
auto_renew: boolean
|
||||
provisioned_at: string | null
|
||||
suspended_at: string | null
|
||||
terminated_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
plan?: Plan
|
||||
subscription?: Subscription
|
||||
}
|
||||
|
||||
export interface Coupon {
|
||||
id: number
|
||||
code: string
|
||||
type: 'percentage' | 'fixed'
|
||||
value: string
|
||||
currency: string | null
|
||||
applies_to: number[] | null
|
||||
max_uses: number | null
|
||||
times_used: number
|
||||
active: boolean
|
||||
expires_at: string | null
|
||||
created_at: string
|
||||
redemptions_count?: number
|
||||
}
|
||||
|
||||
export interface CouponRedemption {
|
||||
id: number
|
||||
coupon_id: number
|
||||
user_id: number
|
||||
subscription_id: number | null
|
||||
discount_amount: string
|
||||
created_at: string
|
||||
user?: {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
}
|
||||
|
||||
export type StatusColor = 'success' | 'error' | 'warning' | 'info' | 'secondary'
|
||||
|
||||
@@ -31,6 +31,38 @@ export function resolveTransactionStatusColor(status: string): StatusColor {
|
||||
return map[status] ?? 'secondary'
|
||||
}
|
||||
|
||||
export function resolveServiceStatusColor(status: string): StatusColor {
|
||||
const map: Record<string, StatusColor> = {
|
||||
active: 'success',
|
||||
pending: 'warning',
|
||||
suspended: 'error',
|
||||
terminated: 'secondary',
|
||||
}
|
||||
return map[status] ?? 'secondary'
|
||||
}
|
||||
|
||||
export function resolveServiceTypeColor(type: string): StatusColor {
|
||||
const map: Record<string, StatusColor> = {
|
||||
vps: 'info',
|
||||
dedicated: 'success',
|
||||
'web-hosting': 'warning',
|
||||
'game-server': 'error',
|
||||
}
|
||||
return map[type] ?? 'secondary'
|
||||
}
|
||||
|
||||
export function resolvePlatformUrl(platform: string, platformServiceId: string | null): string | null {
|
||||
if (!platformServiceId) return null
|
||||
|
||||
const urls: Record<string, string> = {
|
||||
virtfusion: `https://panel.ezscale.cloud/server/${platformServiceId}`,
|
||||
synergycp: `https://dedicated.ezscale.cloud/server/${platformServiceId}`,
|
||||
enhance: `https://hosting.ezscale.cloud/server/${platformServiceId}`,
|
||||
pterodactyl: `https://game.ezscale.cloud/server/${platformServiceId}`,
|
||||
}
|
||||
return urls[platform] ?? null
|
||||
}
|
||||
|
||||
export function formatPrice(price: string | number, cycle?: string): string {
|
||||
const amount = parseFloat(String(price)).toFixed(2)
|
||||
return cycle ? `$${amount}/${cycle}` : `$${amount}`
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Http\Controllers\Account\CheckoutController;
|
||||
use App\Http\Controllers\Account\DashboardController;
|
||||
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 Illuminate\Support\Facades\Route;
|
||||
|
||||
@@ -28,6 +29,9 @@ Route::get('/checkout/paypal/cancel', [CheckoutController::class, 'paypalCancel'
|
||||
Route::get('/checkout/{plan}', [CheckoutController::class, 'show'])->name('account.checkout.show');
|
||||
Route::post('/checkout/{plan}', [CheckoutController::class, 'store'])->name('account.checkout.store');
|
||||
|
||||
// Services
|
||||
Route::resource('services', ServiceController::class)->only(['index', 'show'])->names('account.services');
|
||||
|
||||
// Subscriptions
|
||||
Route::get('/subscriptions', [SubscriptionController::class, 'index'])->name('account.subscriptions.index');
|
||||
Route::get('/subscriptions/{subscription}', [SubscriptionController::class, 'show'])->name('account.subscriptions.show');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\Admin\CouponController;
|
||||
use App\Http\Controllers\Admin\CustomerController;
|
||||
use App\Http\Controllers\Admin\DashboardController;
|
||||
use App\Http\Controllers\Admin\InvoiceController;
|
||||
@@ -31,3 +32,12 @@ Route::post('services/{service}/terminate', [ServiceController::class, 'terminat
|
||||
|
||||
Route::resource('invoices', InvoiceController::class)->only(['index', 'show']);
|
||||
Route::post('invoices/{invoice}/void', [InvoiceController::class, 'void'])->name('invoices.void');
|
||||
|
||||
Route::resource('coupons', CouponController::class)->names([
|
||||
'index' => 'admin.coupons.index',
|
||||
'create' => 'admin.coupons.create',
|
||||
'store' => 'admin.coupons.store',
|
||||
'edit' => 'admin.coupons.edit',
|
||||
'update' => 'admin.coupons.update',
|
||||
'destroy' => 'admin.coupons.destroy',
|
||||
])->except(['show']);
|
||||
|
||||
Reference in New Issue
Block a user