This commit is contained in:
Leonardo
2026-03-06 06:37:13 -03:00
parent d58dc21297
commit f733db8436
146 changed files with 43436 additions and 12779 deletions

View File

@@ -1,45 +1,129 @@
<!-- src/views/pages/billing/PlanFeaturesMatrixPage.vue -->
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { supabase } from '@/lib/supabase/client'
import Toolbar from 'primevue/toolbar'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Checkbox from 'primevue/checkbox'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import InputText from 'primevue/inputtext'
import { useConfirm } from 'primevue/useconfirm'
const toast = useToast()
const confirm = useConfirm()
const loading = ref(false)
const loading = ref(false) // carregamento geral (fetch)
const saving = ref(false) // salvando pendências
const hasPending = ref(false)
const plans = ref([])
const features = ref([])
const links = ref([]) // plan_features rows
const links = ref([]) // estado atual (reflete UI)
const originalLinks = ref([]) // snapshot do banco (para diff / cancelar)
const q = ref('')
const planById = computed(() => {
const m = new Map()
for (const p of plans.value) m.set(p.id, p)
return m
})
const targetFilter = ref('all') // 'all' | 'clinic' | 'therapist' | 'supervisor'
const targetOptions = [
{ label: 'Todos', value: 'all' },
{ label: 'Clínica', value: 'clinic' },
{ label: 'Terapeuta', value: 'therapist' },
{ label: 'Supervisor', value: 'supervisor' }
]
// trava por célula (evita corrida)
const busySet = ref(new Set())
function cellKey (planId, featureId) {
return `${planId}::${featureId}`
}
function isBusy (planId, featureId) {
return busySet.value.has(cellKey(planId, featureId))
}
function setBusy (planId, featureId, v) {
const k = cellKey(planId, featureId)
const next = new Set(busySet.value)
if (v) next.add(k)
else next.delete(k)
busySet.value = next
}
function isUniqueViolation (err) {
if (!err) return false
if (err.code === '23505') return true
const msg = String(err.message || '')
return msg.includes('duplicate key value') || msg.includes('unique constraint')
}
// set de enablement (usa links do estado da UI)
const enabledSet = computed(() => {
// Set de "planId::featureId" para lookup rápido
const s = new Set()
for (const r of links.value) s.add(`${r.plan_id}::${r.feature_id}`)
return s
})
const originalSet = computed(() => {
const s = new Set()
for (const r of originalLinks.value) s.add(`${r.plan_id}::${r.feature_id}`)
return s
})
const filteredPlans = computed(() => {
const t = targetFilter.value
if (t === 'all') return plans.value
return plans.value.filter(p => p.target === t)
})
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))
return features.value.filter(f => {
const key = String(f.key || '').toLowerCase()
const desc = String(f.descricao || '').toLowerCase()
const descEn = String(f.description || '').toLowerCase()
return key.includes(term) || desc.includes(term) || descEn.includes(term)
})
})
function targetLabel (t) {
if (t === 'clinic') return 'Clínica'
if (t === 'therapist') return 'Terapeuta'
if (t === 'supervisor') return 'Supervisor'
return '—'
}
function targetSeverity (t) {
if (t === 'clinic') return 'info'
if (t === 'therapist') return 'success'
if (t === 'supervisor') return 'warn'
return 'secondary'
}
function planTitle (p) {
// ✅ Mostrar nome do plano; fallback para key
return p?.name || p?.plan_name || p?.public_name || p?.key || 'Plano'
}
function markDirtyIfNeeded () {
// compara tamanhos e conteúdo (set diff)
const a = enabledSet.value
const b = originalSet.value
if (a.size !== b.size) {
hasPending.value = true
return
}
for (const k of a) {
if (!b.has(k)) {
hasPending.value = true
return
}
}
hasPending.value = false
}
async function fetchAll () {
loading.value = true
try {
@@ -56,10 +140,13 @@ async function fetchAll () {
plans.value = p || []
features.value = f || []
links.value = pf || []
originalLinks.value = pf || []
hasPending.value = false
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 })
} finally {
loading.value = false
busySet.value = new Set()
}
}
@@ -67,71 +154,326 @@ function isEnabled (planId, featureId) {
return enabledSet.value.has(`${planId}::${featureId}`)
}
async function toggle (planId, featureId, nextValue) {
// otimista (UI responde rápido) — mas com rollback se falhar
const key = `${planId}::${featureId}`
const prev = links.value.slice()
/**
* ✅ Toggle agora NÃO salva no banco.
* Apenas altera o estado local (links) e marca como “pendente”.
*/
function toggleLocal (planId, featureId, nextValue) {
if (loading.value || saving.value) return
if (isBusy(planId, featureId)) return
setBusy(planId, featureId, true)
try {
if (nextValue) {
// adiciona
links.value = [...links.value, { plan_id: planId, feature_id: featureId }]
const { error } = await supabase.from('plan_features').insert({ plan_id: planId, feature_id: featureId })
if (error) throw error
if (!enabledSet.value.has(`${planId}::${featureId}`)) {
links.value = [...links.value, { plan_id: planId, feature_id: featureId }]
}
} else {
// remove
links.value = links.value.filter(x => !(x.plan_id === planId && x.feature_id === featureId))
const { error } = await supabase
.from('plan_features')
.delete()
.eq('plan_id', planId)
.eq('feature_id', featureId)
if (error) throw error
}
} catch (e) {
links.value = prev
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
return
markDirtyIfNeeded()
} finally {
setBusy(planId, featureId, false)
}
}
onMounted(fetchAll)
/**
* ✅ Ação em massa local (sem salvar)
*/
function setAllForPlanLocal (planId, mode) {
if (!planId) return
if (loading.value || saving.value) return
const feats = filteredFeatures.value || []
if (!feats.length) return
if (mode === 'enable') {
const next = links.value.slice()
const exists = new Set(next.map(x => `${x.plan_id}::${x.feature_id}`))
let changed = 0
for (const f of feats) {
const k = `${planId}::${f.id}`
if (!exists.has(k)) {
next.push({ plan_id: planId, feature_id: f.id })
exists.add(k)
changed++
}
}
links.value = next
markDirtyIfNeeded()
if (!changed) {
toast.add({ severity: 'info', summary: 'Sem mudanças', detail: 'Todos os recursos filtrados já estavam marcados.', life: 2200 })
}
return
}
if (mode === 'disable') {
const toRemove = new Set(feats.map(f => `${planId}::${f.id}`))
const before = links.value.length
links.value = links.value.filter(x => !toRemove.has(`${x.plan_id}::${x.feature_id}`))
const changed = before - links.value.length
markDirtyIfNeeded()
if (!changed) {
toast.add({ severity: 'info', summary: 'Sem mudanças', detail: 'Nenhum recurso filtrado estava marcado para remover.', life: 2200 })
}
}
}
/**
* ✅ Confirmação dupla antes de aplicar ação em massa (local)
* (1) confirma ação
* (2) confirma impacto (quantidade)
*/
function confirmMassAction (plan, mode) {
if (!plan?.id) return
const feats = filteredFeatures.value || []
const qtd = feats.length
if (!qtd) {
toast.add({ severity: 'info', summary: 'Nada a fazer', detail: 'Não há recursos no filtro atual.', life: 2200 })
return
}
const modeLabel = mode === 'enable' ? 'marcar' : 'desmarcar'
const modeLabel2 = mode === 'enable' ? 'MARCAR' : 'DESMARCAR'
confirm.require({
header: 'Confirmação',
icon: 'pi pi-exclamation-triangle',
message: `Você quer realmente ${modeLabel} TODOS os recursos filtrados para o plano "${planTitle(plan)}"?`,
acceptClass: mode === 'disable' ? 'p-button-danger' : 'p-button-success',
accept: () => {
// ✅ importante: deixa o primeiro confirm fechar antes de abrir o segundo
setTimeout(() => {
confirm.require({
header: 'Confirmação final',
icon: 'pi pi-exclamation-triangle',
message: `Isso vai ${modeLabel} ${qtd} recurso(s) (apenas na tela) e ficará como "alterações pendentes". Confirmar ${modeLabel2}?`,
acceptClass: mode === 'disable' ? 'p-button-danger' : 'p-button-success',
accept: () => {
setAllForPlanLocal(plan.id, mode) // ✅ aplica local
}
})
}, 0)
}
})
}
function confirmReset () {
if (!hasPending.value || saving.value || loading.value) return
confirm.require({
header: 'Descartar alterações?',
icon: 'pi pi-exclamation-triangle',
message: 'Você quer descartar as alterações pendentes e voltar ao estado do banco?',
acceptClass: 'p-button-danger',
accept: () => {
links.value = (originalLinks.value || []).slice()
markDirtyIfNeeded()
toast.add({ severity: 'info', summary: 'Ok', detail: 'Alterações descartadas.', life: 2200 })
}
})
}
/**
* ✅ Persistência: calcula diff entre originalLinks e links
* - inserts: (UI tem e original não tinha)
* - deletes: (original tinha e UI removeu)
*/
async function saveChanges () {
if (loading.value || saving.value) return
if (!hasPending.value) {
toast.add({ severity: 'info', summary: 'Nada a salvar', detail: 'Não há alterações pendentes.', life: 2200 })
return
}
saving.value = true
try {
const nowSet = enabledSet.value
const wasSet = originalSet.value
const inserts = []
const deletes = []
for (const k of nowSet) {
if (!wasSet.has(k)) {
const [plan_id, feature_id] = k.split('::')
inserts.push({ plan_id, feature_id })
}
}
for (const k of wasSet) {
if (!nowSet.has(k)) {
const [plan_id, feature_id] = k.split('::')
deletes.push({ plan_id, feature_id })
}
}
// aplica inserts
if (inserts.length) {
const { error } = await supabase.from('plan_features').insert(inserts)
if (error && !isUniqueViolation(error)) throw error
}
// aplica deletes (em lote por plano)
if (deletes.length) {
const byPlan = new Map()
for (const d of deletes) {
const arr = byPlan.get(d.plan_id) || []
arr.push(d.feature_id)
byPlan.set(d.plan_id, arr)
}
for (const [planId, featureIds] of byPlan.entries()) {
const { error } = await supabase
.from('plan_features')
.delete()
.eq('plan_id', planId)
.in('feature_id', featureIds)
if (error) throw error
}
}
// snapshot novo
originalLinks.value = links.value.slice()
markDirtyIfNeeded()
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Alterações aplicadas com sucesso.', life: 2600 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e?.message || String(e), life: 5200 })
} finally {
saving.value = false
}
}
// ── 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 || hasPending.value },
{ label: 'Descartar', icon: 'pi pi-undo', command: confirmReset, disabled: loading.value || saving.value || !hasPending.value },
{ label: 'Salvar alterações', icon: 'pi pi-save', command: saveChanges, disabled: loading.value || !hasPending.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 />
<div class="p-4">
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Plan Features</div>
<small class="text-color-secondary mt-1">
Marque quais features pertencem a cada plano. Isso define FREE/PRO sem mexer no código.
</small>
</div>
</template>
<div class="matrix-root">
<template #end>
<div class="flex items-center gap-2">
<span class="p-input-icon-left">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="q"
id="features_search"
class="w-full pr-10"
variant="filled"
/>
</IconField>
<label for="features_search">Filtrar features</label>
</FloatLabel>
</span>
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="fetchAll" />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="matrix-hero__icon-wrap">
<i class="pi pi-th-large matrix-hero__icon" />
</div>
<div class="matrix-hero__sub">
Defina quais recursos cada plano habilita. As mudanças ficam <b>pendentes</b> até clicar em <b>Salvar alterações</b>.
</div>
</div>
<!-- HERO -->
<div ref="heroSentinelRef" class="matrix-hero-sentinel" />
<div ref="heroEl" class="matrix-hero mb-4" :class="{ 'matrix-hero--stuck': heroStuck }">
<div class="matrix-hero__blobs" aria-hidden="true">
<div class="matrix-hero__blob matrix-hero__blob--1" />
<div class="matrix-hero__blob matrix-hero__blob--2" />
</div>
<div class="matrix-hero__inner">
<div class="matrix-hero__info min-w-0">
<div class="matrix-hero__title">Controle de Recursos</div>
</div>
</template>
</Toolbar>
<!-- Ações desktop ( 1200px) -->
<div class="matrix-hero__actions matrix-hero__actions--desktop">
<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 || hasPending" v-tooltip.top="hasPending ? 'Salve ou descarte antes de recarregar.' : ''" @click="fetchAll" />
<Button label="Descartar" icon="pi pi-undo" severity="secondary" outlined size="small" :disabled="loading || saving || !hasPending" @click="confirmReset" />
<Button label="Salvar alterações" icon="pi pi-save" size="small" :loading="saving" :disabled="loading || !hasPending" @click="saveChanges" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="matrix-hero__actions--mobile">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="matrix_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="matrix_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
</div>
</div>
<!-- Search sempre visível, fora do hero sticky -->
<div class="px-4 mb-4">
<FloatLabel variant="on" class="w-full md:w-[340px]">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="features_search" class="w-full pr-10" variant="filled" :disabled="loading || saving" autocomplete="off" />
</IconField>
<label for="features_search">Filtrar recursos (key ou descrição)</label>
</FloatLabel>
</div>
<div class="px-4 pb-4">
<div class="mb-3 surface-100 border-round p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex gap-2 items-center flex-wrap">
<Tag :value="`Planos: ${filteredPlans.length}`" severity="info" icon="pi pi-list" rounded />
<Tag :value="`Recursos: ${filteredFeatures.length}`" severity="success" icon="pi pi-bolt" rounded />
<Tag v-if="hasPending" value="Alterações pendentes" severity="warn" icon="pi pi-clock" rounded />
</div>
<div class="text-color-secondary text-sm">
Dica: use a busca para reduzir a lista e aplique ações em massa com confirmação.
</div>
</div>
</div>
<Divider class="my-4" />
<DataTable
:value="filteredFeatures"
@@ -142,37 +484,130 @@ onMounted(fetchAll)
:scrollable="true"
scrollHeight="70vh"
>
<Column header="Feature" frozen style="min-width: 22rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">
{{ data.key }}
</span>
<small class="text-color-secondary leading-snug mt-1">
{{ data.descricao || '—' }}
</small>
</div>
</template>
</Column>
<Column header="" frozen style="min-width: 28rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.key }}</span>
<small class="text-color-secondary leading-snug mt-1">
{{ data.descricao || data.description || '—' }}
</small>
</div>
</template>
</Column>
<Column
v-for="p in plans"
v-for="p in filteredPlans"
:key="p.id"
:header="p.key"
:style="{ minWidth: '10rem' }"
:style="{ minWidth: '14rem' }"
>
<template #header>
<div class="flex flex-col items-center gap-2 w-full text-center">
<div class="font-semibold truncate w-full" :title="planTitle(p)">
{{ planTitle(p) }}
</div>
<div class="flex items-center justify-center gap-1 flex-wrap">
<small class="text-color-secondary truncate" :title="p.key">{{ p.key }}</small>
<Tag :value="targetLabel(p.target)" :severity="targetSeverity(p.target)" rounded />
</div>
<div class="flex gap-2 justify-center">
<Button
icon="pi pi-check"
severity="success"
size="small"
outlined
:disabled="loading || saving"
v-tooltip.top="'Marcar todas as features filtradas (fica pendente até salvar)'"
@click="confirmMassAction(p, 'enable')"
/>
<Button
icon="pi pi-times"
severity="danger"
size="small"
outlined
:disabled="loading || saving"
v-tooltip.top="'Desmarcar todas as features filtradas (fica pendente até salvar)'"
@click="confirmMassAction(p, 'disable')"
/>
</div>
</div>
</template>
<template #body="{ data }">
<div class="flex justify-center">
<Checkbox
:binary="true"
:modelValue="isEnabled(p.id, data.id)"
@update:modelValue="(val) => toggle(p.id, data.id, val)"
:disabled="loading || saving || isBusy(p.id, data.id)"
:aria-label="`Alternar ${p.key} -> ${data.key}`"
@update:modelValue="(val) => toggleLocal(p.id, data.id, val)"
/>
</div>
</template>
</Column>
</DataTable>
</div><!-- /px-4 pb-4 -->
</div>
</template>
<style scoped>
.matrix-root { padding: 1rem; }
@media (min-width: 768px) { .matrix-root { padding: 1.5rem; } }
.matrix-hero-sentinel { height: 1px; }
.matrix-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;
}
.matrix-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.matrix-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.matrix-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.matrix-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(52,211,153,0.12); }
.matrix-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(99,102,241,0.09); }
.matrix-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.matrix-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;
}
.matrix-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.matrix-hero__info { flex: 1; min-width: 0; }
.matrix-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;
}
.matrix-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.matrix-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.matrix-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.matrix-hero__actions--desktop { display: none; }
.matrix-hero__actions--mobile { display: flex; }
}
</style>