695 lines
22 KiB
Vue
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>
|