Files
website/website/resources/ts/Pages/Admin/Plans/Index.vue
Claude Dev 2061b1f3e3 Build admin panel CRUD: customers, plans, services, invoices
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>
2026-02-09 10:30:26 -05:00

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>