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:
Claude Dev
2026-02-09 10:38:12 -05:00
parent 2061b1f3e3
commit d9ec414264
18 changed files with 1639 additions and 54 deletions

View 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>

View 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>

View 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 ?? '&infin;' }}
</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>

View 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>

View 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"
>
&larr; 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>