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>
This commit is contained in:
@@ -47,6 +47,13 @@ const dateFrom = ref<string>(props.filters.date_from)
|
||||
const dateTo = ref<string>(props.filters.date_to)
|
||||
const expandedRows = ref<Set<number>>(new Set())
|
||||
|
||||
// Detail dialog state
|
||||
const detailDialog = ref<boolean>(false)
|
||||
const selectedLog = ref<AuditLog | null>(null)
|
||||
|
||||
// Export menu state
|
||||
const exportMenu = ref<boolean>(false)
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(search, (value: string) => {
|
||||
@@ -95,6 +102,16 @@ function isExpanded(id: number): boolean {
|
||||
return expandedRows.value.has(id)
|
||||
}
|
||||
|
||||
function openDetailDialog(log: AuditLog): void {
|
||||
selectedLog.value = log
|
||||
detailDialog.value = true
|
||||
}
|
||||
|
||||
function closeDetailDialog(): void {
|
||||
detailDialog.value = false
|
||||
selectedLog.value = null
|
||||
}
|
||||
|
||||
function resolveActionColor(action: string): string {
|
||||
if (action.startsWith('create') || action === 'register') {
|
||||
return 'success'
|
||||
@@ -162,17 +179,72 @@ function formatDateTime(dateStr: string): string {
|
||||
})
|
||||
}
|
||||
|
||||
function formatJson(changes: Record<string, unknown> | null): string {
|
||||
if (!changes) {
|
||||
return '{}'
|
||||
function formatFieldName(field: string): string {
|
||||
return field
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c: string) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '(empty)'
|
||||
}
|
||||
return JSON.stringify(changes, null, 2)
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Yes' : 'No'
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function hasChanges(log: AuditLog): boolean {
|
||||
return log.changes !== null && Object.keys(log.changes).length > 0
|
||||
}
|
||||
|
||||
interface ChangesDiff {
|
||||
type: 'update' | 'create' | 'delete' | 'generic'
|
||||
before: Record<string, unknown> | null
|
||||
after: Record<string, unknown> | null
|
||||
fields: string[]
|
||||
}
|
||||
|
||||
function parseChanges(changes: Record<string, unknown> | null): ChangesDiff {
|
||||
if (!changes || Object.keys(changes).length === 0) {
|
||||
return { type: 'generic', before: null, after: null, fields: [] }
|
||||
}
|
||||
|
||||
const hasBefore = 'before' in changes && changes.before !== null && typeof changes.before === 'object'
|
||||
const hasAfter = 'after' in changes && changes.after !== null && typeof changes.after === 'object'
|
||||
|
||||
if (hasBefore && hasAfter) {
|
||||
const before = changes.before as Record<string, unknown>
|
||||
const after = changes.after as Record<string, unknown>
|
||||
const fields = [...new Set([...Object.keys(before), ...Object.keys(after)])]
|
||||
return { type: 'update', before, after, fields }
|
||||
}
|
||||
|
||||
if (hasAfter && !hasBefore) {
|
||||
const after = changes.after as Record<string, unknown>
|
||||
return { type: 'create', before: null, after, fields: Object.keys(after) }
|
||||
}
|
||||
|
||||
if (hasBefore && !hasAfter) {
|
||||
const before = changes.before as Record<string, unknown>
|
||||
return { type: 'delete', before, after: null, fields: Object.keys(before) }
|
||||
}
|
||||
|
||||
// No before/after structure -- treat top-level keys as generic data
|
||||
return { type: 'generic', before: null, after: null, fields: Object.keys(changes) }
|
||||
}
|
||||
|
||||
function isFieldChanged(before: Record<string, unknown> | null, after: Record<string, unknown> | null, field: string): boolean {
|
||||
if (!before || !after) {
|
||||
return false
|
||||
}
|
||||
return JSON.stringify(before[field]) !== JSON.stringify(after[field])
|
||||
}
|
||||
|
||||
function clearFilters(): void {
|
||||
search.value = ''
|
||||
actionFilter.value = ''
|
||||
@@ -184,6 +256,29 @@ function clearFilters(): void {
|
||||
const hasActiveFilters = computed<boolean>(() => {
|
||||
return search.value !== '' || actionFilter.value !== '' || dateFrom.value !== '' || dateTo.value !== ''
|
||||
})
|
||||
|
||||
function buildExportUrl(format: 'csv' | 'json'): string {
|
||||
const params = new URLSearchParams()
|
||||
params.set('format', format)
|
||||
if (search.value) {
|
||||
params.set('search', search.value)
|
||||
}
|
||||
if (actionFilter.value) {
|
||||
params.set('action', actionFilter.value)
|
||||
}
|
||||
if (dateFrom.value) {
|
||||
params.set('date_from', dateFrom.value)
|
||||
}
|
||||
if (dateTo.value) {
|
||||
params.set('date_to', dateTo.value)
|
||||
}
|
||||
return `/audit-logs/export?${params.toString()}`
|
||||
}
|
||||
|
||||
function exportData(format: 'csv' | 'json'): void {
|
||||
exportMenu.value = false
|
||||
window.location.href = buildExportUrl(format)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -198,9 +293,41 @@ const hasActiveFilters = computed<boolean>(() => {
|
||||
Track all system activity and administrative actions
|
||||
</div>
|
||||
</div>
|
||||
<VChip color="primary" variant="tonal" size="small">
|
||||
{{ auditLogs.total }} entries
|
||||
</VChip>
|
||||
<div class="d-flex align-center gap-3">
|
||||
<!-- Export Dropdown -->
|
||||
<VMenu v-model="exportMenu" location="bottom end">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<VBtn
|
||||
v-bind="menuProps"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="small"
|
||||
prepend-icon="tabler-download"
|
||||
>
|
||||
Export
|
||||
<VIcon icon="tabler-chevron-down" end size="14" />
|
||||
</VBtn>
|
||||
</template>
|
||||
<VList density="compact" min-width="160">
|
||||
<VListItem @click="exportData('csv')">
|
||||
<template #prepend>
|
||||
<VIcon icon="tabler-file-spreadsheet" size="18" />
|
||||
</template>
|
||||
<VListItemTitle>Export as CSV</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem @click="exportData('json')">
|
||||
<template #prepend>
|
||||
<VIcon icon="tabler-file-code" size="18" />
|
||||
</template>
|
||||
<VListItemTitle>Export as JSON</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
|
||||
<VChip color="primary" variant="tonal" size="small">
|
||||
{{ auditLogs.total }} entries
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
@@ -282,6 +409,7 @@ const hasActiveFilters = computed<boolean>(() => {
|
||||
<th>Resource Type</th>
|
||||
<th>Resource ID</th>
|
||||
<th>IP Address</th>
|
||||
<th style="width: 40px;" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -341,15 +469,138 @@ const hasActiveFilters = computed<boolean>(() => {
|
||||
<td class="text-body-2 text-medium-emphasis">
|
||||
{{ log.ip_address ?? '-' }}
|
||||
</td>
|
||||
<td>
|
||||
<VBtn
|
||||
v-if="hasChanges(log)"
|
||||
variant="text"
|
||||
size="x-small"
|
||||
icon="tabler-eye"
|
||||
color="primary"
|
||||
@click.stop="openDetailDialog(log)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Expanded row: changes JSON -->
|
||||
<!-- Expanded row: inline diff preview -->
|
||||
<tr v-if="isExpanded(log.id) && hasChanges(log)">
|
||||
<td colspan="7" class="pa-0">
|
||||
<td colspan="8" class="pa-0">
|
||||
<div class="pa-4 bg-surface-variant">
|
||||
<div class="text-caption font-weight-semibold mb-2">
|
||||
Changes
|
||||
<div class="d-flex align-center justify-space-between mb-3">
|
||||
<div class="text-caption font-weight-semibold">
|
||||
Changes
|
||||
</div>
|
||||
<VBtn
|
||||
variant="text"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
@click.stop="openDetailDialog(log)"
|
||||
>
|
||||
<VIcon icon="tabler-arrows-maximize" start size="14" />
|
||||
View Full Diff
|
||||
</VBtn>
|
||||
</div>
|
||||
<pre class="text-caption" style="white-space: pre-wrap; word-break: break-all;">{{ formatJson(log.changes) }}</pre>
|
||||
|
||||
<!-- Inline diff for before/after -->
|
||||
<template v-if="parseChanges(log.changes).type === 'update'">
|
||||
<VTable density="compact" class="rounded border">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-caption" style="width: 25%;">
|
||||
Field
|
||||
</th>
|
||||
<th class="text-caption" style="width: 37.5%;">
|
||||
Before
|
||||
</th>
|
||||
<th class="text-caption" style="width: 37.5%;">
|
||||
After
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="field in parseChanges(log.changes).fields"
|
||||
:key="field"
|
||||
:class="{ 'bg-warning-lighten-5': isFieldChanged(parseChanges(log.changes).before, parseChanges(log.changes).after, field) }"
|
||||
>
|
||||
<td class="text-caption font-weight-medium">
|
||||
{{ formatFieldName(field) }}
|
||||
</td>
|
||||
<td class="text-caption">
|
||||
<span :class="{ 'text-error text-decoration-line-through': isFieldChanged(parseChanges(log.changes).before, parseChanges(log.changes).after, field) }">
|
||||
{{ formatValue(parseChanges(log.changes).before?.[field]) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-caption">
|
||||
<span :class="{ 'text-success font-weight-medium': isFieldChanged(parseChanges(log.changes).before, parseChanges(log.changes).after, field) }">
|
||||
{{ formatValue(parseChanges(log.changes).after?.[field]) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</template>
|
||||
|
||||
<!-- Create: show new values only -->
|
||||
<template v-else-if="parseChanges(log.changes).type === 'create'">
|
||||
<VChip size="x-small" color="success" variant="tonal" class="mb-2">
|
||||
New Values
|
||||
</VChip>
|
||||
<VTable density="compact" class="rounded border">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-caption" style="width: 30%;">
|
||||
Field
|
||||
</th>
|
||||
<th class="text-caption">
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="field in parseChanges(log.changes).fields" :key="field">
|
||||
<td class="text-caption font-weight-medium">
|
||||
{{ formatFieldName(field) }}
|
||||
</td>
|
||||
<td class="text-caption text-success">
|
||||
{{ formatValue(parseChanges(log.changes).after?.[field]) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</template>
|
||||
|
||||
<!-- Delete: show deleted values only -->
|
||||
<template v-else-if="parseChanges(log.changes).type === 'delete'">
|
||||
<VChip size="x-small" color="error" variant="tonal" class="mb-2">
|
||||
Deleted Values
|
||||
</VChip>
|
||||
<VTable density="compact" class="rounded border">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-caption" style="width: 30%;">
|
||||
Field
|
||||
</th>
|
||||
<th class="text-caption">
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="field in parseChanges(log.changes).fields" :key="field">
|
||||
<td class="text-caption font-weight-medium">
|
||||
{{ formatFieldName(field) }}
|
||||
</td>
|
||||
<td class="text-caption text-error text-decoration-line-through">
|
||||
{{ formatValue(parseChanges(log.changes).before?.[field]) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</template>
|
||||
|
||||
<!-- Generic: raw JSON -->
|
||||
<template v-else>
|
||||
<pre class="text-caption pa-3 rounded bg-surface" style="white-space: pre-wrap; word-break: break-all;">{{ JSON.stringify(log.changes, null, 2) }}</pre>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -368,5 +619,284 @@ const hasActiveFilters = computed<boolean>(() => {
|
||||
/>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Detail Dialog -->
|
||||
<VDialog
|
||||
v-model="detailDialog"
|
||||
max-width="800"
|
||||
scrollable
|
||||
>
|
||||
<VCard v-if="selectedLog">
|
||||
<VCardTitle class="d-flex align-center justify-space-between pa-5">
|
||||
<div class="d-flex align-center gap-3">
|
||||
<VAvatar :color="resolveActionColor(selectedLog.action)" variant="tonal" size="40">
|
||||
<VIcon :icon="resolveActionIcon(selectedLog.action)" size="20" />
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-h6">
|
||||
{{ formatAction(selectedLog.action) }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ formatDateTime(selectedLog.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VBtn icon="tabler-x" variant="text" size="small" @click="closeDetailDialog" />
|
||||
</VCardTitle>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<VCardText class="pa-5">
|
||||
<!-- Log Metadata -->
|
||||
<VRow class="mb-5">
|
||||
<VCol cols="12" sm="6">
|
||||
<div class="text-caption text-medium-emphasis mb-1">
|
||||
User
|
||||
</div>
|
||||
<div v-if="selectedLog.user" class="d-flex align-center gap-2">
|
||||
<VAvatar color="primary" variant="tonal" size="28">
|
||||
<span class="text-caption font-weight-medium">
|
||||
{{ selectedLog.user.name.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-body-2 font-weight-medium">
|
||||
{{ selectedLog.user.name }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ selectedLog.user.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VChip v-else size="small" variant="tonal" color="secondary">
|
||||
System
|
||||
</VChip>
|
||||
</VCol>
|
||||
<VCol cols="6" sm="3">
|
||||
<div class="text-caption text-medium-emphasis mb-1">
|
||||
Resource Type
|
||||
</div>
|
||||
<div class="text-body-2">
|
||||
{{ formatResourceType(selectedLog.resource_type) }}
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol cols="6" sm="3">
|
||||
<div class="text-caption text-medium-emphasis mb-1">
|
||||
Resource ID
|
||||
</div>
|
||||
<div class="text-body-2">
|
||||
{{ selectedLog.resource_id ?? '-' }}
|
||||
</div>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<VRow class="mb-5">
|
||||
<VCol cols="12" sm="6">
|
||||
<div class="text-caption text-medium-emphasis mb-1">
|
||||
IP Address
|
||||
</div>
|
||||
<div class="text-body-2">
|
||||
{{ selectedLog.ip_address ?? '-' }}
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol cols="12" sm="6">
|
||||
<div class="text-caption text-medium-emphasis mb-1">
|
||||
User Agent
|
||||
</div>
|
||||
<div class="text-caption" style="word-break: break-all;">
|
||||
{{ selectedLog.user_agent ?? '-' }}
|
||||
</div>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<VDivider class="mb-5" />
|
||||
|
||||
<!-- Changes Diff View -->
|
||||
<div v-if="hasChanges(selectedLog)">
|
||||
<div class="text-subtitle-1 font-weight-semibold mb-4">
|
||||
State Changes
|
||||
</div>
|
||||
|
||||
<!-- Update: before/after diff -->
|
||||
<template v-if="parseChanges(selectedLog.changes).type === 'update'">
|
||||
<VRow>
|
||||
<!-- Before Column -->
|
||||
<VCol cols="12" md="6">
|
||||
<VCard variant="outlined" color="error">
|
||||
<VCardTitle class="text-body-1 pa-3 d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-minus" size="16" color="error" />
|
||||
Before
|
||||
</VCardTitle>
|
||||
<VDivider />
|
||||
<VCardText class="pa-0">
|
||||
<VTable density="compact">
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="field in parseChanges(selectedLog.changes).fields"
|
||||
:key="field"
|
||||
>
|
||||
<td class="text-caption font-weight-medium" style="width: 40%;">
|
||||
{{ formatFieldName(field) }}
|
||||
</td>
|
||||
<td class="text-caption">
|
||||
<span
|
||||
:class="{
|
||||
'text-error text-decoration-line-through': isFieldChanged(parseChanges(selectedLog.changes).before, parseChanges(selectedLog.changes).after, field),
|
||||
}"
|
||||
>
|
||||
{{ formatValue(parseChanges(selectedLog.changes).before?.[field]) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
<!-- After Column -->
|
||||
<VCol cols="12" md="6">
|
||||
<VCard variant="outlined" color="success">
|
||||
<VCardTitle class="text-body-1 pa-3 d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-plus" size="16" color="success" />
|
||||
After
|
||||
</VCardTitle>
|
||||
<VDivider />
|
||||
<VCardText class="pa-0">
|
||||
<VTable density="compact">
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="field in parseChanges(selectedLog.changes).fields"
|
||||
:key="field"
|
||||
>
|
||||
<td class="text-caption font-weight-medium" style="width: 40%;">
|
||||
{{ formatFieldName(field) }}
|
||||
</td>
|
||||
<td class="text-caption">
|
||||
<span
|
||||
:class="{
|
||||
'text-success font-weight-medium': isFieldChanged(parseChanges(selectedLog.changes).before, parseChanges(selectedLog.changes).after, field),
|
||||
}"
|
||||
>
|
||||
{{ formatValue(parseChanges(selectedLog.changes).after?.[field]) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<!-- Changed fields summary -->
|
||||
<VAlert type="info" variant="tonal" density="compact" class="mt-4">
|
||||
<div class="text-caption">
|
||||
<strong>Changed fields:</strong>
|
||||
{{ parseChanges(selectedLog.changes).fields.filter(f => isFieldChanged(parseChanges(selectedLog.changes).before, parseChanges(selectedLog.changes).after, f)).join(', ') || 'None' }}
|
||||
</div>
|
||||
</VAlert>
|
||||
</template>
|
||||
|
||||
<!-- Create: new values -->
|
||||
<template v-else-if="parseChanges(selectedLog.changes).type === 'create'">
|
||||
<VAlert type="success" variant="tonal" density="compact" class="mb-4">
|
||||
<div class="text-caption">
|
||||
New record created with the following values:
|
||||
</div>
|
||||
</VAlert>
|
||||
<VCard variant="outlined" color="success">
|
||||
<VCardTitle class="text-body-1 pa-3 d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-plus" size="16" color="success" />
|
||||
New Values
|
||||
</VCardTitle>
|
||||
<VDivider />
|
||||
<VCardText class="pa-0">
|
||||
<VTable density="compact">
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="field in parseChanges(selectedLog.changes).fields"
|
||||
:key="field"
|
||||
>
|
||||
<td class="text-caption font-weight-medium" style="width: 40%;">
|
||||
{{ formatFieldName(field) }}
|
||||
</td>
|
||||
<td class="text-caption text-success">
|
||||
{{ formatValue(parseChanges(selectedLog.changes).after?.[field]) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<!-- Delete: deleted values -->
|
||||
<template v-else-if="parseChanges(selectedLog.changes).type === 'delete'">
|
||||
<VAlert type="error" variant="tonal" density="compact" class="mb-4">
|
||||
<div class="text-caption">
|
||||
Record deleted. Previous values:
|
||||
</div>
|
||||
</VAlert>
|
||||
<VCard variant="outlined" color="error">
|
||||
<VCardTitle class="text-body-1 pa-3 d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-minus" size="16" color="error" />
|
||||
Deleted Values
|
||||
</VCardTitle>
|
||||
<VDivider />
|
||||
<VCardText class="pa-0">
|
||||
<VTable density="compact">
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="field in parseChanges(selectedLog.changes).fields"
|
||||
:key="field"
|
||||
>
|
||||
<td class="text-caption font-weight-medium" style="width: 40%;">
|
||||
{{ formatFieldName(field) }}
|
||||
</td>
|
||||
<td class="text-caption text-error text-decoration-line-through">
|
||||
{{ formatValue(parseChanges(selectedLog.changes).before?.[field]) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<!-- Generic: raw JSON -->
|
||||
<template v-else>
|
||||
<VCard variant="outlined">
|
||||
<VCardTitle class="text-body-1 pa-3 d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-code" size="16" />
|
||||
Raw Changes Data
|
||||
</VCardTitle>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<pre class="text-caption rounded pa-3 bg-surface-variant" style="white-space: pre-wrap; word-break: break-all; max-height: 400px; overflow-y: auto;">{{ JSON.stringify(selectedLog.changes, null, 2) }}</pre>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- No changes -->
|
||||
<div v-else class="text-center py-6">
|
||||
<VIcon icon="tabler-file-off" size="36" color="disabled" class="mb-2" />
|
||||
<div class="text-medium-emphasis text-body-2">
|
||||
No change data recorded for this action.
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<VCardActions class="pa-4">
|
||||
<VSpacer />
|
||||
<VBtn variant="tonal" color="secondary" @click="closeDetailDialog">
|
||||
Close
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -94,12 +94,6 @@ function formatDate(dateString: string): string {
|
||||
|
||||
const formattedCreatedAt = computed<string>(() => formatDate(props.coupon.created_at))
|
||||
|
||||
const redemptionHeaders = computed(() => [
|
||||
{ title: 'Customer', key: 'user', sortable: false },
|
||||
{ title: 'Discount', key: 'discount_amount', sortable: true, align: 'end' as const },
|
||||
{ title: 'Redeemed', key: 'created_at', sortable: true },
|
||||
])
|
||||
|
||||
function submit(): void {
|
||||
form.put(`/coupons/${props.coupon.id}`, {
|
||||
preserveScroll: true,
|
||||
@@ -182,44 +176,33 @@ function submit(): void {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Redemption History -->
|
||||
<VCard title="Redemption History" class="mb-6">
|
||||
<VDataTable
|
||||
:headers="redemptionHeaders"
|
||||
:items="redemptions"
|
||||
:items-per-page="10"
|
||||
hover
|
||||
class="text-no-wrap"
|
||||
>
|
||||
<!-- Customer -->
|
||||
<template #item.user="{ item }">
|
||||
<div v-if="item.user" class="d-flex flex-column py-2">
|
||||
<span class="text-body-2 font-weight-medium">{{ item.user.name }}</span>
|
||||
<span class="text-caption text-medium-emphasis">{{ item.user.email }}</span>
|
||||
</div>
|
||||
<span v-else class="text-medium-emphasis">Unknown</span>
|
||||
</template>
|
||||
|
||||
<!-- Discount Amount -->
|
||||
<template #item.discount_amount="{ item }">
|
||||
<span class="font-weight-medium">${{ parseFloat(item.discount_amount).toFixed(2) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Created At -->
|
||||
<template #item.created_at="{ item }">
|
||||
{{ formatDate(item.created_at) }}
|
||||
</template>
|
||||
|
||||
<!-- No data -->
|
||||
<template #no-data>
|
||||
<div class="text-center py-8">
|
||||
<VIcon icon="tabler-receipt-off" size="40" color="disabled" class="mb-2" />
|
||||
<div class="text-medium-emphasis">
|
||||
No redemptions yet.
|
||||
<!-- Redemption History Link -->
|
||||
<VCard class="mb-6">
|
||||
<VCardText class="d-flex align-center justify-space-between">
|
||||
<div class="d-flex align-center gap-3">
|
||||
<VAvatar color="primary" variant="tonal" size="40" rounded>
|
||||
<VIcon icon="tabler-receipt" size="20" />
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-body-1 font-weight-medium">
|
||||
Redemption History
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
{{ redemptions.length }} redemption{{ redemptions.length !== 1 ? 's' : '' }} recorded
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VDataTable>
|
||||
</div>
|
||||
<Link :href="`/coupons/${coupon.id}`" class="text-decoration-none">
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="tabler-eye"
|
||||
size="small"
|
||||
>
|
||||
View All
|
||||
</VBtn>
|
||||
</Link>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
|
||||
@@ -2,14 +2,10 @@
|
||||
import { Link, router } from '@inertiajs/vue3'
|
||||
import { computed } from 'vue'
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import type { Coupon, PaginatedResponse, Plan, StatusColor } from '@/types'
|
||||
|
||||
interface CouponWithCount extends Coupon {
|
||||
redemptions_count: number
|
||||
}
|
||||
import type { CouponWithStats, PaginatedResponse, StatusColor } from '@/types'
|
||||
|
||||
interface Props {
|
||||
coupons: PaginatedResponse<CouponWithCount>
|
||||
coupons: PaginatedResponse<CouponWithStats>
|
||||
}
|
||||
|
||||
defineOptions({ layout: AdminLayout })
|
||||
@@ -22,6 +18,8 @@ const tableHeaders = computed(() => [
|
||||
{ title: 'Value', key: 'value', sortable: true, align: 'end' as const },
|
||||
{ title: 'Plans', key: 'applies_to', sortable: false },
|
||||
{ title: 'Usage', key: 'usage', sortable: false, align: 'center' as const },
|
||||
{ title: 'Total Discount', key: 'total_discount', sortable: false, align: 'end' as const },
|
||||
{ title: 'Last Redeemed', key: 'last_redeemed', sortable: false },
|
||||
{ title: 'Expires', key: 'expires_at', sortable: true },
|
||||
{ title: 'Status', key: 'status', sortable: false, align: 'center' as const },
|
||||
{ title: 'Actions', key: 'actions', sortable: false, align: 'center' as const },
|
||||
@@ -31,7 +29,7 @@ function resolveTypeColor(type: string): StatusColor {
|
||||
return type === 'percentage' ? 'info' : 'warning'
|
||||
}
|
||||
|
||||
function formatValue(coupon: CouponWithCount): string {
|
||||
function formatValue(coupon: CouponWithStats): string {
|
||||
if (coupon.type === 'percentage') {
|
||||
return `${parseFloat(coupon.value)}%`
|
||||
}
|
||||
@@ -57,7 +55,7 @@ function formatPlansApplicable(appliesTo: number[] | null): string {
|
||||
return `${appliesTo.length} plan${appliesTo.length > 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
function resolveCouponStatus(coupon: CouponWithCount): { label: string; color: StatusColor } {
|
||||
function resolveCouponStatus(coupon: CouponWithStats): { label: string; color: StatusColor } {
|
||||
if (!coupon.active) {
|
||||
return { label: 'Inactive', color: 'error' }
|
||||
}
|
||||
@@ -70,7 +68,7 @@ function resolveCouponStatus(coupon: CouponWithCount): { label: string; color: S
|
||||
return { label: 'Active', color: 'success' }
|
||||
}
|
||||
|
||||
function deactivateCoupon(coupon: CouponWithCount): void {
|
||||
function deactivateCoupon(coupon: CouponWithStats): void {
|
||||
if (confirm(`Are you sure you want to deactivate coupon "${coupon.code}"?`)) {
|
||||
router.delete(`/coupons/${coupon.id}`, {
|
||||
preserveScroll: true,
|
||||
@@ -91,11 +89,18 @@ function deactivateCoupon(coupon: CouponWithCount): void {
|
||||
Manage discount coupons and promotions
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/coupons/create">
|
||||
<VBtn color="primary" prepend-icon="tabler-plus">
|
||||
Create Coupon
|
||||
</VBtn>
|
||||
</Link>
|
||||
<div class="d-flex gap-2">
|
||||
<Link href="/coupons/redemptions">
|
||||
<VBtn variant="outlined" prepend-icon="tabler-receipt">
|
||||
View All Redemptions
|
||||
</VBtn>
|
||||
</Link>
|
||||
<Link href="/coupons/create">
|
||||
<VBtn color="primary" prepend-icon="tabler-plus">
|
||||
Create Coupon
|
||||
</VBtn>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Coupons Table -->
|
||||
@@ -142,14 +147,32 @@ function deactivateCoupon(coupon: CouponWithCount): void {
|
||||
|
||||
<!-- Usage -->
|
||||
<template #item.usage="{ item }">
|
||||
<span class="font-weight-medium">
|
||||
{{ item.redemptions_count }}
|
||||
</span>
|
||||
<Link :href="`/coupons/${item.id}`" class="text-decoration-none">
|
||||
<span class="font-weight-medium text-primary">
|
||||
{{ item.redemptions_count }}
|
||||
</span>
|
||||
</Link>
|
||||
<span class="text-medium-emphasis">
|
||||
/ {{ item.max_uses ?? '∞' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Total Discount -->
|
||||
<template #item.total_discount="{ item }">
|
||||
<span v-if="item.redemptions_sum_discount_amount" class="font-weight-medium text-success">
|
||||
${{ parseFloat(item.redemptions_sum_discount_amount).toFixed(2) }}
|
||||
</span>
|
||||
<span v-else class="text-medium-emphasis">$0.00</span>
|
||||
</template>
|
||||
|
||||
<!-- Last Redeemed -->
|
||||
<template #item.last_redeemed="{ item }">
|
||||
<span v-if="item.redemptions_max_created_at">
|
||||
{{ formatDate(item.redemptions_max_created_at) }}
|
||||
</span>
|
||||
<span v-else class="text-medium-emphasis">Never</span>
|
||||
</template>
|
||||
|
||||
<!-- Expires -->
|
||||
<template #item.expires_at="{ item }">
|
||||
<span :class="{ 'text-error': item.expires_at && new Date(item.expires_at) < new Date() }">
|
||||
@@ -179,6 +202,11 @@ function deactivateCoupon(coupon: CouponWithCount): void {
|
||||
/>
|
||||
</template>
|
||||
<VList density="compact">
|
||||
<Link :href="`/coupons/${item.id}`" class="text-decoration-none">
|
||||
<VListItem prepend-icon="tabler-eye">
|
||||
<VListItemTitle>View Redemptions</VListItemTitle>
|
||||
</VListItem>
|
||||
</Link>
|
||||
<Link :href="`/coupons/${item.id}/edit`" class="text-decoration-none">
|
||||
<VListItem prepend-icon="tabler-edit">
|
||||
<VListItemTitle>Edit</VListItemTitle>
|
||||
|
||||
413
website/resources/ts/Pages/Admin/Coupons/Redemptions.vue
Normal file
413
website/resources/ts/Pages/Admin/Coupons/Redemptions.vue
Normal file
@@ -0,0 +1,413 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link, router } from '@inertiajs/vue3'
|
||||
import { computed, ref } from 'vue'
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import type { Coupon, CouponRedemption, PaginatedResponse, StatusColor } from '@/types'
|
||||
|
||||
interface Props {
|
||||
redemptions: PaginatedResponse<CouponRedemption>
|
||||
coupons: Coupon[]
|
||||
stats: {
|
||||
total_redemptions: number
|
||||
total_discount: number
|
||||
unique_customers: number
|
||||
unique_coupons: number
|
||||
}
|
||||
filters: {
|
||||
coupon_id?: number | string
|
||||
customer?: string
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
}
|
||||
}
|
||||
|
||||
defineOptions({ layout: AdminLayout })
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Filter form state
|
||||
const filterForm = ref({
|
||||
coupon_id: props.filters.coupon_id ?? '',
|
||||
customer: props.filters.customer ?? '',
|
||||
date_from: props.filters.date_from ?? '',
|
||||
date_to: props.filters.date_to ?? '',
|
||||
})
|
||||
|
||||
const tableHeaders = computed(() => [
|
||||
{ title: 'Coupon', key: 'coupon', sortable: false },
|
||||
{ title: 'Customer', key: 'user', sortable: false },
|
||||
{ title: 'Subscription', key: 'subscription', sortable: false },
|
||||
{ title: 'Discount Amount', key: 'discount_amount', sortable: true, align: 'end' as const },
|
||||
{ title: 'Redeemed At', key: 'created_at', sortable: true },
|
||||
])
|
||||
|
||||
function applyFilters(): void {
|
||||
router.get('/coupons/redemptions', filterForm.value, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
|
||||
function clearFilters(): void {
|
||||
filterForm.value = {
|
||||
coupon_id: '',
|
||||
customer: '',
|
||||
date_from: '',
|
||||
date_to: '',
|
||||
}
|
||||
router.get('/coupons/redemptions', {}, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
|
||||
function exportToCSV(): void {
|
||||
// Build query string with current filters
|
||||
const params = new URLSearchParams()
|
||||
if (filterForm.value.coupon_id) {
|
||||
params.append('coupon_id', String(filterForm.value.coupon_id))
|
||||
}
|
||||
if (filterForm.value.customer) {
|
||||
params.append('customer', filterForm.value.customer)
|
||||
}
|
||||
if (filterForm.value.date_from) {
|
||||
params.append('date_from', filterForm.value.date_from)
|
||||
}
|
||||
if (filterForm.value.date_to) {
|
||||
params.append('date_to', filterForm.value.date_to)
|
||||
}
|
||||
params.append('export', 'csv')
|
||||
|
||||
window.location.href = `/coupons/redemptions?${params.toString()}`
|
||||
}
|
||||
|
||||
function formatDate(dateString: string | null): string {
|
||||
if (!dateString) {
|
||||
return 'N/A'
|
||||
}
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function formatCouponValue(coupon: { type: string; value: string }): string {
|
||||
if (coupon.type === 'percentage') {
|
||||
return `${parseFloat(coupon.value)}%`
|
||||
}
|
||||
return `$${parseFloat(coupon.value).toFixed(2)}`
|
||||
}
|
||||
|
||||
function resolveSubscriptionStatusColor(status: string): StatusColor {
|
||||
const map: Record<string, StatusColor> = {
|
||||
active: 'success',
|
||||
canceled: 'error',
|
||||
past_due: 'warning',
|
||||
trialing: 'info',
|
||||
incomplete: 'secondary',
|
||||
}
|
||||
return map[status] ?? 'secondary'
|
||||
}
|
||||
|
||||
function resolveCouponTypeColor(type: string): StatusColor {
|
||||
return type === 'percentage' ? 'info' : 'warning'
|
||||
}
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return !!(filterForm.value.coupon_id || filterForm.value.customer || filterForm.value.date_from || filterForm.value.date_to)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="d-flex align-center justify-space-between mb-6">
|
||||
<div>
|
||||
<div class="d-flex align-center gap-2 mb-1">
|
||||
<Link href="/coupons" class="text-decoration-none">
|
||||
<VBtn icon="tabler-arrow-left" variant="text" size="small" />
|
||||
</Link>
|
||||
<span class="text-h4 font-weight-bold">Coupon Redemption History</span>
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis ms-10">
|
||||
View all coupon redemptions across all coupons
|
||||
</div>
|
||||
</div>
|
||||
<VBtn
|
||||
color="success"
|
||||
prepend-icon="tabler-download"
|
||||
@click="exportToCSV"
|
||||
>
|
||||
Export CSV
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<VRow class="mb-6">
|
||||
<VCol cols="12" sm="6" lg="3">
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center gap-4">
|
||||
<VAvatar color="primary" variant="tonal" size="48" rounded>
|
||||
<VIcon icon="tabler-receipt" size="24" />
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-h5 font-weight-bold">
|
||||
{{ stats.total_redemptions }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Total Redemptions
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol cols="12" sm="6" lg="3">
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center gap-4">
|
||||
<VAvatar color="success" variant="tonal" size="48" rounded>
|
||||
<VIcon icon="tabler-currency-dollar" size="24" />
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-h5 font-weight-bold">
|
||||
${{ stats.total_discount.toFixed(2) }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Total Discount Given
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol cols="12" sm="6" lg="3">
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center gap-4">
|
||||
<VAvatar color="info" variant="tonal" size="48" rounded>
|
||||
<VIcon icon="tabler-users" size="24" />
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-h5 font-weight-bold">
|
||||
{{ stats.unique_customers }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Unique Customers
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol cols="12" sm="6" lg="3">
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center gap-4">
|
||||
<VAvatar color="warning" variant="tonal" size="48" rounded>
|
||||
<VIcon icon="tabler-discount-2" size="24" />
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-h5 font-weight-bold">
|
||||
{{ stats.unique_coupons }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Coupons Used
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<!-- Filters -->
|
||||
<VCard class="mb-6">
|
||||
<VCardText>
|
||||
<div class="text-subtitle-1 font-weight-medium mb-4">
|
||||
Filters
|
||||
</div>
|
||||
<VRow>
|
||||
<VCol cols="12" md="3">
|
||||
<VSelect
|
||||
v-model="filterForm.coupon_id"
|
||||
:items="coupons"
|
||||
item-title="code"
|
||||
item-value="id"
|
||||
label="Coupon"
|
||||
clearable
|
||||
density="comfortable"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3">
|
||||
<VTextField
|
||||
v-model="filterForm.customer"
|
||||
label="Customer"
|
||||
placeholder="Name or email"
|
||||
clearable
|
||||
density="comfortable"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="2">
|
||||
<VTextField
|
||||
v-model="filterForm.date_from"
|
||||
label="From Date"
|
||||
type="date"
|
||||
clearable
|
||||
density="comfortable"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="2">
|
||||
<VTextField
|
||||
v-model="filterForm.date_to"
|
||||
label="To Date"
|
||||
type="date"
|
||||
clearable
|
||||
density="comfortable"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="2" class="d-flex align-center gap-2">
|
||||
<VBtn
|
||||
color="primary"
|
||||
block
|
||||
@click="applyFilters"
|
||||
>
|
||||
Apply
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="hasActiveFilters"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
block
|
||||
@click="clearFilters"
|
||||
>
|
||||
Clear
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Redemptions Table -->
|
||||
<VCard>
|
||||
<VDataTable
|
||||
:headers="tableHeaders"
|
||||
:items="redemptions.data"
|
||||
:items-per-page="25"
|
||||
hover
|
||||
class="text-no-wrap"
|
||||
>
|
||||
<!-- Coupon -->
|
||||
<template #item.coupon="{ item }">
|
||||
<div v-if="item.coupon" class="d-flex flex-column py-2">
|
||||
<Link :href="`/coupons/${item.coupon.id}`" class="text-decoration-none">
|
||||
<span class="text-body-2 font-weight-medium font-monospace text-primary">{{ item.coupon.code }}</span>
|
||||
</Link>
|
||||
<div class="d-flex align-center gap-2 mt-1">
|
||||
<VChip
|
||||
:color="resolveCouponTypeColor(item.coupon.type)"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ item.coupon.type }}
|
||||
</VChip>
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
{{ formatCouponValue(item.coupon) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-medium-emphasis">
|
||||
Deleted Coupon
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Customer -->
|
||||
<template #item.user="{ item }">
|
||||
<div v-if="item.user" class="d-flex flex-column py-2">
|
||||
<Link :href="`/customers/${item.user.id}`" class="text-decoration-none">
|
||||
<span class="text-body-2 font-weight-medium text-primary">{{ item.user.name }}</span>
|
||||
</Link>
|
||||
<span class="text-caption text-medium-emphasis">{{ item.user.email }}</span>
|
||||
</div>
|
||||
<span v-else class="text-medium-emphasis">
|
||||
Deleted User
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Subscription -->
|
||||
<template #item.subscription="{ item }">
|
||||
<template v-if="item.subscription">
|
||||
<span class="font-weight-medium">#{{ item.subscription.id }}</span>
|
||||
<VChip
|
||||
:color="resolveSubscriptionStatusColor(item.subscription.stripe_status)"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
class="ms-2 text-capitalize"
|
||||
>
|
||||
{{ item.subscription.stripe_status }}
|
||||
</VChip>
|
||||
</template>
|
||||
<span v-else class="text-medium-emphasis">N/A</span>
|
||||
</template>
|
||||
|
||||
<!-- Discount Amount -->
|
||||
<template #item.discount_amount="{ item }">
|
||||
<span class="font-weight-medium text-success">
|
||||
-${{ parseFloat(item.discount_amount).toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Redeemed At -->
|
||||
<template #item.created_at="{ item }">
|
||||
{{ formatDate(item.created_at) }}
|
||||
</template>
|
||||
|
||||
<!-- No data -->
|
||||
<template #no-data>
|
||||
<div class="text-center py-8">
|
||||
<VIcon icon="tabler-receipt-off" size="48" color="disabled" class="mb-2" />
|
||||
<div class="text-medium-emphasis">
|
||||
{{ hasActiveFilters ? 'No redemptions found with the selected filters.' : 'No coupon redemptions yet.' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Bottom pagination -->
|
||||
<template #bottom>
|
||||
<VDivider />
|
||||
<div class="d-flex align-center justify-space-between pa-4">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Showing {{ redemptions.from ?? 0 }} to {{ redemptions.to ?? 0 }} of {{ redemptions.total }} redemptions
|
||||
</div>
|
||||
<div v-if="redemptions.last_page > 1" class="d-flex gap-2">
|
||||
<template v-for="link in redemptions.links" :key="link.label">
|
||||
<Link
|
||||
v-if="link.url"
|
||||
:href="link.url"
|
||||
class="text-decoration-none"
|
||||
preserve-scroll
|
||||
>
|
||||
<VBtn
|
||||
:variant="link.active ? 'flat' : 'text'"
|
||||
:color="link.active ? 'primary' : undefined"
|
||||
size="small"
|
||||
min-width="36"
|
||||
>
|
||||
<span v-html="link.label" />
|
||||
</VBtn>
|
||||
</Link>
|
||||
<VBtn
|
||||
v-else
|
||||
variant="text"
|
||||
size="small"
|
||||
min-width="36"
|
||||
disabled
|
||||
>
|
||||
<span v-html="link.label" />
|
||||
</VBtn>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VDataTable>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
387
website/resources/ts/Pages/Admin/Coupons/Show.vue
Normal file
387
website/resources/ts/Pages/Admin/Coupons/Show.vue
Normal file
@@ -0,0 +1,387 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
import { computed } from 'vue'
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import type { Coupon, CouponRedemption, CouponRedemptionStats, PaginatedResponse, StatusColor } from '@/types'
|
||||
|
||||
interface Props {
|
||||
coupon: Coupon
|
||||
redemptions: PaginatedResponse<CouponRedemption>
|
||||
stats: CouponRedemptionStats
|
||||
}
|
||||
|
||||
defineOptions({ layout: AdminLayout })
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
function resolveCouponStatus(): { label: string; color: StatusColor } {
|
||||
if (!props.coupon.active) {
|
||||
return { label: 'Inactive', color: 'error' }
|
||||
}
|
||||
if (props.coupon.expires_at && new Date(props.coupon.expires_at) < new Date()) {
|
||||
return { label: 'Expired', color: 'secondary' }
|
||||
}
|
||||
if (props.coupon.max_uses !== null && props.coupon.times_used >= props.coupon.max_uses) {
|
||||
return { label: 'Exhausted', color: 'warning' }
|
||||
}
|
||||
return { label: 'Active', color: 'success' }
|
||||
}
|
||||
|
||||
function formatValue(coupon: Coupon): string {
|
||||
if (coupon.type === 'percentage') {
|
||||
return `${parseFloat(coupon.value)}%`
|
||||
}
|
||||
return `$${parseFloat(coupon.value).toFixed(2)}`
|
||||
}
|
||||
|
||||
function formatDate(dateString: string | null): string {
|
||||
if (!dateString) {
|
||||
return 'N/A'
|
||||
}
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function formatDateTime(dateString: string | null): string {
|
||||
if (!dateString) {
|
||||
return 'N/A'
|
||||
}
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const couponStatus = computed(() => resolveCouponStatus())
|
||||
|
||||
const redemptionHeaders = computed(() => [
|
||||
{ title: 'Customer', key: 'user', sortable: false },
|
||||
{ title: 'Subscription', key: 'subscription', sortable: false },
|
||||
{ title: 'Discount Applied', key: 'discount_amount', sortable: true, align: 'end' as const },
|
||||
{ title: 'Redeemed At', key: 'created_at', sortable: true },
|
||||
])
|
||||
|
||||
function resolveSubscriptionLabel(redemption: CouponRedemption): string {
|
||||
if (!redemption.subscription) {
|
||||
return 'N/A'
|
||||
}
|
||||
return `#${redemption.subscription.id} (${redemption.subscription.type})`
|
||||
}
|
||||
|
||||
function resolveSubscriptionStatusColor(status: string): StatusColor {
|
||||
const map: Record<string, StatusColor> = {
|
||||
active: 'success',
|
||||
canceled: 'error',
|
||||
past_due: 'warning',
|
||||
trialing: 'info',
|
||||
incomplete: 'secondary',
|
||||
}
|
||||
return map[status] ?? 'secondary'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="d-flex align-center justify-space-between mb-6">
|
||||
<div>
|
||||
<div class="d-flex align-center gap-2 mb-1">
|
||||
<Link href="/coupons" class="text-decoration-none">
|
||||
<VBtn icon="tabler-arrow-left" variant="text" size="small" />
|
||||
</Link>
|
||||
<span class="text-h4 font-weight-bold">Coupon Details</span>
|
||||
<VChip
|
||||
:color="couponStatus.color"
|
||||
size="small"
|
||||
class="ms-2"
|
||||
>
|
||||
{{ couponStatus.label }}
|
||||
</VChip>
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis ms-10">
|
||||
Viewing coupon "{{ coupon.code }}"
|
||||
</div>
|
||||
</div>
|
||||
<Link :href="`/coupons/${coupon.id}/edit`">
|
||||
<VBtn color="primary" prepend-icon="tabler-edit">
|
||||
Edit Coupon
|
||||
</VBtn>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<!-- Main Content -->
|
||||
<VCol cols="12" lg="8">
|
||||
<!-- Redemption Stats -->
|
||||
<VRow class="mb-6">
|
||||
<VCol cols="12" sm="4">
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center gap-4">
|
||||
<VAvatar color="primary" variant="tonal" size="48" rounded>
|
||||
<VIcon icon="tabler-receipt" size="24" />
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-h5 font-weight-bold">
|
||||
{{ stats.total_redemptions }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Total Redemptions
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol cols="12" sm="4">
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center gap-4">
|
||||
<VAvatar color="success" variant="tonal" size="48" rounded>
|
||||
<VIcon icon="tabler-currency-dollar" size="24" />
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-h5 font-weight-bold">
|
||||
${{ stats.total_discount.toFixed(2) }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Total Discount Given
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol cols="12" sm="4">
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center gap-4">
|
||||
<VAvatar color="info" variant="tonal" size="48" rounded>
|
||||
<VIcon icon="tabler-clock" size="24" />
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-h5 font-weight-bold">
|
||||
{{ formatDate(stats.latest_redemption) }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Last Redemption
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<!-- Redemption History Table -->
|
||||
<VCard title="Redemption History">
|
||||
<VDataTable
|
||||
:headers="redemptionHeaders"
|
||||
:items="redemptions.data"
|
||||
:items-per-page="25"
|
||||
hover
|
||||
class="text-no-wrap"
|
||||
>
|
||||
<!-- Customer -->
|
||||
<template #item.user="{ item }">
|
||||
<div v-if="item.user" class="d-flex flex-column py-2">
|
||||
<Link :href="`/customers/${item.user.id}`" class="text-decoration-none">
|
||||
<span class="text-body-2 font-weight-medium text-primary">{{ item.user.name }}</span>
|
||||
</Link>
|
||||
<span class="text-caption text-medium-emphasis">{{ item.user.email }}</span>
|
||||
</div>
|
||||
<span v-else class="text-medium-emphasis">
|
||||
Deleted User
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Subscription -->
|
||||
<template #item.subscription="{ item }">
|
||||
<template v-if="item.subscription">
|
||||
<span class="font-weight-medium">{{ resolveSubscriptionLabel(item) }}</span>
|
||||
<VChip
|
||||
:color="resolveSubscriptionStatusColor(item.subscription.stripe_status)"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
class="ms-2 text-capitalize"
|
||||
>
|
||||
{{ item.subscription.stripe_status }}
|
||||
</VChip>
|
||||
</template>
|
||||
<span v-else class="text-medium-emphasis">N/A</span>
|
||||
</template>
|
||||
|
||||
<!-- Discount Amount -->
|
||||
<template #item.discount_amount="{ item }">
|
||||
<span class="font-weight-medium text-success">
|
||||
-${{ parseFloat(item.discount_amount).toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Redeemed At -->
|
||||
<template #item.created_at="{ item }">
|
||||
{{ formatDateTime(item.created_at) }}
|
||||
</template>
|
||||
|
||||
<!-- No data -->
|
||||
<template #no-data>
|
||||
<div class="text-center py-8">
|
||||
<VIcon icon="tabler-receipt-off" size="48" color="disabled" class="mb-2" />
|
||||
<div class="text-medium-emphasis">
|
||||
No redemptions for this coupon yet.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Bottom pagination info -->
|
||||
<template #bottom>
|
||||
<VDivider />
|
||||
<div class="d-flex align-center justify-space-between pa-4">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Showing {{ redemptions.from ?? 0 }} to {{ redemptions.to ?? 0 }} of {{ redemptions.total }} redemptions
|
||||
</div>
|
||||
<div v-if="redemptions.last_page > 1" class="d-flex gap-2">
|
||||
<template v-for="link in redemptions.links" :key="link.label">
|
||||
<Link
|
||||
v-if="link.url"
|
||||
:href="link.url"
|
||||
class="text-decoration-none"
|
||||
preserve-scroll
|
||||
>
|
||||
<VBtn
|
||||
:variant="link.active ? 'flat' : 'text'"
|
||||
:color="link.active ? 'primary' : undefined"
|
||||
size="small"
|
||||
min-width="36"
|
||||
>
|
||||
<span v-html="link.label" />
|
||||
</VBtn>
|
||||
</Link>
|
||||
<VBtn
|
||||
v-else
|
||||
variant="text"
|
||||
size="small"
|
||||
min-width="36"
|
||||
disabled
|
||||
>
|
||||
<span v-html="link.label" />
|
||||
</VBtn>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VDataTable>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<VCol cols="12" lg="4">
|
||||
<!-- Coupon Info -->
|
||||
<VCard title="Coupon Info" class="mb-6">
|
||||
<VCardText>
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<span class="text-body-2 text-medium-emphasis">Code</span>
|
||||
<span class="font-weight-medium font-monospace">{{ coupon.code }}</span>
|
||||
</div>
|
||||
<VDivider class="mb-4" />
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<span class="text-body-2 text-medium-emphasis">Status</span>
|
||||
<VChip
|
||||
:color="couponStatus.color"
|
||||
size="small"
|
||||
>
|
||||
{{ couponStatus.label }}
|
||||
</VChip>
|
||||
</div>
|
||||
<VDivider class="mb-4" />
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<span class="text-body-2 text-medium-emphasis">Type</span>
|
||||
<VChip
|
||||
:color="coupon.type === 'percentage' ? 'info' : 'warning'"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ coupon.type }}
|
||||
</VChip>
|
||||
</div>
|
||||
<VDivider class="mb-4" />
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<span class="text-body-2 text-medium-emphasis">Value</span>
|
||||
<span class="text-body-2 font-weight-medium">{{ formatValue(coupon) }}</span>
|
||||
</div>
|
||||
<VDivider class="mb-4" />
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<span class="text-body-2 text-medium-emphasis">Usage</span>
|
||||
<span class="text-body-2 font-weight-medium">
|
||||
{{ coupon.times_used }} / {{ coupon.max_uses ?? '∞' }}
|
||||
</span>
|
||||
</div>
|
||||
<VDivider class="mb-4" />
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<span class="text-body-2 text-medium-emphasis">Expires</span>
|
||||
<span
|
||||
class="text-body-2"
|
||||
:class="{ 'text-error': coupon.expires_at && new Date(coupon.expires_at) < new Date() }"
|
||||
>
|
||||
{{ coupon.expires_at ? formatDate(coupon.expires_at) : 'Never' }}
|
||||
</span>
|
||||
</div>
|
||||
<VDivider class="mb-4" />
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<span class="text-body-2 text-medium-emphasis">Created</span>
|
||||
<span class="text-body-2">{{ formatDate(coupon.created_at) }}</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Plan Restrictions -->
|
||||
<VCard title="Plan Restrictions" class="mb-6">
|
||||
<VCardText>
|
||||
<div v-if="!coupon.applies_to || coupon.applies_to.length === 0" class="text-body-2 text-medium-emphasis">
|
||||
Applies to all plans (no restrictions).
|
||||
</div>
|
||||
<div v-else>
|
||||
<VChip
|
||||
v-for="planId in coupon.applies_to"
|
||||
:key="planId"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="secondary"
|
||||
class="me-2 mb-2"
|
||||
>
|
||||
Plan #{{ planId }}
|
||||
</VChip>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Actions -->
|
||||
<VCard>
|
||||
<VCardText>
|
||||
<Link :href="`/coupons/${coupon.id}/edit`" class="text-decoration-none">
|
||||
<VBtn
|
||||
color="primary"
|
||||
block
|
||||
prepend-icon="tabler-edit"
|
||||
class="mb-3"
|
||||
>
|
||||
Edit Coupon
|
||||
</VBtn>
|
||||
</Link>
|
||||
<Link href="/coupons" class="text-decoration-none">
|
||||
<VBtn
|
||||
variant="outlined"
|
||||
block
|
||||
>
|
||||
Back to List
|
||||
</VBtn>
|
||||
</Link>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,10 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link, router, useForm } from '@inertiajs/vue3'
|
||||
import { ref } from 'vue'
|
||||
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
|
||||
@@ -77,6 +85,7 @@ interface Props {
|
||||
subscriptions: CustomerSubscription[]
|
||||
recentInvoices: CustomerInvoice[]
|
||||
auditLogs: PaginatedResponse<AuditLog>
|
||||
plans: Plan[]
|
||||
}
|
||||
|
||||
defineOptions({ layout: AdminLayout })
|
||||
@@ -89,6 +98,32 @@ 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,
|
||||
@@ -101,6 +136,44 @@ function handleUnsuspend(): void {
|
||||
})
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -381,6 +454,47 @@ function goToAuditPage(page: number): void {
|
||||
<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>
|
||||
@@ -994,5 +1108,189 @@ function goToAuditPage(page: number): void {
|
||||
</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-Annually', value: 'semi_annually' },
|
||||
{ title: 'Annually', value: 'annually' },
|
||||
]"
|
||||
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>
|
||||
|
||||
296
website/resources/ts/Pages/Admin/Invoices/Create.vue
Normal file
296
website/resources/ts/Pages/Admin/Invoices/Create.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link, useForm } from '@inertiajs/vue3'
|
||||
import { computed } from 'vue'
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
|
||||
import AppTextarea from '@/Components/app-form-elements/AppTextarea.vue'
|
||||
import { formatPrice } from '@/utils/resolvers'
|
||||
|
||||
interface CustomerOption {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
interface LineItem {
|
||||
description: string
|
||||
quantity: number
|
||||
unit_price: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
customers: CustomerOption[]
|
||||
}
|
||||
|
||||
defineOptions({ layout: AdminLayout })
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const customerOptions = computed(() => {
|
||||
return props.customers.map((c: CustomerOption) => ({
|
||||
title: `${c.name} (${c.email})`,
|
||||
value: c.id,
|
||||
}))
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
customer_id: null as number | null,
|
||||
items: [{ description: '', quantity: 1, unit_price: '' }] as LineItem[],
|
||||
due_date: '',
|
||||
notes: '',
|
||||
send_immediately: false,
|
||||
})
|
||||
|
||||
function addLineItem(): void {
|
||||
form.items.push({ description: '', quantity: 1, unit_price: '' })
|
||||
}
|
||||
|
||||
function removeLineItem(index: number): void {
|
||||
if (form.items.length > 1) {
|
||||
form.items.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function lineTotal(item: LineItem): number {
|
||||
return (parseFloat(item.unit_price) || 0) * (item.quantity || 0)
|
||||
}
|
||||
|
||||
const subtotal = computed<number>(() => {
|
||||
return form.items.reduce((sum: number, item: LineItem) => sum + lineTotal(item), 0)
|
||||
})
|
||||
|
||||
function submitDraft(): void {
|
||||
form.send_immediately = false
|
||||
form.post('/invoices', { preserveScroll: true })
|
||||
}
|
||||
|
||||
function submitAndSend(): void {
|
||||
form.send_immediately = true
|
||||
form.post('/invoices', { preserveScroll: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="d-flex align-center justify-space-between mb-6">
|
||||
<div>
|
||||
<div class="d-flex align-center gap-2 mb-1">
|
||||
<Link href="/invoices" class="text-decoration-none">
|
||||
<VBtn icon="tabler-arrow-left" variant="text" size="small" />
|
||||
</Link>
|
||||
<span class="text-h4 font-weight-bold">Create Invoice</span>
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis ms-10">
|
||||
Create a new manual invoice for a customer
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitDraft">
|
||||
<VRow>
|
||||
<!-- Main Content -->
|
||||
<VCol cols="12" lg="8">
|
||||
<!-- Customer Selection -->
|
||||
<VCard title="Customer" class="mb-6">
|
||||
<VCardText>
|
||||
<VAutocomplete
|
||||
v-model="form.customer_id"
|
||||
:items="customerOptions"
|
||||
label="Select Customer"
|
||||
placeholder="Search by name or email..."
|
||||
:error-messages="form.errors.customer_id"
|
||||
clearable
|
||||
no-data-text="No customers found"
|
||||
/>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Line Items -->
|
||||
<VCard title="Line Items" class="mb-6">
|
||||
<VCardText>
|
||||
<!-- Items Header -->
|
||||
<VRow class="mb-2 d-none d-md-flex">
|
||||
<VCol cols="12" md="5">
|
||||
<span class="text-body-2 font-weight-medium text-medium-emphasis">Description</span>
|
||||
</VCol>
|
||||
<VCol cols="12" md="2">
|
||||
<span class="text-body-2 font-weight-medium text-medium-emphasis">Qty</span>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3">
|
||||
<span class="text-body-2 font-weight-medium text-medium-emphasis">Unit Price</span>
|
||||
</VCol>
|
||||
<VCol cols="12" md="2">
|
||||
<span class="text-body-2 font-weight-medium text-medium-emphasis">Total</span>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<VDivider class="mb-4 d-none d-md-flex" />
|
||||
|
||||
<!-- Item Rows -->
|
||||
<div
|
||||
v-for="(item, index) in form.items"
|
||||
:key="index"
|
||||
class="mb-3"
|
||||
>
|
||||
<VRow align="center">
|
||||
<VCol cols="12" md="5">
|
||||
<AppTextField
|
||||
v-model="item.description"
|
||||
placeholder="Item description"
|
||||
density="compact"
|
||||
:error-messages="form.errors[`items.${index}.description`]"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="2">
|
||||
<AppTextField
|
||||
v-model.number="item.quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="1"
|
||||
density="compact"
|
||||
:error-messages="form.errors[`items.${index}.quantity`]"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
<AppTextField
|
||||
v-model="item.unit_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
density="compact"
|
||||
prefix="$"
|
||||
:error-messages="form.errors[`items.${index}.unit_price`]"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="10" md="1" class="text-body-2 font-weight-medium">
|
||||
{{ formatPrice(lineTotal(item)) }}
|
||||
</VCol>
|
||||
<VCol cols="2" md="1">
|
||||
<VBtn
|
||||
icon="tabler-trash"
|
||||
color="error"
|
||||
variant="text"
|
||||
size="small"
|
||||
:disabled="form.items.length <= 1"
|
||||
@click="removeLineItem(index)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
|
||||
<div v-if="form.errors.items" class="text-error text-body-2 mb-3">
|
||||
{{ form.errors.items }}
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
prepend-icon="tabler-plus"
|
||||
size="small"
|
||||
@click="addLineItem"
|
||||
>
|
||||
Add Line Item
|
||||
</VBtn>
|
||||
|
||||
<!-- Totals -->
|
||||
<VDivider class="my-4" />
|
||||
<div class="d-flex flex-column align-end ga-2">
|
||||
<div class="d-flex align-center ga-6" style="min-width: 200px;">
|
||||
<span class="text-body-1 font-weight-bold">Total</span>
|
||||
<VSpacer />
|
||||
<span class="text-body-1 font-weight-bold">{{ formatPrice(subtotal) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Notes -->
|
||||
<VCard title="Notes" class="mb-6">
|
||||
<VCardText>
|
||||
<AppTextarea
|
||||
v-model="form.notes"
|
||||
label="Invoice Notes"
|
||||
placeholder="Optional notes to include on the invoice..."
|
||||
rows="3"
|
||||
:error-messages="form.errors.notes"
|
||||
/>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<VCol cols="12" lg="4">
|
||||
<!-- Due Date -->
|
||||
<VCard title="Due Date" class="mb-6">
|
||||
<VCardText>
|
||||
<AppTextField
|
||||
v-model="form.due_date"
|
||||
label="Due Date"
|
||||
type="date"
|
||||
:error-messages="form.errors.due_date"
|
||||
/>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Summary -->
|
||||
<VCard title="Summary" class="mb-6">
|
||||
<VCardText>
|
||||
<div class="d-flex justify-space-between align-center mb-3">
|
||||
<span class="text-body-2 text-medium-emphasis">Line Items</span>
|
||||
<span class="text-body-2 font-weight-medium">{{ form.items.length }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between align-center mb-3">
|
||||
<span class="text-body-2 text-medium-emphasis">Subtotal</span>
|
||||
<span class="text-body-2 font-weight-medium">{{ formatPrice(subtotal) }}</span>
|
||||
</div>
|
||||
<VDivider class="my-2" />
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<span class="text-body-1 font-weight-bold">Total</span>
|
||||
<span class="text-body-1 font-weight-bold">{{ formatPrice(subtotal) }}</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Actions -->
|
||||
<VCard>
|
||||
<VCardText>
|
||||
<VBtn
|
||||
color="primary"
|
||||
block
|
||||
:loading="form.processing && form.send_immediately"
|
||||
:disabled="form.processing"
|
||||
prepend-icon="tabler-send"
|
||||
class="mb-3"
|
||||
@click="submitAndSend"
|
||||
>
|
||||
Create & Send
|
||||
</VBtn>
|
||||
<VBtn
|
||||
type="submit"
|
||||
variant="tonal"
|
||||
color="secondary"
|
||||
block
|
||||
:loading="form.processing && !form.send_immediately"
|
||||
:disabled="form.processing"
|
||||
prepend-icon="tabler-file-plus"
|
||||
class="mb-3"
|
||||
>
|
||||
Save as Draft
|
||||
</VBtn>
|
||||
<Link href="/invoices" class="text-decoration-none">
|
||||
<VBtn
|
||||
variant="outlined"
|
||||
block
|
||||
>
|
||||
Cancel
|
||||
</VBtn>
|
||||
</Link>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
324
website/resources/ts/Pages/Admin/Invoices/Edit.vue
Normal file
324
website/resources/ts/Pages/Admin/Invoices/Edit.vue
Normal file
@@ -0,0 +1,324 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link, useForm } from '@inertiajs/vue3'
|
||||
import { computed } from 'vue'
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
|
||||
import AppTextarea from '@/Components/app-form-elements/AppTextarea.vue'
|
||||
import { resolveInvoiceStatusColor, formatPrice } from '@/utils/resolvers'
|
||||
|
||||
interface InvoiceUser {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
interface InvoiceLineItem {
|
||||
id: number
|
||||
description: string
|
||||
amount: string
|
||||
quantity: number
|
||||
}
|
||||
|
||||
interface InvoiceDetail {
|
||||
id: number
|
||||
user_id: number
|
||||
number: string
|
||||
total: string
|
||||
tax: string
|
||||
currency: string
|
||||
status: string
|
||||
gateway: string | null
|
||||
notes: string | null
|
||||
due_date: string | null
|
||||
created_at: string
|
||||
user: InvoiceUser | null
|
||||
items: InvoiceLineItem[]
|
||||
}
|
||||
|
||||
interface LineItemForm {
|
||||
description: string
|
||||
quantity: number
|
||||
unit_price: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
invoice: InvoiceDetail
|
||||
}
|
||||
|
||||
defineOptions({ layout: AdminLayout })
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
function formatDateForInput(dateStr: string | null): string {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
function itemsToFormItems(items: InvoiceLineItem[]): LineItemForm[] {
|
||||
return items.map((item: InvoiceLineItem) => ({
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.amount,
|
||||
}))
|
||||
}
|
||||
|
||||
const form = useForm({
|
||||
items: itemsToFormItems(props.invoice.items),
|
||||
due_date: formatDateForInput(props.invoice.due_date),
|
||||
notes: props.invoice.notes ?? '',
|
||||
})
|
||||
|
||||
function addLineItem(): void {
|
||||
form.items.push({ description: '', quantity: 1, unit_price: '' })
|
||||
}
|
||||
|
||||
function removeLineItem(index: number): void {
|
||||
if (form.items.length > 1) {
|
||||
form.items.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function lineTotal(item: LineItemForm): number {
|
||||
return (parseFloat(item.unit_price) || 0) * (item.quantity || 0)
|
||||
}
|
||||
|
||||
const subtotal = computed<number>(() => {
|
||||
return form.items.reduce((sum: number, item: LineItemForm) => sum + lineTotal(item), 0)
|
||||
})
|
||||
|
||||
function submit(): void {
|
||||
form.put(`/invoices/${props.invoice.id}`, {
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="d-flex align-center justify-space-between mb-6">
|
||||
<div>
|
||||
<div class="d-flex align-center gap-2 mb-1">
|
||||
<Link :href="`/invoices/${invoice.id}`" class="text-decoration-none">
|
||||
<VBtn icon="tabler-arrow-left" variant="text" size="small" />
|
||||
</Link>
|
||||
<span class="text-h4 font-weight-bold">Edit Invoice {{ invoice.number }}</span>
|
||||
<VChip
|
||||
:color="resolveInvoiceStatusColor(invoice.status)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ invoice.status }}
|
||||
</VChip>
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis ms-10">
|
||||
{{ invoice.user?.name ?? 'Unknown Customer' }} · {{ invoice.user?.email ?? '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<VRow>
|
||||
<!-- Main Content -->
|
||||
<VCol cols="12" lg="8">
|
||||
<!-- Line Items -->
|
||||
<VCard title="Line Items" class="mb-6">
|
||||
<VCardText>
|
||||
<!-- Items Header -->
|
||||
<VRow class="mb-2 d-none d-md-flex">
|
||||
<VCol cols="12" md="5">
|
||||
<span class="text-body-2 font-weight-medium text-medium-emphasis">Description</span>
|
||||
</VCol>
|
||||
<VCol cols="12" md="2">
|
||||
<span class="text-body-2 font-weight-medium text-medium-emphasis">Qty</span>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3">
|
||||
<span class="text-body-2 font-weight-medium text-medium-emphasis">Unit Price</span>
|
||||
</VCol>
|
||||
<VCol cols="12" md="2">
|
||||
<span class="text-body-2 font-weight-medium text-medium-emphasis">Total</span>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<VDivider class="mb-4 d-none d-md-flex" />
|
||||
|
||||
<!-- Item Rows -->
|
||||
<div
|
||||
v-for="(item, index) in form.items"
|
||||
:key="index"
|
||||
class="mb-3"
|
||||
>
|
||||
<VRow align="center">
|
||||
<VCol cols="12" md="5">
|
||||
<AppTextField
|
||||
v-model="item.description"
|
||||
placeholder="Item description"
|
||||
density="compact"
|
||||
:error-messages="form.errors[`items.${index}.description`]"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="2">
|
||||
<AppTextField
|
||||
v-model.number="item.quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="1"
|
||||
density="compact"
|
||||
:error-messages="form.errors[`items.${index}.quantity`]"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
<AppTextField
|
||||
v-model="item.unit_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
density="compact"
|
||||
prefix="$"
|
||||
:error-messages="form.errors[`items.${index}.unit_price`]"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="10" md="1" class="text-body-2 font-weight-medium">
|
||||
{{ formatPrice(lineTotal(item)) }}
|
||||
</VCol>
|
||||
<VCol cols="2" md="1">
|
||||
<VBtn
|
||||
icon="tabler-trash"
|
||||
color="error"
|
||||
variant="text"
|
||||
size="small"
|
||||
:disabled="form.items.length <= 1"
|
||||
@click="removeLineItem(index)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
|
||||
<div v-if="form.errors.items" class="text-error text-body-2 mb-3">
|
||||
{{ form.errors.items }}
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
prepend-icon="tabler-plus"
|
||||
size="small"
|
||||
@click="addLineItem"
|
||||
>
|
||||
Add Line Item
|
||||
</VBtn>
|
||||
|
||||
<!-- Totals -->
|
||||
<VDivider class="my-4" />
|
||||
<div class="d-flex flex-column align-end ga-2">
|
||||
<div class="d-flex align-center ga-6" style="min-width: 200px;">
|
||||
<span class="text-body-1 font-weight-bold">Total</span>
|
||||
<VSpacer />
|
||||
<span class="text-body-1 font-weight-bold">{{ formatPrice(subtotal) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Notes -->
|
||||
<VCard title="Notes" class="mb-6">
|
||||
<VCardText>
|
||||
<AppTextarea
|
||||
v-model="form.notes"
|
||||
label="Invoice Notes"
|
||||
placeholder="Optional notes to include on the invoice..."
|
||||
rows="3"
|
||||
:error-messages="form.errors.notes"
|
||||
/>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<VCol cols="12" lg="4">
|
||||
<!-- Invoice Info -->
|
||||
<VCard title="Invoice Info" class="mb-6">
|
||||
<VCardText>
|
||||
<div class="d-flex justify-space-between align-center mb-3">
|
||||
<span class="text-body-2 text-medium-emphasis">Invoice #</span>
|
||||
<span class="text-body-2 font-weight-medium">{{ invoice.number }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between align-center mb-3">
|
||||
<span class="text-body-2 text-medium-emphasis">Status</span>
|
||||
<VChip
|
||||
:color="resolveInvoiceStatusColor(invoice.status)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ invoice.status }}
|
||||
</VChip>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between align-center mb-3">
|
||||
<span class="text-body-2 text-medium-emphasis">Customer</span>
|
||||
<span class="text-body-2 font-weight-medium">{{ invoice.user?.name ?? 'Unknown' }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<span class="text-body-2 text-medium-emphasis">Gateway</span>
|
||||
<span class="text-body-2 text-capitalize">{{ invoice.gateway ?? 'Manual' }}</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Due Date -->
|
||||
<VCard title="Due Date" class="mb-6">
|
||||
<VCardText>
|
||||
<AppTextField
|
||||
v-model="form.due_date"
|
||||
label="Due Date"
|
||||
type="date"
|
||||
:error-messages="form.errors.due_date"
|
||||
/>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Summary -->
|
||||
<VCard title="Summary" class="mb-6">
|
||||
<VCardText>
|
||||
<div class="d-flex justify-space-between align-center mb-3">
|
||||
<span class="text-body-2 text-medium-emphasis">Line Items</span>
|
||||
<span class="text-body-2 font-weight-medium">{{ form.items.length }}</span>
|
||||
</div>
|
||||
<VDivider class="my-2" />
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<span class="text-body-1 font-weight-bold">Total</span>
|
||||
<span class="text-body-1 font-weight-bold">{{ formatPrice(subtotal) }}</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Actions -->
|
||||
<VCard>
|
||||
<VCardText>
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
block
|
||||
:loading="form.processing"
|
||||
:disabled="form.processing"
|
||||
prepend-icon="tabler-check"
|
||||
class="mb-3"
|
||||
>
|
||||
Update Invoice
|
||||
</VBtn>
|
||||
<Link :href="`/invoices/${invoice.id}`" class="text-decoration-none">
|
||||
<VBtn
|
||||
variant="outlined"
|
||||
block
|
||||
>
|
||||
Cancel
|
||||
</VBtn>
|
||||
</Link>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link, router } from '@inertiajs/vue3'
|
||||
import { Link, router, useForm } from '@inertiajs/vue3'
|
||||
import { ref, watch } from 'vue'
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import { resolveInvoiceStatusColor, formatPrice } from '@/utils/resolvers'
|
||||
@@ -42,9 +42,11 @@ const props = defineProps<Props>()
|
||||
|
||||
const search = ref<string>(props.filters.search)
|
||||
const status = ref<string>(props.filters.status)
|
||||
const resendingId = ref<number | null>(null)
|
||||
|
||||
const statusOptions = [
|
||||
{ title: 'All Statuses', value: '' },
|
||||
{ title: 'Draft', value: 'draft' },
|
||||
{ title: 'Paid', value: 'paid' },
|
||||
{ title: 'Pending', value: 'pending' },
|
||||
{ title: 'Overdue', value: 'overdue' },
|
||||
@@ -72,6 +74,15 @@ watch(status, () => {
|
||||
applyFilters()
|
||||
})
|
||||
|
||||
function resendInvoice(invoice: InvoiceItem): void {
|
||||
resendingId.value = invoice.id
|
||||
const resendForm = useForm({})
|
||||
resendForm.post(`/invoices/${invoice.id}/resend`, {
|
||||
preserveScroll: true,
|
||||
onFinish: () => { resendingId.value = null },
|
||||
})
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '---'
|
||||
const date = new Date(dateStr)
|
||||
@@ -90,6 +101,11 @@ function formatDate(dateStr: string | null): string {
|
||||
Manage all customer invoices
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/invoices/create">
|
||||
<VBtn color="primary" prepend-icon="tabler-plus">
|
||||
Create Invoice
|
||||
</VBtn>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
@@ -175,11 +191,31 @@ function formatDate(dateStr: string | null): string {
|
||||
{{ formatDate(invoice.paid_at) }}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<Link :href="`/invoices/${invoice.id}`">
|
||||
<VBtn variant="text" size="small" color="primary">
|
||||
<VIcon icon="tabler-eye" size="18" />
|
||||
<div class="d-flex align-center justify-center gap-1">
|
||||
<Link :href="`/invoices/${invoice.id}`">
|
||||
<VBtn variant="text" size="small" color="primary">
|
||||
<VIcon icon="tabler-eye" size="18" />
|
||||
</VBtn>
|
||||
</Link>
|
||||
<Link
|
||||
v-if="invoice.status === 'draft' || invoice.status === 'pending'"
|
||||
:href="`/invoices/${invoice.id}/edit`"
|
||||
>
|
||||
<VBtn variant="text" size="small" color="warning">
|
||||
<VIcon icon="tabler-edit" size="18" />
|
||||
</VBtn>
|
||||
</Link>
|
||||
<VBtn
|
||||
v-if="invoice.status !== 'void'"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="info"
|
||||
:loading="resendingId === invoice.id"
|
||||
@click="resendInvoice(invoice)"
|
||||
>
|
||||
<VIcon icon="tabler-mail-forward" size="18" />
|
||||
</VBtn>
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -41,6 +41,7 @@ interface InvoiceDetail {
|
||||
gateway: string | null
|
||||
gateway_invoice_id: string | null
|
||||
invoice_pdf: string | null
|
||||
notes: string | null
|
||||
due_date: string | null
|
||||
paid_at: string | null
|
||||
created_at: string
|
||||
@@ -60,6 +61,11 @@ const props = defineProps<Props>()
|
||||
|
||||
const voidDialog = ref<boolean>(false)
|
||||
const voidForm = useForm({})
|
||||
const resendForm = useForm({})
|
||||
|
||||
const isEditable = computed<boolean>(() => {
|
||||
return props.invoice.status === 'draft' || props.invoice.status === 'pending'
|
||||
})
|
||||
|
||||
const subtotal = computed<number>(() => {
|
||||
return props.invoice.items.reduce((sum, item) => {
|
||||
@@ -74,6 +80,12 @@ function submitVoid(): void {
|
||||
})
|
||||
}
|
||||
|
||||
function submitResend(): void {
|
||||
resendForm.post(`/invoices/${props.invoice.id}/resend`, {
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '---'
|
||||
const date = new Date(dateStr)
|
||||
@@ -119,6 +131,27 @@ function formatDateTime(dateStr: string | null): string {
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<Link v-if="isEditable" :href="`/invoices/${invoice.id}/edit`">
|
||||
<VBtn
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
>
|
||||
<VIcon icon="tabler-edit" start />
|
||||
Edit
|
||||
</VBtn>
|
||||
</Link>
|
||||
|
||||
<VBtn
|
||||
color="info"
|
||||
variant="tonal"
|
||||
:loading="resendForm.processing"
|
||||
:disabled="resendForm.processing"
|
||||
@click="submitResend"
|
||||
>
|
||||
<VIcon icon="tabler-mail-forward" start />
|
||||
Resend Email
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
color="info"
|
||||
variant="tonal"
|
||||
@@ -209,6 +242,19 @@ function formatDateTime(dateStr: string | null): string {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Notes Card -->
|
||||
<VCard v-if="invoice.notes" class="mt-4">
|
||||
<VCardTitle class="d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-notes" size="22" />
|
||||
<span>Notes</span>
|
||||
</VCardTitle>
|
||||
<VCardText>
|
||||
<div class="text-body-2">
|
||||
{{ invoice.notes }}
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Customer Card -->
|
||||
<VCard class="mt-4">
|
||||
<VCardTitle class="d-flex align-center gap-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link, router } from '@inertiajs/vue3'
|
||||
import { Link, router, useForm } from '@inertiajs/vue3'
|
||||
import { ref, watch } from 'vue'
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import type { PaginatedResponse, StatusColor } from '@/types'
|
||||
@@ -29,6 +29,7 @@ interface ServiceItem {
|
||||
domain: string | null
|
||||
ipv4_address: string | null
|
||||
created_at: string
|
||||
deleted_at: string | null
|
||||
user: ServiceUser | null
|
||||
plan: ServicePlan | null
|
||||
}
|
||||
@@ -37,6 +38,7 @@ interface Filters {
|
||||
search: string
|
||||
service_type: string
|
||||
status: string
|
||||
show_archived: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -51,6 +53,11 @@ const props = defineProps<Props>()
|
||||
const search = ref<string>(props.filters.search)
|
||||
const serviceType = ref<string>(props.filters.service_type)
|
||||
const status = ref<string>(props.filters.status)
|
||||
const showArchived = ref<boolean>(props.filters.show_archived)
|
||||
|
||||
const deleteDialog = ref<boolean>(false)
|
||||
const serviceToDelete = ref<ServiceItem | null>(null)
|
||||
const deleteForm = useForm({})
|
||||
|
||||
const serviceTypeOptions = [
|
||||
{ title: 'All Types', value: '' },
|
||||
@@ -66,6 +73,7 @@ const statusOptions = [
|
||||
{ title: 'Suspended', value: 'suspended' },
|
||||
{ title: 'Terminated', value: 'terminated' },
|
||||
{ title: 'Pending', value: 'pending' },
|
||||
{ title: 'Failed', value: 'failed' },
|
||||
]
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -75,6 +83,7 @@ function applyFilters(): void {
|
||||
search: search.value || undefined,
|
||||
service_type: serviceType.value || undefined,
|
||||
status: status.value || undefined,
|
||||
show_archived: showArchived.value || undefined,
|
||||
}, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
@@ -86,16 +95,33 @@ watch(search, () => {
|
||||
searchTimeout = setTimeout(applyFilters, 300)
|
||||
})
|
||||
|
||||
watch([serviceType, status], () => {
|
||||
watch([serviceType, status, showArchived], () => {
|
||||
applyFilters()
|
||||
})
|
||||
|
||||
function openDeleteDialog(service: ServiceItem): void {
|
||||
serviceToDelete.value = service
|
||||
deleteDialog.value = true
|
||||
}
|
||||
|
||||
function confirmDelete(): void {
|
||||
if (!serviceToDelete.value) return
|
||||
deleteForm.delete(`/services/${serviceToDelete.value.id}`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
deleteDialog.value = false
|
||||
serviceToDelete.value = null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function resolveServiceStatusColor(statusVal: string): StatusColor {
|
||||
const map: Record<string, StatusColor> = {
|
||||
active: 'success',
|
||||
suspended: 'warning',
|
||||
terminated: 'error',
|
||||
pending: 'info',
|
||||
failed: 'error',
|
||||
}
|
||||
return map[statusVal] ?? 'secondary'
|
||||
}
|
||||
@@ -143,7 +169,7 @@ function formatDate(dateStr: string): string {
|
||||
<VCard class="mb-6">
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VCol cols="12" md="5">
|
||||
<VTextField
|
||||
v-model="search"
|
||||
prepend-inner-icon="tabler-search"
|
||||
@@ -154,7 +180,7 @@ function formatDate(dateStr: string): string {
|
||||
@click:clear="search = ''"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" sm="6" md="3">
|
||||
<VCol cols="12" sm="6" md="2">
|
||||
<VSelect
|
||||
v-model="serviceType"
|
||||
:items="serviceTypeOptions"
|
||||
@@ -163,7 +189,7 @@ function formatDate(dateStr: string): string {
|
||||
label="Service Type"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" sm="6" md="3">
|
||||
<VCol cols="12" sm="6" md="2">
|
||||
<VSelect
|
||||
v-model="status"
|
||||
:items="statusOptions"
|
||||
@@ -172,6 +198,15 @@ function formatDate(dateStr: string): string {
|
||||
label="Status"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" sm="6" md="3" class="d-flex align-center">
|
||||
<VSwitch
|
||||
v-model="showArchived"
|
||||
label="Show archived"
|
||||
density="compact"
|
||||
hide-details
|
||||
color="primary"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
@@ -201,7 +236,11 @@ function formatDate(dateStr: string): string {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="service in services.data" :key="service.id">
|
||||
<tr
|
||||
v-for="service in services.data"
|
||||
:key="service.id"
|
||||
:class="{ 'opacity-50': service.deleted_at }"
|
||||
>
|
||||
<td class="text-body-2 font-weight-medium">
|
||||
#{{ service.id }}
|
||||
</td>
|
||||
@@ -225,6 +264,15 @@ function formatDate(dateStr: string): string {
|
||||
</td>
|
||||
<td>
|
||||
<VChip
|
||||
v-if="service.deleted_at"
|
||||
color="secondary"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
>
|
||||
Archived
|
||||
</VChip>
|
||||
<VChip
|
||||
v-else
|
||||
:color="resolveServiceStatusColor(service.status)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
@@ -244,6 +292,15 @@ function formatDate(dateStr: string): string {
|
||||
<VIcon icon="tabler-eye" size="18" />
|
||||
</VBtn>
|
||||
</Link>
|
||||
<VBtn
|
||||
v-if="!service.deleted_at"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="openDeleteDialog(service)"
|
||||
>
|
||||
<VIcon icon="tabler-archive" size="18" />
|
||||
</VBtn>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -263,5 +320,31 @@ function formatDate(dateStr: string): string {
|
||||
Showing {{ services.from }} to {{ services.to }} of {{ services.total }} services
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<VDialog v-model="deleteDialog" max-width="500" persistent>
|
||||
<VCard>
|
||||
<VCardTitle class="text-h5 pa-5">
|
||||
Archive Service
|
||||
</VCardTitle>
|
||||
<VCardText class="px-5 pb-2">
|
||||
Are you sure you want to archive service #{{ serviceToDelete?.id }}? The service will be hidden from the default list but can be restored later.
|
||||
</VCardText>
|
||||
<VCardActions class="pa-5">
|
||||
<VSpacer />
|
||||
<VBtn variant="text" :disabled="deleteForm.processing" @click="deleteDialog = false">
|
||||
Cancel
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="error"
|
||||
variant="flat"
|
||||
:loading="deleteForm.processing"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
Archive
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -19,6 +19,13 @@ interface ServicePlan {
|
||||
billing_cycle: string
|
||||
}
|
||||
|
||||
interface AvailablePlan {
|
||||
id: number
|
||||
name: string
|
||||
price: string
|
||||
billing_cycle: string
|
||||
}
|
||||
|
||||
interface ProvisioningLogItem {
|
||||
id: number
|
||||
action: string
|
||||
@@ -44,6 +51,7 @@ interface ServiceDetail {
|
||||
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
|
||||
@@ -53,6 +61,7 @@ interface ServiceDetail {
|
||||
|
||||
interface Props {
|
||||
service: ServiceDetail
|
||||
availablePlans: AvailablePlan[]
|
||||
}
|
||||
|
||||
defineOptions({ layout: AdminLayout })
|
||||
@@ -60,60 +69,108 @@ defineOptions({ layout: AdminLayout })
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const confirmDialog = ref<boolean>(false)
|
||||
const confirmAction = ref<'suspend' | 'unsuspend' | 'terminate'>('suspend')
|
||||
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,
|
||||
suspendForm.processing || unsuspendForm.processing || terminateForm.processing || provisionForm.processing || modifyForm.processing || archiveForm.processing || restoreForm.processing,
|
||||
)
|
||||
|
||||
function openConfirmDialog(action: 'suspend' | 'unsuspend' | 'terminate'): void {
|
||||
function openConfirmDialog(action: 'suspend' | 'unsuspend' | 'terminate' | 'provision' | 'archive' | 'restore'): void {
|
||||
confirmAction.value = action
|
||||
|
||||
if (action === 'suspend') {
|
||||
confirmTitle.value = 'Suspend Service'
|
||||
confirmMessage.value = `Are you sure you want to suspend service #${props.service.id}? The customer will lose access to their service.`
|
||||
confirmColor.value = 'warning'
|
||||
} else if (action === 'unsuspend') {
|
||||
confirmTitle.value = 'Unsuspend Service'
|
||||
confirmMessage.value = `Are you sure you want to unsuspend service #${props.service.id}? The customer will regain access to their service.`
|
||||
confirmColor.value = 'success'
|
||||
} else {
|
||||
confirmTitle.value = 'Terminate Service'
|
||||
confirmMessage.value = `Are you sure you want to terminate service #${props.service.id}? This action may be irreversible. The service will be permanently deactivated.`
|
||||
confirmColor.value = 'error'
|
||||
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`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { confirmDialog.value = false },
|
||||
})
|
||||
suspendForm.post(`/services/${props.service.id}/suspend`, opts)
|
||||
} else if (action === 'unsuspend') {
|
||||
unsuspendForm.post(`/services/${props.service.id}/unsuspend`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { confirmDialog.value = false },
|
||||
})
|
||||
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`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { confirmDialog.value = false },
|
||||
})
|
||||
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',
|
||||
@@ -203,6 +260,14 @@ function formatPrice(price: string | number, cycle?: string): string {
|
||||
>
|
||||
{{ 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' }} · {{ service.user?.email ?? '' }}
|
||||
@@ -211,6 +276,28 @@ function formatPrice(price: string | number, cycle?: string): string {
|
||||
</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"
|
||||
@@ -234,7 +321,7 @@ function formatPrice(price: string | number, cycle?: string): string {
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
v-if="service.status !== 'terminated'"
|
||||
v-if="service.status !== 'terminated' && !service.deleted_at"
|
||||
color="error"
|
||||
variant="tonal"
|
||||
:disabled="isProcessing"
|
||||
@@ -243,6 +330,28 @@ function formatPrice(price: string | number, cycle?: string): string {
|
||||
<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>
|
||||
|
||||
@@ -478,6 +587,69 @@ function formatPrice(price: string | number, cycle?: string): string {
|
||||
</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>
|
||||
|
||||
@@ -15,6 +15,7 @@ interface Props {
|
||||
api: SettingsGroup
|
||||
billing: SettingsGroup
|
||||
notifications: SettingsGroup
|
||||
discord: SettingsGroup
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,10 +39,13 @@ const apiForm = useForm({
|
||||
group: 'api',
|
||||
virtfusion_api_url: (props.settings.api.virtfusion_api_url as string) ?? '',
|
||||
virtfusion_api_token: '',
|
||||
pterodactyl_api_url: (props.settings.api.pterodactyl_api_url as string) ?? '',
|
||||
pterodactyl_api_token: '',
|
||||
synergycp_api_url: (props.settings.api.synergycp_api_url as string) ?? '',
|
||||
synergycp_api_token: '',
|
||||
enhance_api_url: (props.settings.api.enhance_api_url as string) ?? '',
|
||||
enhance_api_token: '',
|
||||
enhance_organization_id: '',
|
||||
})
|
||||
|
||||
// Billing settings form
|
||||
@@ -52,21 +56,64 @@ const billingForm = useForm({
|
||||
suspension_warning_days: (props.settings.billing.suspension_warning_days as string) ?? '3',
|
||||
auto_terminate_days: (props.settings.billing.auto_terminate_days as string) ?? '14',
|
||||
bandwidth_overage_rate: (props.settings.billing.bandwidth_overage_rate as string) ?? '0.05',
|
||||
bandwidth_alert_75: (props.settings.billing.bandwidth_alert_75 as string) === '1',
|
||||
bandwidth_alert_90: (props.settings.billing.bandwidth_alert_90 as string) === '1',
|
||||
bandwidth_alert_100: (props.settings.billing.bandwidth_alert_100 as string) === '1',
|
||||
bandwidth_alert_75_email: (props.settings.billing.bandwidth_alert_75_email as string) === '1',
|
||||
bandwidth_alert_90_email: (props.settings.billing.bandwidth_alert_90_email as string) === '1',
|
||||
bandwidth_alert_100_email: (props.settings.billing.bandwidth_alert_100_email as string) === '1',
|
||||
bandwidth_grace_period_days: (props.settings.billing.bandwidth_grace_period_days as string) ?? '3',
|
||||
bandwidth_auto_suspend: (props.settings.billing.bandwidth_auto_suspend as string) === '1',
|
||||
})
|
||||
|
||||
// Notifications settings form
|
||||
const notificationsForm = useForm({
|
||||
group: 'notifications',
|
||||
discord_webhook_url: (props.settings.notifications.discord_webhook_url as string) ?? '',
|
||||
slack_webhook_url: (props.settings.notifications.slack_webhook_url as string) ?? '',
|
||||
email_from_address: (props.settings.notifications.email_from_address as string) ?? '',
|
||||
email_from_name: (props.settings.notifications.email_from_name as string) ?? '',
|
||||
})
|
||||
|
||||
// Discord webhooks form
|
||||
const discordForm = useForm({
|
||||
group: 'discord',
|
||||
discord_payment_webhook_url: (props.settings.discord.discord_payment_webhook_url as string) ?? '',
|
||||
discord_payment_webhook_enabled: (props.settings.discord.discord_payment_webhook_enabled as string) === '1',
|
||||
discord_provisioning_webhook_url: (props.settings.discord.discord_provisioning_webhook_url as string) ?? '',
|
||||
discord_provisioning_webhook_enabled: (props.settings.discord.discord_provisioning_webhook_enabled as string) === '1',
|
||||
discord_support_webhook_url: (props.settings.discord.discord_support_webhook_url as string) ?? '',
|
||||
discord_support_webhook_enabled: (props.settings.discord.discord_support_webhook_enabled as string) === '1',
|
||||
discord_system_webhook_url: (props.settings.discord.discord_system_webhook_url as string) ?? '',
|
||||
discord_system_webhook_enabled: (props.settings.discord.discord_system_webhook_enabled as string) === '1',
|
||||
})
|
||||
|
||||
// Visibility toggles for sensitive API fields
|
||||
const showVirtfusionToken = ref<boolean>(false)
|
||||
const showPterodactylToken = ref<boolean>(false)
|
||||
const showSynergycpToken = ref<boolean>(false)
|
||||
const showEnhanceToken = ref<boolean>(false)
|
||||
const showEnhanceOrgId = ref<boolean>(false)
|
||||
|
||||
// API connection test state
|
||||
interface TestResult {
|
||||
loading: boolean
|
||||
success: boolean | null
|
||||
message: string
|
||||
}
|
||||
|
||||
const apiTestResults = ref<Record<string, TestResult>>({
|
||||
virtfusion: { loading: false, success: null, message: '' },
|
||||
pterodactyl: { loading: false, success: null, message: '' },
|
||||
synergycp: { loading: false, success: null, message: '' },
|
||||
enhance: { loading: false, success: null, message: '' },
|
||||
})
|
||||
|
||||
// Discord webhook test state
|
||||
const discordTestResults = ref<Record<string, TestResult>>({
|
||||
payment: { loading: false, success: null, message: '' },
|
||||
provisioning: { loading: false, success: null, message: '' },
|
||||
support: { loading: false, success: null, message: '' },
|
||||
system: { loading: false, success: null, message: '' },
|
||||
})
|
||||
|
||||
const currencyOptions = [
|
||||
{ title: 'USD - US Dollar', value: 'USD' },
|
||||
@@ -79,7 +126,8 @@ const currencyOptions = [
|
||||
const tabItems = [
|
||||
{ value: 'general', title: 'General', icon: 'tabler-building' },
|
||||
{ value: 'api', title: 'API Credentials', icon: 'tabler-key' },
|
||||
{ value: 'billing', title: 'Billing', icon: 'tabler-credit-card' },
|
||||
{ value: 'discord', title: 'Discord Webhooks', icon: 'tabler-brand-discord' },
|
||||
{ value: 'billing', title: 'Billing & Bandwidth', icon: 'tabler-credit-card' },
|
||||
{ value: 'notifications', title: 'Notifications', icon: 'tabler-bell' },
|
||||
]
|
||||
|
||||
@@ -106,6 +154,97 @@ function submitNotifications(): void {
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
|
||||
function submitDiscord(): void {
|
||||
discordForm.put('/settings', {
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
|
||||
async function testApiConnection(provider: string): Promise<void> {
|
||||
const result = apiTestResults.value[provider]
|
||||
if (!result) return
|
||||
|
||||
result.loading = true
|
||||
result.success = null
|
||||
result.message = ''
|
||||
|
||||
const urlKey = `${provider}_api_url` as keyof typeof apiForm
|
||||
const tokenKey = `${provider}_api_token` as keyof typeof apiForm
|
||||
|
||||
try {
|
||||
const response = await fetch('/settings/test-api', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
provider,
|
||||
url: apiForm[urlKey] || null,
|
||||
token: apiForm[tokenKey] || null,
|
||||
organization_id: provider === 'enhance' ? (apiForm.enhance_organization_id || null) : null,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json() as { success: boolean; message: string }
|
||||
result.success = data.success
|
||||
result.message = data.message
|
||||
}
|
||||
catch (e) {
|
||||
result.success = false
|
||||
result.message = 'Network error. Please try again.'
|
||||
}
|
||||
finally {
|
||||
result.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testDiscordWebhook(channel: string): Promise<void> {
|
||||
const result = discordTestResults.value[channel]
|
||||
if (!result) return
|
||||
|
||||
result.loading = true
|
||||
result.success = null
|
||||
result.message = ''
|
||||
|
||||
const urlKey = `discord_${channel}_webhook_url` as keyof typeof discordForm
|
||||
const webhookUrl = discordForm[urlKey] as string
|
||||
|
||||
if (!webhookUrl) {
|
||||
result.success = false
|
||||
result.message = 'Please enter a webhook URL first.'
|
||||
result.loading = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/settings/test-discord', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
webhook_url: webhookUrl,
|
||||
channel,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json() as { success: boolean; message: string }
|
||||
result.success = data.success
|
||||
result.message = data.message
|
||||
}
|
||||
catch (e) {
|
||||
result.success = false
|
||||
result.message = 'Network error. Please try again.'
|
||||
}
|
||||
finally {
|
||||
result.loading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -200,10 +339,31 @@ function submitNotifications(): void {
|
||||
<VTabsWindowItem value="api">
|
||||
<form @submit.prevent="submitApi">
|
||||
<!-- VirtFusion -->
|
||||
<div class="text-h6 mb-3">
|
||||
<VIcon icon="tabler-server" start />
|
||||
VirtFusion (VPS)
|
||||
<div class="d-flex align-center mb-3">
|
||||
<VIcon icon="tabler-server" class="me-2" />
|
||||
<span class="text-h6">VirtFusion (VPS)</span>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="info"
|
||||
:loading="apiTestResults.virtfusion.loading"
|
||||
@click="testApiConnection('virtfusion')"
|
||||
>
|
||||
<VIcon icon="tabler-plug-connected" start />
|
||||
Test Connection
|
||||
</VBtn>
|
||||
</div>
|
||||
<VAlert
|
||||
v-if="apiTestResults.virtfusion.message"
|
||||
:type="apiTestResults.virtfusion.success ? 'success' : 'error'"
|
||||
variant="tonal"
|
||||
closable
|
||||
class="mb-3"
|
||||
@click:close="apiTestResults.virtfusion.message = ''"
|
||||
>
|
||||
{{ apiTestResults.virtfusion.message }}
|
||||
</VAlert>
|
||||
<VRow class="mb-4">
|
||||
<VCol cols="12" md="6">
|
||||
<AppTextField
|
||||
@@ -234,11 +394,88 @@ function submitNotifications(): void {
|
||||
|
||||
<VDivider class="mb-4" />
|
||||
|
||||
<!-- SynergyCP -->
|
||||
<div class="text-h6 mb-3">
|
||||
<VIcon icon="tabler-server-2" start />
|
||||
SynergyCP (Dedicated)
|
||||
<!-- Pterodactyl -->
|
||||
<div class="d-flex align-center mb-3">
|
||||
<VIcon icon="tabler-device-gamepad-2" class="me-2" />
|
||||
<span class="text-h6">Pterodactyl (Game Servers)</span>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="info"
|
||||
:loading="apiTestResults.pterodactyl.loading"
|
||||
@click="testApiConnection('pterodactyl')"
|
||||
>
|
||||
<VIcon icon="tabler-plug-connected" start />
|
||||
Test Connection
|
||||
</VBtn>
|
||||
</div>
|
||||
<VAlert
|
||||
v-if="apiTestResults.pterodactyl.message"
|
||||
:type="apiTestResults.pterodactyl.success ? 'success' : 'error'"
|
||||
variant="tonal"
|
||||
closable
|
||||
class="mb-3"
|
||||
@click:close="apiTestResults.pterodactyl.message = ''"
|
||||
>
|
||||
{{ apiTestResults.pterodactyl.message }}
|
||||
</VAlert>
|
||||
<VRow class="mb-4">
|
||||
<VCol cols="12" md="6">
|
||||
<AppTextField
|
||||
v-model="apiForm.pterodactyl_api_url"
|
||||
label="Panel URL"
|
||||
placeholder="https://game.ezscale.cloud"
|
||||
:error-messages="apiForm.errors.pterodactyl_api_url"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<AppTextField
|
||||
v-model="apiForm.pterodactyl_api_token"
|
||||
label="API Key"
|
||||
:type="showPterodactylToken ? 'text' : 'password'"
|
||||
:placeholder="props.settings.api.pterodactyl_api_token_set ? '******** (key is set, leave blank to keep)' : 'Enter API key'"
|
||||
:error-messages="apiForm.errors.pterodactyl_api_token"
|
||||
>
|
||||
<template #append-inner>
|
||||
<VIcon
|
||||
:icon="showPterodactylToken ? 'tabler-eye-off' : 'tabler-eye'"
|
||||
style="cursor: pointer;"
|
||||
@click="showPterodactylToken = !showPterodactylToken"
|
||||
/>
|
||||
</template>
|
||||
</AppTextField>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<VDivider class="mb-4" />
|
||||
|
||||
<!-- SynergyCP -->
|
||||
<div class="d-flex align-center mb-3">
|
||||
<VIcon icon="tabler-server-2" class="me-2" />
|
||||
<span class="text-h6">SynergyCP (Dedicated)</span>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="info"
|
||||
:loading="apiTestResults.synergycp.loading"
|
||||
@click="testApiConnection('synergycp')"
|
||||
>
|
||||
<VIcon icon="tabler-plug-connected" start />
|
||||
Test Connection
|
||||
</VBtn>
|
||||
</div>
|
||||
<VAlert
|
||||
v-if="apiTestResults.synergycp.message"
|
||||
:type="apiTestResults.synergycp.success ? 'success' : 'error'"
|
||||
variant="tonal"
|
||||
closable
|
||||
class="mb-3"
|
||||
@click:close="apiTestResults.synergycp.message = ''"
|
||||
>
|
||||
{{ apiTestResults.synergycp.message }}
|
||||
</VAlert>
|
||||
<VRow class="mb-4">
|
||||
<VCol cols="12" md="6">
|
||||
<AppTextField
|
||||
@@ -270,12 +507,33 @@ function submitNotifications(): void {
|
||||
<VDivider class="mb-4" />
|
||||
|
||||
<!-- Enhance -->
|
||||
<div class="text-h6 mb-3">
|
||||
<VIcon icon="tabler-world" start />
|
||||
Enhance (Web Hosting)
|
||||
<div class="d-flex align-center mb-3">
|
||||
<VIcon icon="tabler-world" class="me-2" />
|
||||
<span class="text-h6">Enhance (Web Hosting)</span>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="info"
|
||||
:loading="apiTestResults.enhance.loading"
|
||||
@click="testApiConnection('enhance')"
|
||||
>
|
||||
<VIcon icon="tabler-plug-connected" start />
|
||||
Test Connection
|
||||
</VBtn>
|
||||
</div>
|
||||
<VAlert
|
||||
v-if="apiTestResults.enhance.message"
|
||||
:type="apiTestResults.enhance.success ? 'success' : 'error'"
|
||||
variant="tonal"
|
||||
closable
|
||||
class="mb-3"
|
||||
@click:close="apiTestResults.enhance.message = ''"
|
||||
>
|
||||
{{ apiTestResults.enhance.message }}
|
||||
</VAlert>
|
||||
<VRow class="mb-4">
|
||||
<VCol cols="12" md="6">
|
||||
<VCol cols="12" md="4">
|
||||
<AppTextField
|
||||
v-model="apiForm.enhance_api_url"
|
||||
label="API URL"
|
||||
@@ -283,7 +541,7 @@ function submitNotifications(): void {
|
||||
:error-messages="apiForm.errors.enhance_api_url"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VCol cols="12" md="4">
|
||||
<AppTextField
|
||||
v-model="apiForm.enhance_api_token"
|
||||
label="API Token"
|
||||
@@ -300,6 +558,23 @@ function submitNotifications(): void {
|
||||
</template>
|
||||
</AppTextField>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<AppTextField
|
||||
v-model="apiForm.enhance_organization_id"
|
||||
label="Organization ID"
|
||||
:type="showEnhanceOrgId ? 'text' : 'password'"
|
||||
:placeholder="props.settings.api.enhance_organization_id_set ? '******** (ID is set, leave blank to keep)' : 'Enter organization ID'"
|
||||
:error-messages="apiForm.errors.enhance_organization_id"
|
||||
>
|
||||
<template #append-inner>
|
||||
<VIcon
|
||||
:icon="showEnhanceOrgId ? 'tabler-eye-off' : 'tabler-eye'"
|
||||
style="cursor: pointer;"
|
||||
@click="showEnhanceOrgId = !showEnhanceOrgId"
|
||||
/>
|
||||
</template>
|
||||
</AppTextField>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<VDivider class="mb-4" />
|
||||
@@ -372,10 +647,248 @@ function submitNotifications(): void {
|
||||
</form>
|
||||
</VTabsWindowItem>
|
||||
|
||||
<!-- Billing Tab -->
|
||||
<!-- Discord Webhooks Tab -->
|
||||
<VTabsWindowItem value="discord">
|
||||
<form @submit.prevent="submitDiscord">
|
||||
<VAlert type="info" variant="tonal" class="mb-6">
|
||||
Configure Discord webhook URLs to receive real-time notifications in your Discord server.
|
||||
Each channel can be independently enabled or disabled.
|
||||
</VAlert>
|
||||
|
||||
<!-- Payment Notifications -->
|
||||
<VCard variant="outlined" class="mb-4">
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="tabler-cash" color="success" />
|
||||
</template>
|
||||
<VCardTitle>Payment Notifications</VCardTitle>
|
||||
<VCardSubtitle>Receive alerts for successful and failed payments</VCardSubtitle>
|
||||
<template #append>
|
||||
<div class="d-flex align-center gap-3">
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="info"
|
||||
:loading="discordTestResults.payment.loading"
|
||||
@click="testDiscordWebhook('payment')"
|
||||
>
|
||||
<VIcon icon="tabler-send" start />
|
||||
Test
|
||||
</VBtn>
|
||||
<VSwitch
|
||||
v-model="discordForm.discord_payment_webhook_enabled"
|
||||
color="primary"
|
||||
hide-details
|
||||
inset
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VAlert
|
||||
v-if="discordTestResults.payment.message"
|
||||
:type="discordTestResults.payment.success ? 'success' : 'error'"
|
||||
variant="tonal"
|
||||
closable
|
||||
class="mb-3"
|
||||
@click:close="discordTestResults.payment.message = ''"
|
||||
>
|
||||
{{ discordTestResults.payment.message }}
|
||||
</VAlert>
|
||||
<AppTextField
|
||||
v-model="discordForm.discord_payment_webhook_url"
|
||||
label="Webhook URL"
|
||||
:placeholder="props.settings.discord.discord_payment_webhook_url_set ? '******** (URL is set, leave blank to keep)' : 'https://discord.com/api/webhooks/...'"
|
||||
:error-messages="discordForm.errors.discord_payment_webhook_url"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<VIcon icon="tabler-brand-discord" />
|
||||
</template>
|
||||
</AppTextField>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Provisioning Alerts -->
|
||||
<VCard variant="outlined" class="mb-4">
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="tabler-server" color="info" />
|
||||
</template>
|
||||
<VCardTitle>Provisioning Alerts</VCardTitle>
|
||||
<VCardSubtitle>Get notified when services are provisioned, suspended, or terminated</VCardSubtitle>
|
||||
<template #append>
|
||||
<div class="d-flex align-center gap-3">
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="info"
|
||||
:loading="discordTestResults.provisioning.loading"
|
||||
@click="testDiscordWebhook('provisioning')"
|
||||
>
|
||||
<VIcon icon="tabler-send" start />
|
||||
Test
|
||||
</VBtn>
|
||||
<VSwitch
|
||||
v-model="discordForm.discord_provisioning_webhook_enabled"
|
||||
color="primary"
|
||||
hide-details
|
||||
inset
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VAlert
|
||||
v-if="discordTestResults.provisioning.message"
|
||||
:type="discordTestResults.provisioning.success ? 'success' : 'error'"
|
||||
variant="tonal"
|
||||
closable
|
||||
class="mb-3"
|
||||
@click:close="discordTestResults.provisioning.message = ''"
|
||||
>
|
||||
{{ discordTestResults.provisioning.message }}
|
||||
</VAlert>
|
||||
<AppTextField
|
||||
v-model="discordForm.discord_provisioning_webhook_url"
|
||||
label="Webhook URL"
|
||||
:placeholder="props.settings.discord.discord_provisioning_webhook_url_set ? '******** (URL is set, leave blank to keep)' : 'https://discord.com/api/webhooks/...'"
|
||||
:error-messages="discordForm.errors.discord_provisioning_webhook_url"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<VIcon icon="tabler-brand-discord" />
|
||||
</template>
|
||||
</AppTextField>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Support Ticket -->
|
||||
<VCard variant="outlined" class="mb-4">
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="tabler-headset" color="warning" />
|
||||
</template>
|
||||
<VCardTitle>Support Ticket Notifications</VCardTitle>
|
||||
<VCardSubtitle>Get notified when tickets are created or updated</VCardSubtitle>
|
||||
<template #append>
|
||||
<div class="d-flex align-center gap-3">
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="info"
|
||||
:loading="discordTestResults.support.loading"
|
||||
@click="testDiscordWebhook('support')"
|
||||
>
|
||||
<VIcon icon="tabler-send" start />
|
||||
Test
|
||||
</VBtn>
|
||||
<VSwitch
|
||||
v-model="discordForm.discord_support_webhook_enabled"
|
||||
color="primary"
|
||||
hide-details
|
||||
inset
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VAlert
|
||||
v-if="discordTestResults.support.message"
|
||||
:type="discordTestResults.support.success ? 'success' : 'error'"
|
||||
variant="tonal"
|
||||
closable
|
||||
class="mb-3"
|
||||
@click:close="discordTestResults.support.message = ''"
|
||||
>
|
||||
{{ discordTestResults.support.message }}
|
||||
</VAlert>
|
||||
<AppTextField
|
||||
v-model="discordForm.discord_support_webhook_url"
|
||||
label="Webhook URL"
|
||||
:placeholder="props.settings.discord.discord_support_webhook_url_set ? '******** (URL is set, leave blank to keep)' : 'https://discord.com/api/webhooks/...'"
|
||||
:error-messages="discordForm.errors.discord_support_webhook_url"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<VIcon icon="tabler-brand-discord" />
|
||||
</template>
|
||||
</AppTextField>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- System Alerts -->
|
||||
<VCard variant="outlined" class="mb-6">
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="tabler-alert-triangle" color="error" />
|
||||
</template>
|
||||
<VCardTitle>System Alerts</VCardTitle>
|
||||
<VCardSubtitle>Critical system notifications, errors, and warnings</VCardSubtitle>
|
||||
<template #append>
|
||||
<div class="d-flex align-center gap-3">
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="info"
|
||||
:loading="discordTestResults.system.loading"
|
||||
@click="testDiscordWebhook('system')"
|
||||
>
|
||||
<VIcon icon="tabler-send" start />
|
||||
Test
|
||||
</VBtn>
|
||||
<VSwitch
|
||||
v-model="discordForm.discord_system_webhook_enabled"
|
||||
color="primary"
|
||||
hide-details
|
||||
inset
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VAlert
|
||||
v-if="discordTestResults.system.message"
|
||||
:type="discordTestResults.system.success ? 'success' : 'error'"
|
||||
variant="tonal"
|
||||
closable
|
||||
class="mb-3"
|
||||
@click:close="discordTestResults.system.message = ''"
|
||||
>
|
||||
{{ discordTestResults.system.message }}
|
||||
</VAlert>
|
||||
<AppTextField
|
||||
v-model="discordForm.discord_system_webhook_url"
|
||||
label="Webhook URL"
|
||||
:placeholder="props.settings.discord.discord_system_webhook_url_set ? '******** (URL is set, leave blank to keep)' : 'https://discord.com/api/webhooks/...'"
|
||||
:error-messages="discordForm.errors.discord_system_webhook_url"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<VIcon icon="tabler-brand-discord" />
|
||||
</template>
|
||||
</AppTextField>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="discordForm.processing"
|
||||
:disabled="discordForm.processing"
|
||||
>
|
||||
<VIcon icon="tabler-device-floppy" start />
|
||||
Save Discord Settings
|
||||
</VBtn>
|
||||
</form>
|
||||
</VTabsWindowItem>
|
||||
|
||||
<!-- Billing & Bandwidth Tab -->
|
||||
<VTabsWindowItem value="billing">
|
||||
<form @submit.prevent="submitBilling">
|
||||
<VRow>
|
||||
<!-- Billing Settings Section -->
|
||||
<div class="text-h6 mb-3">
|
||||
<VIcon icon="tabler-credit-card" start />
|
||||
Billing Configuration
|
||||
</div>
|
||||
|
||||
<VRow class="mb-6">
|
||||
<VCol cols="12" md="6">
|
||||
<AppSelect
|
||||
v-model="billingForm.default_currency"
|
||||
@@ -385,18 +898,6 @@ function submitNotifications(): void {
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<AppTextField
|
||||
v-model="billingForm.bandwidth_overage_rate"
|
||||
label="Bandwidth Overage Rate ($/GB)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.05"
|
||||
:error-messages="billingForm.errors.bandwidth_overage_rate"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="4">
|
||||
<AppTextField
|
||||
v-model="billingForm.grace_period_days"
|
||||
@@ -473,7 +974,7 @@ function submitNotifications(): void {
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12">
|
||||
<VAlert type="info" variant="tonal" class="mb-4">
|
||||
<VAlert type="info" variant="tonal">
|
||||
<strong>Dunning timeline:</strong>
|
||||
Invoice overdue → {{ billingForm.grace_period_days || 0 }} days grace period →
|
||||
Warning sent → {{ billingForm.suspension_warning_days || 0 }} days →
|
||||
@@ -481,19 +982,210 @@ function submitNotifications(): void {
|
||||
Service terminated
|
||||
</VAlert>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<VCol cols="12">
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="billingForm.processing"
|
||||
:disabled="billingForm.processing"
|
||||
<VDivider class="mb-6" />
|
||||
|
||||
<!-- Bandwidth Overage Section -->
|
||||
<div class="text-h6 mb-3">
|
||||
<VIcon icon="tabler-chart-arrows" start />
|
||||
Bandwidth Overage Rates
|
||||
</div>
|
||||
|
||||
<VRow class="mb-4">
|
||||
<VCol cols="12" md="4">
|
||||
<AppTextField
|
||||
v-model="billingForm.bandwidth_overage_rate"
|
||||
label="Overage Rate ($/GB)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.05"
|
||||
:error-messages="billingForm.errors.bandwidth_overage_rate"
|
||||
>
|
||||
<VIcon icon="tabler-device-floppy" start />
|
||||
Save Billing Settings
|
||||
</VBtn>
|
||||
<template #append-inner>
|
||||
<VTooltip location="top">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<VIcon
|
||||
v-bind="tooltipProps"
|
||||
icon="tabler-info-circle"
|
||||
size="18"
|
||||
/>
|
||||
</template>
|
||||
Price charged per GB over the plan's included bandwidth
|
||||
</VTooltip>
|
||||
</template>
|
||||
</AppTextField>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="4">
|
||||
<AppTextField
|
||||
v-model="billingForm.bandwidth_grace_period_days"
|
||||
label="Grace Period Before Billing (days)"
|
||||
type="number"
|
||||
min="0"
|
||||
max="365"
|
||||
placeholder="3"
|
||||
:error-messages="billingForm.errors.bandwidth_grace_period_days"
|
||||
>
|
||||
<template #append-inner>
|
||||
<VTooltip location="top">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<VIcon
|
||||
v-bind="tooltipProps"
|
||||
icon="tabler-info-circle"
|
||||
size="18"
|
||||
/>
|
||||
</template>
|
||||
Days after overage detected before billing begins
|
||||
</VTooltip>
|
||||
</template>
|
||||
</AppTextField>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="4">
|
||||
<div class="d-flex align-center pt-6" style="min-height: 56px;">
|
||||
<VSwitch
|
||||
v-model="billingForm.bandwidth_auto_suspend"
|
||||
label="Auto-suspend on overage"
|
||||
color="error"
|
||||
hide-details
|
||||
inset
|
||||
/>
|
||||
<VTooltip location="top">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<VIcon
|
||||
v-bind="tooltipProps"
|
||||
icon="tabler-info-circle"
|
||||
size="18"
|
||||
class="ms-2"
|
||||
/>
|
||||
</template>
|
||||
Automatically suspend services that exceed their bandwidth limit
|
||||
</VTooltip>
|
||||
</div>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<!-- Alert Thresholds -->
|
||||
<div class="text-subtitle-1 font-weight-medium mb-3">
|
||||
Alert Thresholds
|
||||
</div>
|
||||
|
||||
<VCard variant="outlined" class="mb-6">
|
||||
<VTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Threshold</th>
|
||||
<th class="text-center">
|
||||
Alert Enabled
|
||||
</th>
|
||||
<th class="text-center">
|
||||
Email Notification
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-center">
|
||||
<VChip color="warning" variant="tonal" size="small" class="me-2">
|
||||
75%
|
||||
</VChip>
|
||||
Bandwidth usage at 75%
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<VSwitch
|
||||
v-model="billingForm.bandwidth_alert_75"
|
||||
color="primary"
|
||||
hide-details
|
||||
inset
|
||||
class="d-inline-flex"
|
||||
/>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<VSwitch
|
||||
v-model="billingForm.bandwidth_alert_75_email"
|
||||
color="primary"
|
||||
hide-details
|
||||
inset
|
||||
:disabled="!billingForm.bandwidth_alert_75"
|
||||
class="d-inline-flex"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-center">
|
||||
<VChip color="error" variant="tonal" size="small" class="me-2">
|
||||
90%
|
||||
</VChip>
|
||||
Bandwidth usage at 90%
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<VSwitch
|
||||
v-model="billingForm.bandwidth_alert_90"
|
||||
color="primary"
|
||||
hide-details
|
||||
inset
|
||||
class="d-inline-flex"
|
||||
/>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<VSwitch
|
||||
v-model="billingForm.bandwidth_alert_90_email"
|
||||
color="primary"
|
||||
hide-details
|
||||
inset
|
||||
:disabled="!billingForm.bandwidth_alert_90"
|
||||
class="d-inline-flex"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-center">
|
||||
<VChip color="error" size="small" class="me-2">
|
||||
100%
|
||||
</VChip>
|
||||
Bandwidth limit reached
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<VSwitch
|
||||
v-model="billingForm.bandwidth_alert_100"
|
||||
color="primary"
|
||||
hide-details
|
||||
inset
|
||||
class="d-inline-flex"
|
||||
/>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<VSwitch
|
||||
v-model="billingForm.bandwidth_alert_100_email"
|
||||
color="primary"
|
||||
hide-details
|
||||
inset
|
||||
:disabled="!billingForm.bandwidth_alert_100"
|
||||
class="d-inline-flex"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</VCard>
|
||||
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="billingForm.processing"
|
||||
:disabled="billingForm.processing"
|
||||
>
|
||||
<VIcon icon="tabler-device-floppy" start />
|
||||
Save Billing & Bandwidth Settings
|
||||
</VBtn>
|
||||
</form>
|
||||
</VTabsWindowItem>
|
||||
|
||||
@@ -501,32 +1193,6 @@ function submitNotifications(): void {
|
||||
<VTabsWindowItem value="notifications">
|
||||
<form @submit.prevent="submitNotifications">
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<AppTextField
|
||||
v-model="notificationsForm.discord_webhook_url"
|
||||
label="Discord Webhook URL"
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
:error-messages="notificationsForm.errors.discord_webhook_url"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<VIcon icon="tabler-brand-discord" />
|
||||
</template>
|
||||
</AppTextField>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<AppTextField
|
||||
v-model="notificationsForm.slack_webhook_url"
|
||||
label="Slack Webhook URL (Optional)"
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
:error-messages="notificationsForm.errors.slack_webhook_url"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<VIcon icon="tabler-brand-slack" />
|
||||
</template>
|
||||
</AppTextField>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<AppTextField
|
||||
v-model="notificationsForm.email_from_address"
|
||||
@@ -546,6 +1212,20 @@ function submitNotifications(): void {
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12">
|
||||
<VAlert type="info" variant="tonal" class="mb-4">
|
||||
Discord webhook notifications are configured in the
|
||||
<a
|
||||
href="#"
|
||||
class="text-primary font-weight-medium"
|
||||
@click.prevent="activeTab = 'discord'"
|
||||
>
|
||||
Discord Webhooks
|
||||
</a>
|
||||
tab.
|
||||
</VAlert>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12">
|
||||
<VBtn
|
||||
type="submit"
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useForm, Link } from '@inertiajs/vue3'
|
||||
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||
import type { PaymentMethod } from '@/types'
|
||||
import { loadStripe, type Stripe, type StripeElements, type StripeCardElement } from '@stripe/stripe-js'
|
||||
|
||||
interface Props {
|
||||
paymentMethods: PaymentMethod[]
|
||||
defaultPaymentMethod: string | null
|
||||
intent: { client_secret: string }
|
||||
stripeKey: string
|
||||
}
|
||||
|
||||
defineOptions({ layout: AccountLayout })
|
||||
|
||||
defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
let stripe: Stripe | null = null
|
||||
let elements: StripeElements | null = null
|
||||
let cardElement: StripeCardElement | null = null
|
||||
|
||||
const isAddingCard = ref(false)
|
||||
const addCardError = ref<string | null>(null)
|
||||
|
||||
const defaultForm = useForm({
|
||||
payment_method_id: '',
|
||||
@@ -61,6 +71,89 @@ function resolveBrandIcon(brand: string): string {
|
||||
|
||||
return brandMap[brand.toLowerCase()] || 'tabler-credit-card'
|
||||
}
|
||||
|
||||
async function handleAddCard(): Promise<void> {
|
||||
if (!stripe || !cardElement) {
|
||||
return
|
||||
}
|
||||
|
||||
isAddingCard.value = true
|
||||
addCardError.value = null
|
||||
|
||||
try {
|
||||
const { setupIntent, error } = await stripe.confirmCardSetup(props.intent.client_secret, {
|
||||
payment_method: {
|
||||
card: cardElement,
|
||||
},
|
||||
})
|
||||
|
||||
if (error) {
|
||||
addCardError.value = error.message || 'An error occurred while adding your card.'
|
||||
isAddingCard.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (setupIntent?.payment_method) {
|
||||
// Submit the payment method ID to the backend
|
||||
const addForm = useForm({
|
||||
payment_method_id: setupIntent.payment_method as string,
|
||||
})
|
||||
|
||||
addForm.post('/billing/payment-methods', {
|
||||
onSuccess: () => {
|
||||
// Reset the card element
|
||||
cardElement?.clear()
|
||||
},
|
||||
onError: () => {
|
||||
addCardError.value = 'Failed to save payment method.'
|
||||
},
|
||||
onFinish: () => {
|
||||
isAddingCard.value = false
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Error adding card:', err)
|
||||
addCardError.value = 'An unexpected error occurred.'
|
||||
isAddingCard.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Load Stripe.js
|
||||
stripe = await loadStripe(props.stripeKey)
|
||||
|
||||
if (!stripe) {
|
||||
console.error('Failed to load Stripe')
|
||||
return
|
||||
}
|
||||
|
||||
// Create elements
|
||||
elements = stripe.elements()
|
||||
|
||||
// Create card element
|
||||
cardElement = elements.create('card', {
|
||||
style: {
|
||||
base: {
|
||||
fontSize: '16px',
|
||||
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
|
||||
'::placeholder': {
|
||||
color: 'rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity))',
|
||||
},
|
||||
},
|
||||
invalid: {
|
||||
color: 'rgb(var(--v-theme-error))',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Mount the card element
|
||||
const cardElementContainer = document.getElementById('card-element')
|
||||
if (cardElementContainer) {
|
||||
cardElement.mount(cardElementContainer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -149,16 +242,32 @@ function resolveBrandIcon(brand: string): string {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Add New Card Info -->
|
||||
<!-- Add New Card -->
|
||||
<VCard>
|
||||
<VCardTitle>Add a New Card</VCardTitle>
|
||||
<VCardText>
|
||||
<VAlert type="info" variant="tonal">
|
||||
<div class="text-body-2">
|
||||
To add a new payment method, please use the checkout flow when purchasing a new plan,
|
||||
or contact our support team for assistance.
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="text-body-2 text-medium-emphasis d-block mb-2">Card Information</label>
|
||||
<div
|
||||
id="card-element"
|
||||
class="pa-3 rounded border"
|
||||
style="min-height: 40px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VAlert v-if="addCardError" type="error" variant="tonal" class="mb-4">
|
||||
{{ addCardError }}
|
||||
</VAlert>
|
||||
|
||||
<VBtn
|
||||
color="primary"
|
||||
:loading="isAddingCard"
|
||||
:disabled="isAddingCard"
|
||||
@click="handleAddCard"
|
||||
>
|
||||
<VIcon icon="tabler-plus" start />
|
||||
Add Card
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -198,7 +198,7 @@ const unpaidInvoices = computed<Invoice[]>(() => {
|
||||
</template>
|
||||
|
||||
<VListItemTitle class="font-weight-semibold">
|
||||
{{ subscription.plan?.name || subscription.type }}
|
||||
{{ subscription.plan_name || subscription.type }}
|
||||
</VListItemTitle>
|
||||
|
||||
<VListItemSubtitle>
|
||||
@@ -211,10 +211,10 @@ const unpaidInvoices = computed<Invoice[]>(() => {
|
||||
{{ subscription.stripe_status }}
|
||||
</VChip>
|
||||
<span
|
||||
v-if="subscription.plan"
|
||||
v-if="subscription.plan_price"
|
||||
class="text-body-2"
|
||||
>
|
||||
{{ formatPrice(subscription.plan.price, subscription.plan.billing_cycle) }}
|
||||
{{ formatPrice(subscription.plan_price, subscription.plan_billing_cycle) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
import { ref, computed } from 'vue'
|
||||
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||
import { formatPrice } from '@/utils/resolvers'
|
||||
import type { Plan } from '@/types'
|
||||
|
||||
interface Props {
|
||||
@@ -10,83 +10,482 @@ interface Props {
|
||||
|
||||
defineOptions({ layout: AccountLayout })
|
||||
|
||||
defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const serviceTypeLabels: Record<string, string> = {
|
||||
vps: 'VPS Servers',
|
||||
dedicated: 'Dedicated Servers',
|
||||
hosting: 'Web Hosting',
|
||||
game: 'Game Servers',
|
||||
// Active service type tab
|
||||
const serviceTypes = computed(() => Object.keys(props.plansByType))
|
||||
const activeTab = ref<string>(serviceTypes.value[0] || 'vps')
|
||||
|
||||
const serviceTypeMeta: Record<string, { label: string; icon: string; description: string }> = {
|
||||
vps: { label: 'VPS Servers', icon: 'tabler-server', description: 'High-performance NVMe virtual private servers' },
|
||||
dedicated: { label: 'Dedicated Servers', icon: 'tabler-server-cog', description: 'Bare-metal servers with full hardware access' },
|
||||
hosting: { label: 'Web Hosting', icon: 'tabler-world', description: 'Managed web hosting with cPanel alternative' },
|
||||
mysql: { label: 'MySQL Hosting', icon: 'tabler-database', description: 'Managed MySQL database hosting' },
|
||||
game: { label: 'Game Servers', icon: 'tabler-device-gamepad-2', description: 'Game server hosting with instant setup' },
|
||||
}
|
||||
|
||||
// Billing cycle toggle
|
||||
const billingCycle = ref<'monthly' | 'quarterly' | 'semi_annual' | 'annual'>('monthly')
|
||||
|
||||
const cycles = [
|
||||
{ value: 'monthly' as const, label: 'Monthly', months: 1, discount: 0 },
|
||||
{ value: 'quarterly' as const, label: 'Quarterly', months: 3, discount: 0.05 },
|
||||
{ value: 'semi_annual' as const, label: 'Semi-Annual', months: 6, discount: 0.10 },
|
||||
{ value: 'annual' as const, label: 'Annual', months: 12, discount: 0.15 },
|
||||
]
|
||||
|
||||
const activeCycle = computed(() => cycles.find(c => c.value === billingCycle.value) || cycles[0])
|
||||
|
||||
function cyclePrice(basePrice: string): number {
|
||||
const monthly = parseFloat(basePrice)
|
||||
return monthly * (1 - activeCycle.value.discount) * activeCycle.value.months
|
||||
}
|
||||
|
||||
function effectiveMonthly(basePrice: string): number {
|
||||
const monthly = parseFloat(basePrice)
|
||||
return monthly * (1 - activeCycle.value.discount)
|
||||
}
|
||||
|
||||
// Popular plan slugs
|
||||
const popularSlugs = new Set(['vps-mini', 'hosting-medium', 'mysql-silver'])
|
||||
const bestValueSlugs = new Set(['vps-standard'])
|
||||
|
||||
function isPlanPopular(plan: Plan): boolean {
|
||||
return popularSlugs.has(plan.slug)
|
||||
}
|
||||
|
||||
function isPlanBestValue(plan: Plan): boolean {
|
||||
return bestValueSlugs.has(plan.slug)
|
||||
}
|
||||
|
||||
// Feature display config per service type
|
||||
interface FeatureDisplay {
|
||||
key: string
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const featureConfig: Record<string, FeatureDisplay[]> = {
|
||||
vps: [
|
||||
{ key: 'cpu', label: 'CPU', icon: 'tabler-cpu' },
|
||||
{ key: 'ram', label: 'RAM', icon: 'tabler-brand-stackoverflow' },
|
||||
{ key: 'storage', label: 'Storage', icon: 'tabler-database' },
|
||||
{ key: 'bandwidth', label: 'Bandwidth', icon: 'tabler-transfer' },
|
||||
{ key: 'ipv4', label: 'IPv4', icon: 'tabler-network' },
|
||||
{ key: 'os', label: 'OS', icon: 'tabler-brand-ubuntu' },
|
||||
],
|
||||
dedicated: [
|
||||
{ key: 'cpu', label: 'CPU', icon: 'tabler-cpu' },
|
||||
{ key: 'cores', label: 'Cores', icon: 'tabler-cpu-2' },
|
||||
{ key: 'ram', label: 'RAM', icon: 'tabler-brand-stackoverflow' },
|
||||
{ key: 'storage_bays', label: 'Storage Bays', icon: 'tabler-database' },
|
||||
{ key: 'bandwidth', label: 'Bandwidth', icon: 'tabler-transfer' },
|
||||
{ key: 'ipv4', label: 'IPv4', icon: 'tabler-network' },
|
||||
],
|
||||
hosting: [
|
||||
{ key: 'storage', label: 'Storage', icon: 'tabler-database' },
|
||||
{ key: 'domains', label: 'Domains', icon: 'tabler-world' },
|
||||
{ key: 'email', label: 'Email', icon: 'tabler-mail' },
|
||||
{ key: 'databases', label: 'Databases', icon: 'tabler-database' },
|
||||
{ key: 'bandwidth', label: 'Bandwidth', icon: 'tabler-transfer' },
|
||||
{ key: 'ssl', label: 'SSL', icon: 'tabler-lock' },
|
||||
],
|
||||
mysql: [
|
||||
{ key: 'storage', label: 'Storage', icon: 'tabler-database' },
|
||||
{ key: 'backups', label: 'Backups', icon: 'tabler-cloud-upload' },
|
||||
{ key: 'ssl', label: 'Security', icon: 'tabler-lock' },
|
||||
],
|
||||
game: [
|
||||
{ key: 'cpu', label: 'CPU', icon: 'tabler-cpu' },
|
||||
{ key: 'ram', label: 'RAM', icon: 'tabler-brand-stackoverflow' },
|
||||
{ key: 'storage', label: 'Storage', icon: 'tabler-database' },
|
||||
{ key: 'bandwidth', label: 'Bandwidth', icon: 'tabler-transfer' },
|
||||
],
|
||||
}
|
||||
|
||||
function getFeaturesForType(type: string): FeatureDisplay[] {
|
||||
return featureConfig[type] || featureConfig.vps
|
||||
}
|
||||
|
||||
function isOutOfStock(plan: Plan): boolean {
|
||||
return plan.stock_quantity !== null && plan.stock_quantity <= 0
|
||||
}
|
||||
|
||||
function stockLabel(plan: Plan): string | null {
|
||||
if (plan.stock_quantity === null) return null
|
||||
if (plan.stock_quantity <= 0) return 'Out of Stock'
|
||||
if (plan.stock_quantity <= 3) return `${plan.stock_quantity} left`
|
||||
return null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-h4 font-weight-bold mb-6">Plans & Pricing</div>
|
||||
|
||||
<div v-for="(plans, type) in plansByType" :key="type" class="mb-10">
|
||||
<div class="text-h5 font-weight-medium mb-4">
|
||||
{{ serviceTypeLabels[type as string] || type }}
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="text-h4 font-weight-bold mb-2">
|
||||
Plans & Pricing
|
||||
</div>
|
||||
<div class="text-body-1 text-medium-emphasis mx-auto" style="max-width: 560px;">
|
||||
Choose the perfect plan for your needs. All plans include 24/7 support and 99.9% uptime guarantee.
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<VCol
|
||||
v-for="plan in plans"
|
||||
:key="plan.id"
|
||||
cols="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
>
|
||||
<VCard class="d-flex flex-column h-100">
|
||||
<VCardTitle>{{ plan.name }}</VCardTitle>
|
||||
<VCardText v-if="plan.description" class="text-medium-emphasis">
|
||||
{{ plan.description }}
|
||||
</VCardText>
|
||||
|
||||
<VCardText>
|
||||
<div class="text-h4 font-weight-bold">
|
||||
{{ formatPrice(plan.price, plan.billing_cycle) }}
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VCardText v-if="plan.features" class="flex-grow-1">
|
||||
<VList density="compact" class="pa-0">
|
||||
<VListItem
|
||||
v-for="(value, feature) in plan.features"
|
||||
:key="feature as string"
|
||||
class="px-0"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="tabler-check" color="success" size="18" class="me-2" />
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2">
|
||||
<span class="font-weight-medium">{{ feature }}:</span> {{ value }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="pa-4">
|
||||
<span v-if="plan.stock_quantity !== null && plan.stock_quantity <= 0" class="text-body-2 font-weight-medium text-error w-100 text-center">
|
||||
Out of Stock
|
||||
</span>
|
||||
<Link
|
||||
v-else
|
||||
:href="`/checkout/${plan.id}`"
|
||||
class="text-decoration-none w-100"
|
||||
>
|
||||
<VBtn block>
|
||||
Order Now
|
||||
</VBtn>
|
||||
</Link>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
|
||||
<div v-if="!plansByType || Object.keys(plansByType).length === 0" class="text-center py-12">
|
||||
<div class="text-medium-emphasis">No plans are currently available.</div>
|
||||
<!-- Billing Cycle Toggle -->
|
||||
<div class="d-flex justify-center mb-8">
|
||||
<div class="cycle-toggle-wrapper">
|
||||
<div
|
||||
v-for="cycle in cycles"
|
||||
:key="cycle.value"
|
||||
class="cycle-option"
|
||||
:class="{ 'cycle-option--active': billingCycle === cycle.value }"
|
||||
@click="billingCycle = cycle.value"
|
||||
>
|
||||
<span class="cycle-option__label">{{ cycle.label }}</span>
|
||||
<span v-if="cycle.discount > 0" class="cycle-option__discount">
|
||||
Save {{ (cycle.discount * 100).toFixed(0) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Type Tabs -->
|
||||
<VTabs
|
||||
v-model="activeTab"
|
||||
color="primary"
|
||||
class="mb-6"
|
||||
show-arrows
|
||||
>
|
||||
<VTab
|
||||
v-for="type in serviceTypes"
|
||||
:key="type"
|
||||
:value="type"
|
||||
>
|
||||
<VIcon :icon="serviceTypeMeta[type]?.icon || 'tabler-server'" size="20" start />
|
||||
{{ serviceTypeMeta[type]?.label || type }}
|
||||
</VTab>
|
||||
</VTabs>
|
||||
|
||||
<!-- Plans Grid -->
|
||||
<VWindow v-model="activeTab">
|
||||
<VWindowItem
|
||||
v-for="(plans, type) in plansByType"
|
||||
:key="type"
|
||||
:value="type"
|
||||
>
|
||||
<!-- Type description -->
|
||||
<div v-if="serviceTypeMeta[type as string]?.description" class="text-body-2 text-medium-emphasis mb-5">
|
||||
{{ serviceTypeMeta[type as string].description }}
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<VCol
|
||||
v-for="plan in plans"
|
||||
:key="plan.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
:lg="plans.length <= 3 ? 4 : 3"
|
||||
>
|
||||
<VCard
|
||||
class="plan-card d-flex flex-column h-100"
|
||||
:class="{
|
||||
'plan-card--popular': isPlanPopular(plan),
|
||||
'plan-card--best-value': isPlanBestValue(plan),
|
||||
'plan-card--out-of-stock': isOutOfStock(plan),
|
||||
}"
|
||||
:elevation="isPlanPopular(plan) || isPlanBestValue(plan) ? 4 : 1"
|
||||
>
|
||||
<!-- Badge -->
|
||||
<div v-if="isPlanPopular(plan)" class="plan-badge plan-badge--popular">
|
||||
Most Popular
|
||||
</div>
|
||||
<div v-else-if="isPlanBestValue(plan)" class="plan-badge plan-badge--best-value">
|
||||
Best Value
|
||||
</div>
|
||||
|
||||
<!-- Stock indicator -->
|
||||
<div v-if="stockLabel(plan)" class="stock-indicator" :class="{ 'stock-indicator--out': isOutOfStock(plan) }">
|
||||
<VIcon :icon="isOutOfStock(plan) ? 'tabler-alert-circle' : 'tabler-flame'" size="14" class="me-1" />
|
||||
{{ stockLabel(plan) }}
|
||||
</div>
|
||||
|
||||
<VCardText class="pa-5 pb-0">
|
||||
<!-- Plan name -->
|
||||
<div class="text-h6 font-weight-bold mb-1">
|
||||
{{ plan.name }}
|
||||
</div>
|
||||
|
||||
<div v-if="plan.description" class="text-caption text-medium-emphasis mb-4" style="min-height: 32px;">
|
||||
{{ plan.description }}
|
||||
</div>
|
||||
|
||||
<!-- Price -->
|
||||
<div class="price-block mb-4">
|
||||
<div class="d-flex align-end gap-1">
|
||||
<span class="text-h4 font-weight-bold price-amount">
|
||||
${{ effectiveMonthly(plan.price).toFixed(2) }}
|
||||
</span>
|
||||
<span class="text-body-2 text-medium-emphasis pb-1">/mo</span>
|
||||
</div>
|
||||
<div v-if="billingCycle !== 'monthly'" class="text-caption text-medium-emphasis mt-1">
|
||||
${{ cyclePrice(plan.price).toFixed(2) }} billed {{ activeCycle.label.toLowerCase() }}
|
||||
</div>
|
||||
<div v-if="activeCycle.discount > 0" class="text-caption text-success mt-1">
|
||||
Save ${{ (parseFloat(plan.price) * activeCycle.discount * activeCycle.months).toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VDivider class="mb-4" />
|
||||
|
||||
<!-- Features -->
|
||||
<div class="features-list">
|
||||
<div
|
||||
v-for="feat in getFeaturesForType(type as string)"
|
||||
:key="feat.key"
|
||||
class="feature-row"
|
||||
>
|
||||
<VIcon :icon="feat.icon" size="18" color="primary" class="feature-icon" />
|
||||
<span class="text-body-2">
|
||||
<span class="text-medium-emphasis">{{ feat.label }}:</span>
|
||||
<span class="font-weight-medium ms-1">{{ plan.features?.[feat.key] || '---' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VSpacer />
|
||||
|
||||
<!-- CTA -->
|
||||
<VCardActions class="pa-5 pt-4">
|
||||
<span
|
||||
v-if="isOutOfStock(plan)"
|
||||
class="text-body-2 font-weight-medium text-error w-100 text-center"
|
||||
>
|
||||
Out of Stock
|
||||
</span>
|
||||
<Link
|
||||
v-else
|
||||
:href="`/checkout/${plan.id}`"
|
||||
class="text-decoration-none w-100"
|
||||
>
|
||||
<VBtn
|
||||
block
|
||||
:color="isPlanPopular(plan) || isPlanBestValue(plan) ? 'primary' : undefined"
|
||||
:variant="isPlanPopular(plan) || isPlanBestValue(plan) ? 'flat' : 'outlined'"
|
||||
size="large"
|
||||
class="order-btn"
|
||||
>
|
||||
<VIcon icon="tabler-shopping-cart" start size="18" />
|
||||
Order Now
|
||||
</VBtn>
|
||||
</Link>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="!plansByType || Object.keys(plansByType).length === 0" class="text-center py-16">
|
||||
<VIcon icon="tabler-package-off" size="64" color="disabled" class="mb-4" />
|
||||
<div class="text-h6 text-medium-emphasis mb-2">
|
||||
No plans available
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Plans are being configured. Please check back soon.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Cycle toggle */
|
||||
.cycle-toggle-wrapper {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 14px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.cycle-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 10px 24px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cycle-option:hover:not(.cycle-option--active) {
|
||||
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||
}
|
||||
|
||||
.cycle-option--active {
|
||||
background: rgb(var(--v-theme-primary));
|
||||
box-shadow: 0 4px 14px rgba(115, 103, 240, 0.4);
|
||||
}
|
||||
|
||||
.cycle-option__label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cycle-option--active .cycle-option__label {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.cycle-option__discount {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
color: rgb(var(--v-theme-success));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cycle-option--active .cycle-option__discount {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.cycle-toggle-wrapper {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cycle-option {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Plan cards */
|
||||
.plan-card {
|
||||
position: relative;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.plan-card:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.12) !important;
|
||||
}
|
||||
|
||||
.plan-card--popular {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.plan-card--popular:hover {
|
||||
box-shadow: 0 16px 40px rgba(115, 103, 240, 0.2) !important;
|
||||
}
|
||||
|
||||
.plan-card--best-value {
|
||||
border-color: rgb(var(--v-theme-success));
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.plan-card--best-value:hover {
|
||||
box-shadow: 0 16px 40px rgba(40, 199, 111, 0.15) !important;
|
||||
}
|
||||
|
||||
.plan-card--out-of-stock {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.plan-card--out-of-stock:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.plan-badge {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 4px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.plan-badge--popular {
|
||||
background: rgb(var(--v-theme-primary));
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(115, 103, 240, 0.4);
|
||||
}
|
||||
|
||||
.plan-badge--best-value {
|
||||
background: rgb(var(--v-theme-success));
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(40, 199, 111, 0.4);
|
||||
}
|
||||
|
||||
/* Stock indicator */
|
||||
.stock-indicator {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
background: rgba(var(--v-theme-warning), 0.15);
|
||||
color: rgb(var(--v-theme-warning));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stock-indicator--out {
|
||||
background: rgba(var(--v-theme-error), 0.15);
|
||||
color: rgb(var(--v-theme-error));
|
||||
}
|
||||
|
||||
/* Price */
|
||||
.price-amount {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Features */
|
||||
.features-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.feature-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Order button */
|
||||
.order-btn {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.order-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||
import { resolveServiceStatusColor, resolveServiceTypeColor, formatPrice } from '@/utils/resolvers'
|
||||
@@ -10,67 +11,450 @@ interface Props {
|
||||
|
||||
defineOptions({ layout: AccountLayout })
|
||||
|
||||
defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const viewMode = ref<'grid' | 'list'>('grid')
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '--'
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
|
||||
const serviceTypeIcon = (type: string): string => {
|
||||
const icons: Record<string, string> = {
|
||||
vps: 'tabler-server',
|
||||
dedicated: 'tabler-server-2',
|
||||
'game-server': 'tabler-device-gamepad-2',
|
||||
'web-hosting': 'tabler-world-www',
|
||||
}
|
||||
return icons[type] ?? 'tabler-server'
|
||||
}
|
||||
|
||||
const serviceCounts = computed(() => {
|
||||
const counts = {
|
||||
total: props.services.length,
|
||||
active: props.services.filter(s => s.status === 'active').length,
|
||||
suspended: props.services.filter(s => s.status === 'suspended').length,
|
||||
pending: props.services.filter(s => s.status === 'pending').length,
|
||||
}
|
||||
return counts
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between mb-6">
|
||||
<div class="text-h4 font-weight-bold">
|
||||
Services
|
||||
</div>
|
||||
<Link
|
||||
href="/plans"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<VBtn>
|
||||
<VIcon
|
||||
icon="tabler-plus"
|
||||
start
|
||||
/>
|
||||
Order New Service
|
||||
</VBtn>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<VCard v-if="services.length === 0">
|
||||
<VCardText class="text-center py-12">
|
||||
<VIcon
|
||||
icon="tabler-server-off"
|
||||
size="48"
|
||||
class="text-medium-emphasis mb-4"
|
||||
/>
|
||||
<div class="text-h6 text-medium-emphasis mb-2">
|
||||
No services yet
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis mb-4">
|
||||
You don't have any services. Browse our plans to get started.
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="d-flex flex-wrap align-center justify-space-between gap-4 mb-4">
|
||||
<div>
|
||||
<h1 class="text-h3 font-weight-bold mb-2" style="font-family: 'DM Sans', sans-serif; letter-spacing: -0.02em;">
|
||||
Your Services
|
||||
</h1>
|
||||
<p class="text-body-1 text-medium-emphasis">
|
||||
Manage your hosting services and server infrastructure
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/plans"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<VBtn>Browse Plans</VBtn>
|
||||
<VBtn
|
||||
size="large"
|
||||
color="primary"
|
||||
class="text-none font-weight-semibold px-6"
|
||||
elevation="0"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-plus"
|
||||
start
|
||||
size="20"
|
||||
/>
|
||||
Order New Service
|
||||
</VBtn>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<!-- Stats Overview -->
|
||||
<VRow v-if="services.length > 0">
|
||||
<VCol
|
||||
cols="6"
|
||||
md="3"
|
||||
>
|
||||
<VCard
|
||||
class="stat-card"
|
||||
:style="{
|
||||
borderLeft: '3px solid rgb(var(--v-theme-primary))',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}"
|
||||
elevation="0"
|
||||
>
|
||||
<VCardText class="pa-4">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div>
|
||||
<div class="text-h4 font-weight-bold mb-1">
|
||||
{{ serviceCounts.total }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Total Services
|
||||
</div>
|
||||
</div>
|
||||
<VAvatar
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
size="48"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-server-2"
|
||||
size="24"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="6"
|
||||
md="3"
|
||||
>
|
||||
<VCard
|
||||
class="stat-card"
|
||||
:style="{
|
||||
borderLeft: '3px solid rgb(var(--v-theme-success))',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}"
|
||||
elevation="0"
|
||||
>
|
||||
<VCardText class="pa-4">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div>
|
||||
<div class="text-h4 font-weight-bold mb-1">
|
||||
{{ serviceCounts.active }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Active
|
||||
</div>
|
||||
</div>
|
||||
<VAvatar
|
||||
color="success"
|
||||
variant="tonal"
|
||||
size="48"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-circle-check"
|
||||
size="24"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="6"
|
||||
md="3"
|
||||
>
|
||||
<VCard
|
||||
class="stat-card"
|
||||
:style="{
|
||||
borderLeft: '3px solid rgb(var(--v-theme-warning))',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}"
|
||||
elevation="0"
|
||||
>
|
||||
<VCardText class="pa-4">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div>
|
||||
<div class="text-h4 font-weight-bold mb-1">
|
||||
{{ serviceCounts.pending }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Pending
|
||||
</div>
|
||||
</div>
|
||||
<VAvatar
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
size="48"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-clock"
|
||||
size="24"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="6"
|
||||
md="3"
|
||||
>
|
||||
<VCard
|
||||
class="stat-card"
|
||||
:style="{
|
||||
borderLeft: '3px solid rgb(var(--v-theme-error))',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}"
|
||||
elevation="0"
|
||||
>
|
||||
<VCardText class="pa-4">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div>
|
||||
<div class="text-h4 font-weight-bold mb-1">
|
||||
{{ serviceCounts.suspended }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Suspended
|
||||
</div>
|
||||
</div>
|
||||
<VAvatar
|
||||
color="error"
|
||||
variant="tonal"
|
||||
size="48"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-alert-circle"
|
||||
size="24"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
|
||||
<!-- View Mode Toggle -->
|
||||
<div
|
||||
v-if="services.length > 0"
|
||||
class="d-flex justify-end mb-6"
|
||||
>
|
||||
<VBtnToggle
|
||||
v-model="viewMode"
|
||||
mandatory
|
||||
variant="outlined"
|
||||
divided
|
||||
>
|
||||
<VBtn value="grid">
|
||||
<VIcon icon="tabler-layout-grid" />
|
||||
</VBtn>
|
||||
<VBtn value="list">
|
||||
<VIcon icon="tabler-list" />
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<VCard
|
||||
v-if="services.length === 0"
|
||||
class="empty-state"
|
||||
elevation="0"
|
||||
>
|
||||
<VCardText class="text-center pa-12">
|
||||
<div class="empty-icon-wrapper mb-6">
|
||||
<VIcon
|
||||
icon="tabler-server-off"
|
||||
size="64"
|
||||
class="text-medium-emphasis"
|
||||
style="opacity: 0.5;"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="text-h4 font-weight-bold mb-3" style="font-family: 'DM Sans', sans-serif;">
|
||||
No Services Yet
|
||||
</h3>
|
||||
<p class="text-body-1 text-medium-emphasis mb-6" style="max-width: 480px; margin-left: auto; margin-right: auto;">
|
||||
Your services will appear here once you've ordered them. Browse our plans to get started with powerful hosting solutions.
|
||||
</p>
|
||||
<Link
|
||||
href="/plans"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<VBtn
|
||||
size="large"
|
||||
color="primary"
|
||||
class="text-none px-8"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-shopping-cart"
|
||||
start
|
||||
/>
|
||||
Browse Plans
|
||||
</VBtn>
|
||||
</Link>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VCard v-else>
|
||||
<!-- Grid View -->
|
||||
<VRow v-if="services.length > 0 && viewMode === 'grid'">
|
||||
<VCol
|
||||
v-for="(service, index) in services"
|
||||
:key="service.id"
|
||||
cols="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
>
|
||||
<VCard
|
||||
class="service-card h-100"
|
||||
elevation="0"
|
||||
:style="{
|
||||
animationDelay: `${index * 50}ms`,
|
||||
}"
|
||||
>
|
||||
<!-- Card Header with gradient overlay -->
|
||||
<div
|
||||
class="service-card-header pa-4"
|
||||
:style="{
|
||||
background: `linear-gradient(135deg, rgb(var(--v-theme-${resolveServiceTypeColor(service.service_type)})) 0%, rgb(var(--v-theme-${resolveServiceTypeColor(service.service_type)}-darken-1)) 100%)`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
style="position: absolute; top: 0; right: 0; width: 100px; height: 100px; background: rgba(255,255,255,0.1); border-radius: 50%; transform: translate(30%, -30%);"
|
||||
/>
|
||||
<div class="d-flex align-center justify-space-between position-relative">
|
||||
<div class="d-flex align-center gap-3">
|
||||
<VAvatar
|
||||
:color="resolveServiceTypeColor(service.service_type)"
|
||||
variant="flat"
|
||||
size="48"
|
||||
style="background: rgba(255,255,255,0.2);"
|
||||
>
|
||||
<VIcon
|
||||
:icon="serviceTypeIcon(service.service_type)"
|
||||
size="24"
|
||||
color="white"
|
||||
/>
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-body-2 text-white" style="opacity: 0.9;">
|
||||
{{ service.plan?.name || 'Service' }}
|
||||
</div>
|
||||
<div class="text-caption text-white" style="opacity: 0.7;">
|
||||
{{ service.platform }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VChip
|
||||
:color="resolveServiceStatusColor(service.status)"
|
||||
size="small"
|
||||
class="text-capitalize status-pulse"
|
||||
>
|
||||
{{ service.status }}
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Body -->
|
||||
<VCardText class="pa-4">
|
||||
<h3 class="text-h6 font-weight-bold mb-3" style="font-family: 'DM Sans', sans-serif;">
|
||||
{{ service.hostname || service.domain || `Service #${service.id}` }}
|
||||
</h3>
|
||||
|
||||
<div class="service-details mb-4">
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<span class="text-body-2 text-medium-emphasis">
|
||||
<VIcon
|
||||
icon="tabler-network"
|
||||
size="16"
|
||||
class="me-1"
|
||||
/>
|
||||
IP Address
|
||||
</span>
|
||||
<span class="text-body-2 font-weight-medium">
|
||||
<code v-if="service.ipv4_address">{{ service.ipv4_address }}</code>
|
||||
<span
|
||||
v-else
|
||||
class="text-medium-emphasis"
|
||||
>Pending</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<span class="text-body-2 text-medium-emphasis">
|
||||
<VIcon
|
||||
icon="tabler-coin"
|
||||
size="16"
|
||||
class="me-1"
|
||||
/>
|
||||
Price
|
||||
</span>
|
||||
<span class="text-body-2 font-weight-medium">
|
||||
{{ service.plan ? formatPrice(service.plan.price, service.plan.billing_cycle) : '--' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<span class="text-body-2 text-medium-emphasis">
|
||||
<VIcon
|
||||
icon="tabler-calendar"
|
||||
size="16"
|
||||
class="me-1"
|
||||
/>
|
||||
Next Renewal
|
||||
</span>
|
||||
<span class="text-body-2 font-weight-medium">
|
||||
{{ service.subscription?.current_period_end ? formatDate(service.subscription.current_period_end) : '--' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VDivider class="my-4" />
|
||||
|
||||
<Link
|
||||
:href="`/services/${service.id}`"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<VBtn
|
||||
block
|
||||
color="primary"
|
||||
variant="flat"
|
||||
class="text-none"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-settings"
|
||||
start
|
||||
size="20"
|
||||
/>
|
||||
Manage Service
|
||||
<VIcon
|
||||
icon="tabler-arrow-right"
|
||||
end
|
||||
size="18"
|
||||
/>
|
||||
</VBtn>
|
||||
</Link>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<!-- List View -->
|
||||
<VCard
|
||||
v-if="services.length > 0 && viewMode === 'list'"
|
||||
elevation="0"
|
||||
>
|
||||
<VTable hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Service</th>
|
||||
<th>Plan</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>IP Address</th>
|
||||
<th>Renewal Date</th>
|
||||
<th class="text-end">
|
||||
<th class="font-weight-bold">
|
||||
Service
|
||||
</th>
|
||||
<th class="font-weight-bold">
|
||||
Plan
|
||||
</th>
|
||||
<th class="font-weight-bold">
|
||||
Type
|
||||
</th>
|
||||
<th class="font-weight-bold">
|
||||
Status
|
||||
</th>
|
||||
<th class="font-weight-bold">
|
||||
IP Address
|
||||
</th>
|
||||
<th class="font-weight-bold">
|
||||
Renewal Date
|
||||
</th>
|
||||
<th class="text-end font-weight-bold">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
@@ -79,17 +463,34 @@ function formatDate(dateStr: string | null): string {
|
||||
<tr
|
||||
v-for="service in services"
|
||||
:key="service.id"
|
||||
class="service-row"
|
||||
>
|
||||
<td>
|
||||
<div class="font-weight-medium">
|
||||
{{ service.hostname || service.domain || `Service #${service.id}` }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
{{ service.platform }}
|
||||
<div class="d-flex align-center gap-3">
|
||||
<VAvatar
|
||||
:color="resolveServiceTypeColor(service.service_type)"
|
||||
variant="tonal"
|
||||
size="36"
|
||||
>
|
||||
<VIcon
|
||||
:icon="serviceTypeIcon(service.service_type)"
|
||||
size="20"
|
||||
/>
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="font-weight-medium">
|
||||
{{ service.hostname || service.domain || `Service #${service.id}` }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
{{ service.platform }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ service.plan?.name || '--' }}
|
||||
<div class="font-weight-medium">
|
||||
{{ service.plan?.name || '--' }}
|
||||
</div>
|
||||
<div
|
||||
v-if="service.plan"
|
||||
class="text-body-2 text-medium-emphasis"
|
||||
@@ -110,20 +511,20 @@ function formatDate(dateStr: string | null): string {
|
||||
<VChip
|
||||
:color="resolveServiceStatusColor(service.status)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
class="text-capitalize status-pulse"
|
||||
>
|
||||
{{ service.status }}
|
||||
</VChip>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="service.ipv4_address">{{ service.ipv4_address }}</span>
|
||||
<code v-if="service.ipv4_address">{{ service.ipv4_address }}</code>
|
||||
<span
|
||||
v-else
|
||||
class="text-medium-emphasis"
|
||||
>--</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ service.subscription?.current_period_end ? formatDate(service.subscription.current_period_end) : formatDate(null) }}
|
||||
{{ service.subscription?.current_period_end ? formatDate(service.subscription.current_period_end) : '--' }}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<Link
|
||||
@@ -133,10 +534,13 @@ function formatDate(dateStr: string | null): string {
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="text-none"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-eye"
|
||||
icon="tabler-settings"
|
||||
start
|
||||
size="18"
|
||||
/>
|
||||
Manage
|
||||
</VBtn>
|
||||
@@ -148,3 +552,132 @@ function formatDate(dateStr: string | null): string {
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Typography */
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap');
|
||||
|
||||
/* Stats Cards */
|
||||
.stat-card {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, transparent 0%, rgba(var(--v-theme-primary), 0.03) 100%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(var(--v-theme-primary), 0.12);
|
||||
}
|
||||
|
||||
/* Service Cards */
|
||||
.service-card {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
animation: fadeInUp 0.5s ease-out backwards;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.service-card:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.1);
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
|
||||
.service-card-header {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.service-card:hover .service-card-header {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
/* Status Pulse Animation */
|
||||
.status-pulse {
|
||||
position: relative;
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
border: 2px dashed rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
background: linear-gradient(135deg, rgba(var(--v-theme-surface), 1) 0%, rgba(var(--v-theme-primary), 0.02) 100%);
|
||||
}
|
||||
|
||||
.empty-icon-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.empty-icon-wrapper::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -20px;
|
||||
background: radial-gradient(circle, rgba(var(--v-theme-primary), 0.1) 0%, transparent 70%);
|
||||
animation: ripple 3s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Service Details */
|
||||
.service-details code {
|
||||
background: rgba(var(--v-theme-primary), 0.08);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* List View Row Hover */
|
||||
.service-row {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.service-row:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.02);
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@ defineProps<Props>()
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div>
|
||||
<div class="text-h6 font-weight-bold">
|
||||
{{ subscription.plan?.name || subscription.type }}
|
||||
{{ subscription.plan_name || subscription.type }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">
|
||||
{{ subscription.gateway || 'stripe' }} ·
|
||||
@@ -64,8 +64,8 @@ defineProps<Props>()
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="subscription.plan" class="text-body-2 text-medium-emphasis mt-3">
|
||||
{{ formatPrice(subscription.plan.price, subscription.plan.billing_cycle) }}
|
||||
<div v-if="subscription.plan_price" class="text-body-2 text-medium-emphasis mt-3">
|
||||
{{ formatPrice(subscription.plan_price, subscription.plan_billing_cycle) }}
|
||||
</div>
|
||||
|
||||
<div v-if="subscription.ends_at" class="text-body-2 text-error mt-2">
|
||||
|
||||
@@ -69,7 +69,15 @@ function formatDate(dateString: string): string {
|
||||
})
|
||||
}
|
||||
|
||||
const currentPlan = computed(() => props.subscription.plan)
|
||||
const currentPlan = computed(() => {
|
||||
if (!props.subscription.plan_name) return null
|
||||
return {
|
||||
name: props.subscription.plan_name,
|
||||
price: props.subscription.plan_price,
|
||||
billing_cycle: props.subscription.plan_billing_cycle,
|
||||
features: props.subscription.plan_features,
|
||||
}
|
||||
})
|
||||
|
||||
const isActive = computed<boolean>(() => props.subscription.stripe_status === 'active')
|
||||
const isCancelling = computed<boolean>(() => !!props.subscription.ends_at && props.subscription.stripe_status !== 'canceled')
|
||||
|
||||
Reference in New Issue
Block a user