Files
agenciapsilmno/src/views/pages/saas/SaasSubscriptionHealthPage.vue
2026-03-17 21:08:14 -03:00

695 lines
22 KiB
Vue

<!-- src/views/pages/saas/SubscriptionHealthPage.vue -->
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import TabView from 'primevue/tabview'
import TabPanel from 'primevue/tabpanel'
import Message from 'primevue/message'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const loading = ref(false)
const fixing = ref(false)
const fixingOwner = ref(null)
const isFetching = ref(false)
const activeTab = ref(0) // 0 = terapeutas, 1 = clínicas
const q = ref('')
const personalRows = ref([]) // v_subscription_feature_mismatch
const clinicRows = ref([]) // v_tenant_feature_exceptions
// -----------------------------
// Labels / Severities
// -----------------------------
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 'Faltando'
if (t === 'unexpected_entitlement') return 'Inesperado'
return t || '—'
}
function helpForMismatch (t) {
if (t === 'missing_entitlement') return 'O plano exige, mas não está ativo'
if (t === 'unexpected_entitlement') return 'Está ativo, mas não consta no plano'
return ''
}
function severityForException () {
return 'info'
}
function labelForException (t) {
if (t === 'commercial_exception') return 'Exceção comercial'
return t || 'Exceção'
}
function helpForException () {
return 'Feature liberada manualmente fora do plano (exceção controlada)'
}
// -----------------------------
// Computeds (Terapeutas)
// -----------------------------
const totalPersonal = computed(() => personalRows.value.length)
const totalPersonalMissing = computed(() => personalRows.value.filter(r => r.mismatch_type === 'missing_entitlement').length)
const totalPersonalUnexpected = computed(() => personalRows.value.filter(r => r.mismatch_type === 'unexpected_entitlement').length)
const totalPersonalWithoutOwner = computed(() => personalRows.value.filter(r => !r.owner_id).length)
// -----------------------------
// Computeds (Clínicas)
// -----------------------------
const totalClinic = computed(() => clinicRows.value.length)
// -----------------------------
// Search filtering
// -----------------------------
const filteredPersonal = computed(() => {
const term = String(q.value || '').trim().toLowerCase()
if (!term) return personalRows.value
return personalRows.value.filter(r =>
String(r.owner_id || '').toLowerCase().includes(term) ||
String(r.feature_key || '').toLowerCase().includes(term) ||
String(r.mismatch_type || '').toLowerCase().includes(term)
)
})
const filteredClinic = computed(() => {
const term = String(q.value || '').trim().toLowerCase()
if (!term) return clinicRows.value
return clinicRows.value.filter(r =>
String(r.tenant_id || '').toLowerCase().includes(term) ||
String(r.tenant_name || '').toLowerCase().includes(term) ||
String(r.plan_key || '').toLowerCase().includes(term) ||
String(r.feature_key || '').toLowerCase().includes(term) ||
String(r.exception_type || '').toLowerCase().includes(term)
)
})
// -----------------------------
// Fetch
// -----------------------------
async function fetchPersonal () {
const { data, error } = await supabase
.from('v_subscription_feature_mismatch')
.select('*')
if (error) throw error
personalRows.value = (data || []).map(r => ({
...r,
__rowKey: `${r.owner_id || 'no_owner'}|${r.feature_key || 'no_feature'}|${r.mismatch_type || 'no_type'}`
}))
}
async function fetchClinic () {
const { data, error } = await supabase
.from('v_tenant_feature_exceptions')
.select('*')
if (error) throw error
clinicRows.value = (data || []).map(r => ({
...r,
__rowKey: `${r.tenant_id || 'no_tenant'}|${r.feature_key || 'no_feature'}|${r.exception_type || 'no_type'}`
}))
}
async function fetchAll () {
if (isFetching.value) return
isFetching.value = true
loading.value = true
try {
await Promise.all([fetchPersonal(), fetchClinic()])
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 })
} finally {
loading.value = false
isFetching.value = false
}
}
async function reloadActiveTab () {
if (isFetching.value) return
isFetching.value = true
loading.value = true
try {
if (activeTab.value === 0) await fetchPersonal()
else await fetchClinic()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 })
} finally {
loading.value = false
isFetching.value = false
}
}
// -----------------------------
// Navigation helpers
// -----------------------------
async function inferOwnerKey (ownerId) {
if (!ownerId) return null
try {
const { data, error } = await supabase
.from('tenants')
.select('id')
.eq('id', ownerId)
.maybeSingle()
if (!error && data?.id) return `clinic:${ownerId}`
} catch (_) {}
try {
const { data, error } = await supabase
.from('tenant_members')
.select('tenant_id')
.eq('tenant_id', ownerId)
.limit(1)
if (!error && Array.isArray(data) && data.length) return `clinic:${ownerId}`
} catch (_) {}
return `therapist:${ownerId}`
}
async function openOwnerSubscriptions (ownerId) {
if (!ownerId) {
toast.add({
severity: 'warn',
summary: 'Owner ausente',
detail: 'Esta linha está sem owner_id. Verifique dados inconsistentes.',
life: 4200
})
return
}
const key = await inferOwnerKey(ownerId)
router.push({ path: '/saas/subscriptions', query: { q: key || ownerId } })
}
function openClinicSubscriptions (tenantId) {
if (!tenantId) return
router.push({ path: '/saas/subscriptions', query: { q: `clinic:${tenantId}` } })
}
// -----------------------------
// Fix (Terapeutas)
// -----------------------------
function askFixOwner (ownerId) {
if (!ownerId) {
toast.add({
severity: 'warn',
summary: 'Owner ausente',
detail: 'Não é possível corrigir um owner vazio.',
life: 4500
})
return
}
confirm.require({
header: 'Confirmar correção',
message: `Reconstruir entitlements para este owner?\n\n${ownerId}\n\nIsso recalcula os recursos ativos com base no plano atual.`,
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: () => fixOwner(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 fetchPersonal()
} catch (e) {
toast.add({
severity: 'error',
summary: 'Erro ao corrigir owner',
detail: e?.message || String(e),
life: 5200
})
} finally {
fixing.value = false
fixingOwner.value = null
}
}
function askFixAll () {
if (!totalPersonal.value) return
confirm.require({
header: 'Confirmar correção geral',
message: `Reconstruir entitlements para TODOS os owners com divergência?\n\nTotal: ${totalPersonal.value}\n\nObs.: linhas sem owner_id indicam dado inconsistente.`,
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: () => fixAll()
})
}
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: 'Correção aplicada',
detail: 'Entitlements reconstruídos para todos os owners com divergência.',
life: 3000
})
await fetchPersonal()
} catch (e) {
toast.add({
severity: 'error',
summary: 'Erro ao corrigir tudo',
detail: e?.message || String(e),
life: 5200
})
} finally {
fixing.value = false
}
}
// -----------------------------
// Exceptions (Clínicas)
// -----------------------------
function askRemoveException (tenantId, featureKey) {
if (!tenantId || !featureKey) return
confirm.require({
header: 'Remover exceção',
message: `Desativar a exceção desta clínica?\n\nTenant: ${tenantId}\nFeature: ${featureKey}\n\nIsso desliga a liberação manual fora do plano.`,
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: () => removeException(tenantId, featureKey)
})
}
async function removeException (tenantId, featureKey) {
fixing.value = true
try {
const { error } = await supabase.rpc('set_tenant_feature_exception', {
p_tenant_id: tenantId,
p_feature_key: featureKey,
p_enabled: false,
p_reason: 'Remoção via Saúde das Assinaturas'
})
if (error) throw error
toast.add({
severity: 'success',
summary: 'Exceção removida',
detail: 'A liberação manual foi desativada para esta feature.',
life: 2800
})
await fetchClinic()
} catch (e) {
toast.add({
severity: 'error',
summary: 'Erro ao remover exceção',
detail: e?.message || String(e),
life: 5200
})
} finally {
fixing.value = false
}
}
// -------------------------
// Hero sticky
// -------------------------
const heroRef = ref(null)
const sentinelRef = ref(null)
const heroStuck = ref(false)
let heroObserver = null
const mobileMenuRef = ref(null)
const heroMenuItems = computed(() => [
{
label: 'Recarregar aba',
icon: 'pi pi-refresh',
command: reloadActiveTab,
disabled: loading.value || fixing.value
},
{
label: 'Recarregar tudo',
icon: 'pi pi-sync',
command: fetchAll,
disabled: loading.value || fixing.value
},
...(activeTab.value === 0 && totalPersonal.value > 0 ? [{
label: 'Corrigir tudo (terapeutas)',
icon: 'pi pi-wrench',
command: askFixAll,
disabled: loading.value || fixing.value
}] : [])
])
onMounted(() => {
fetchAll()
if (sentinelRef.value) {
heroObserver = new IntersectionObserver(
([entry]) => { heroStuck.value = !entry.isIntersecting },
{ rootMargin: `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`, threshold: 0 }
)
heroObserver.observe(sentinelRef.value)
}
})
onBeforeUnmount(() => {
heroObserver?.disconnect()
})
</script>
<template>
<Toast />
<ConfirmDialog />
<!-- Sentinel -->
<div ref="sentinelRef" class="h-px" />
<!-- Hero sticky -->
<div
ref="heroRef"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Saúde das Assinaturas</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Terapeutas: divergências entre plano (esperado) e entitlements (atual). Clínicas: exceções comerciais (features liberadas manualmente fora do plano).</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
outlined
size="small"
:loading="loading"
:disabled="fixing"
@click="reloadActiveTab"
/>
<Button
label="Recarregar tudo"
icon="pi pi-sync"
severity="secondary"
outlined
size="small"
:loading="loading"
:disabled="fixing"
@click="fetchAll"
/>
<Button
v-if="activeTab === 0 && totalPersonal > 0"
label="Corrigir tudo (terapeutas)"
icon="pi pi-wrench"
severity="danger"
size="small"
:loading="fixing"
:disabled="loading"
@click="askFixAll"
/>
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden shrink-0">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
@click="(e) => mobileMenuRef.toggle(e)"
/>
<Menu ref="mobileMenuRef" :model="heroMenuItems" popup />
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- busca -->
<div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="q"
id="health_search"
class="w-full pr-10"
variant="filled"
:disabled="loading || fixing"
autocomplete="off"
/>
</IconField>
<label for="health_search">Buscar...</label>
</FloatLabel>
</div>
<TabView v-model:activeIndex="activeTab">
<!-- ===================================================== -->
<!-- Terapeutas (Personal) -->
<!-- ===================================================== -->
<TabPanel header="Terapeutas (Pessoal)">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] 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="`Divergências: ${totalPersonal}`" severity="secondary" />
<Tag :value="`Faltando: ${totalPersonalMissing}`" severity="danger" />
<Tag :value="`Inesperado: ${totalPersonalUnexpected}`" severity="warning" />
<Tag v-if="totalPersonalWithoutOwner > 0" :value="`Sem owner: ${totalPersonalWithoutOwner}`" severity="warn" />
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
<span class="font-medium">Faltando</span>: o plano exige, mas não está ativo ·
<span class="font-medium">Inesperado</span>: está ativo sem constar no plano
</div>
</div>
</div>
<DataTable
:value="filteredPersonal"
dataKey="__rowKey"
:loading="loading"
stripedRows
responsiveLayout="scroll"
sortField="owner_id"
:sortOrder="1"
>
<Column header="Owner (User)" style="min-width: 22rem">
<template #body="{ data }">
<div class="flex items-center justify-between gap-2">
<span class="font-medium">
{{ data.owner_id || '—' }}
</span>
<Tag v-if="!data.owner_id" value="Sem owner" severity="warn" rounded />
</div>
</template>
</Column>
<Column header="Recurso" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.feature_key }}</span>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ helpForMismatch(data.mismatch_type) || '—' }}
</div>
</div>
</template>
</Column>
<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: 20rem">
<template #body="{ data }">
<div class="flex gap-2 flex-wrap">
<Button
icon="pi pi-external-link"
severity="secondary"
outlined
v-tooltip.top="data.owner_id ? 'Abrir assinaturas deste owner (com filtro)' : 'Owner ausente'"
:disabled="loading || fixing || !data.owner_id"
@click="openOwnerSubscriptions(data.owner_id)"
/>
<Button
label="Corrigir owner"
icon="pi pi-wrench"
severity="danger"
outlined
v-tooltip.top="data.owner_id ? 'Reconstruir entitlements deste owner' : 'Owner ausente'"
:loading="fixing && fixingOwner === data.owner_id"
:disabled="loading || !data.owner_id || (fixing && fixingOwner !== data.owner_id)"
@click="askFixOwner(data.owner_id)"
/>
</div>
</template>
</Column>
</DataTable>
<Divider class="my-5" />
<Message severity="info" class="mt-4">
<div class="text-[1rem] line-height-3">
<div class="mb-0">
<span class="font-semibold">Dica:</span>
Se você alterar o plano e o acesso não refletir imediatamente, esta aba exibirá as divergências entre o plano ativo e os entitlements atuais.
A ação <span class="font-medium">Corrigir</span> reconstrói os entitlements do owner com base no plano vigente e elimina inconsistências.
</div>
</div>
</Message>
</TabPanel>
<!-- ===================================================== -->
<!-- Clínicas (Tenant) -->
<!-- ===================================================== -->
<TabPanel header="Clínicas (Exceções)">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] 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="`Exceções ativas: ${totalClinic}`" severity="info" />
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
Exceções comerciais: features liberadas manualmente fora do plano. Útil para testes, suporte e acordos.
</div>
</div>
</div>
<DataTable
:value="filteredClinic"
dataKey="__rowKey"
:loading="loading"
stripedRows
responsiveLayout="scroll"
sortField="tenant_id"
:sortOrder="1"
>
<Column header="Clínica (Tenant)" style="min-width: 22rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.tenant_name || data.tenant_id }}</span>
<div class="text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_name ? data.tenant_id : '—' }}</div>
</div>
</template>
</Column>
<Column header="Plano" style="width: 12rem">
<template #body="{ data }">
<Tag :value="data.plan_key || '—'" severity="secondary" />
</template>
</Column>
<Column header="Feature" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.feature_key }}</span>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ helpForException() }}
</div>
</div>
</template>
</Column>
<Column header="Tipo" style="width: 14rem">
<template #body="{ data }">
<Tag :severity="severityForException()" :value="labelForException(data.exception_type)" />
</template>
</Column>
<Column header="Ações" style="min-width: 22rem">
<template #body="{ data }">
<div class="flex gap-2 flex-wrap">
<Button
icon="pi pi-external-link"
severity="secondary"
outlined
v-tooltip.top="'Abrir assinaturas desta clínica (com filtro)'"
:disabled="loading || fixing || !data.tenant_id"
@click="openClinicSubscriptions(data.tenant_id)"
/>
<Button
label="Remover exceção"
icon="pi pi-ban"
severity="danger"
outlined
v-tooltip.top="'Desativar a liberação manual desta feature'"
:disabled="loading || fixing || !data.tenant_id || !data.feature_key"
@click="askRemoveException(data.tenant_id, data.feature_key)"
/>
</div>
</template>
</Column>
</DataTable>
<Divider class="my-5" />
<Message severity="info" class="mt-4">
<div class="text-[1rem] line-height-3">
<div class="mb-2">
<span class="font-semibold">Observação:</span>
Exceção é uma escolha de negócio. Quando ativa, pode liberar acesso mesmo que o plano não permita.
Utilize <span class="font-medium">Remover exceção</span> quando a liberação deixar de fazer sentido.
</div>
<div class="mb-0">
<span class="font-semibold">Dica:</span>
Exceções comerciais liberam recursos fora do plano.
Se o acesso não refletir como esperado, verifique se existe uma exceção ativa para esta clínica.
A ação <span class="font-medium">Remover exceção</span> restaura o comportamento estritamente definido pelo plano.
</div>
</div>
</Message>
</TabPanel>
</TabView>
</div>
</template>