Files
website/website/resources/ts/Pages/Admin/Services/Show.vue
Claude Dev 45d25d61ba Idempotent provisioning, service soft-delete, Plans page redesign, doc updates
Part A: Fix duplicate Service creation on provisioning retry
- All 4 provisioning services use Service::firstOrCreate() keyed on
  subscription_id+service_type to prevent duplicates on queue retries
- HandleSubscriptionCreated sends notification before provisioning,
  no longer re-throws on failure
- RetryProvisioningCommand simplified to reuse existing Service records

Part B: Plans/Pricing page complete redesign
- Service type tabs (VPS, Dedicated, Web Hosting, MySQL)
- Billing cycle segmented toggle (monthly/quarterly/semi-annual/annual)
- Feature icons per service type, Popular/Best Value badges
- Stock indicators, effective monthly price calculations

Part C: Admin service soft-delete/archive
- Service model uses SoftDeletes trait
- Admin can archive and restore services
- Show archived toggle on services list
- Migration adds deleted_at column

Docs: Updated TASKS.md, CLAUDE.md, PROJECT_DEVELOPMENT.md, MEMORY.md
- Phase 3 marked complete, test counts updated (252 passing)
- SupportPal references replaced with standalone ticket system
- Frontend design skill background rule added
- Closed GitHub issues #3, #6, #7, #8, #9

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 06:30:57 -05:00

680 lines
21 KiB
Vue

