1297 lines
44 KiB
Vue
1297 lines
44 KiB
Vue
<script lang="ts" setup>
|
|
import { Link, router, useForm } from '@inertiajs/vue3'
|
|
import { computed, ref } from 'vue'
|
|
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
|
import { resolveInvoiceStatusColor, resolveSubscriptionStatusColor } from '@/utils/resolvers'
|
|
import type { AuditLog, PaginatedResponse } from '@/types'
|
|
|
|
interface Plan {
|
|
id: number
|
|
name: string
|
|
price: string
|
|
billing_cycle: string
|
|
service_type: string
|
|
}
|
|
|
|
interface CustomerProfile {
|
|
billing_address_line1: string | null
|
|
billing_address_line2: string | null
|
|
billing_city: string | null
|
|
billing_state: string | null
|
|
billing_zip: string | null
|
|
billing_country: string | null
|
|
tax_id: string | null
|
|
tax_exempt: boolean
|
|
company_name: string | null
|
|
company_vat: string | null
|
|
}
|
|
|
|
interface Customer {
|
|
id: number
|
|
name: string
|
|
email: string
|
|
phone: string | null
|
|
company: string | null
|
|
admin_notes: string | null
|
|
status: string
|
|
created_at: string
|
|
email_verified_at: string | null
|
|
profile: CustomerProfile | null
|
|
services: CustomerService[]
|
|
}
|
|
|
|
interface CustomerService {
|
|
id: number
|
|
service_type: string
|
|
platform: string | null
|
|
hostname: string | null
|
|
domain: string | null
|
|
status: string
|
|
ipv4_address: string | null
|
|
created_at: string
|
|
plan: {
|
|
id: number
|
|
name: string
|
|
price: string
|
|
billing_cycle: string
|
|
} | null
|
|
}
|
|
|
|
interface CustomerSubscription {
|
|
id: number
|
|
type: string
|
|
stripe_status: string
|
|
gateway: string
|
|
current_period_start: string | null
|
|
current_period_end: string | null
|
|
ends_at: string | null
|
|
created_at: string
|
|
plan_name: string | null
|
|
plan_price: string | null
|
|
plan_billing_cycle: string | null
|
|
}
|
|
|
|
interface CustomerInvoice {
|
|
id: number
|
|
number: string
|
|
total: string
|
|
status: string
|
|
gateway: string
|
|
created_at: string
|
|
}
|
|
|
|
interface Props {
|
|
customer: Customer
|
|
subscriptions: CustomerSubscription[]
|
|
recentInvoices: CustomerInvoice[]
|
|
auditLogs: PaginatedResponse<AuditLog>
|
|
plans: Plan[]
|
|
}
|
|
|
|
defineOptions({ layout: AdminLayout })
|
|
|
|
const props = defineProps<Props>()
|
|
|
|
const activeTab = ref<string>('overview')
|
|
|
|
const suspendForm = useForm({})
|
|
const unsuspendForm = useForm({})
|
|
const expandedRows = ref<Set<number>>(new Set())
|
|
|
|
// Admin action dialogs
|
|
const showPlaceOrderDialog = ref(false)
|
|
const showSendNotificationDialog = ref(false)
|
|
const showPurgeConfirmDialog = ref(false)
|
|
const showResetPasswordConfirmDialog = ref(false)
|
|
|
|
// Place order form
|
|
const placeOrderForm = useForm({
|
|
plan_id: null as number | null,
|
|
billing_cycle: 'monthly',
|
|
})
|
|
|
|
// Send notification form
|
|
const sendNotificationForm = useForm({
|
|
subject: '',
|
|
message: '',
|
|
})
|
|
|
|
// Purge confirmation
|
|
const purgeConfirmEmail = ref('')
|
|
|
|
// Filter plans by their availability
|
|
const availablePlans = computed(() => {
|
|
return props.plans.filter(plan => plan.service_type !== 'addon')
|
|
})
|
|
|
|
function handleSuspend(): void {
|
|
suspendForm.post(`/customers/${props.customer.id}/suspend`, {
|
|
preserveScroll: true,
|
|
})
|
|
}
|
|
|
|
function handleUnsuspend(): void {
|
|
unsuspendForm.post(`/customers/${props.customer.id}/unsuspend`, {
|
|
preserveScroll: true,
|
|
})
|
|
}
|
|
|
|
function handlePlaceOrder(): void {
|
|
placeOrderForm.post(`/customers/${props.customer.id}/place-order`, {
|
|
preserveScroll: true,
|
|
onSuccess: () => {
|
|
showPlaceOrderDialog.value = false
|
|
placeOrderForm.reset()
|
|
},
|
|
})
|
|
}
|
|
|
|
function handleSendNotification(): void {
|
|
sendNotificationForm.post(`/customers/${props.customer.id}/send-notification`, {
|
|
preserveScroll: true,
|
|
onSuccess: () => {
|
|
showSendNotificationDialog.value = false
|
|
sendNotificationForm.reset()
|
|
},
|
|
})
|
|
}
|
|
|
|
function handleResetPassword(): void {
|
|
router.post(`/customers/${props.customer.id}/reset-password`, {}, {
|
|
preserveScroll: true,
|
|
onSuccess: () => {
|
|
showResetPasswordConfirmDialog.value = false
|
|
},
|
|
})
|
|
}
|
|
|
|
function handlePurge(): void {
|
|
router.delete(`/customers/${props.customer.id}/purge`, {
|
|
preserveScroll: false,
|
|
onSuccess: () => {
|
|
showPurgeConfirmDialog.value = false
|
|
},
|
|
})
|
|
}
|
|
|
|
function resolveUserStatusColor(status: string): string {
|
|
const map: Record<string, string> = {
|
|
active: 'success',
|
|
suspended: 'warning',
|
|
banned: 'error',
|
|
}
|
|
return map[status] ?? 'secondary'
|
|
}
|
|
|
|
function resolveServiceStatusColor(status: string): string {
|
|
const map: Record<string, string> = {
|
|
active: 'success',
|
|
suspended: 'warning',
|
|
pending: 'info',
|
|
terminated: 'error',
|
|
}
|
|
return map[status] ?? 'secondary'
|
|
}
|
|
|
|
function formatCurrency(value: number | string): string {
|
|
const num = typeof value === 'string' ? parseFloat(value) : value
|
|
return `$${num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
const date = new Date(dateStr)
|
|
return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
|
|
}
|
|
|
|
function formatAction(action: string): string {
|
|
return action
|
|
.replace(/_/g, ' ')
|
|
.replace(/\b\w/g, (c: string) => c.toUpperCase())
|
|
}
|
|
|
|
function customerInitials(name: string): string {
|
|
return name
|
|
.split(' ')
|
|
.map((n: string) => n.charAt(0))
|
|
.join('')
|
|
.toUpperCase()
|
|
.slice(0, 2)
|
|
}
|
|
|
|
function formatBillingAddress(profile: CustomerProfile | null): string {
|
|
if (!profile) {
|
|
return 'No billing address on file'
|
|
}
|
|
const parts = [
|
|
profile.billing_address_line1,
|
|
profile.billing_address_line2,
|
|
[profile.billing_city, profile.billing_state, profile.billing_zip].filter(Boolean).join(', '),
|
|
profile.billing_country,
|
|
].filter(Boolean)
|
|
return parts.length > 0 ? parts.join('\n') : 'No billing address on file'
|
|
}
|
|
|
|
function resolveActionColor(action: string): string {
|
|
if (action.startsWith('login') || action === 'login') {
|
|
return 'info'
|
|
}
|
|
if (action.includes('impersonate')) {
|
|
return 'warning'
|
|
}
|
|
if (action.startsWith('create') || action === 'register') {
|
|
return 'success'
|
|
}
|
|
if (action.startsWith('update') || action.startsWith('edit') || action.includes('updated') || action.includes('changed')) {
|
|
return 'primary'
|
|
}
|
|
if (action.startsWith('delete') || action.startsWith('terminate') || action.startsWith('destroy')) {
|
|
return 'error'
|
|
}
|
|
if (action.startsWith('suspend')) {
|
|
return 'warning'
|
|
}
|
|
if (action.startsWith('unsuspend')) {
|
|
return 'success'
|
|
}
|
|
return 'secondary'
|
|
}
|
|
|
|
function resolveActionIcon(action: string): string {
|
|
if (action.startsWith('login') || action === 'login') {
|
|
return 'tabler-login'
|
|
}
|
|
if (action.includes('impersonate')) {
|
|
return 'tabler-user-shield'
|
|
}
|
|
if (action.startsWith('create') || action === 'register') {
|
|
return 'tabler-plus'
|
|
}
|
|
if (action.startsWith('update') || action.startsWith('edit') || action.includes('updated') || action.includes('changed')) {
|
|
return 'tabler-pencil'
|
|
}
|
|
if (action.startsWith('delete') || action.startsWith('terminate') || action.startsWith('destroy')) {
|
|
return 'tabler-trash'
|
|
}
|
|
if (action.startsWith('suspend')) {
|
|
return 'tabler-ban'
|
|
}
|
|
if (action.startsWith('unsuspend')) {
|
|
return 'tabler-circle-check'
|
|
}
|
|
return 'tabler-activity'
|
|
}
|
|
|
|
function formatRelativeTime(dateStr: string): string {
|
|
const date = new Date(dateStr)
|
|
const now = new Date()
|
|
const diffMs = now.getTime() - date.getTime()
|
|
const diffSeconds = Math.floor(diffMs / 1000)
|
|
const diffMinutes = Math.floor(diffSeconds / 60)
|
|
const diffHours = Math.floor(diffMinutes / 60)
|
|
const diffDays = Math.floor(diffHours / 24)
|
|
|
|
if (diffSeconds < 60) {
|
|
return 'just now'
|
|
}
|
|
if (diffMinutes < 60) {
|
|
return `${diffMinutes}m ago`
|
|
}
|
|
if (diffHours < 24) {
|
|
return `${diffHours}h ago`
|
|
}
|
|
if (diffDays < 7) {
|
|
return `${diffDays}d ago`
|
|
}
|
|
return formatDate(dateStr)
|
|
}
|
|
|
|
function formatDateTime(dateStr: string): string {
|
|
const date = new Date(dateStr)
|
|
return date.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
function toggleRow(id: number): void {
|
|
if (expandedRows.value.has(id)) {
|
|
expandedRows.value.delete(id)
|
|
}
|
|
else {
|
|
expandedRows.value.add(id)
|
|
}
|
|
}
|
|
|
|
function isExpanded(id: number): boolean {
|
|
return expandedRows.value.has(id)
|
|
}
|
|
|
|
function hasChanges(log: AuditLog): boolean {
|
|
return log.changes !== null && Object.keys(log.changes).length > 0
|
|
}
|
|
|
|
function formatJson(changes: Record<string, unknown> | null): string {
|
|
if (!changes) {
|
|
return '{}'
|
|
}
|
|
return JSON.stringify(changes, null, 2)
|
|
}
|
|
|
|
function goToAuditPage(page: number): void {
|
|
router.get(`/customers/${props.customer.id}`, { audit_page: page }, {
|
|
preserveState: true,
|
|
preserveScroll: true,
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<!-- Breadcrumb -->
|
|
<div class="d-flex align-center ga-2 mb-4">
|
|
<Link href="/customers" class="text-decoration-none">
|
|
<VBtn variant="text" size="small" color="primary">
|
|
<VIcon icon="tabler-arrow-left" start />
|
|
Customers
|
|
</VBtn>
|
|
</Link>
|
|
<VIcon icon="tabler-chevron-right" size="16" color="disabled" />
|
|
<span class="text-body-2 text-medium-emphasis">{{ customer.name }}</span>
|
|
</div>
|
|
|
|
<!-- Customer Header Card -->
|
|
<VCard class="mb-6">
|
|
<VCardText>
|
|
<div class="d-flex align-center justify-space-between flex-wrap gap-4">
|
|
<div class="d-flex align-center gap-4">
|
|
<VAvatar color="primary" variant="tonal" size="56">
|
|
<span class="text-h6 font-weight-medium">
|
|
{{ customerInitials(customer.name) }}
|
|
</span>
|
|
</VAvatar>
|
|
<div>
|
|
<div class="text-h5 font-weight-bold">
|
|
{{ customer.name }}
|
|
</div>
|
|
<div class="text-body-2 text-medium-emphasis">
|
|
{{ customer.email }}
|
|
</div>
|
|
<div class="d-flex align-center ga-2 mt-1">
|
|
<VChip
|
|
:color="resolveUserStatusColor(customer.status)"
|
|
size="small"
|
|
class="text-capitalize"
|
|
>
|
|
{{ customer.status }}
|
|
</VChip>
|
|
<VChip
|
|
v-if="customer.email_verified_at"
|
|
color="success"
|
|
size="small"
|
|
variant="tonal"
|
|
>
|
|
<VIcon icon="tabler-mail-check" start size="14" />
|
|
Verified
|
|
</VChip>
|
|
<VChip
|
|
v-else
|
|
color="warning"
|
|
size="small"
|
|
variant="tonal"
|
|
>
|
|
<VIcon icon="tabler-mail-x" start size="14" />
|
|
Unverified
|
|
</VChip>
|
|
<span class="text-caption text-medium-emphasis">
|
|
Customer since {{ formatDate(customer.created_at) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="d-flex align-center ga-2">
|
|
<Link :href="`/customers/${customer.id}/edit`" class="text-decoration-none">
|
|
<VBtn
|
|
color="primary"
|
|
variant="tonal"
|
|
size="small"
|
|
>
|
|
<VIcon icon="tabler-edit" start />
|
|
Edit
|
|
</VBtn>
|
|
</Link>
|
|
<VBtn
|
|
color="info"
|
|
variant="tonal"
|
|
size="small"
|
|
@click="router.post(`/impersonate/${customer.id}`)"
|
|
>
|
|
<VIcon icon="tabler-user-shield" start />
|
|
Impersonate
|
|
</VBtn>
|
|
<VBtn
|
|
v-if="customer.status !== 'suspended'"
|
|
color="warning"
|
|
variant="tonal"
|
|
size="small"
|
|
:loading="suspendForm.processing"
|
|
@click="handleSuspend"
|
|
>
|
|
<VIcon icon="tabler-ban" start />
|
|
Suspend
|
|
</VBtn>
|
|
<VBtn
|
|
v-else
|
|
color="success"
|
|
variant="tonal"
|
|
size="small"
|
|
:loading="unsuspendForm.processing"
|
|
@click="handleUnsuspend"
|
|
>
|
|
<VIcon icon="tabler-circle-check" start />
|
|
Unsuspend
|
|
</VBtn>
|
|
|
|
<VMenu>
|
|
<template #activator="{ props: menuProps }">
|
|
<VBtn
|
|
v-bind="menuProps"
|
|
color="secondary"
|
|
variant="tonal"
|
|
size="small"
|
|
icon="tabler-dots-vertical"
|
|
/>
|
|
</template>
|
|
<VList>
|
|
<VListItem @click="showPlaceOrderDialog = true">
|
|
<template #prepend>
|
|
<VIcon icon="tabler-shopping-cart" />
|
|
</template>
|
|
<VListItemTitle>Place Order</VListItemTitle>
|
|
</VListItem>
|
|
<VListItem @click="showSendNotificationDialog = true">
|
|
<template #prepend>
|
|
<VIcon icon="tabler-mail" />
|
|
</template>
|
|
<VListItemTitle>Send Notification</VListItemTitle>
|
|
</VListItem>
|
|
<VListItem @click="showResetPasswordConfirmDialog = true">
|
|
<template #prepend>
|
|
<VIcon icon="tabler-lock-open" />
|
|
</template>
|
|
<VListItemTitle>Reset Password</VListItemTitle>
|
|
</VListItem>
|
|
<VDivider />
|
|
<VListItem @click="showPurgeConfirmDialog = true">
|
|
<template #prepend>
|
|
<VIcon icon="tabler-trash" color="error" />
|
|
</template>
|
|
<VListItemTitle class="text-error">
|
|
Purge Customer
|
|
</VListItemTitle>
|
|
</VListItem>
|
|
</VList>
|
|
</VMenu>
|
|
</div>
|
|
</div>
|
|
</VCardText>
|
|
</VCard>
|
|
|
|
<!-- Tabs -->
|
|
<VTabs v-model="activeTab" class="mb-6">
|
|
<VTab value="overview">
|
|
<VIcon icon="tabler-user" start />
|
|
Overview
|
|
</VTab>
|
|
<VTab value="services">
|
|
<VIcon icon="tabler-server" start />
|
|
Services
|
|
<VChip size="x-small" color="primary" variant="tonal" class="ms-2">
|
|
{{ customer.services.length }}
|
|
</VChip>
|
|
</VTab>
|
|
<VTab value="billing">
|
|
<VIcon icon="tabler-credit-card" start />
|
|
Billing
|
|
</VTab>
|
|
<VTab value="audit-log">
|
|
<VIcon icon="tabler-history" start />
|
|
Audit Log
|
|
<VChip size="x-small" color="secondary" variant="tonal" class="ms-2">
|
|
{{ auditLogs.total }}
|
|
</VChip>
|
|
</VTab>
|
|
</VTabs>
|
|
|
|
<!-- Tab Content -->
|
|
<VWindow v-model="activeTab">
|
|
<!-- Overview Tab -->
|
|
<VWindowItem value="overview">
|
|
<VRow>
|
|
<!-- User Info -->
|
|
<VCol cols="12" md="6">
|
|
<VCard>
|
|
<VCardTitle class="d-flex align-center gap-2">
|
|
<VIcon icon="tabler-info-circle" size="22" />
|
|
<span>Customer Information</span>
|
|
</VCardTitle>
|
|
<VCardText>
|
|
<VList density="compact" class="pa-0">
|
|
<VListItem>
|
|
<template #prepend>
|
|
<VIcon icon="tabler-user" size="20" class="me-3" />
|
|
</template>
|
|
<VListItemTitle class="text-caption text-medium-emphasis">
|
|
Name
|
|
</VListItemTitle>
|
|
<VListItemSubtitle class="text-body-2 font-weight-medium text-high-emphasis">
|
|
{{ customer.name }}
|
|
</VListItemSubtitle>
|
|
</VListItem>
|
|
|
|
<VListItem>
|
|
<template #prepend>
|
|
<VIcon icon="tabler-mail" size="20" class="me-3" />
|
|
</template>
|
|
<VListItemTitle class="text-caption text-medium-emphasis">
|
|
Email
|
|
</VListItemTitle>
|
|
<VListItemSubtitle class="text-body-2 font-weight-medium text-high-emphasis">
|
|
{{ customer.email }}
|
|
</VListItemSubtitle>
|
|
</VListItem>
|
|
|
|
<VListItem v-if="customer.phone">
|
|
<template #prepend>
|
|
<VIcon icon="tabler-phone" size="20" class="me-3" />
|
|
</template>
|
|
<VListItemTitle class="text-caption text-medium-emphasis">
|
|
Phone
|
|
</VListItemTitle>
|
|
<VListItemSubtitle class="text-body-2 font-weight-medium text-high-emphasis">
|
|
{{ customer.phone }}
|
|
</VListItemSubtitle>
|
|
</VListItem>
|
|
|
|
<VListItem v-if="customer.company">
|
|
<template #prepend>
|
|
<VIcon icon="tabler-building" size="20" class="me-3" />
|
|
</template>
|
|
<VListItemTitle class="text-caption text-medium-emphasis">
|
|
Company
|
|
</VListItemTitle>
|
|
<VListItemSubtitle class="text-body-2 font-weight-medium text-high-emphasis">
|
|
{{ customer.company }}
|
|
</VListItemSubtitle>
|
|
</VListItem>
|
|
|
|
<VListItem>
|
|
<template #prepend>
|
|
<VIcon icon="tabler-calendar" size="20" class="me-3" />
|
|
</template>
|
|
<VListItemTitle class="text-caption text-medium-emphasis">
|
|
Member Since
|
|
</VListItemTitle>
|
|
<VListItemSubtitle class="text-body-2 font-weight-medium text-high-emphasis">
|
|
{{ formatDate(customer.created_at) }}
|
|
</VListItemSubtitle>
|
|
</VListItem>
|
|
</VList>
|
|
</VCardText>
|
|
</VCard>
|
|
</VCol>
|
|
|
|
<!-- Billing Address -->
|
|
<VCol cols="12" md="6">
|
|
<VCard class="mb-4">
|
|
<VCardTitle class="d-flex align-center gap-2">
|
|
<VIcon icon="tabler-map-pin" size="22" />
|
|
<span>Billing Address</span>
|
|
</VCardTitle>
|
|
<VCardText>
|
|
<div class="text-body-2" style="white-space: pre-line;">
|
|
{{ formatBillingAddress(customer.profile) }}
|
|
</div>
|
|
<div v-if="customer.profile?.tax_id" class="mt-3">
|
|
<span class="text-caption text-medium-emphasis">Tax ID:</span>
|
|
<span class="text-body-2 ms-1">{{ customer.profile.tax_id }}</span>
|
|
</div>
|
|
<VChip
|
|
v-if="customer.profile?.tax_exempt"
|
|
color="info"
|
|
size="small"
|
|
variant="tonal"
|
|
class="mt-2"
|
|
>
|
|
Tax Exempt
|
|
</VChip>
|
|
</VCardText>
|
|
</VCard>
|
|
|
|
<!-- Admin Notes -->
|
|
<VCard v-if="customer.admin_notes" class="mb-4">
|
|
<VCardTitle class="d-flex align-center gap-2">
|
|
<VIcon icon="tabler-notes" size="22" />
|
|
<span>Admin Notes</span>
|
|
</VCardTitle>
|
|
<VCardText>
|
|
<div class="text-body-2" style="white-space: pre-line;">
|
|
{{ customer.admin_notes }}
|
|
</div>
|
|
</VCardText>
|
|
</VCard>
|
|
|
|
<!-- Quick Stats -->
|
|
<VCard>
|
|
<VCardTitle class="d-flex align-center gap-2">
|
|
<VIcon icon="tabler-chart-bar" size="22" />
|
|
<span>Quick Stats</span>
|
|
</VCardTitle>
|
|
<VCardText>
|
|
<VRow>
|
|
<VCol cols="4" class="text-center">
|
|
<div class="text-h5 font-weight-bold text-primary">
|
|
{{ customer.services.length }}
|
|
</div>
|
|
<div class="text-caption text-medium-emphasis">
|
|
Services
|
|
</div>
|
|
</VCol>
|
|
<VCol cols="4" class="text-center">
|
|
<div class="text-h5 font-weight-bold text-info">
|
|
{{ subscriptions.length }}
|
|
</div>
|
|
<div class="text-caption text-medium-emphasis">
|
|
Subscriptions
|
|
</div>
|
|
</VCol>
|
|
<VCol cols="4" class="text-center">
|
|
<div class="text-h5 font-weight-bold text-success">
|
|
{{ recentInvoices.length }}
|
|
</div>
|
|
<div class="text-caption text-medium-emphasis">
|
|
Invoices
|
|
</div>
|
|
</VCol>
|
|
</VRow>
|
|
</VCardText>
|
|
</VCard>
|
|
</VCol>
|
|
|
|
<!-- Recent Activity -->
|
|
<VCol cols="12">
|
|
<VCard>
|
|
<VCardTitle class="d-flex align-center justify-space-between">
|
|
<div class="d-flex align-center gap-2">
|
|
<VIcon icon="tabler-activity" size="22" />
|
|
<span>Recent Activity</span>
|
|
</div>
|
|
<VChip size="small" color="primary" variant="tonal">
|
|
{{ auditLogs.total }} total
|
|
</VChip>
|
|
</VCardTitle>
|
|
|
|
<VCardText v-if="auditLogs.data.length === 0" class="text-center py-8">
|
|
<VIcon icon="tabler-inbox" size="48" color="disabled" class="mb-2" />
|
|
<div class="text-medium-emphasis">
|
|
No activity recorded yet.
|
|
</div>
|
|
</VCardText>
|
|
|
|
<VTable v-else density="comfortable" hover>
|
|
<thead>
|
|
<tr>
|
|
<th>Action</th>
|
|
<th>Resource</th>
|
|
<th>IP Address</th>
|
|
<th>Date</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="log in auditLogs.data.slice(0, 5)" :key="log.id">
|
|
<td>
|
|
<VChip
|
|
:color="resolveActionColor(log.action)"
|
|
size="small"
|
|
variant="tonal"
|
|
>
|
|
<VIcon :icon="resolveActionIcon(log.action)" start size="14" />
|
|
{{ formatAction(log.action) }}
|
|
</VChip>
|
|
</td>
|
|
<td class="text-body-2">
|
|
<span class="text-capitalize">{{ log.resource_type ?? '-' }}</span>
|
|
<span v-if="log.resource_id" class="text-medium-emphasis">
|
|
#{{ log.resource_id }}
|
|
</span>
|
|
</td>
|
|
<td class="text-body-2 text-medium-emphasis">
|
|
{{ log.ip_address ?? 'N/A' }}
|
|
</td>
|
|
<td class="text-body-2">
|
|
{{ formatRelativeTime(log.created_at) }}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</VTable>
|
|
|
|
<VCardText v-if="auditLogs.data.length > 5" class="text-center pt-2">
|
|
<VBtn
|
|
variant="text"
|
|
color="primary"
|
|
size="small"
|
|
@click="activeTab = 'audit-log'"
|
|
>
|
|
View all audit logs
|
|
<VIcon icon="tabler-arrow-right" end />
|
|
</VBtn>
|
|
</VCardText>
|
|
</VCard>
|
|
</VCol>
|
|
</VRow>
|
|
</VWindowItem>
|
|
|
|
<!-- Services Tab -->
|
|
<VWindowItem value="services">
|
|
<VCard>
|
|
<VCardTitle class="d-flex align-center gap-2">
|
|
<VIcon icon="tabler-server" size="22" />
|
|
<span>Services</span>
|
|
</VCardTitle>
|
|
|
|
<VCardText v-if="customer.services.length === 0" class="text-center py-12">
|
|
<VIcon icon="tabler-server-off" size="48" color="disabled" class="mb-2" />
|
|
<div class="text-medium-emphasis">
|
|
This customer has no services.
|
|
</div>
|
|
</VCardText>
|
|
|
|
<VTable v-else density="comfortable" hover>
|
|
<thead>
|
|
<tr>
|
|
<th>Service</th>
|
|
<th>Plan</th>
|
|
<th>Type</th>
|
|
<th>Status</th>
|
|
<th>IP Address</th>
|
|
<th>Created</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="service in customer.services" :key="service.id">
|
|
<td>
|
|
<div class="text-body-2 font-weight-medium">
|
|
{{ service.hostname || service.domain || `Service #${service.id}` }}
|
|
</div>
|
|
<div v-if="service.platform" class="text-caption text-medium-emphasis text-capitalize">
|
|
{{ service.platform }}
|
|
</div>
|
|
</td>
|
|
<td class="text-body-2">
|
|
{{ service.plan?.name ?? 'N/A' }}
|
|
</td>
|
|
<td>
|
|
<VChip size="small" variant="tonal" class="text-capitalize">
|
|
{{ service.service_type }}
|
|
</VChip>
|
|
</td>
|
|
<td>
|
|
<VChip
|
|
:color="resolveServiceStatusColor(service.status)"
|
|
size="small"
|
|
class="text-capitalize"
|
|
>
|
|
{{ service.status }}
|
|
</VChip>
|
|
</td>
|
|
<td class="text-body-2 text-medium-emphasis">
|
|
{{ service.ipv4_address ?? 'N/A' }}
|
|
</td>
|
|
<td class="text-body-2">
|
|
{{ formatDate(service.created_at) }}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</VTable>
|
|
</VCard>
|
|
</VWindowItem>
|
|
|
|
<!-- Billing Tab -->
|
|
<VWindowItem value="billing">
|
|
<VRow>
|
|
<!-- Subscriptions -->
|
|
<VCol cols="12">
|
|
<VCard class="mb-6">
|
|
<VCardTitle class="d-flex align-center gap-2">
|
|
<VIcon icon="tabler-receipt" size="22" />
|
|
<span>Subscriptions</span>
|
|
</VCardTitle>
|
|
|
|
<VCardText v-if="subscriptions.length === 0" class="text-center py-8">
|
|
<VIcon icon="tabler-receipt-off" size="48" color="disabled" class="mb-2" />
|
|
<div class="text-medium-emphasis">
|
|
No subscriptions found.
|
|
</div>
|
|
</VCardText>
|
|
|
|
<VTable v-else density="comfortable" hover>
|
|
<thead>
|
|
<tr>
|
|
<th>Plan</th>
|
|
<th>Gateway</th>
|
|
<th>Status</th>
|
|
<th class="text-end">
|
|
Price
|
|
</th>
|
|
<th>Renewal</th>
|
|
<th>Created</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="sub in subscriptions" :key="sub.id">
|
|
<td class="text-body-2 font-weight-medium">
|
|
{{ sub.plan_name ?? sub.type }}
|
|
</td>
|
|
<td>
|
|
<VChip size="small" variant="tonal" class="text-capitalize">
|
|
{{ sub.gateway }}
|
|
</VChip>
|
|
</td>
|
|
<td>
|
|
<VChip
|
|
:color="resolveSubscriptionStatusColor(sub.stripe_status)"
|
|
size="small"
|
|
class="text-capitalize"
|
|
>
|
|
{{ sub.stripe_status }}
|
|
</VChip>
|
|
</td>
|
|
<td class="text-end text-body-2 font-weight-medium">
|
|
<template v-if="sub.plan_price">
|
|
{{ formatCurrency(sub.plan_price) }}/{{ sub.plan_billing_cycle ?? 'mo' }}
|
|
</template>
|
|
<span v-else class="text-medium-emphasis">—</span>
|
|
</td>
|
|
<td class="text-body-2">
|
|
<template v-if="sub.current_period_end">
|
|
{{ formatDate(sub.current_period_end) }}
|
|
</template>
|
|
<span v-else class="text-medium-emphasis">—</span>
|
|
<div v-if="sub.ends_at" class="text-caption text-error">
|
|
Cancels {{ formatDate(sub.ends_at) }}
|
|
</div>
|
|
</td>
|
|
<td class="text-body-2">
|
|
{{ formatDate(sub.created_at) }}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</VTable>
|
|
</VCard>
|
|
</VCol>
|
|
|
|
<!-- Invoices -->
|
|
<VCol cols="12">
|
|
<VCard>
|
|
<VCardTitle class="d-flex align-center justify-space-between">
|
|
<div class="d-flex align-center gap-2">
|
|
<VIcon icon="tabler-file-invoice" size="22" />
|
|
<span>Recent Invoices</span>
|
|
</div>
|
|
<VChip size="small" color="info" variant="tonal">
|
|
Last 10
|
|
</VChip>
|
|
</VCardTitle>
|
|
|
|
<VCardText v-if="recentInvoices.length === 0" class="text-center py-8">
|
|
<VIcon icon="tabler-file-off" size="48" color="disabled" class="mb-2" />
|
|
<div class="text-medium-emphasis">
|
|
No invoices found.
|
|
</div>
|
|
</VCardText>
|
|
|
|
<VTable v-else density="comfortable" hover>
|
|
<thead>
|
|
<tr>
|
|
<th>Invoice #</th>
|
|
<th>Gateway</th>
|
|
<th>Status</th>
|
|
<th class="text-end">
|
|
Amount
|
|
</th>
|
|
<th>Date</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="invoice in recentInvoices" :key="invoice.id">
|
|
<td class="text-body-2 font-weight-medium">
|
|
{{ invoice.number }}
|
|
</td>
|
|
<td>
|
|
<VChip size="small" variant="tonal" class="text-capitalize">
|
|
{{ invoice.gateway }}
|
|
</VChip>
|
|
</td>
|
|
<td>
|
|
<VChip
|
|
:color="resolveInvoiceStatusColor(invoice.status)"
|
|
size="small"
|
|
class="text-capitalize"
|
|
>
|
|
{{ invoice.status }}
|
|
</VChip>
|
|
</td>
|
|
<td class="text-end text-body-2 font-weight-medium">
|
|
{{ formatCurrency(invoice.total) }}
|
|
</td>
|
|
<td class="text-body-2">
|
|
{{ formatDate(invoice.created_at) }}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</VTable>
|
|
</VCard>
|
|
</VCol>
|
|
</VRow>
|
|
</VWindowItem>
|
|
|
|
<!-- Audit Log Tab -->
|
|
<VWindowItem value="audit-log">
|
|
<VCard>
|
|
<VCardTitle class="d-flex align-center justify-space-between">
|
|
<div class="d-flex align-center gap-2">
|
|
<VIcon icon="tabler-history" size="22" />
|
|
<span>Audit Log</span>
|
|
</div>
|
|
<VChip size="small" color="primary" variant="tonal">
|
|
{{ auditLogs.total }} entries
|
|
</VChip>
|
|
</VCardTitle>
|
|
|
|
<VCardText v-if="auditLogs.data.length === 0" class="text-center py-12">
|
|
<VIcon icon="tabler-clipboard-off" size="48" color="disabled" class="mb-2" />
|
|
<div class="text-h6 text-medium-emphasis mb-1">
|
|
No audit log entries for this customer
|
|
</div>
|
|
<div class="text-body-2 text-disabled">
|
|
Activity will appear here as it occurs.
|
|
</div>
|
|
</VCardText>
|
|
|
|
<VTable v-else density="comfortable" hover>
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 40px;" />
|
|
<th>Date</th>
|
|
<th>Action</th>
|
|
<th>Performed By</th>
|
|
<th>IP Address</th>
|
|
<th>Details</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template v-for="log in auditLogs.data" :key="log.id">
|
|
<tr
|
|
:class="{ 'cursor-pointer': hasChanges(log) }"
|
|
@click="hasChanges(log) ? toggleRow(log.id) : undefined"
|
|
>
|
|
<td>
|
|
<VBtn
|
|
v-if="hasChanges(log)"
|
|
variant="text"
|
|
size="x-small"
|
|
:icon="isExpanded(log.id) ? 'tabler-chevron-down' : 'tabler-chevron-right'"
|
|
@click.stop="toggleRow(log.id)"
|
|
/>
|
|
</td>
|
|
<td class="text-body-2">
|
|
<div>{{ formatRelativeTime(log.created_at) }}</div>
|
|
<div class="text-caption text-medium-emphasis">
|
|
{{ formatDateTime(log.created_at) }}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<VChip
|
|
:color="resolveActionColor(log.action)"
|
|
size="small"
|
|
variant="tonal"
|
|
>
|
|
<VIcon :icon="resolveActionIcon(log.action)" start size="14" />
|
|
{{ formatAction(log.action) }}
|
|
</VChip>
|
|
</td>
|
|
<td>
|
|
<div v-if="log.admin" class="d-flex align-center gap-2">
|
|
<VAvatar color="warning" variant="tonal" size="30">
|
|
<span class="text-caption font-weight-medium">
|
|
{{ log.admin.name.charAt(0).toUpperCase() }}
|
|
</span>
|
|
</VAvatar>
|
|
<div>
|
|
<div class="text-body-2 font-weight-medium">
|
|
{{ log.admin.name }}
|
|
</div>
|
|
<div class="text-caption text-medium-emphasis">
|
|
Admin
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else-if="log.user" class="d-flex align-center gap-2">
|
|
<VAvatar color="primary" variant="tonal" size="30">
|
|
<span class="text-caption font-weight-medium">
|
|
{{ log.user.name.charAt(0).toUpperCase() }}
|
|
</span>
|
|
</VAvatar>
|
|
<div>
|
|
<div class="text-body-2 font-weight-medium">
|
|
{{ log.user.name }}
|
|
</div>
|
|
<div class="text-caption text-medium-emphasis">
|
|
{{ log.user.email }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<VChip v-else size="small" variant="tonal" color="secondary">
|
|
System
|
|
</VChip>
|
|
</td>
|
|
<td class="text-body-2 text-medium-emphasis">
|
|
{{ log.ip_address ?? '-' }}
|
|
</td>
|
|
<td class="text-body-2">
|
|
<span v-if="log.resource_type" class="text-capitalize">
|
|
{{ log.resource_type }}
|
|
</span>
|
|
<span v-if="log.resource_id" class="text-medium-emphasis">
|
|
#{{ log.resource_id }}
|
|
</span>
|
|
<VChip
|
|
v-if="hasChanges(log)"
|
|
size="x-small"
|
|
variant="tonal"
|
|
color="info"
|
|
class="ms-2"
|
|
>
|
|
{{ Object.keys(log.changes!).length }} fields
|
|
</VChip>
|
|
<span v-if="!log.resource_type && !hasChanges(log)" class="text-medium-emphasis">-</span>
|
|
</td>
|
|
</tr>
|
|
<!-- Expanded row: changes JSON -->
|
|
<tr v-if="isExpanded(log.id) && hasChanges(log)">
|
|
<td colspan="6" class="pa-0">
|
|
<div class="pa-4 bg-surface-variant">
|
|
<div class="text-caption font-weight-semibold mb-2">
|
|
Changes
|
|
</div>
|
|
<pre class="text-caption" style="white-space: pre-wrap; word-break: break-all;">{{ formatJson(log.changes) }}</pre>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</VTable>
|
|
|
|
<!-- Pagination -->
|
|
<VCardText v-if="auditLogs.last_page > 1" class="d-flex align-center justify-center pt-4">
|
|
<VPagination
|
|
:model-value="Math.ceil((auditLogs.from || 1) / 15)"
|
|
:length="auditLogs.last_page"
|
|
:total-visible="7"
|
|
rounded
|
|
@update:model-value="goToAuditPage"
|
|
/>
|
|
</VCardText>
|
|
</VCard>
|
|
</VWindowItem>
|
|
</VWindow>
|
|
|
|
<!-- Place Order Dialog -->
|
|
<VDialog v-model="showPlaceOrderDialog" max-width="600">
|
|
<VCard>
|
|
<VCardTitle class="d-flex align-center gap-2">
|
|
<VIcon icon="tabler-shopping-cart" />
|
|
Place Order for {{ customer.name }}
|
|
</VCardTitle>
|
|
<VCardText>
|
|
<VRow>
|
|
<VCol cols="12">
|
|
<VSelect
|
|
v-model="placeOrderForm.plan_id"
|
|
:items="availablePlans"
|
|
item-title="name"
|
|
item-value="id"
|
|
label="Plan"
|
|
placeholder="Select a plan"
|
|
:error-messages="placeOrderForm.errors.plan_id"
|
|
>
|
|
<template #item="{ props: itemProps, item }">
|
|
<VListItem v-bind="itemProps">
|
|
<template #prepend>
|
|
<VIcon :icon="item.raw.service_type === 'vps' ? 'tabler-server' : item.raw.service_type === 'dedicated' ? 'tabler-server-2' : item.raw.service_type === 'hosting' ? 'tabler-world' : 'tabler-device-gamepad-2'" />
|
|
</template>
|
|
<VListItemTitle>{{ item.raw.name }}</VListItemTitle>
|
|
<VListItemSubtitle>
|
|
${{ item.raw.price }}/{{ item.raw.billing_cycle }}
|
|
</VListItemSubtitle>
|
|
</VListItem>
|
|
</template>
|
|
</VSelect>
|
|
</VCol>
|
|
<VCol cols="12">
|
|
<VSelect
|
|
v-model="placeOrderForm.billing_cycle"
|
|
:items="[
|
|
{ title: 'Monthly', value: 'monthly' },
|
|
{ title: 'Quarterly', value: 'quarterly' },
|
|
{ title: 'Semi-Annual', value: 'semi_annual' },
|
|
{ title: 'Annual', value: 'annual' },
|
|
]"
|
|
label="Billing Cycle"
|
|
:error-messages="placeOrderForm.errors.billing_cycle"
|
|
/>
|
|
</VCol>
|
|
</VRow>
|
|
</VCardText>
|
|
<VCardActions class="justify-end">
|
|
<VBtn @click="showPlaceOrderDialog = false">
|
|
Cancel
|
|
</VBtn>
|
|
<VBtn
|
|
color="primary"
|
|
:loading="placeOrderForm.processing"
|
|
@click="handlePlaceOrder"
|
|
>
|
|
Place Order
|
|
</VBtn>
|
|
</VCardActions>
|
|
</VCard>
|
|
</VDialog>
|
|
|
|
<!-- Send Notification Dialog -->
|
|
<VDialog v-model="showSendNotificationDialog" max-width="600">
|
|
<VCard>
|
|
<VCardTitle class="d-flex align-center gap-2">
|
|
<VIcon icon="tabler-mail" />
|
|
Send Notification to {{ customer.name }}
|
|
</VCardTitle>
|
|
<VCardText>
|
|
<VRow>
|
|
<VCol cols="12">
|
|
<VTextField
|
|
v-model="sendNotificationForm.subject"
|
|
label="Subject"
|
|
placeholder="Enter email subject"
|
|
:error-messages="sendNotificationForm.errors.subject"
|
|
/>
|
|
</VCol>
|
|
<VCol cols="12">
|
|
<VTextarea
|
|
v-model="sendNotificationForm.message"
|
|
label="Message"
|
|
placeholder="Enter email message"
|
|
rows="6"
|
|
:error-messages="sendNotificationForm.errors.message"
|
|
/>
|
|
</VCol>
|
|
</VRow>
|
|
</VCardText>
|
|
<VCardActions class="justify-end">
|
|
<VBtn @click="showSendNotificationDialog = false">
|
|
Cancel
|
|
</VBtn>
|
|
<VBtn
|
|
color="primary"
|
|
:loading="sendNotificationForm.processing"
|
|
@click="handleSendNotification"
|
|
>
|
|
Send
|
|
</VBtn>
|
|
</VCardActions>
|
|
</VCard>
|
|
</VDialog>
|
|
|
|
<!-- Reset Password Confirmation Dialog -->
|
|
<VDialog v-model="showResetPasswordConfirmDialog" max-width="500">
|
|
<VCard>
|
|
<VCardTitle class="d-flex align-center gap-2">
|
|
<VIcon icon="tabler-lock-open" color="warning" />
|
|
Reset Password
|
|
</VCardTitle>
|
|
<VCardText>
|
|
<VAlert type="warning" variant="tonal" class="mb-4">
|
|
<div class="text-body-2">
|
|
This will generate a new random password and email it to <strong>{{ customer.email }}</strong>.
|
|
</div>
|
|
</VAlert>
|
|
<div class="text-body-2">
|
|
Are you sure you want to reset the password for <strong>{{ customer.name }}</strong>?
|
|
</div>
|
|
</VCardText>
|
|
<VCardActions class="justify-end">
|
|
<VBtn @click="showResetPasswordConfirmDialog = false">
|
|
Cancel
|
|
</VBtn>
|
|
<VBtn
|
|
color="warning"
|
|
@click="handleResetPassword"
|
|
>
|
|
Reset Password
|
|
</VBtn>
|
|
</VCardActions>
|
|
</VCard>
|
|
</VDialog>
|
|
|
|
<!-- Purge Customer Confirmation Dialog -->
|
|
<VDialog v-model="showPurgeConfirmDialog" max-width="500">
|
|
<VCard>
|
|
<VCardTitle class="d-flex align-center gap-2">
|
|
<VIcon icon="tabler-alert-triangle" color="error" />
|
|
Purge Customer
|
|
</VCardTitle>
|
|
<VCardText>
|
|
<VAlert type="error" variant="tonal" class="mb-4">
|
|
<div class="text-body-2 font-weight-bold mb-2">
|
|
WARNING: This action is IRREVERSIBLE!
|
|
</div>
|
|
<div class="text-body-2">
|
|
This will permanently delete:
|
|
</div>
|
|
<ul class="mt-2">
|
|
<li>Customer account and profile</li>
|
|
<li>All services ({{ customer.services.length }})</li>
|
|
<li>All subscriptions ({{ subscriptions.length }})</li>
|
|
<li>All invoices ({{ recentInvoices.length }})</li>
|
|
<li>All orders and audit logs</li>
|
|
</ul>
|
|
</VAlert>
|
|
<div class="text-body-2">
|
|
Type <strong>{{ customer.email }}</strong> to confirm:
|
|
</div>
|
|
<VTextField
|
|
v-model="purgeConfirmEmail"
|
|
class="mt-2"
|
|
placeholder="Enter email to confirm"
|
|
density="compact"
|
|
/>
|
|
</VCardText>
|
|
<VCardActions class="justify-end">
|
|
<VBtn @click="showPurgeConfirmDialog = false">
|
|
Cancel
|
|
</VBtn>
|
|
<VBtn
|
|
color="error"
|
|
:disabled="purgeConfirmEmail !== customer.email"
|
|
@click="handlePurge"
|
|
>
|
|
Purge Customer
|
|
</VBtn>
|
|
</VCardActions>
|
|
</VCard>
|
|
</VDialog>
|
|
</div>
|
|
</template>
|