Files
agenciapsilmno/src/views/pages/saas/SaasPlanLimitsPage.vue
T

598 lines
28 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.
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/views/pages/saas/SaasPlanLimitsPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<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>
<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>