Files
website/website/resources/ts/Pages/Admin/Coupons/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

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 ?? '&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() }">
{{ 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>