ZERADO
This commit is contained in:
@@ -1,32 +1,34 @@
|
||||
<!-- src/views/pages/saas/SubscriptionHealthPage.vue -->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
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 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'
|
||||
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 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)
|
||||
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'
|
||||
@@ -34,40 +36,200 @@ function severityForMismatch (t) {
|
||||
}
|
||||
|
||||
function labelForMismatch (t) {
|
||||
if (t === 'missing_entitlement') return 'Missing'
|
||||
if (t === 'unexpected_entitlement') return 'Unexpected'
|
||||
return t || '-'
|
||||
if (t === 'missing_entitlement') return 'Faltando'
|
||||
if (t === 'unexpected_entitlement') return 'Inesperado'
|
||||
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 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 filteredRows () {
|
||||
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 rows.value
|
||||
return rows.value.filter(r =>
|
||||
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'}`
|
||||
}))
|
||||
}
|
||||
|
||||
function openOwnerSubscriptions (ownerId) {
|
||||
if (!ownerId) return
|
||||
router.push({ path: '/saas/subscriptions', query: { q: ownerId } })
|
||||
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) {
|
||||
@@ -89,15 +251,32 @@ async function fixOwner (ownerId) {
|
||||
life: 2500
|
||||
})
|
||||
|
||||
await fetchAll()
|
||||
await fetchPersonal()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
|
||||
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 {
|
||||
@@ -106,126 +285,482 @@ async function fixAll () {
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Sistema corrigido',
|
||||
summary: 'Correção aplicada',
|
||||
detail: 'Entitlements reconstruídos para todos os owners com divergência.',
|
||||
life: 3000
|
||||
})
|
||||
|
||||
await fetchAll()
|
||||
await fetchPersonal()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro ao corrigir tudo',
|
||||
detail: e?.message || String(e),
|
||||
life: 5200
|
||||
})
|
||||
} finally {
|
||||
fixing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchAll)
|
||||
// -----------------------------
|
||||
// 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 />
|
||||
|
||||
<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>
|
||||
<!-- Info decorativa (scrolls away naturalmente) -->
|
||||
<div class="flex items-start gap-4 px-4 pb-3">
|
||||
<div class="health-hero__icon-wrap">
|
||||
<i class="pi pi-shield health-hero__icon" />
|
||||
</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 class="health-hero__sub">
|
||||
Terapeutas: divergências entre plano (esperado) e entitlements (atual).
|
||||
Clínicas: exceções comerciais (features liberadas manualmente fora do plano).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- sentinel -->
|
||||
<div ref="sentinelRef" style="height: 1px; pointer-events: none;" />
|
||||
|
||||
<!-- hero -->
|
||||
<div
|
||||
ref="heroRef"
|
||||
class="health-hero"
|
||||
:class="{ 'health-hero--stuck': heroStuck }"
|
||||
>
|
||||
<div class="health-hero__blobs" aria-hidden="true">
|
||||
<div class="health-hero__blob health-hero__blob--1" />
|
||||
<div class="health-hero__blob health-hero__blob--2" />
|
||||
</div>
|
||||
|
||||
<div class="health-hero__inner">
|
||||
<!-- Título -->
|
||||
<div class="health-hero__info min-w-0">
|
||||
<div class="health-hero__title">Saúde das Assinaturas</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações desktop (≥ 1200px) -->
|
||||
<div class="health-hero__actions health-hero__actions--desktop">
|
||||
<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="health-hero__actions--mobile">
|
||||
<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-4 pb-4">
|
||||
<!-- busca -->
|
||||
<div class="mb-4">
|
||||
<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="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="`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-color-secondary text-sm">
|
||||
<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>
|
||||
<small class="text-color-secondary">
|
||||
{{ helpForMismatch(data.mismatch_type) || '—' }}
|
||||
</small>
|
||||
</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-sm line-height-3">
|
||||
<p 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.
|
||||
</p>
|
||||
</div>
|
||||
</Message>
|
||||
</TabPanel>
|
||||
|
||||
<!-- ===================================================== -->
|
||||
<!-- Clínicas (Tenant) -->
|
||||
<!-- ===================================================== -->
|
||||
<TabPanel header="Clínicas (Exceções)">
|
||||
<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="`Exceções ativas: ${totalClinic}`" severity="info" />
|
||||
</div>
|
||||
|
||||
<div class="text-color-secondary text-sm">
|
||||
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>
|
||||
<small class="text-color-secondary">{{ data.tenant_name ? data.tenant_id : '—' }}</small>
|
||||
</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>
|
||||
<small class="text-color-secondary">
|
||||
{{ helpForException() }}
|
||||
</small>
|
||||
</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-sm line-height-3">
|
||||
<p 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.
|
||||
</p>
|
||||
|
||||
<p 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.
|
||||
</p>
|
||||
</div>
|
||||
</Message>
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Hero */
|
||||
.health-hero {
|
||||
position: sticky;
|
||||
top: var(--layout-sticky-top, 56px);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
padding: 1.5rem;
|
||||
margin: 1rem;
|
||||
}
|
||||
.health-hero--stuck {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.health-hero__blobs {
|
||||
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
|
||||
}
|
||||
.health-hero__blob {
|
||||
position: absolute; border-radius: 50%; filter: blur(70px);
|
||||
}
|
||||
.health-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(248,113,113,0.12); }
|
||||
.health-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(251,113,133,0.09); }
|
||||
|
||||
.health-hero__inner {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
|
||||
}
|
||||
.health-hero__icon-wrap {
|
||||
flex-shrink: 0;
|
||||
width: 4rem; height: 4rem; border-radius: 1.125rem;
|
||||
border: 2px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
display: grid; place-items: center;
|
||||
}
|
||||
.health-hero__icon { font-size: 1.5rem; color: var(--text-color); }
|
||||
|
||||
.health-hero__info { flex: 1; min-width: 0; }
|
||||
.health-hero__title {
|
||||
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
|
||||
color: var(--text-color); line-height: 1.2;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.health-hero__sub {
|
||||
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
|
||||
}
|
||||
|
||||
.health-hero__actions--desktop {
|
||||
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
|
||||
}
|
||||
.health-hero__actions--mobile { display: none; }
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
.health-hero__actions--desktop { display: none; }
|
||||
.health-hero__actions--mobile { display: flex; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user