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

706 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- src/views/pages/billing/SaasPlanLimitsPage.vue -->
<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { supabase } from '@/lib/supabase/client'
import InputNumber from 'primevue/inputnumber'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
const toast = useToast()
const confirm = useConfirm()
// ─── State ────────────────────────────────────────────────────────────────────
const loading = ref(false)
const saving = ref(false)
const plans = ref([])
const features = ref([])
const planFeatures = ref([]) // { plan_id, feature_id, enabled, limits }
const q = ref('')
const targetFilter = ref('all')
const targetOptions = [
{ label: 'Todos', value: 'all' },
{ label: 'Clínica', value: 'clinic' },
{ label: 'Terapeuta', value: 'therapist' }
]
// Dialog
const showDlg = ref(false)
const dlgPlan = ref(null) // plano selecionado
const dlgFeature = ref(null) // feature selecionada
const dlgPlanFeature = ref(null) // registro atual de plan_features
// Campos do form de limites (editável pelo admin)
// Cada "campo" é um par { key, label, type, value }
// O admin define quais keys existem em cada feature
const limitFields = ref([])
const newLimitKey = ref('')
const newLimitValue = ref(null)
const newLimitType = ref('number') // 'number' | 'boolean' | 'text'
const limitTypeOptions = [
{ label: 'Número', value: 'number' },
{ label: 'Texto', value: 'text' },
{ label: 'Booleano', value: 'boolean' }
]
// ─── Helpers ──────────────────────────────────────────────────────────────────
function targetLabel(t) {
if (t === 'clinic') return 'Clínica'
if (t === 'therapist') return 'Terapeuta'
return '—'
}
function targetSeverity(t) {
if (t === 'clinic') return 'info'
if (t === 'therapist') return 'success'
return 'secondary'
}
function featureDomain(key) {
const k = String(key || '')
if (k.includes('.')) return k.split('.')[0]
if (k.includes('_')) return k.split('_')[0]
return k
}
function domainSeverity(domain) {
const d = String(domain || '').toLowerCase()
if (d.includes('agenda') || d.includes('scheduling')) return 'info'
if (d.includes('billing') || d.includes('plano')) return 'success'
if (d.includes('portal') || d.includes('patient')) return 'warn'
return 'secondary'
}
function limitsDisplay(limits) {
if (!limits || typeof limits !== 'object' || !Object.keys(limits).length) return '—'
return Object.entries(limits)
.map(([k, v]) => {
if (v === -1 || v === null) return `${k}: ilimitado`
if (typeof v === 'boolean') return `${k}: ${v ? 'sim' : 'não'}`
return `${k}: ${v}`
})
.join(' · ')
}
function limitValueDisplay(v) {
if (v === null || v === undefined) return '—'
if (v === -1) return 'Ilimitado'
if (typeof v === 'boolean') return v ? 'Sim' : 'Não'
return String(v)
}
// ─── Computeds ────────────────────────────────────────────────────────────────
const filteredPlans = computed(() => {
let list = plans.value || []
if (targetFilter.value !== 'all') {
list = list.filter(p => p.target === targetFilter.value)
}
return list
})
const filteredFeatures = computed(() => {
const term = String(q.value || '').trim().toLowerCase()
if (!term) return features.value
return (features.value || []).filter(f =>
String(f.key || '').toLowerCase().includes(term) ||
String(f.descricao || '').toLowerCase().includes(term)
)
})
// Linha da tabela = 1 feature × N planos
const tableRows = computed(() => {
return filteredFeatures.value.map(f => {
const planCols = {}
for (const p of filteredPlans.value) {
const pf = planFeatures.value.find(x => x.plan_id === p.id && x.feature_id === f.id)
planCols[p.id] = {
enabled: pf?.enabled ?? false,
limits: pf?.limits ?? null,
hasRecord: !!pf
}
}
return { feature: f, planCols }
})
})
// ─── Fetch ────────────────────────────────────────────────────────────────────
async function fetchAll() {
loading.value = true
try {
const [
{ data: p, error: ep },
{ data: f, error: ef },
{ data: pf, error: epf }
] = await Promise.all([
supabase.from('plans').select('id,key,name,target,is_active').order('key'),
supabase.from('features').select('id,key,name,descricao').order('key'),
supabase.from('plan_features').select('plan_id,feature_id,enabled,limits')
])
if (ep) throw ep
if (ef) throw ef
if (epf) throw epf
plans.value = p || []
features.value = f || []
planFeatures.value = pf || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 4500 })
} finally {
loading.value = false
}
}
// ─── Dialog: abrir ────────────────────────────────────────────────────────────
function openLimits(plan, feature) {
if (saving.value) return
dlgPlan.value = plan
dlgFeature.value = feature
const pf = planFeatures.value.find(x => x.plan_id === plan.id && x.feature_id === feature.id)
dlgPlanFeature.value = pf || null
// Monta campos a partir dos limits existentes
const existingLimits = pf?.limits && typeof pf.limits === 'object' ? pf.limits : {}
limitFields.value = Object.entries(existingLimits).map(([key, value]) => ({
key,
value,
type: typeof value === 'boolean' ? 'boolean' : (typeof value === 'number' ? 'number' : 'text')
}))
newLimitKey.value = ''
newLimitValue.value = null
newLimitType.value = 'number'
showDlg.value = true
}
// ─── Dialog: add campo ────────────────────────────────────────────────────────
function addLimitField() {
const k = String(newLimitKey.value || '').trim()
.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
if (!k) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe o nome do limite (ex: max_patients).', life: 3000 })
return
}
if (limitFields.value.some(f => f.key === k)) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Já existe um campo com esse nome.', life: 3000 })
return
}
let v = newLimitValue.value
if (newLimitType.value === 'boolean') v = !!v
else if (newLimitType.value === 'number') v = v ?? 0
else v = String(v ?? '')
limitFields.value.push({ key: k, value: v, type: newLimitType.value })
newLimitKey.value = ''
newLimitValue.value = null
}
function removeLimitField(index) {
limitFields.value.splice(index, 1)
}
function setUnlimited(index) {
limitFields.value[index].value = -1
limitFields.value[index].type = 'number'
}
// ─── Dialog: salvar ───────────────────────────────────────────────────────────
async function saveLimits() {
if (!dlgPlan.value || !dlgFeature.value) return
if (saving.value) return
saving.value = true
try {
// Monta o objeto limits a partir dos campos
const limits = {}
for (const field of limitFields.value) {
let v = field.value
if (field.type === 'boolean') v = !!v
else if (field.type === 'number') v = v === null ? 0 : Number(v)
else v = String(v ?? '')
limits[field.key] = v
}
const payload = {
plan_id: dlgPlan.value.id,
feature_id: dlgFeature.value.id,
enabled: dlgPlanFeature.value?.enabled ?? true,
limits: Object.keys(limits).length ? limits : null
}
const { error } = await supabase
.from('plan_features')
.upsert(payload, { onConflict: 'plan_id,feature_id' })
if (error) throw error
toast.add({ severity: 'success', summary: 'Ok', detail: 'Limites salvos.', life: 2500 })
showDlg.value = false
await fetchAll()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 4500 })
} finally {
saving.value = false
}
}
// ─── Limpar limites ───────────────────────────────────────────────────────────
function askClearLimits(plan, feature) {
const pf = planFeatures.value.find(x => x.plan_id === plan.id && x.feature_id === feature.id)
if (!pf || !pf.limits) {
toast.add({ severity: 'info', summary: 'Nada a limpar', detail: 'Este plano/feature não tem limites definidos.', life: 2500 })
return
}
confirm.require({
message: `Remover todos os limites de "${feature.key}" no plano "${plan.key}"?\n\nO acesso continuará habilitado, mas sem restrições de quantidade.`,
header: 'Confirmar remoção de limites',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: () => doClearLimits(plan, feature)
})
}
async function doClearLimits(plan, feature) {
try {
const { error } = await supabase
.from('plan_features')
.update({ limits: null })
.eq('plan_id', plan.id)
.eq('feature_id', feature.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Ok', detail: 'Limites removidos.', life: 2500 })
await fetchAll()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 4500 })
}
}
// ── Hero sticky ───────────────────────────────────────────
const heroEl = ref(null)
const heroSentinelRef = ref(null)
const heroMenuRef = ref(null)
const heroStuck = ref(false)
let disconnectStickyObserver = null
const heroMenuItems = computed(() => [
{ label: 'Recarregar', icon: 'pi pi-refresh', command: fetchAll, disabled: loading.value || saving.value },
{ separator: true },
{
label: 'Filtrar por público',
items: targetOptions.map(o => ({
label: o.label,
command: () => { targetFilter.value = o.value }
}))
}
])
onMounted(async () => {
await fetchAll()
const sentinel = heroSentinelRef.value
if (sentinel) {
const io = new IntersectionObserver(
([entry]) => { heroStuck.value = !entry.isIntersecting },
{ rootMargin: `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px` }
)
io.observe(sentinel)
disconnectStickyObserver = () => io.disconnect()
}
})
onBeforeUnmount(() => {
try { disconnectStickyObserver?.() } catch {}
})
</script>
<template>
<Toast />
<ConfirmDialog />
<!-- Sentinel -->
<div ref="heroSentinelRef" class="h-px" />
<!-- Hero sticky -->
<div
ref="heroEl"
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-orange-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-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)]">Limites por Plano</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Configure os limites reais de cada feature por plano.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="limits_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="limits_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Search -->
<div>
<FloatLabel variant="on" class="w-full md:w-80">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="limits_search" class="w-full pr-10" variant="filled" :disabled="loading || saving" autocomplete="off" />
</IconField>
<label for="limits_search">Filtrar features (key ou descrição)</label>
</FloatLabel>
</div>
<!-- Legenda rápida -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="flex flex-wrap gap-4 items-center">
<div class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-info-circle text-blue-400" />
<span><strong>Sem limites</strong> = acesso habilitado sem restrição de quantidade</span>
</div>
<div class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-info-circle text-orange-400" />
<span><strong>-1</strong> = ilimitado (explícito no JSON, útil para planos PRO)</span>
</div>
<div class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-info-circle text-red-400" />
<span><strong>0 ou N</strong> = limite máximo que o sistema vai verificar</span>
</div>
<div class="flex items-center gap-2 ml-auto">
<Tag :value="`${filteredPlans.length} plano(s)`" severity="info" icon="pi pi-star" rounded />
<Tag :value="`${filteredFeatures.length} feature(s)`" severity="success" icon="pi pi-bolt" rounded />
</div>
</div>
</div>
<!-- Tabela: feature × planos -->
<DataTable
:value="tableRows"
dataKey="feature.id"
:loading="loading"
stripedRows
responsiveLayout="scroll"
:scrollable="true"
scrollHeight="65vh"
>
<!-- Coluna fixa: Feature -->
<Column header="Feature" frozen style="min-width: 26rem">
<template #body="{ data }">
<div class="flex flex-col">
<div class="flex items-center gap-2">
<Tag
:value="featureDomain(data.feature.key)"
:severity="domainSeverity(featureDomain(data.feature.key))"
rounded
/>
<span class="font-medium font-mono text-[1rem]">{{ data.feature.key }}</span>
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1 leading-snug">
{{ data.feature.descricao || '—' }}
</div>
</div>
</template>
</Column>
<!-- Coluna dinâmica: 1 por plano filtrado -->
<Column
v-for="plan in filteredPlans"
:key="plan.id"
:style="{ minWidth: '20rem' }"
>
<template #header>
<div class="flex flex-col gap-1 w-full">
<div class="flex items-center justify-between gap-2">
<span class="font-semibold truncate" :title="plan.name">{{ plan.name || plan.key }}</span>
<Tag :value="targetLabel(plan.target)" :severity="targetSeverity(plan.target)" rounded />
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] font-mono">{{ plan.key }}</div>
</div>
</template>
<template #body="{ data }">
<div class="flex flex-col gap-2">
<!-- Status: habilitado ou não -->
<div class="flex items-center gap-2">
<Tag
v-if="data.planCols[plan.id].hasRecord"
:value="data.planCols[plan.id].enabled ? 'Habilitado' : 'Desabilitado'"
:severity="data.planCols[plan.id].enabled ? 'success' : 'secondary'"
rounded
/>
<Tag v-else value="Sem vínculo" severity="secondary" rounded />
</div>
<!-- Limites atuais -->
<div
v-if="data.planCols[plan.id].limits"
class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-2"
>
<div
v-for="(val, key) in data.planCols[plan.id].limits"
:key="key"
class="flex items-center gap-1"
>
<span class="font-mono font-medium">{{ key }}:</span>
<span>{{ limitValueDisplay(val) }}</span>
</div>
</div>
<div v-else-if="data.planCols[plan.id].hasRecord" class="text-[1rem] text-[var(--text-color-secondary)]">
Sem limites definidos
</div>
<!-- Ações -->
<div v-if="data.planCols[plan.id].hasRecord" class="flex gap-2 flex-wrap">
<Button
icon="pi pi-pencil"
size="small"
severity="secondary"
outlined
v-tooltip.top="'Editar limites'"
:disabled="loading || saving"
@click="openLimits(plan, data.feature)"
/>
<Button
icon="pi pi-times"
size="small"
severity="danger"
outlined
v-tooltip.top="'Limpar limites'"
:disabled="loading || saving || !data.planCols[plan.id].limits"
@click="askClearLimits(plan, data.feature)"
/>
</div>
<div v-else class="text-[1rem] text-[var(--text-color-secondary)] italic">
Feature não vinculada a este plano.<br/>
Configure em <strong>Recursos por Plano</strong>.
</div>
</div>
</template>
</Column>
</DataTable>
</div>
<!-- Dialog: editar limites de plan_features -->
<Dialog
v-model:visible="showDlg"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
:style="{ width: '680px' }"
>
<template #header>
<div class="flex flex-col gap-1">
<div class="text-[1rem] font-semibold">Limites {{ dlgFeature?.key }}</div>
<div class="flex items-center gap-2 flex-wrap">
<Tag :value="dlgPlan?.name || dlgPlan?.key" severity="secondary" />
<Tag :value="targetLabel(dlgPlan?.target)" :severity="targetSeverity(dlgPlan?.target)" rounded />
</div>
</div>
</template>
<div class="flex flex-col gap-4">
<!-- Campos existentes -->
<div v-if="limitFields.length">
<div class="font-semibold mb-2">Limites configurados</div>
<div class="flex flex-col gap-2">
<div
v-for="(field, idx) in limitFields"
:key="idx"
class="flex items-center gap-3 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3"
>
<!-- Key (não editável) -->
<div class="flex-1 min-w-0">
<div class="font-mono font-medium text-[1rem]">{{ field.key }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">{{ field.type }}</div>
</div>
<!-- Valor -->
<div class="w-40 shrink-0">
<template v-if="field.type === 'number'">
<InputNumber
v-model="field.value"
class="w-full"
inputClass="w-full"
:disabled="saving"
:min="-1"
placeholder="-1 = ilimitado"
/>
</template>
<template v-else-if="field.type === 'boolean'">
<SelectButton
v-model="field.value"
:options="[{ label: 'Sim', value: true }, { label: 'Não', value: false }]"
optionLabel="label"
optionValue="value"
:disabled="saving"
/>
</template>
<template v-else>
<InputText v-model="field.value" class="w-full" :disabled="saving" />
</template>
</div>
<!-- Ações rápidas -->
<div class="flex gap-1 shrink-0">
<Button
icon="pi pi-infinity"
size="small"
severity="secondary"
outlined
v-tooltip.top="'Definir como ilimitado (-1)'"
:disabled="saving || field.type !== 'number'"
@click="setUnlimited(idx)"
/>
<Button
icon="pi pi-trash"
size="small"
severity="danger"
outlined
v-tooltip.top="'Remover este campo'"
:disabled="saving"
@click="removeLimitField(idx)"
/>
</div>
</div>
</div>
</div>
<div v-else class="text-[1rem] text-[var(--text-color-secondary)] rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 text-center">
Nenhum limite configurado. Adicione abaixo.
</div>
<Divider />
<!-- Adicionar novo campo -->
<div>
<div class="font-semibold mb-3">Adicionar campo de limite</div>
<div class="flex flex-col gap-3">
<!-- Nome -->
<div>
<label class="text-[1rem] font-semibold text-[var(--text-color-secondary)] uppercase tracking-wide block mb-1">
Nome do campo *
</label>
<InputText
id="new-limit-key"
v-model="newLimitKey"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
placeholder="ex: max_patients"
@keydown.enter.prevent="addLimitField"
/>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
Ex: <span class="font-mono">max_patients</span>, <span class="font-mono">max_sessions_per_month</span>
</div>
</div>
<!-- Tipo + Valor + Botão -->
<div class="flex items-end gap-2 flex-wrap">
<div>
<label class="text-[1rem] font-semibold text-[var(--text-color-secondary)] uppercase tracking-wide block mb-1">Tipo</label>
<SelectButton
v-model="newLimitType"
:options="limitTypeOptions"
optionLabel="label"
optionValue="value"
:disabled="saving"
/>
</div>
<div class="flex-1" style="min-width: 8rem;">
<label class="text-[1rem] font-semibold text-[var(--text-color-secondary)] uppercase tracking-wide block mb-1">Valor inicial</label>
<InputNumber
v-if="newLimitType === 'number'"
v-model="newLimitValue"
class="w-full"
inputClass="w-full"
variant="filled"
:disabled="saving"
:min="-1"
placeholder="-1 = ilimitado"
/>
<SelectButton
v-else-if="newLimitType === 'boolean'"
v-model="newLimitValue"
:options="[{ label: 'Sim', value: true }, { label: 'Não', value: false }]"
optionLabel="label"
optionValue="value"
:disabled="saving"
/>
<InputText
v-else
v-model="newLimitValue"
class="w-full"
variant="filled"
:disabled="saving"
/>
</div>
<Button
icon="pi pi-plus"
label="Adicionar"
:disabled="saving || !newLimitKey?.trim()"
@click="addLimitField"
/>
</div>
</div>
</div>
<!-- Dica de boas práticas -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
<div class="font-semibold mb-1">Convenções recomendadas</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1">
<div><span class="font-mono">max_patients</span> número máximo de pacientes</div>
<div><span class="font-mono">max_sessions_per_month</span> sessões/mês</div>
<div><span class="font-mono">max_members</span> membros da clínica</div>
<div><span class="font-mono">max_therapists</span> terapeutas vinculados</div>
<div><span class="font-mono">-1</span> sem limite (planos PRO)</div>
<div><span class="font-mono">0</span> bloqueado completamente</div>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="showDlg = false" />
<Button label="Salvar limites" icon="pi pi-check" :loading="saving" @click="saveLimits" />
</template>
</Dialog>
</template>