Phase 5 (Admin Panel): - Customer management: searchable/filterable list, detail view with tabs (overview/services/billing), suspend/unsuspend actions with audit logging - Plan management: full CRUD with create/edit/archive, dynamic feature key-value editor, service type filters, subscriber counts - Service management: list with type/status filters, detail view with provisioning logs, suspend/unsuspend/terminate with confirmation dialogs - Invoice management: list with status filters, detail view with line items and payment transactions, void invoice action - Admin nav: added Customers, Plans, Services, Invoices links - 52 tests passing, build clean Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
306 lines
8.4 KiB
Vue
306 lines
8.4 KiB
Vue
<script lang="ts" setup>
|
|
import { Link, router } from '@inertiajs/vue3'
|
|
import { computed, ref, watch } from 'vue'
|
|
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
|
import type { Plan, StatusColor } from '@/types'
|
|
import { formatPrice } from '@/utils/resolvers'
|
|
|
|
interface PlanWithSubscribers extends Plan {
|
|
subscribers_count: number
|
|
created_at: string
|
|
}
|
|
|
|
interface Filters {
|
|
service_type: string
|
|
search: string
|
|
}
|
|
|
|
interface Props {
|
|
plans: PlanWithSubscribers[]
|
|
filters: Filters
|
|
}
|
|
|
|
defineOptions({ layout: AdminLayout })
|
|
|
|
const props = defineProps<Props>()
|
|
|
|
const search = ref<string>(props.filters.search)
|
|
const activeTab = ref<string>(props.filters.service_type)
|
|
|
|
const serviceTypeTabs: { title: string; value: string }[] = [
|
|
{ title: 'All', value: 'all' },
|
|
{ title: 'VPS', value: 'vps' },
|
|
{ title: 'Dedicated', value: 'dedicated' },
|
|
{ title: 'Hosting', value: 'hosting' },
|
|
{ title: 'MySQL', value: 'mysql' },
|
|
{ title: 'Game Server', value: 'game_server' },
|
|
]
|
|
|
|
function resolveServiceTypeColor(type: string): StatusColor {
|
|
const map: Record<string, StatusColor> = {
|
|
vps: 'success',
|
|
dedicated: 'info',
|
|
hosting: 'warning',
|
|
mysql: 'secondary',
|
|
game_server: 'error',
|
|
}
|
|
return map[type] ?? 'secondary'
|
|
}
|
|
|
|
function resolveServiceTypeLabel(type: string): string {
|
|
const map: Record<string, string> = {
|
|
vps: 'VPS',
|
|
dedicated: 'Dedicated',
|
|
hosting: 'Hosting',
|
|
mysql: 'MySQL',
|
|
game_server: 'Game Server',
|
|
}
|
|
return map[type] ?? type
|
|
}
|
|
|
|
function resolveBillingCycleLabel(cycle: string): string {
|
|
const map: Record<string, string> = {
|
|
monthly: 'Monthly',
|
|
quarterly: 'Quarterly',
|
|
semi_annual: 'Semi-Annual',
|
|
annual: 'Annual',
|
|
}
|
|
return map[cycle] ?? cycle
|
|
}
|
|
|
|
function resolvePlanStatusColor(status: string): StatusColor {
|
|
return status === 'active' ? 'success' : 'error'
|
|
}
|
|
|
|
const tableHeaders = computed(() => [
|
|
{ title: 'Plan Name', key: 'name', sortable: true },
|
|
{ title: 'Service Type', key: 'service_type', sortable: true },
|
|
{ title: 'Price', key: 'price', sortable: true, align: 'end' as const },
|
|
{ title: 'Billing Cycle', key: 'billing_cycle', sortable: true },
|
|
{ title: 'Stock', key: 'stock_quantity', sortable: true, align: 'center' as const },
|
|
{ title: 'Status', key: 'status', sortable: true, align: 'center' as const },
|
|
{ title: 'Subscribers', key: 'subscribers_count', sortable: true, align: 'center' as const },
|
|
{ title: 'Actions', key: 'actions', sortable: false, align: 'center' as const },
|
|
])
|
|
|
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
|
|
|
function applyFilters(): void {
|
|
router.get('/plans', {
|
|
service_type: activeTab.value,
|
|
search: search.value || undefined,
|
|
}, {
|
|
preserveState: true,
|
|
replace: true,
|
|
})
|
|
}
|
|
|
|
watch(activeTab, () => {
|
|
applyFilters()
|
|
})
|
|
|
|
watch(search, () => {
|
|
if (searchTimeout) {
|
|
clearTimeout(searchTimeout)
|
|
}
|
|
searchTimeout = setTimeout(() => {
|
|
applyFilters()
|
|
}, 300)
|
|
})
|
|
|
|
function archivePlan(plan: PlanWithSubscribers): void {
|
|
if (confirm(`Are you sure you want to archive "${plan.name}"? It will be set to inactive.`)) {
|
|
router.delete(`/plans/${plan.id}`, {
|
|
preserveScroll: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
function reactivatePlan(plan: PlanWithSubscribers): void {
|
|
router.put(`/plans/${plan.id}`, {
|
|
...plan,
|
|
status: 'active',
|
|
features: plan.features
|
|
? Object.entries(plan.features).map(([key, value]) => ({ key, value }))
|
|
: [],
|
|
}, {
|
|
preserveScroll: true,
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<!-- Header -->
|
|
<div class="d-flex align-center justify-space-between mb-6">
|
|
<div>
|
|
<div class="text-h4 font-weight-bold">
|
|
Plans
|
|
</div>
|
|
<div class="text-body-2 text-medium-emphasis">
|
|
Manage your service plans and pricing
|
|
</div>
|
|
</div>
|
|
<Link href="/plans/create">
|
|
<VBtn color="primary" prepend-icon="tabler-plus">
|
|
Create Plan
|
|
</VBtn>
|
|
</Link>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<VCard class="mb-6">
|
|
<VCardText>
|
|
<VRow align="center">
|
|
<VCol cols="12" md="8">
|
|
<VTabs
|
|
v-model="activeTab"
|
|
density="comfortable"
|
|
>
|
|
<VTab
|
|
v-for="tab in serviceTypeTabs"
|
|
:key="tab.value"
|
|
:value="tab.value"
|
|
>
|
|
{{ tab.title }}
|
|
</VTab>
|
|
</VTabs>
|
|
</VCol>
|
|
<VCol cols="12" md="4">
|
|
<VTextField
|
|
v-model="search"
|
|
placeholder="Search plans..."
|
|
prepend-inner-icon="tabler-search"
|
|
variant="outlined"
|
|
density="compact"
|
|
clearable
|
|
hide-details
|
|
/>
|
|
</VCol>
|
|
</VRow>
|
|
</VCardText>
|
|
</VCard>
|
|
|
|
<!-- Plans Table -->
|
|
<VCard>
|
|
<VDataTable
|
|
:headers="tableHeaders"
|
|
:items="plans"
|
|
:items-per-page="25"
|
|
hover
|
|
class="text-no-wrap"
|
|
>
|
|
<!-- Plan Name -->
|
|
<template #item.name="{ item }">
|
|
<div class="d-flex flex-column py-2">
|
|
<span class="text-body-1 font-weight-medium">{{ item.name }}</span>
|
|
<span
|
|
v-if="item.description"
|
|
class="text-caption text-medium-emphasis text-truncate"
|
|
style="max-width: 300px;"
|
|
>
|
|
{{ item.description }}
|
|
</span>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Service Type -->
|
|
<template #item.service_type="{ item }">
|
|
<VChip
|
|
:color="resolveServiceTypeColor(item.service_type)"
|
|
size="small"
|
|
variant="tonal"
|
|
>
|
|
{{ resolveServiceTypeLabel(item.service_type) }}
|
|
</VChip>
|
|
</template>
|
|
|
|
<!-- Price -->
|
|
<template #item.price="{ item }">
|
|
<span class="font-weight-medium">{{ formatPrice(item.price) }}</span>
|
|
</template>
|
|
|
|
<!-- Billing Cycle -->
|
|
<template #item.billing_cycle="{ item }">
|
|
{{ resolveBillingCycleLabel(item.billing_cycle) }}
|
|
</template>
|
|
|
|
<!-- Stock -->
|
|
<template #item.stock_quantity="{ item }">
|
|
<template v-if="item.stock_quantity !== null">
|
|
<VChip
|
|
:color="item.stock_quantity > 0 ? 'success' : 'error'"
|
|
size="small"
|
|
variant="tonal"
|
|
>
|
|
{{ item.stock_quantity }}
|
|
</VChip>
|
|
</template>
|
|
<span v-else class="text-medium-emphasis">Unlimited</span>
|
|
</template>
|
|
|
|
<!-- Status -->
|
|
<template #item.status="{ item }">
|
|
<VChip
|
|
:color="resolvePlanStatusColor(item.status)"
|
|
size="small"
|
|
class="text-capitalize"
|
|
>
|
|
{{ item.status }}
|
|
</VChip>
|
|
</template>
|
|
|
|
<!-- Subscribers -->
|
|
<template #item.subscribers_count="{ item }">
|
|
<span class="font-weight-medium">{{ item.subscribers_count }}</span>
|
|
</template>
|
|
|
|
<!-- Actions -->
|
|
<template #item.actions="{ item }">
|
|
<VMenu>
|
|
<template #activator="{ props: menuProps }">
|
|
<VBtn
|
|
icon="tabler-dots-vertical"
|
|
variant="text"
|
|
size="small"
|
|
v-bind="menuProps"
|
|
/>
|
|
</template>
|
|
<VList density="compact">
|
|
<Link :href="`/plans/${item.id}/edit`" class="text-decoration-none">
|
|
<VListItem prepend-icon="tabler-edit">
|
|
<VListItemTitle>Edit</VListItemTitle>
|
|
</VListItem>
|
|
</Link>
|
|
<VListItem
|
|
v-if="item.status === 'active'"
|
|
prepend-icon="tabler-archive"
|
|
@click="archivePlan(item)"
|
|
>
|
|
<VListItemTitle>Archive</VListItemTitle>
|
|
</VListItem>
|
|
<VListItem
|
|
v-else
|
|
prepend-icon="tabler-refresh"
|
|
@click="reactivatePlan(item)"
|
|
>
|
|
<VListItemTitle>Reactivate</VListItemTitle>
|
|
</VListItem>
|
|
</VList>
|
|
</VMenu>
|
|
</template>
|
|
|
|
<!-- No data -->
|
|
<template #no-data>
|
|
<div class="text-center py-8">
|
|
<VIcon icon="tabler-package" size="48" color="disabled" class="mb-2" />
|
|
<div class="text-medium-emphasis">
|
|
No plans found.
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</VDataTable>
|
|
</VCard>
|
|
</div>
|
|
</template>
|