Files
website/website/resources/ts/Pages/Admin/Settings/Index.vue
Claude Dev 45d25d61ba 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>
2026-02-10 06:30:57 -05:00

1248 lines
48 KiB
Vue

<script lang="ts" setup>
import { useForm } from '@inertiajs/vue3'
import { ref } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
import AppSelect from '@/Components/app-form-elements/AppSelect.vue'
interface SettingsGroup {
[key: string]: string | boolean | null
}
interface Props {
settings: {
general: SettingsGroup
api: SettingsGroup
billing: SettingsGroup
notifications: SettingsGroup
discord: SettingsGroup
}
}
defineOptions({ layout: AdminLayout })
const props = defineProps<Props>()
const activeTab = ref<string>('general')
// General settings form
const generalForm = useForm({
group: 'general',
company_name: (props.settings.general.company_name as string) ?? '',
company_email: (props.settings.general.company_email as string) ?? '',
support_url: (props.settings.general.support_url as string) ?? '',
status_page_url: (props.settings.general.status_page_url as string) ?? '',
})
// API credentials form
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
const billingForm = useForm({
group: 'billing',
default_currency: (props.settings.billing.default_currency as string) ?? 'USD',
grace_period_days: (props.settings.billing.grace_period_days as string) ?? '7',
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',
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' },
{ title: 'EUR - Euro', value: 'EUR' },
{ title: 'GBP - British Pound', value: 'GBP' },
{ title: 'CAD - Canadian Dollar', value: 'CAD' },
{ title: 'AUD - Australian Dollar', value: 'AUD' },
]
const tabItems = [
{ value: 'general', title: 'General', icon: 'tabler-building' },
{ value: 'api', title: 'API Credentials', icon: 'tabler-key' },
{ 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' },
]
function submitGeneral(): void {
generalForm.put('/settings', {
preserveScroll: true,
})
}
function submitApi(): void {
apiForm.put('/settings', {
preserveScroll: true,
})
}
function submitBilling(): void {
billingForm.put('/settings', {
preserveScroll: true,
})
}
function submitNotifications(): void {
notificationsForm.put('/settings', {
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>
<div>
<div class="d-flex align-center justify-space-between mb-6">
<div>
<div class="text-h4 font-weight-bold">
System Settings
</div>
<div class="text-body-2 text-medium-emphasis">
Configure your EZSCALE platform settings
</div>
</div>
</div>
<VCard>
<VTabs
v-model="activeTab"
class="v-tabs-pill"
>
<VTab
v-for="tab in tabItems"
:key="tab.value"
:value="tab.value"
>
<VIcon :icon="tab.icon" start />
{{ tab.title }}
</VTab>
</VTabs>
<VDivider />
<VCardText>
<VTabsWindow v-model="activeTab">
<!-- General Tab -->
<VTabsWindowItem value="general">
<form @submit.prevent="submitGeneral">
<VRow>
<VCol cols="12" md="6">
<AppTextField
v-model="generalForm.company_name"
label="Company Name"
placeholder="EZSCALE"
:error-messages="generalForm.errors.company_name"
/>
</VCol>
<VCol cols="12" md="6">
<AppTextField
v-model="generalForm.company_email"
label="Company Email"
type="email"
placeholder="support@ezscale.cloud"
:error-messages="generalForm.errors.company_email"
/>
</VCol>
<VCol cols="12" md="6">
<AppTextField
v-model="generalForm.support_url"
label="Support URL"
placeholder="https://support.ezscale.cloud"
:error-messages="generalForm.errors.support_url"
/>
</VCol>
<VCol cols="12" md="6">
<AppTextField
v-model="generalForm.status_page_url"
label="Status Page URL"
placeholder="https://status.ezscale.cloud"
:error-messages="generalForm.errors.status_page_url"
/>
</VCol>
<VCol cols="12">
<VBtn
type="submit"
color="primary"
:loading="generalForm.processing"
:disabled="generalForm.processing"
>
<VIcon icon="tabler-device-floppy" start />
Save General Settings
</VBtn>
</VCol>
</VRow>
</form>
</VTabsWindowItem>
<!-- API Credentials Tab -->
<VTabsWindowItem value="api">
<form @submit.prevent="submitApi">
<!-- VirtFusion -->
<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
v-model="apiForm.virtfusion_api_url"
label="API URL"
placeholder="https://vps.ezscale.cloud/api/v1"
:error-messages="apiForm.errors.virtfusion_api_url"
/>
</VCol>
<VCol cols="12" md="6">
<AppTextField
v-model="apiForm.virtfusion_api_token"
label="API Token"
:type="showVirtfusionToken ? 'text' : 'password'"
:placeholder="props.settings.api.virtfusion_api_token_set ? '******** (token is set, leave blank to keep)' : 'Enter API token'"
:error-messages="apiForm.errors.virtfusion_api_token"
>
<template #append-inner>
<VIcon
:icon="showVirtfusionToken ? 'tabler-eye-off' : 'tabler-eye'"
style="cursor: pointer;"
@click="showVirtfusionToken = !showVirtfusionToken"
/>
</template>
</AppTextField>
</VCol>
</VRow>
<VDivider class="mb-4" />
<!-- 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
v-model="apiForm.synergycp_api_url"
label="API URL"
placeholder="https://dedicated.ezscale.cloud/api"
:error-messages="apiForm.errors.synergycp_api_url"
/>
</VCol>
<VCol cols="12" md="6">
<AppTextField
v-model="apiForm.synergycp_api_token"
label="API Token"
:type="showSynergycpToken ? 'text' : 'password'"
:placeholder="props.settings.api.synergycp_api_token_set ? '******** (token is set, leave blank to keep)' : 'Enter API token'"
:error-messages="apiForm.errors.synergycp_api_token"
>
<template #append-inner>
<VIcon
:icon="showSynergycpToken ? 'tabler-eye-off' : 'tabler-eye'"
style="cursor: pointer;"
@click="showSynergycpToken = !showSynergycpToken"
/>
</template>
</AppTextField>
</VCol>
</VRow>
<VDivider class="mb-4" />
<!-- Enhance -->
<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="4">
<AppTextField
v-model="apiForm.enhance_api_url"
label="API URL"
placeholder="https://hosting.ezscale.cloud/api"
:error-messages="apiForm.errors.enhance_api_url"
/>
</VCol>
<VCol cols="12" md="4">
<AppTextField
v-model="apiForm.enhance_api_token"
label="API Token"
:type="showEnhanceToken ? 'text' : 'password'"
:placeholder="props.settings.api.enhance_api_token_set ? '******** (token is set, leave blank to keep)' : 'Enter API token'"
:error-messages="apiForm.errors.enhance_api_token"
>
<template #append-inner>
<VIcon
:icon="showEnhanceToken ? 'tabler-eye-off' : 'tabler-eye'"
style="cursor: pointer;"
@click="showEnhanceToken = !showEnhanceToken"
/>
</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" />
<!-- Stripe (read-only) -->
<div class="text-h6 mb-3">
<VIcon icon="tabler-brand-stripe" start />
Stripe
<VChip size="x-small" color="info" variant="tonal" class="ms-2">
Read-only (.env)
</VChip>
</div>
<VRow class="mb-4">
<VCol cols="12" md="6">
<AppTextField
:model-value="(props.settings.api.stripe_publishable_key as string) || 'Not configured'"
label="Publishable Key"
readonly
disabled
/>
</VCol>
<VCol cols="12" md="6">
<AppTextField
:model-value="(props.settings.api.stripe_secret_key as string) || 'Not configured'"
label="Secret Key"
readonly
disabled
/>
</VCol>
</VRow>
<VDivider class="mb-4" />
<!-- PayPal (read-only) -->
<div class="text-h6 mb-3">
<VIcon icon="tabler-brand-paypal" start />
PayPal
<VChip size="x-small" color="info" variant="tonal" class="ms-2">
Read-only (.env)
</VChip>
</div>
<VRow class="mb-6">
<VCol cols="12" md="6">
<AppTextField
:model-value="(props.settings.api.paypal_client_id as string) || 'Not configured'"
label="Client ID"
readonly
disabled
/>
</VCol>
<VCol cols="12" md="6">
<AppTextField
:model-value="(props.settings.api.paypal_client_secret as string) || 'Not configured'"
label="Client Secret"
readonly
disabled
/>
</VCol>
</VRow>
<VBtn
type="submit"
color="primary"
:loading="apiForm.processing"
:disabled="apiForm.processing"
>
<VIcon icon="tabler-device-floppy" start />
Save API Credentials
</VBtn>
</form>
</VTabsWindowItem>
<!-- 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">
<!-- 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"
label="Default Currency"
:items="currencyOptions"
:error-messages="billingForm.errors.default_currency"
/>
</VCol>
<VCol cols="12" md="4">
<AppTextField
v-model="billingForm.grace_period_days"
label="Grace Period (days)"
type="number"
min="0"
max="365"
placeholder="7"
:error-messages="billingForm.errors.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 invoice due date before suspension warning is sent
</VTooltip>
</template>
</AppTextField>
</VCol>
<VCol cols="12" md="4">
<AppTextField
v-model="billingForm.suspension_warning_days"
label="Suspension Warning (days)"
type="number"
min="0"
max="365"
placeholder="3"
:error-messages="billingForm.errors.suspension_warning_days"
>
<template #append-inner>
<VTooltip location="top">
<template #activator="{ props: tooltipProps }">
<VIcon
v-bind="tooltipProps"
icon="tabler-info-circle"
size="18"
/>
</template>
Days after warning before service is suspended
</VTooltip>
</template>
</AppTextField>
</VCol>
<VCol cols="12" md="4">
<AppTextField
v-model="billingForm.auto_terminate_days"
label="Auto-Terminate (days)"
type="number"
min="0"
max="365"
placeholder="14"
:error-messages="billingForm.errors.auto_terminate_days"
>
<template #append-inner>
<VTooltip location="top">
<template #activator="{ props: tooltipProps }">
<VIcon
v-bind="tooltipProps"
icon="tabler-info-circle"
size="18"
/>
</template>
Days after suspension before service is automatically terminated
</VTooltip>
</template>
</AppTextField>
</VCol>
<VCol cols="12">
<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;
Service suspended &rarr; {{ billingForm.auto_terminate_days || 0 }} days &rarr;
Service terminated
</VAlert>
</VCol>
</VRow>
<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"
>
<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>
<!-- Notifications Tab -->
<VTabsWindowItem value="notifications">
<form @submit.prevent="submitNotifications">
<VRow>
<VCol cols="12" md="6">
<AppTextField
v-model="notificationsForm.email_from_address"
label="Email From Address"
type="email"
placeholder="noreply@ezscale.cloud"
:error-messages="notificationsForm.errors.email_from_address"
/>
</VCol>
<VCol cols="12" md="6">
<AppTextField
v-model="notificationsForm.email_from_name"
label="Email From Name"
placeholder="EZSCALE"
:error-messages="notificationsForm.errors.email_from_name"
/>
</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"
color="primary"
:loading="notificationsForm.processing"
:disabled="notificationsForm.processing"
>
<VIcon icon="tabler-device-floppy" start />
Save Notification Settings
</VBtn>
</VCol>
</VRow>
</form>
</VTabsWindowItem>
</VTabsWindow>
</VCardText>
</VCard>
</div>
</template>