Files
website/website/resources/ts/Pages/Admin/Customers/Show.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

681 lines
23 KiB
Vue

<script lang="ts" setup>
import { Link, useForm } from '@inertiajs/vue3'
import { ref } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
import { resolveInvoiceStatusColor, resolveSubscriptionStatusColor } from '@/utils/resolvers'
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
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 CustomerAuditLog {
id: number
action: string
resource_type: string
resource_id: number | null
ip_address: string | null
created_at: string
}
interface Props {
customer: Customer
subscriptions: CustomerSubscription[]
recentInvoices: CustomerInvoice[]
auditLogs: CustomerAuditLog[]
}
defineOptions({ layout: AdminLayout })
const props = defineProps<Props>()
const activeTab = ref<string>('overview')
const suspendForm = useForm({})
const unsuspendForm = useForm({})
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 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'
}
</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">
<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>
</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>
</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>
<!-- 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">
Last 20
</VChip>
</VCardTitle>
<VCardText v-if="auditLogs.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" :key="log.id">
<td class="text-body-2 font-weight-medium">
{{ formatAction(log.action) }}
</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">
{{ formatDate(log.created_at) }}
</td>
</tr>
</tbody>
</VTable>
</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">&mdash;</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">&mdash;</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>
</VWindow>
</div>
</template>