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:
Claude Dev
2026-02-10 06:30:57 -05:00
parent bf4f5f97c0
commit 45d25d61ba
101 changed files with 13225 additions and 1888 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 ?? '&infin;' }}
</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>

View 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>

View 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 ?? '&infin;' }}
</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>

View File

@@ -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>

View 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>

View 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' }} &middot; {{ 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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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' }} &middot; {{ 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>

View File

@@ -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 &rarr; {{ billingForm.grace_period_days || 0 }} days grace period &rarr;
Warning sent &rarr; {{ billingForm.suspension_warning_days || 0 }} days &rarr;
@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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 &amp; 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>

View File

@@ -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

View File

@@ -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' }} &middot;
@@ -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">

View File

@@ -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')