Add plan upgrade/downgrade, order management, impersonation, and contact form
- Plan upgrade/downgrade flow: UpgradeController with price difference calculations, Upgrade.vue with feature comparison and confirmation dialog - Admin order management: Order model/migration/factory, OrderController with process/complete/cancel/notes, Index and Show pages with filters - Admin impersonation: start/stop endpoints, session-based tracking, impersonation banner in AccountLayout, audit logging - Contact form: ContactRequest validation, ContactController with email, marketing route for form submission - FlashMessages now supports info alerts - Inertia shared data includes impersonation state - 114 tests passing (623 assertions) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link, useForm } from '@inertiajs/vue3'
|
||||
import { Link, router, useForm } from '@inertiajs/vue3'
|
||||
import { ref } from 'vue'
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import { resolveInvoiceStatusColor, resolveSubscriptionStatusColor } from '@/utils/resolvers'
|
||||
@@ -230,6 +230,15 @@ function formatBillingAddress(profile: CustomerProfile | null): string {
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center ga-2">
|
||||
<VBtn
|
||||
color="info"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
@click="router.post(`/impersonate/${customer.id}`)"
|
||||
>
|
||||
<VIcon icon="tabler-user-shield" start />
|
||||
Impersonate
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="customer.status !== 'suspended'"
|
||||
color="warning"
|
||||
|
||||
225
website/resources/ts/Pages/Admin/Orders/Index.vue
Normal file
225
website/resources/ts/Pages/Admin/Orders/Index.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link, router } from '@inertiajs/vue3'
|
||||
import { ref, watch } from 'vue'
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import { formatPrice } from '@/utils/resolvers'
|
||||
import type { PaginatedResponse, StatusColor } from '@/types'
|
||||
|
||||
interface OrderUser {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
interface OrderPlan {
|
||||
id: number
|
||||
name: string
|
||||
service_type: string
|
||||
price: string
|
||||
billing_cycle: string
|
||||
}
|
||||
|
||||
interface OrderItem {
|
||||
id: number
|
||||
user_id: number
|
||||
plan_id: number
|
||||
order_number: string
|
||||
status: string
|
||||
total: string
|
||||
currency: string
|
||||
payment_gateway: string | null
|
||||
created_at: string
|
||||
user: OrderUser | null
|
||||
plan: OrderPlan | null
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
search: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
orders: PaginatedResponse<OrderItem>
|
||||
filters: Filters
|
||||
}
|
||||
|
||||
defineOptions({ layout: AdminLayout })
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const search = ref<string>(props.filters.search)
|
||||
const status = ref<string>(props.filters.status)
|
||||
|
||||
const statusOptions = [
|
||||
{ title: 'All Statuses', value: '' },
|
||||
{ title: 'Pending', value: 'pending' },
|
||||
{ title: 'Processing', value: 'processing' },
|
||||
{ title: 'Completed', value: 'completed' },
|
||||
{ title: 'Cancelled', value: 'cancelled' },
|
||||
{ title: 'Failed', value: 'failed' },
|
||||
]
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function applyFilters(): void {
|
||||
router.get('/orders', {
|
||||
search: search.value || undefined,
|
||||
status: status.value || undefined,
|
||||
}, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
|
||||
watch(search, () => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(applyFilters, 300)
|
||||
})
|
||||
|
||||
watch(status, () => {
|
||||
applyFilters()
|
||||
})
|
||||
|
||||
function resolveOrderStatusColor(statusVal: string): StatusColor {
|
||||
const map: Record<string, StatusColor> = {
|
||||
pending: 'warning',
|
||||
processing: 'info',
|
||||
completed: 'success',
|
||||
cancelled: 'error',
|
||||
failed: 'error',
|
||||
}
|
||||
return map[statusVal] ?? 'secondary'
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between mb-6">
|
||||
<div>
|
||||
<div class="text-h4 font-weight-bold">
|
||||
Orders
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Manage all customer orders
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<VCard class="mb-6">
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12" md="8">
|
||||
<VTextField
|
||||
v-model="search"
|
||||
prepend-inner-icon="tabler-search"
|
||||
placeholder="Search by order number, customer name, or email..."
|
||||
density="compact"
|
||||
clearable
|
||||
hide-details
|
||||
@click:clear="search = ''"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-model="status"
|
||||
:items="statusOptions"
|
||||
density="compact"
|
||||
hide-details
|
||||
label="Status"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Orders Table -->
|
||||
<VCard>
|
||||
<VCardText v-if="orders.data.length === 0" class="text-center py-12">
|
||||
<VIcon icon="tabler-shopping-cart-off" size="48" color="disabled" class="mb-2" />
|
||||
<div class="text-medium-emphasis">
|
||||
No orders found.
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VTable v-else density="comfortable" hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order #</th>
|
||||
<th>Customer</th>
|
||||
<th>Plan</th>
|
||||
<th class="text-end">
|
||||
Total
|
||||
</th>
|
||||
<th>Status</th>
|
||||
<th>Gateway</th>
|
||||
<th>Created</th>
|
||||
<th class="text-center">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="order in orders.data" :key="order.id">
|
||||
<td class="text-body-2 font-weight-medium">
|
||||
{{ order.order_number }}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="text-body-2 font-weight-medium">{{ order.user?.name ?? 'Unknown' }}</span>
|
||||
<span class="text-caption text-medium-emphasis">{{ order.user?.email ?? '' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-body-2">
|
||||
{{ order.plan?.name ?? 'N/A' }}
|
||||
</td>
|
||||
<td class="text-end text-body-2 font-weight-medium">
|
||||
{{ formatPrice(order.total) }}
|
||||
</td>
|
||||
<td>
|
||||
<VChip
|
||||
:color="resolveOrderStatusColor(order.status)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ order.status }}
|
||||
</VChip>
|
||||
</td>
|
||||
<td class="text-body-2 text-capitalize">
|
||||
{{ order.payment_gateway ?? '---' }}
|
||||
</td>
|
||||
<td class="text-body-2">
|
||||
{{ formatDate(order.created_at) }}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<Link :href="`/orders/${order.id}`">
|
||||
<VBtn variant="text" size="small" color="primary">
|
||||
<VIcon icon="tabler-eye" size="18" />
|
||||
</VBtn>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
|
||||
<!-- Pagination -->
|
||||
<VCardText v-if="orders.last_page > 1" class="d-flex align-center justify-center pt-2">
|
||||
<VPagination
|
||||
:model-value="orders.data.length > 0 ? Math.ceil((orders.from ?? 1) / 25) : 1"
|
||||
:length="orders.last_page"
|
||||
:total-visible="7"
|
||||
@update:model-value="(page: number) => router.get('/orders', { ...props.filters, page }, { preserveState: true, preserveScroll: true })"
|
||||
/>
|
||||
</VCardText>
|
||||
|
||||
<VCardText v-if="orders.total > 0" class="text-center text-caption text-medium-emphasis">
|
||||
Showing {{ orders.from }} to {{ orders.to }} of {{ orders.total }} orders
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
520
website/resources/ts/Pages/Admin/Orders/Show.vue
Normal file
520
website/resources/ts/Pages/Admin/Orders/Show.vue
Normal file
@@ -0,0 +1,520 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link, useForm } from '@inertiajs/vue3'
|
||||
import { computed, ref } from 'vue'
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||
import AppTextarea from '@/Components/app-form-elements/AppTextarea.vue'
|
||||
import { formatPrice } from '@/utils/resolvers'
|
||||
import type { StatusColor } from '@/types'
|
||||
|
||||
interface OrderUser {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface OrderPlan {
|
||||
id: number
|
||||
name: string
|
||||
service_type: string
|
||||
price: string
|
||||
billing_cycle: string
|
||||
}
|
||||
|
||||
interface OrderInvoice {
|
||||
id: number
|
||||
number: string
|
||||
total: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface OrderService {
|
||||
id: number
|
||||
hostname: string | null
|
||||
status: string
|
||||
ipv4_address: string | null
|
||||
}
|
||||
|
||||
interface OrderDetail {
|
||||
id: number
|
||||
user_id: number
|
||||
plan_id: number
|
||||
invoice_id: number | null
|
||||
service_id: number | null
|
||||
order_number: string
|
||||
status: string
|
||||
total: string
|
||||
currency: string
|
||||
payment_gateway: string | null
|
||||
configuration: Record<string, string> | null
|
||||
admin_notes: string | null
|
||||
completed_at: string | null
|
||||
cancelled_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
user: OrderUser | null
|
||||
plan: OrderPlan | null
|
||||
invoice: OrderInvoice | null
|
||||
service: OrderService | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
order: OrderDetail
|
||||
}
|
||||
|
||||
defineOptions({ layout: AdminLayout })
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const confirmDialog = ref<boolean>(false)
|
||||
const confirmAction = ref<'process' | 'complete' | 'cancel'>('process')
|
||||
const confirmTitle = ref<string>('')
|
||||
const confirmMessage = ref<string>('')
|
||||
const confirmColor = ref<string>('info')
|
||||
|
||||
const processForm = useForm({})
|
||||
const completeForm = useForm({})
|
||||
const cancelForm = useForm({})
|
||||
|
||||
const notesForm = useForm({
|
||||
admin_notes: props.order.admin_notes ?? '',
|
||||
})
|
||||
|
||||
const isProcessing = computed<boolean>(() =>
|
||||
processForm.processing || completeForm.processing || cancelForm.processing,
|
||||
)
|
||||
|
||||
function openConfirmDialog(action: 'process' | 'complete' | 'cancel'): void {
|
||||
confirmAction.value = action
|
||||
|
||||
if (action === 'process') {
|
||||
confirmTitle.value = 'Process Order'
|
||||
confirmMessage.value = `Are you sure you want to mark order ${props.order.order_number} as processing?`
|
||||
confirmColor.value = 'info'
|
||||
}
|
||||
else if (action === 'complete') {
|
||||
confirmTitle.value = 'Complete Order'
|
||||
confirmMessage.value = `Are you sure you want to mark order ${props.order.order_number} as completed?`
|
||||
confirmColor.value = 'success'
|
||||
}
|
||||
else {
|
||||
confirmTitle.value = 'Cancel Order'
|
||||
confirmMessage.value = `Are you sure you want to cancel order ${props.order.order_number}? This action cannot be undone.`
|
||||
confirmColor.value = 'error'
|
||||
}
|
||||
|
||||
confirmDialog.value = true
|
||||
}
|
||||
|
||||
function executeAction(): void {
|
||||
const action = confirmAction.value
|
||||
|
||||
if (action === 'process') {
|
||||
processForm.post(`/orders/${props.order.id}/process`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { confirmDialog.value = false },
|
||||
})
|
||||
}
|
||||
else if (action === 'complete') {
|
||||
completeForm.post(`/orders/${props.order.id}/complete`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { confirmDialog.value = false },
|
||||
})
|
||||
}
|
||||
else {
|
||||
cancelForm.post(`/orders/${props.order.id}/cancel`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { confirmDialog.value = false },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function saveNotes(): void {
|
||||
notesForm.put(`/orders/${props.order.id}/notes`, {
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
|
||||
function resolveOrderStatusColor(statusVal: string): StatusColor {
|
||||
const map: Record<string, StatusColor> = {
|
||||
pending: 'warning',
|
||||
processing: 'info',
|
||||
completed: 'success',
|
||||
cancelled: 'error',
|
||||
failed: 'error',
|
||||
}
|
||||
return map[statusVal] ?? 'secondary'
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '---'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string | null): string {
|
||||
if (!dateStr) return '---'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function formatServiceType(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
vps: 'VPS',
|
||||
dedicated: 'Dedicated',
|
||||
web_hosting: 'Web Hosting',
|
||||
hosting: 'Web Hosting',
|
||||
game: 'Game Hosting',
|
||||
game_server: 'Game Hosting',
|
||||
}
|
||||
return map[type] ?? type
|
||||
}
|
||||
|
||||
function configurationEntries(): Array<{ key: string; value: string }> {
|
||||
if (!props.order.configuration) return []
|
||||
return Object.entries(props.order.configuration).map(([key, value]) => ({
|
||||
key: key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
|
||||
value: String(value),
|
||||
}))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="d-flex align-center justify-space-between mb-6">
|
||||
<div class="d-flex align-center gap-4">
|
||||
<Link href="/orders">
|
||||
<VBtn variant="text" icon="tabler-arrow-left" size="small" />
|
||||
</Link>
|
||||
<div>
|
||||
<div class="d-flex align-center gap-2">
|
||||
<span class="text-h4 font-weight-bold">Order {{ order.order_number }}</span>
|
||||
<VChip
|
||||
:color="resolveOrderStatusColor(order.status)"
|
||||
size="small"
|
||||
class="text-capitalize"
|
||||
>
|
||||
{{ order.status }}
|
||||
</VChip>
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">
|
||||
{{ order.user?.name ?? 'Unknown Customer' }} · {{ order.user?.email ?? '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<VBtn
|
||||
v-if="order.status === 'pending'"
|
||||
color="info"
|
||||
variant="tonal"
|
||||
:disabled="isProcessing"
|
||||
@click="openConfirmDialog('process')"
|
||||
>
|
||||
<VIcon icon="tabler-player-play" start />
|
||||
Process
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
v-if="order.status === 'processing'"
|
||||
color="success"
|
||||
variant="tonal"
|
||||
:disabled="isProcessing"
|
||||
@click="openConfirmDialog('complete')"
|
||||
>
|
||||
<VIcon icon="tabler-check" start />
|
||||
Complete
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
v-if="order.status === 'pending' || order.status === 'processing'"
|
||||
color="error"
|
||||
variant="tonal"
|
||||
:disabled="isProcessing"
|
||||
@click="openConfirmDialog('cancel')"
|
||||
>
|
||||
<VIcon icon="tabler-x" start />
|
||||
Cancel
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<!-- Order Details -->
|
||||
<VCol cols="12" lg="6">
|
||||
<VCard>
|
||||
<VCardTitle class="d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-shopping-cart" size="22" />
|
||||
<span>Order Details</span>
|
||||
</VCardTitle>
|
||||
|
||||
<VCardText>
|
||||
<VList density="compact" class="pa-0">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Order Number</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2 font-weight-medium">
|
||||
{{ order.order_number }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Total</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2 font-weight-medium">
|
||||
{{ formatPrice(order.total) }}
|
||||
<span class="text-uppercase text-medium-emphasis ms-1">{{ order.currency }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Gateway</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2 text-capitalize">
|
||||
{{ order.payment_gateway ?? 'N/A' }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Created</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2">
|
||||
{{ formatDateTime(order.created_at) }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem v-if="order.completed_at">
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Completed</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2 text-success">
|
||||
{{ formatDateTime(order.completed_at) }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem v-if="order.cancelled_at">
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Cancelled</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2 text-error">
|
||||
{{ formatDateTime(order.cancelled_at) }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Customer Card -->
|
||||
<VCard class="mt-4">
|
||||
<VCardTitle class="d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-user" size="22" />
|
||||
<span>Customer</span>
|
||||
</VCardTitle>
|
||||
|
||||
<VCardText v-if="order.user">
|
||||
<div class="d-flex align-center gap-3">
|
||||
<VAvatar color="primary" variant="tonal" size="40">
|
||||
<span class="text-body-1 font-weight-semibold">
|
||||
{{ order.user.name.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-body-1 font-weight-medium">
|
||||
{{ order.user.name }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
{{ order.user.email }}
|
||||
</div>
|
||||
</div>
|
||||
<VSpacer />
|
||||
<Link :href="`/customers/${order.user.id}`">
|
||||
<VBtn variant="tonal" size="small" color="primary">
|
||||
View Customer
|
||||
</VBtn>
|
||||
</Link>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Related Resources -->
|
||||
<VCard v-if="order.invoice || order.service" class="mt-4">
|
||||
<VCardTitle class="d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-link" size="22" />
|
||||
<span>Related Resources</span>
|
||||
</VCardTitle>
|
||||
|
||||
<VCardText>
|
||||
<VList density="compact" class="pa-0">
|
||||
<VListItem v-if="order.invoice">
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Invoice</span>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<Link :href="`/invoices/${order.invoice.id}`" class="text-body-2 font-weight-medium text-primary text-decoration-none">
|
||||
{{ order.invoice.number }}
|
||||
</Link>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem v-if="order.service">
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Service</span>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<Link :href="`/services/${order.service.id}`" class="text-body-2 font-weight-medium text-primary text-decoration-none">
|
||||
Service #{{ order.service.id }}
|
||||
</Link>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
<!-- Plan & Configuration -->
|
||||
<VCol cols="12" lg="6">
|
||||
<!-- Plan Info -->
|
||||
<VCard>
|
||||
<VCardTitle class="d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-package" size="22" />
|
||||
<span>Plan</span>
|
||||
</VCardTitle>
|
||||
|
||||
<VCardText v-if="order.plan">
|
||||
<VList density="compact" class="pa-0">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Name</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2 font-weight-medium">
|
||||
{{ order.plan.name }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Service Type</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2">
|
||||
{{ formatServiceType(order.plan.service_type) }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Price</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2 font-weight-medium">
|
||||
{{ formatPrice(order.plan.price, order.plan.billing_cycle) }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
|
||||
<VCardText v-else class="text-center py-6">
|
||||
<div class="text-medium-emphasis">
|
||||
Plan information unavailable.
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Configuration -->
|
||||
<VCard class="mt-4">
|
||||
<VCardTitle class="d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-settings" size="22" />
|
||||
<span>Configuration</span>
|
||||
</VCardTitle>
|
||||
|
||||
<VCardText v-if="configurationEntries().length > 0">
|
||||
<VList density="compact" class="pa-0">
|
||||
<VListItem v-for="entry in configurationEntries()" :key="entry.key">
|
||||
<template #prepend>
|
||||
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">{{ entry.key }}</span>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2">
|
||||
{{ entry.value }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
|
||||
<VCardText v-else class="text-center py-6">
|
||||
<VIcon icon="tabler-inbox" size="36" color="disabled" class="mb-2" />
|
||||
<div class="text-medium-emphasis">
|
||||
No configuration data.
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Admin Notes -->
|
||||
<VCard class="mt-4">
|
||||
<VCardTitle class="d-flex align-center gap-2">
|
||||
<VIcon icon="tabler-notes" size="22" />
|
||||
<span>Admin Notes</span>
|
||||
</VCardTitle>
|
||||
|
||||
<VCardText>
|
||||
<AppTextarea
|
||||
v-model="notesForm.admin_notes"
|
||||
placeholder="Add internal notes about this order..."
|
||||
rows="4"
|
||||
:error-messages="notesForm.errors.admin_notes"
|
||||
counter="1000"
|
||||
maxlength="1000"
|
||||
/>
|
||||
|
||||
<div class="d-flex justify-end mt-3">
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
:loading="notesForm.processing"
|
||||
:disabled="notesForm.processing"
|
||||
@click="saveNotes"
|
||||
>
|
||||
<VIcon icon="tabler-device-floppy" start />
|
||||
Save Notes
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<VDialog v-model="confirmDialog" max-width="500" persistent>
|
||||
<VCard>
|
||||
<VCardTitle class="text-h5 pa-5">
|
||||
{{ confirmTitle }}
|
||||
</VCardTitle>
|
||||
<VCardText class="px-5 pb-2">
|
||||
{{ confirmMessage }}
|
||||
</VCardText>
|
||||
<VCardActions class="pa-5">
|
||||
<VSpacer />
|
||||
<VBtn variant="text" :disabled="isProcessing" @click="confirmDialog = false">
|
||||
Cancel
|
||||
</VBtn>
|
||||
<VBtn
|
||||
:color="confirmColor"
|
||||
variant="flat"
|
||||
:loading="isProcessing"
|
||||
@click="executeAction"
|
||||
>
|
||||
Confirm
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -83,18 +83,36 @@ const platformLabel = computed<string>(() => {
|
||||
Managed by {{ platformLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<VBtn
|
||||
v-if="controlPanelUrl && !isTerminated"
|
||||
:href="controlPanelUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-external-link"
|
||||
start
|
||||
/>
|
||||
Open Control Panel
|
||||
</VBtn>
|
||||
<div class="d-flex ga-3">
|
||||
<Link
|
||||
v-if="service.status === 'active' && service.plan"
|
||||
:href="`/services/${service.id}/upgrade`"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-arrows-exchange"
|
||||
start
|
||||
/>
|
||||
Upgrade / Downgrade
|
||||
</VBtn>
|
||||
</Link>
|
||||
<VBtn
|
||||
v-if="controlPanelUrl && !isTerminated"
|
||||
:href="controlPanelUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-external-link"
|
||||
start
|
||||
/>
|
||||
Open Control Panel
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Suspended Notice -->
|
||||
@@ -334,6 +352,24 @@ const platformLabel = computed<string>(() => {
|
||||
<VCardTitle>Quick Actions</VCardTitle>
|
||||
<VCardText>
|
||||
<div class="d-flex flex-column ga-3">
|
||||
<Link
|
||||
v-if="service.status === 'active' && service.plan"
|
||||
:href="`/services/${service.id}/upgrade`"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<VBtn
|
||||
block
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-arrows-exchange"
|
||||
start
|
||||
/>
|
||||
Upgrade / Downgrade
|
||||
</VBtn>
|
||||
</Link>
|
||||
|
||||
<VBtn
|
||||
v-if="controlPanelUrl"
|
||||
:href="controlPanelUrl"
|
||||
|
||||
468
website/resources/ts/Pages/Services/Upgrade.vue
Normal file
468
website/resources/ts/Pages/Services/Upgrade.vue
Normal file
@@ -0,0 +1,468 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { Link, useForm } from '@inertiajs/vue3'
|
||||
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||
import { formatPrice } from '@/utils/resolvers'
|
||||
import type { Plan } from '@/types'
|
||||
|
||||
interface AvailablePlan extends Plan {
|
||||
price_difference: number
|
||||
is_upgrade: boolean
|
||||
}
|
||||
|
||||
interface ServiceData {
|
||||
id: number
|
||||
hostname: string | null
|
||||
ipv4_address: string | null
|
||||
domain: string | null
|
||||
status: string
|
||||
service_type: string
|
||||
plan: Plan
|
||||
}
|
||||
|
||||
interface Props {
|
||||
service: ServiceData
|
||||
currentPlan: Plan
|
||||
availablePlans: AvailablePlan[]
|
||||
}
|
||||
|
||||
defineOptions({ layout: AccountLayout })
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const showConfirmDialog = ref<boolean>(false)
|
||||
const selectedPlan = ref<AvailablePlan | null>(null)
|
||||
|
||||
const form = useForm({
|
||||
plan_id: 0,
|
||||
})
|
||||
|
||||
const upgradePlans = computed<AvailablePlan[]>(() =>
|
||||
props.availablePlans.filter(plan => plan.is_upgrade),
|
||||
)
|
||||
|
||||
const downgradePlans = computed<AvailablePlan[]>(() =>
|
||||
props.availablePlans.filter(plan => !plan.is_upgrade),
|
||||
)
|
||||
|
||||
const serviceLabel = computed<string>(() =>
|
||||
props.service.hostname || props.service.domain || `Service #${props.service.id}`,
|
||||
)
|
||||
|
||||
const confirmActionLabel = computed<string>(() => {
|
||||
if (!selectedPlan.value) return ''
|
||||
return selectedPlan.value.is_upgrade ? 'Upgrade' : 'Downgrade'
|
||||
})
|
||||
|
||||
const confirmActionColor = computed<string>(() => {
|
||||
if (!selectedPlan.value) return 'primary'
|
||||
return selectedPlan.value.is_upgrade ? 'success' : 'warning'
|
||||
})
|
||||
|
||||
function formatPriceDifference(difference: number): string {
|
||||
const abs = Math.abs(difference).toFixed(2)
|
||||
if (difference > 0) return `+$${abs}`
|
||||
if (difference < 0) return `-$${abs}`
|
||||
return '$0.00'
|
||||
}
|
||||
|
||||
function openConfirmDialog(plan: AvailablePlan): void {
|
||||
selectedPlan.value = plan
|
||||
form.plan_id = plan.id
|
||||
showConfirmDialog.value = true
|
||||
}
|
||||
|
||||
function submitUpgrade(): void {
|
||||
form.post(`/services/${props.service.id}/upgrade`, {
|
||||
onSuccess: () => {
|
||||
showConfirmDialog.value = false
|
||||
selectedPlan.value = null
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<Link
|
||||
:href="`/services/${service.id}`"
|
||||
class="text-primary text-body-2 text-decoration-none"
|
||||
>
|
||||
← Back to Service
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="d-flex align-center justify-space-between mb-6">
|
||||
<div>
|
||||
<div class="text-h4 font-weight-bold">
|
||||
Upgrade / Downgrade
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">
|
||||
{{ serviceLabel }} — Currently on <strong>{{ currentPlan.name }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Plan -->
|
||||
<VCard class="mb-6">
|
||||
<VCardTitle class="d-flex align-center ga-2">
|
||||
<VIcon
|
||||
icon="tabler-star"
|
||||
color="primary"
|
||||
size="20"
|
||||
/>
|
||||
Current Plan
|
||||
</VCardTitle>
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="4"
|
||||
>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Plan
|
||||
</div>
|
||||
<div class="text-h6 font-weight-bold mt-1">
|
||||
{{ currentPlan.name }}
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="4"
|
||||
>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Price
|
||||
</div>
|
||||
<div class="text-h6 font-weight-bold mt-1">
|
||||
{{ formatPrice(currentPlan.price, currentPlan.billing_cycle) }}
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="4"
|
||||
>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Service Type
|
||||
</div>
|
||||
<div class="text-body-1 text-capitalize mt-1">
|
||||
{{ currentPlan.service_type }}
|
||||
</div>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<div
|
||||
v-if="currentPlan.features && Object.keys(currentPlan.features).length > 0"
|
||||
class="mt-4"
|
||||
>
|
||||
<div class="text-body-2 text-medium-emphasis mb-2">
|
||||
Features
|
||||
</div>
|
||||
<VList density="compact">
|
||||
<VListItem
|
||||
v-for="(value, key) in currentPlan.features"
|
||||
:key="String(key)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
icon="tabler-check"
|
||||
color="success"
|
||||
size="18"
|
||||
/>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2">
|
||||
<span class="font-weight-medium text-capitalize">{{ String(key).replace(/_/g, ' ') }}:</span>
|
||||
{{ value }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- No Plans Available -->
|
||||
<VCard v-if="availablePlans.length === 0">
|
||||
<VCardText class="text-center py-12">
|
||||
<VIcon
|
||||
icon="tabler-arrows-exchange"
|
||||
size="48"
|
||||
class="text-medium-emphasis mb-4"
|
||||
/>
|
||||
<div class="text-h6 text-medium-emphasis mb-2">
|
||||
No alternative plans available
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
There are no other plans available for this service type.
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Upgrade Plans -->
|
||||
<div v-if="upgradePlans.length > 0">
|
||||
<div class="text-h5 font-weight-bold mb-4 d-flex align-center ga-2">
|
||||
<VIcon
|
||||
icon="tabler-arrow-up"
|
||||
color="success"
|
||||
size="24"
|
||||
/>
|
||||
Upgrade Options
|
||||
</div>
|
||||
<VRow class="mb-6">
|
||||
<VCol
|
||||
v-for="plan in upgradePlans"
|
||||
:key="plan.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
lg="4"
|
||||
>
|
||||
<VCard
|
||||
class="h-100"
|
||||
border
|
||||
>
|
||||
<VCardText>
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<div class="text-h6 font-weight-bold">
|
||||
{{ plan.name }}
|
||||
</div>
|
||||
<VChip
|
||||
color="success"
|
||||
size="small"
|
||||
>
|
||||
{{ formatPriceDifference(plan.price_difference) }}/{{ currentPlan.billing_cycle }}
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="text-h5 font-weight-bold mb-4">
|
||||
{{ formatPrice(plan.price, plan.billing_cycle) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="plan.description"
|
||||
class="text-body-2 text-medium-emphasis mb-4"
|
||||
>
|
||||
{{ plan.description }}
|
||||
</div>
|
||||
|
||||
<!-- Feature Comparison -->
|
||||
<div
|
||||
v-if="plan.features && Object.keys(plan.features).length > 0"
|
||||
class="mb-4"
|
||||
>
|
||||
<VList density="compact">
|
||||
<VListItem
|
||||
v-for="(value, key) in plan.features"
|
||||
:key="String(key)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
icon="tabler-check"
|
||||
color="success"
|
||||
size="16"
|
||||
/>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2">
|
||||
<span class="font-weight-medium text-capitalize">{{ String(key).replace(/_/g, ' ') }}:</span>
|
||||
{{ value }}
|
||||
<template v-if="currentPlan.features && currentPlan.features[String(key)] && currentPlan.features[String(key)] !== value">
|
||||
<span class="text-medium-emphasis text-caption ml-1">(currently: {{ currentPlan.features[String(key)] }})</span>
|
||||
</template>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
color="success"
|
||||
block
|
||||
@click="openConfirmDialog(plan)"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-arrow-up"
|
||||
start
|
||||
/>
|
||||
Upgrade
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
|
||||
<!-- Downgrade Plans -->
|
||||
<div v-if="downgradePlans.length > 0">
|
||||
<div class="text-h5 font-weight-bold mb-4 d-flex align-center ga-2">
|
||||
<VIcon
|
||||
icon="tabler-arrow-down"
|
||||
color="warning"
|
||||
size="24"
|
||||
/>
|
||||
Downgrade Options
|
||||
</div>
|
||||
<VRow class="mb-6">
|
||||
<VCol
|
||||
v-for="plan in downgradePlans"
|
||||
:key="plan.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
lg="4"
|
||||
>
|
||||
<VCard
|
||||
class="h-100"
|
||||
border
|
||||
>
|
||||
<VCardText>
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<div class="text-h6 font-weight-bold">
|
||||
{{ plan.name }}
|
||||
</div>
|
||||
<VChip
|
||||
color="warning"
|
||||
size="small"
|
||||
>
|
||||
{{ formatPriceDifference(plan.price_difference) }}/{{ currentPlan.billing_cycle }}
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="text-h5 font-weight-bold mb-4">
|
||||
{{ formatPrice(plan.price, plan.billing_cycle) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="plan.description"
|
||||
class="text-body-2 text-medium-emphasis mb-4"
|
||||
>
|
||||
{{ plan.description }}
|
||||
</div>
|
||||
|
||||
<!-- Feature Comparison -->
|
||||
<div
|
||||
v-if="plan.features && Object.keys(plan.features).length > 0"
|
||||
class="mb-4"
|
||||
>
|
||||
<VList density="compact">
|
||||
<VListItem
|
||||
v-for="(value, key) in plan.features"
|
||||
:key="String(key)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
icon="tabler-check"
|
||||
:color="currentPlan.features && currentPlan.features[String(key)] && currentPlan.features[String(key)] !== value ? 'warning' : 'success'"
|
||||
size="16"
|
||||
/>
|
||||
</template>
|
||||
<VListItemTitle class="text-body-2">
|
||||
<span class="font-weight-medium text-capitalize">{{ String(key).replace(/_/g, ' ') }}:</span>
|
||||
{{ value }}
|
||||
<template v-if="currentPlan.features && currentPlan.features[String(key)] && currentPlan.features[String(key)] !== value">
|
||||
<span class="text-medium-emphasis text-caption ml-1">(currently: {{ currentPlan.features[String(key)] }})</span>
|
||||
</template>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
color="warning"
|
||||
block
|
||||
@click="openConfirmDialog(plan)"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-arrow-down"
|
||||
start
|
||||
/>
|
||||
Downgrade
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<VDialog
|
||||
v-model="showConfirmDialog"
|
||||
max-width="520"
|
||||
>
|
||||
<VCard v-if="selectedPlan">
|
||||
<VCardTitle class="text-h5 pa-6 pb-2">
|
||||
Confirm {{ confirmActionLabel }}
|
||||
</VCardTitle>
|
||||
<VCardText class="pa-6 pt-2">
|
||||
<p class="text-body-1 mb-4">
|
||||
Are you sure you want to {{ confirmActionLabel.toLowerCase() }} your service from
|
||||
<strong>{{ currentPlan.name }}</strong> to <strong>{{ selectedPlan.name }}</strong>?
|
||||
</p>
|
||||
|
||||
<VCard
|
||||
variant="tonal"
|
||||
:color="confirmActionColor"
|
||||
class="mb-4"
|
||||
>
|
||||
<VCardText>
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Price Change
|
||||
</div>
|
||||
<div class="text-h6 font-weight-bold">
|
||||
{{ formatPriceDifference(selectedPlan.price_difference) }}/{{ currentPlan.billing_cycle }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
New Price
|
||||
</div>
|
||||
<div class="text-h6 font-weight-bold">
|
||||
{{ formatPrice(selectedPlan.price, selectedPlan.billing_cycle) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VAlert
|
||||
v-if="selectedPlan.is_upgrade"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-0"
|
||||
>
|
||||
An invoice for the price difference will be generated.
|
||||
</VAlert>
|
||||
<VAlert
|
||||
v-else
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-0"
|
||||
>
|
||||
A credit memo for the price difference will be applied to your account.
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
<VCardActions class="pa-6 pt-0">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
:disabled="form.processing"
|
||||
@click="showConfirmDialog = false"
|
||||
>
|
||||
Cancel
|
||||
</VBtn>
|
||||
<VBtn
|
||||
:color="confirmActionColor"
|
||||
:loading="form.processing"
|
||||
@click="submitUpgrade"
|
||||
>
|
||||
<VIcon
|
||||
:icon="selectedPlan.is_upgrade ? 'tabler-arrow-up' : 'tabler-arrow-down'"
|
||||
start
|
||||
/>
|
||||
Confirm {{ confirmActionLabel }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user