first commit
This commit is contained in:
178
src/views/pages/saas/SaasPlanFeaturesMatrixPage.vue
Normal file
178
src/views/pages/saas/SaasPlanFeaturesMatrixPage.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, 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'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const plans = ref([])
|
||||
const features = ref([])
|
||||
const links = ref([]) // plan_features rows
|
||||
|
||||
const q = ref('')
|
||||
|
||||
const planById = computed(() => {
|
||||
const m = new Map()
|
||||
for (const p of plans.value) m.set(p.id, p)
|
||||
return m
|
||||
})
|
||||
|
||||
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 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))
|
||||
})
|
||||
|
||||
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('*').order('key', { ascending: true }),
|
||||
supabase.from('features').select('*').order('key', { ascending: true }),
|
||||
supabase.from('plan_features').select('plan_id, feature_id')
|
||||
])
|
||||
|
||||
if (ep) throw ep
|
||||
if (ef) throw ef
|
||||
if (epf) throw epf
|
||||
|
||||
plans.value = p || []
|
||||
features.value = f || []
|
||||
links.value = pf || []
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchAll)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<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>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
</template>
|
||||
</Toolbar>
|
||||
|
||||
<DataTable
|
||||
:value="filteredFeatures"
|
||||
dataKey="id"
|
||||
:loading="loading"
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
: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
|
||||
v-for="p in plans"
|
||||
:key="p.id"
|
||||
:header="p.key"
|
||||
:style="{ minWidth: '10rem' }"
|
||||
>
|
||||
<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)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user