<script lang="ts" setup>
import { Link, useForm } from '@inertiajs/vue3'
import { computed, ref } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
import type { StatusColor } from '@/types'
interface ServiceUser {
id: number
name: string
email: string
status: string
}
interface ServicePlan {
id: number
name: string
service_type: string
price: string
billing_cycle: string
}
interface AvailablePlan {
id: number
name: string
price: string
billing_cycle: string
}
interface ProvisioningLogItem {
id: number
action: string
platform: string | null
platform_response: Record<string, unknown> | null
status: string
error_message: string | null
created_at: string
}
interface ServiceDetail {
id: number
user_id: number
service_type: string
platform: string | null
platform_service_id: string | null
status: string
hostname: string | null
domain: string | null
ipv4_address: string | null
ipv6_address: string | null
auto_renew: boolean
provisioned_at: string | null
suspended_at: string | null
terminated_at: string | null
deleted_at: string | null
created_at: string
updated_at: string
user: ServiceUser | null
plan: ServicePlan | null
provisioning_logs: ProvisioningLogItem[]
}
interface Props {
service: ServiceDetail
availablePlans: AvailablePlan[]
}
defineOptions({ layout: AdminLayout })
const props = defineProps<Props>()
const confirmDialog = ref<boolean>(false)
const confirmAction = ref<'suspend' | 'unsuspend' | 'terminate' | 'provision' | 'archive' | 'restore'>('suspend')
const confirmTitle = ref<string>('')
const confirmMessage = ref<string>('')
const confirmColor = ref<string>('warning')
const modifyDialog = ref<boolean>(false)
const suspendForm = useForm({})
const unsuspendForm = useForm({})
const terminateForm = useForm({})
const provisionForm = useForm({})
const archiveForm = useForm({})
const restoreForm = useForm({})
const modifyForm = useForm({
plan_id: props.service.plan?.id ?? null,
notes: '',
})
const isProcessing = computed<boolean>(() =>
suspendForm.processing || unsuspendForm.processing || terminateForm.processing || provisionForm.processing || modifyForm.processing || archiveForm.processing || restoreForm.processing,
)
function openConfirmDialog(action: 'suspend' | 'unsuspend' | 'terminate' | 'provision' | 'archive' | 'restore'): void {
confirmAction.value = action
const actions: Record<string, { title: string; message: string; color: string }> = {
suspend: {
title: 'Suspend Service',
message: `Are you sure you want to suspend service #${props.service.id}? The customer will lose access to their service.`,
color: 'warning',
},
unsuspend: {
title: 'Unsuspend Service',
message: `Are you sure you want to unsuspend service #${props.service.id}? The customer will regain access to their service.`,
color: 'success',
},
provision: {
title: 'Provision Service',
message: `Are you sure you want to manually provision service #${props.service.id}? This will trigger provisioning on the configured platform.`,
color: 'info',
},
terminate: {
title: 'Terminate Service',
message: `Are you sure you want to terminate service #${props.service.id}? This action may be irreversible. The service will be permanently deactivated.`,
color: 'error',
},
archive: {
title: 'Archive Service',
message: `Are you sure you want to archive service #${props.service.id}? It will be hidden from the default list but can be restored later.`,
color: 'error',
},
restore: {
title: 'Restore Service',
message: `Are you sure you want to restore service #${props.service.id}? It will be visible in the services list again.`,
color: 'success',
},
}
const config = actions[action]
confirmTitle.value = config.title
confirmMessage.value = config.message
confirmColor.value = config.color
confirmDialog.value = true
}
function executeAction(): void {
const action = confirmAction.value
const opts = {
preserveScroll: true,
onSuccess: () => { confirmDialog.value = false },
}
if (action === 'suspend') {
suspendForm.post(`/services/${props.service.id}/suspend`, opts)
} else if (action === 'unsuspend') {
unsuspendForm.post(`/services/${props.service.id}/unsuspend`, opts)
} else if (action === 'provision') {
provisionForm.post(`/services/${props.service.id}/provision`, opts)
} else if (action === 'archive') {
archiveForm.delete(`/services/${props.service.id}`, opts)
} else if (action === 'restore') {
restoreForm.post(`/services/${props.service.id}/restore`, opts)
} else {
terminateForm.post(`/services/${props.service.id}/terminate`, opts)
}
}
function openModifyDialog(): void {
modifyForm.plan_id = props.service.plan?.id ?? null
modifyForm.notes = ''
modifyDialog.value = true
}
function submitModify(): void {
modifyForm.put(`/services/${props.service.id}`, {
preserveScroll: true,
onSuccess: () => {
modifyDialog.value = false
},
})
}
function resolveServiceStatusColor(statusVal: string): StatusColor {
const map: Record<string, StatusColor> = {
active: 'success',
suspended: 'warning',
terminated: 'error',
pending: 'info',
}
return map[statusVal] ?? 'secondary'
}
function resolveServiceTypeColor(type: string): string {
const map: Record<string, string> = {
vps: 'primary',
dedicated: 'info',
web_hosting: 'success',
game: 'warning',
}
return map[type] ?? 'secondary'
}
function formatServiceType(type: string): string {
const map: Record<string, string> = {
vps: 'VPS',
dedicated: 'Dedicated',
web_hosting: 'Web Hosting',
game: 'Game Hosting',
}
return map[type] ?? type
}
function resolveLogStatusColor(statusVal: string): StatusColor {
const map: Record<string, StatusColor> = {
success: 'success',
failed: 'error',
pending: 'warning',
in_progress: 'info',
}
return map[statusVal] ?? 'secondary'
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '---'
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
}
function formatDateTime(dateStr: string | null): string {
if (!dateStr) return '---'
const date = new Date(dateStr)
return date.toLocaleString('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function formatPrice(price: string | number, cycle?: string): string {
const amount = parseFloat(String(price)).toFixed(2)
return cycle ? `$${amount}/${cycle}` : `$${amount}`
}
</script>
<template>
<div>
<!-- Header -->
<div class="d-flex align-center justify-space-between mb-6">
<div class="d-flex align-center gap-4">
<Link href="/services">
<VBtn variant="text" icon="tabler-arrow-left" size="small" />
</Link>
<div>
<div class="d-flex align-center gap-2">
<span class="text-h4 font-weight-bold">Service #{{ service.id }}</span>
<VChip
:color="resolveServiceStatusColor(service.status)"
size="small"
class="text-capitalize"
>
{{ service.status }}
</VChip>
<VChip
:color="resolveServiceTypeColor(service.service_type)"
size="small"
variant="tonal"
>
{{ formatServiceType(service.service_type) }}
</VChip>
<VChip
v-if="service.deleted_at"
color="secondary"
size="small"
variant="outlined"
>
Archived
</VChip>
</div>
<div class="text-body-2 text-medium-emphasis mt-1">
{{ service.user?.name ?? 'Unknown Customer' }} &middot; {{ service.user?.email ?? '' }}
</div>
</div>
</div>
<div class="d-flex gap-2">
<VBtn
v-if="!service.provisioned_at && service.status !== 'terminated'"
color="info"
variant="tonal"
:disabled="isProcessing"
@click="openConfirmDialog('provision')"
>
<VIcon icon="tabler-rocket" start />
Provision
</VBtn>
<VBtn
v-if="service.status !== 'terminated'"
color="primary"
variant="tonal"
:disabled="isProcessing"
@click="openModifyDialog"
>
<VIcon icon="tabler-edit" start />
Modify Service
</VBtn>
<VBtn
v-if="service.status === 'active'"
color="warning"
variant="tonal"
:disabled="isProcessing"
@click="openConfirmDialog('suspend')"
>
<VIcon icon="tabler-player-pause" start />
Suspend
</VBtn>
<VBtn
v-if="service.status === 'suspended'"
color="success"
variant="tonal"
:disabled="isProcessing"
@click="openConfirmDialog('unsuspend')"
>
<VIcon icon="tabler-player-play" start />
Unsuspend
</VBtn>
<VBtn
v-if="service.status !== 'terminated' && !service.deleted_at"
color="error"
variant="tonal"
:disabled="isProcessing"
@click="openConfirmDialog('terminate')"
>
<VIcon icon="tabler-trash" start />
Terminate
</VBtn>
<VBtn
v-if="!service.deleted_at"
color="error"
variant="outlined"
:disabled="isProcessing"
@click="openConfirmDialog('archive')"
>
<VIcon icon="tabler-archive" start />
Archive
</VBtn>
<VBtn
v-if="service.deleted_at"
color="success"
variant="flat"
:disabled="isProcessing"
@click="openConfirmDialog('restore')"
>
<VIcon icon="tabler-refresh" start />
Restore
</VBtn>
</div>
</div>
<VRow>
<!-- Service Details -->
<VCol cols="12" lg="6">
<VCard>
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-server" size="22" />
<span>Service Details</span>
</VCardTitle>
<VCardText>
<VList density="compact" class="pa-0">
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Plan</span>
</template>
<VListItemTitle class="text-body-2 font-weight-medium">
{{ service.plan?.name ?? 'N/A' }}
<template v-if="service.plan">
&middot; {{ formatPrice(service.plan.price, service.plan.billing_cycle) }}
</template>
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Platform</span>
</template>
<VListItemTitle class="text-body-2">
{{ service.platform ?? 'Not assigned' }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Platform ID</span>
</template>
<VListItemTitle class="text-body-2">
{{ service.platform_service_id ?? '---' }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Hostname</span>
</template>
<VListItemTitle class="text-body-2">
{{ service.hostname ?? '---' }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Domain</span>
</template>
<VListItemTitle class="text-body-2">
{{ service.domain ?? '---' }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">IPv4 Address</span>
</template>
<VListItemTitle class="text-body-2">
{{ service.ipv4_address ?? '---' }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">IPv6 Address</span>
</template>
<VListItemTitle class="text-body-2">
{{ service.ipv6_address ?? '---' }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Auto Renew</span>
</template>
<VListItemTitle>
<VChip :color="service.auto_renew ? 'success' : 'secondary'" size="small">
{{ service.auto_renew ? 'Yes' : 'No' }}
</VChip>
</VListItemTitle>
</VListItem>
</VList>
</VCardText>
</VCard>
</VCol>
<!-- Dates & Customer -->
<VCol cols="12" lg="6">
<VCard class="mb-4">
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-calendar" size="22" />
<span>Dates</span>
</VCardTitle>
<VCardText>
<VList density="compact" class="pa-0">
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Created</span>
</template>
<VListItemTitle class="text-body-2">
{{ formatDateTime(service.created_at) }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Provisioned</span>
</template>
<VListItemTitle class="text-body-2">
{{ formatDateTime(service.provisioned_at) }}
</VListItemTitle>
</VListItem>
<VListItem v-if="service.suspended_at">
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Suspended</span>
</template>
<VListItemTitle class="text-body-2 text-warning">
{{ formatDateTime(service.suspended_at) }}
</VListItemTitle>
</VListItem>
<VListItem v-if="service.terminated_at">
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Terminated</span>
</template>
<VListItemTitle class="text-body-2 text-error">
{{ formatDateTime(service.terminated_at) }}
</VListItemTitle>
</VListItem>
</VList>
</VCardText>
</VCard>
<VCard>
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-user" size="22" />
<span>Customer</span>
</VCardTitle>
<VCardText v-if="service.user">
<div class="d-flex align-center gap-3">
<VAvatar color="primary" variant="tonal" size="40">
<span class="text-body-1 font-weight-semibold">
{{ service.user.name.charAt(0).toUpperCase() }}
</span>
</VAvatar>
<div>
<div class="text-body-1 font-weight-medium">
{{ service.user.name }}
</div>
<div class="text-body-2 text-medium-emphasis">
{{ service.user.email }}
</div>
</div>
<VSpacer />
<Link :href="`/customers/${service.user.id}`">
<VBtn variant="tonal" size="small" color="primary">
View Customer
</VBtn>
</Link>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- Provisioning Logs -->
<VCard class="mt-6">
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-list-details" size="22" />
<span>Provisioning Logs</span>
<VSpacer />
<VChip size="small" color="secondary" variant="tonal">
{{ service.provisioning_logs.length }} {{ service.provisioning_logs.length === 1 ? 'entry' : 'entries' }}
</VChip>
</VCardTitle>
<VCardText v-if="service.provisioning_logs.length === 0" class="text-center py-8">
<VIcon icon="tabler-inbox" size="48" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">
No provisioning logs recorded for this service.
</div>
</VCardText>
<VTable v-else density="comfortable" hover>
<thead>
<tr>
<th>Action</th>
<th>Status</th>
<th>Platform</th>
<th>Message</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
<tr v-for="log in service.provisioning_logs" :key="log.id">
<td class="text-body-2 font-weight-medium text-capitalize">
{{ log.action }}
</td>
<td>
<VChip
:color="resolveLogStatusColor(log.status)"
size="small"
class="text-capitalize"
>
{{ log.status }}
</VChip>
</td>
<td class="text-body-2">
{{ log.platform ?? '---' }}
</td>
<td class="text-body-2 text-medium-emphasis" style="max-width: 400px;">
<span class="d-inline-block text-truncate" style="max-width: 400px;">
{{ log.error_message ?? 'OK' }}
</span>
</td>
<td class="text-body-2">
{{ formatDateTime(log.created_at) }}
</td>
</tr>
</tbody>
</VTable>
</VCard>
<!-- Modify Service Dialog -->
<VDialog v-model="modifyDialog" max-width="600" persistent>
<VCard>
<VCardTitle class="text-h5 pa-5">
Modify Service #{{ service.id }}
</VCardTitle>
<VCardText class="px-5 pb-2">
<VRow>
<VCol cols="12">
<label class="text-body-2 text-medium-emphasis mb-1 d-block">Change Plan</label>
<VSelect
v-model="modifyForm.plan_id"
:items="availablePlans"
item-title="name"
item-value="id"
:error-messages="modifyForm.errors.plan_id"
placeholder="Select a plan"
variant="outlined"
density="comfortable"
>
<template #item="{ props: itemProps, item }">
<VListItem v-bind="itemProps">
<template #subtitle>
{{ formatPrice(item.raw.price, item.raw.billing_cycle) }}
</template>
</VListItem>
</template>
</VSelect>
<div class="text-caption text-medium-emphasis mt-1">
Current plan: {{ service.plan?.name ?? 'N/A' }}
</div>
</VCol>
<VCol cols="12">
<label class="text-body-2 text-medium-emphasis mb-1 d-block">Admin Notes (Optional)</label>
<VTextarea
v-model="modifyForm.notes"
:error-messages="modifyForm.errors.notes"
placeholder="Add internal notes about this modification..."
variant="outlined"
density="comfortable"
rows="3"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions class="pa-5">
<VSpacer />
<VBtn variant="text" :disabled="modifyForm.processing" @click="modifyDialog = false">
Cancel
</VBtn>
<VBtn
color="primary"
variant="flat"
:loading="modifyForm.processing"
@click="submitModify"
>
Update Service
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Confirmation Dialog -->
<VDialog v-model="confirmDialog" max-width="500" persistent>
<VCard>
<VCardTitle class="text-h5 pa-5">
{{ confirmTitle }}
</VCardTitle>
<VCardText class="px-5 pb-2">
{{ confirmMessage }}
</VCardText>
<VCardActions class="pa-5">
<VSpacer />
<VBtn variant="text" :disabled="isProcessing" @click="confirmDialog = false">
Cancel
</VBtn>
<VBtn
:color="confirmColor"
variant="flat"
:loading="isProcessing"
@click="executeAction"
>
Confirm
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>