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>
239 lines
7.5 KiB
Vue
239 lines
7.5 KiB
Vue
<script lang="ts" setup>
|
|
import { Link, router } from '@inertiajs/vue3'
|
|
import { computed } from 'vue'
|
|
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
|
import type { CouponWithStats, PaginatedResponse, StatusColor } from '@/types'
|
|
|
|
interface Props {
|
|
coupons: PaginatedResponse<CouponWithStats>
|
|
}
|
|
|
|
defineOptions({ layout: AdminLayout })
|
|
|
|
const props = defineProps<Props>()
|
|
|
|
const tableHeaders = computed(() => [
|
|
{ title: 'Code', key: 'code', sortable: true },
|
|
{ title: 'Type', key: 'type', sortable: true },
|
|
{ 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 },
|
|
])
|
|
|
|
function resolveTypeColor(type: string): StatusColor {
|
|
return type === 'percentage' ? 'info' : 'warning'
|
|
}
|
|
|
|
function formatValue(coupon: CouponWithStats): string {
|
|
if (coupon.type === 'percentage') {
|
|
return `${parseFloat(coupon.value)}%`
|
|
}
|
|
return `$${parseFloat(coupon.value).toFixed(2)}`
|
|
}
|
|
|
|
function formatDate(dateString: string | null): string {
|
|
if (!dateString) {
|
|
return 'Never'
|
|
}
|
|
const date = new Date(dateString)
|
|
return date.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
})
|
|
}
|
|
|
|
function formatPlansApplicable(appliesTo: number[] | null): string {
|
|
if (!appliesTo || appliesTo.length === 0) {
|
|
return 'All Plans'
|
|
}
|
|
return `${appliesTo.length} plan${appliesTo.length > 1 ? 's' : ''}`
|
|
}
|
|
|
|
function resolveCouponStatus(coupon: CouponWithStats): { label: string; color: StatusColor } {
|
|
if (!coupon.active) {
|
|
return { label: 'Inactive', color: 'error' }
|
|
}
|
|
if (coupon.expires_at && new Date(coupon.expires_at) < new Date()) {
|
|
return { label: 'Expired', color: 'secondary' }
|
|
}
|
|
if (coupon.max_uses !== null && coupon.times_used >= coupon.max_uses) {
|
|
return { label: 'Exhausted', color: 'warning' }
|
|
}
|
|
return { label: 'Active', color: 'success' }
|
|
}
|
|
|
|
function deactivateCoupon(coupon: CouponWithStats): void {
|
|
if (confirm(`Are you sure you want to deactivate coupon "${coupon.code}"?`)) {
|
|
router.delete(`/coupons/${coupon.id}`, {
|
|
preserveScroll: true,
|
|
})
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<!-- Header -->
|
|
<div class="d-flex align-center justify-space-between mb-6">
|
|
<div>
|
|
<div class="text-h4 font-weight-bold">
|
|
Coupons
|
|
</div>
|
|
<div class="text-body-2 text-medium-emphasis">
|
|
Manage discount coupons and promotions
|
|
</div>
|
|
</div>
|
|
<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 -->
|
|
<VCard>
|
|
<VDataTable
|
|
:headers="tableHeaders"
|
|
:items="coupons.data"
|
|
:items-per-page="25"
|
|
hover
|
|
class="text-no-wrap"
|
|
>
|
|
<!-- Code -->
|
|
<template #item.code="{ item }">
|
|
<span class="font-weight-medium font-monospace">{{ item.code }}</span>
|
|
</template>
|
|
|
|
<!-- Type -->
|
|
<template #item.type="{ item }">
|
|
<VChip
|
|
:color="resolveTypeColor(item.type)"
|
|
size="small"
|
|
variant="tonal"
|
|
class="text-capitalize"
|
|
>
|
|
{{ item.type }}
|
|
</VChip>
|
|
</template>
|
|
|
|
<!-- Value -->
|
|
<template #item.value="{ item }">
|
|
<span class="font-weight-medium">{{ formatValue(item) }}</span>
|
|
</template>
|
|
|
|
<!-- Plans Applicable -->
|
|
<template #item.applies_to="{ item }">
|
|
<VChip
|
|
size="small"
|
|
variant="tonal"
|
|
:color="!item.applies_to || item.applies_to.length === 0 ? 'success' : 'secondary'"
|
|
>
|
|
{{ formatPlansApplicable(item.applies_to) }}
|
|
</VChip>
|
|
</template>
|
|
|
|
<!-- Usage -->
|
|
<template #item.usage="{ item }">
|
|
<Link :href="`/coupons/${item.id}`" class="text-decoration-none">
|
|
<span class="font-weight-medium text-primary">
|
|
{{ item.redemptions_count }}
|
|
</span>
|
|
</Link>
|
|
<span class="text-medium-emphasis">
|
|
/ {{ item.max_uses ?? '∞' }}
|
|
</span>
|
|
</template>
|
|
|
|
<!-- Total Discount -->
|
|
<template #item.total_discount="{ item }">
|
|
<span v-if="item.redemptions_sum_discount_amount" class="font-weight-medium text-success">
|
|
${{ parseFloat(item.redemptions_sum_discount_amount).toFixed(2) }}
|
|
</span>
|
|
<span v-else class="text-medium-emphasis">$0.00</span>
|
|
</template>
|
|
|
|
<!-- Last Redeemed -->
|
|
<template #item.last_redeemed="{ item }">
|
|
<span v-if="item.redemptions_max_created_at">
|
|
{{ formatDate(item.redemptions_max_created_at) }}
|
|
</span>
|
|
<span v-else class="text-medium-emphasis">Never</span>
|
|
</template>
|
|
|
|
<!-- Expires -->
|
|
<template #item.expires_at="{ item }">
|
|
<span :class="{ 'text-error': item.expires_at && new Date(item.expires_at) < new Date() }">
|
|
{{ formatDate(item.expires_at) }}
|
|
</span>
|
|
</template>
|
|
|
|
<!-- Status -->
|
|
<template #item.status="{ item }">
|
|
<VChip
|
|
:color="resolveCouponStatus(item).color"
|
|
size="small"
|
|
>
|
|
{{ resolveCouponStatus(item).label }}
|
|
</VChip>
|
|
</template>
|
|
|
|
<!-- Actions -->
|
|
<template #item.actions="{ item }">
|
|
<VMenu>
|
|
<template #activator="{ props: menuProps }">
|
|
<VBtn
|
|
icon="tabler-dots-vertical"
|
|
variant="text"
|
|
size="small"
|
|
v-bind="menuProps"
|
|
/>
|
|
</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>
|
|
</VListItem>
|
|
</Link>
|
|
<VListItem
|
|
v-if="item.active"
|
|
prepend-icon="tabler-ban"
|
|
@click="deactivateCoupon(item)"
|
|
>
|
|
<VListItemTitle>Deactivate</VListItemTitle>
|
|
</VListItem>
|
|
</VList>
|
|
</VMenu>
|
|
</template>
|
|
|
|
<!-- No data -->
|
|
<template #no-data>
|
|
<div class="text-center py-8">
|
|
<VIcon icon="tabler-discount-2" size="48" color="disabled" class="mb-2" />
|
|
<div class="text-medium-emphasis">
|
|
No coupons found.
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</VDataTable>
|
|
</VCard>
|
|
</div>
|
|
</template>
|