first commit
This commit is contained in:
231
src/views/pages/saas/SaasSubscriptionHealthPage.vue
Normal file
231
src/views/pages/saas/SaasSubscriptionHealthPage.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import Toolbar from 'primevue/toolbar'
|
||||
import Button from 'primevue/button'
|
||||
import Toast from 'primevue/toast'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
import Tag from 'primevue/tag'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Divider from 'primevue/divider'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const loading = ref(false)
|
||||
const fixing = ref(false)
|
||||
const fixingOwner = ref(null)
|
||||
|
||||
const rows = ref([])
|
||||
const q = ref('')
|
||||
|
||||
const total = computed(() => rows.value.length)
|
||||
const totalMissing = computed(() => rows.value.filter(r => r.mismatch_type === 'missing_entitlement').length)
|
||||
const totalUnexpected = computed(() => rows.value.filter(r => r.mismatch_type === 'unexpected_entitlement').length)
|
||||
|
||||
function severityForMismatch (t) {
|
||||
if (t === 'missing_entitlement') return 'danger'
|
||||
if (t === 'unexpected_entitlement') return 'warning'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
function labelForMismatch (t) {
|
||||
if (t === 'missing_entitlement') return 'Missing'
|
||||
if (t === 'unexpected_entitlement') return 'Unexpected'
|
||||
return t || '-'
|
||||
}
|
||||
|
||||
async function fetchAll () {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('v_subscription_feature_mismatch')
|
||||
.select('*')
|
||||
|
||||
if (error) throw error
|
||||
rows.value = data || []
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function filteredRows () {
|
||||
const term = String(q.value || '').trim().toLowerCase()
|
||||
if (!term) return rows.value
|
||||
return rows.value.filter(r =>
|
||||
String(r.owner_id || '').toLowerCase().includes(term) ||
|
||||
String(r.feature_key || '').toLowerCase().includes(term) ||
|
||||
String(r.mismatch_type || '').toLowerCase().includes(term)
|
||||
)
|
||||
}
|
||||
|
||||
function openOwnerSubscriptions (ownerId) {
|
||||
if (!ownerId) return
|
||||
router.push({ path: '/saas/subscriptions', query: { q: ownerId } })
|
||||
}
|
||||
|
||||
async function fixOwner (ownerId) {
|
||||
if (!ownerId) return
|
||||
|
||||
fixing.value = true
|
||||
fixingOwner.value = ownerId
|
||||
|
||||
try {
|
||||
const { error } = await supabase.rpc('rebuild_owner_entitlements', {
|
||||
p_owner_id: ownerId
|
||||
})
|
||||
if (error) throw error
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Corrigido',
|
||||
detail: 'Entitlements reconstruídos para este owner.',
|
||||
life: 2500
|
||||
})
|
||||
|
||||
await fetchAll()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
|
||||
} finally {
|
||||
fixing.value = false
|
||||
fixingOwner.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function fixAll () {
|
||||
fixing.value = true
|
||||
try {
|
||||
const { error } = await supabase.rpc('fix_all_subscription_mismatches')
|
||||
if (error) throw error
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Sistema corrigido',
|
||||
detail: 'Entitlements reconstruídos para todos os owners com divergência.',
|
||||
life: 3000
|
||||
})
|
||||
|
||||
await fetchAll()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
|
||||
} finally {
|
||||
fixing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchAll)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<div class="p-4">
|
||||
<Toolbar class="mb-4">
|
||||
<template #start>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-xl font-semibold leading-none">Subscription Health</div>
|
||||
<small class="text-color-secondary mt-1">
|
||||
Divergências entre Plano (esperado) e Entitlements (atual). Use “Fix” para reconstruir.
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #end>
|
||||
<div class="flex items-center gap-2 flex-wrap justify-end">
|
||||
<span class="p-input-icon-left">
|
||||
<i class="pi pi-search" />
|
||||
<InputText v-model="q" placeholder="Buscar owner_id, feature_key..." />
|
||||
</span>
|
||||
|
||||
<Button
|
||||
label="Recarregar"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading="loading"
|
||||
@click="fetchAll"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="total > 0"
|
||||
label="Fix All"
|
||||
icon="pi pi-refresh"
|
||||
severity="danger"
|
||||
:loading="fixing"
|
||||
@click="fixAll"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Toolbar>
|
||||
|
||||
<!-- resumo conceitual -->
|
||||
<div class="surface-100 border-round p-3 mb-4">
|
||||
<div class="flex flex-wrap gap-2 items-center justify-content-between">
|
||||
<div class="flex gap-2 items-center flex-wrap">
|
||||
<Tag :value="`Total: ${total}`" severity="secondary" />
|
||||
<Tag :value="`Missing: ${totalMissing}`" severity="danger" />
|
||||
<Tag :value="`Unexpected: ${totalUnexpected}`" severity="warning" />
|
||||
</div>
|
||||
|
||||
<div class="text-color-secondary text-sm">
|
||||
“Missing” = plano exige, mas não está ativo · “Unexpected” = ativo sem constar no plano
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="filteredRows()"
|
||||
dataKey="owner_id"
|
||||
:loading="loading"
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
sortField="owner_id"
|
||||
:sortOrder="1"
|
||||
>
|
||||
<Column field="owner_id" header="Owner" style="min-width: 22rem" />
|
||||
|
||||
<Column field="feature_key" header="Feature" style="min-width: 18rem" />
|
||||
|
||||
<Column header="Tipo" style="width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :severity="severityForMismatch(data.mismatch_type)" :value="labelForMismatch(data.mismatch_type)" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Ações" style="min-width: 18rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
icon="pi pi-external-link"
|
||||
severity="secondary"
|
||||
outlined
|
||||
v-tooltip.top="'Abrir subscriptions deste owner (filtro)'"
|
||||
@click="openOwnerSubscriptions(data.owner_id)"
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Fix owner"
|
||||
icon="pi pi-wrench"
|
||||
severity="danger"
|
||||
outlined
|
||||
:loading="fixing && fixingOwner === data.owner_id"
|
||||
@click="fixOwner(data.owner_id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<Divider class="my-5" />
|
||||
|
||||
<div class="text-color-secondary text-sm">
|
||||
Dica: se você trocar plano e o cliente não refletir de imediato, essa página te mostra exatamente o que ficou divergente.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user