Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras
This commit is contained in:
@@ -15,416 +15,371 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
import Textarea from 'primevue/textarea';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref([])
|
||||
const loading = ref(false);
|
||||
const rows = ref([]);
|
||||
|
||||
const showDlg = ref(false)
|
||||
const saving = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const showDlg = ref(false);
|
||||
const saving = ref(false);
|
||||
const isEdit = ref(false);
|
||||
|
||||
const q = ref('')
|
||||
const q = ref('');
|
||||
|
||||
const form = ref({
|
||||
id: null,
|
||||
key: '',
|
||||
name: '',
|
||||
descricao: ''
|
||||
})
|
||||
id: null,
|
||||
key: '',
|
||||
name: '',
|
||||
descricao: ''
|
||||
});
|
||||
|
||||
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')
|
||||
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');
|
||||
}
|
||||
|
||||
function isFkViolation (err) {
|
||||
if (!err) return false
|
||||
if (err.code === '23503') return true
|
||||
const msg = String(err.message || '').toLowerCase()
|
||||
return msg.includes('foreign key') || msg.includes('violates foreign key')
|
||||
function isFkViolation(err) {
|
||||
if (!err) return false;
|
||||
if (err.code === '23503') return true;
|
||||
const msg = String(err.message || '').toLowerCase();
|
||||
return msg.includes('foreign key') || msg.includes('violates foreign key');
|
||||
}
|
||||
|
||||
function slugifyKey (s) {
|
||||
return String(s || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^a-z0-9._]/g, '')
|
||||
function slugifyKey(s) {
|
||||
return String(s || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^a-z0-9._]/g, '');
|
||||
}
|
||||
|
||||
function featureDomain (key) {
|
||||
const k = String(key || '').trim()
|
||||
if (!k) return 'geral'
|
||||
if (k.includes('.')) return k.split('.')[0]
|
||||
if (k.includes('_')) return k.split('_')[0]
|
||||
return k
|
||||
function featureDomain(key) {
|
||||
const k = String(key || '').trim();
|
||||
if (!k) return 'geral';
|
||||
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('assin') || d.includes('plano')) return 'success'
|
||||
if (d.includes('portal') || d.includes('patient')) return 'warn'
|
||||
if (d.includes('admin') || d.includes('saas')) return 'secondary'
|
||||
return 'secondary'
|
||||
function domainSeverity(domain) {
|
||||
const d = String(domain || '').toLowerCase();
|
||||
if (d.includes('agenda') || d.includes('scheduling')) return 'info';
|
||||
if (d.includes('billing') || d.includes('assin') || d.includes('plano')) return 'success';
|
||||
if (d.includes('portal') || d.includes('patient')) return 'warn';
|
||||
if (d.includes('admin') || d.includes('saas')) return 'secondary';
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const term = String(q.value || '').trim().toLowerCase()
|
||||
if (!term) return rows.value
|
||||
return (rows.value || []).filter(r => {
|
||||
return [r.key, r.name, r.descricao].some(s => String(s || '').toLowerCase().includes(term))
|
||||
})
|
||||
})
|
||||
const term = String(q.value || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!term) return rows.value;
|
||||
return (rows.value || []).filter((r) => {
|
||||
return [r.key, r.name, r.descricao].some((s) =>
|
||||
String(s || '')
|
||||
.toLowerCase()
|
||||
.includes(term)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function fetchAll () {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('features')
|
||||
.select('id, key, name, descricao, created_at')
|
||||
.order('key', { ascending: true })
|
||||
async function fetchAll() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase.from('features').select('id, key, name, descricao, created_at').order('key', { ascending: true });
|
||||
|
||||
if (error) throw error
|
||||
rows.value = data || []
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 4500 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
if (error) throw error;
|
||||
rows.value = data || [];
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 4500 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate () {
|
||||
isEdit.value = false
|
||||
form.value = { id: null, key: '', name: '', descricao: '' }
|
||||
showDlg.value = true
|
||||
function openCreate() {
|
||||
isEdit.value = false;
|
||||
form.value = { id: null, key: '', name: '', descricao: '' };
|
||||
showDlg.value = true;
|
||||
}
|
||||
|
||||
function openEdit (row) {
|
||||
isEdit.value = true
|
||||
form.value = {
|
||||
id: row.id,
|
||||
key: row.key ?? '',
|
||||
name: row.name ?? '',
|
||||
descricao: row.descricao ?? ''
|
||||
}
|
||||
showDlg.value = true
|
||||
function openEdit(row) {
|
||||
isEdit.value = true;
|
||||
form.value = {
|
||||
id: row.id,
|
||||
key: row.key ?? '',
|
||||
name: row.name ?? '',
|
||||
descricao: row.descricao ?? ''
|
||||
};
|
||||
showDlg.value = true;
|
||||
}
|
||||
|
||||
function validate () {
|
||||
const k = slugifyKey(form.value.key)
|
||||
if (!k) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe a key do recurso.', life: 3000 })
|
||||
return false
|
||||
}
|
||||
if (!String(form.value.name || '').trim()) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe o nome do recurso.', life: 3000 })
|
||||
return false
|
||||
}
|
||||
|
||||
const exists = rows.value.some(r =>
|
||||
String(r.key || '').trim().toLowerCase() === k && r.id !== form.value.id
|
||||
)
|
||||
if (exists) {
|
||||
toast.add({ severity: 'warn', summary: 'Key já existente', detail: 'Já existe um recurso com essa key.', life: 3000 })
|
||||
return false
|
||||
}
|
||||
|
||||
form.value.key = k
|
||||
form.value.name = String(form.value.name || '').trim()
|
||||
form.value.descricao = String(form.value.descricao || '').trim()
|
||||
return true
|
||||
}
|
||||
|
||||
async function save () {
|
||||
if (saving.value) return
|
||||
if (!validate()) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
key: form.value.key,
|
||||
name: form.value.name,
|
||||
descricao: form.value.descricao
|
||||
function validate() {
|
||||
const k = slugifyKey(form.value.key);
|
||||
if (!k) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe a key do recurso.', life: 3000 });
|
||||
return false;
|
||||
}
|
||||
if (!String(form.value.name || '').trim()) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe o nome do recurso.', life: 3000 });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
const { error } = await supabase.from('features').update(payload).eq('id', form.value.id)
|
||||
if (error) throw error
|
||||
toast.add({ severity: 'success', summary: 'Ok', detail: 'Recurso atualizado.', life: 2500 })
|
||||
} else {
|
||||
const { error } = await supabase.from('features').insert(payload)
|
||||
if (error) throw error
|
||||
toast.add({ severity: 'success', summary: 'Ok', detail: 'Recurso criado.', life: 2500 })
|
||||
const exists = rows.value.some(
|
||||
(r) =>
|
||||
String(r.key || '')
|
||||
.trim()
|
||||
.toLowerCase() === k && r.id !== form.value.id
|
||||
);
|
||||
if (exists) {
|
||||
toast.add({ severity: 'warn', summary: 'Key já existente', detail: 'Já existe um recurso com essa key.', life: 3000 });
|
||||
return false;
|
||||
}
|
||||
|
||||
showDlg.value = false
|
||||
await fetchAll()
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) {
|
||||
toast.add({ severity: 'warn', summary: 'Key já existente', detail: 'Já existe um recurso com essa key.', life: 3500 })
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 4500 })
|
||||
form.value.key = k;
|
||||
form.value.name = String(form.value.name || '').trim();
|
||||
form.value.descricao = String(form.value.descricao || '').trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saving.value) return;
|
||||
if (!validate()) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
key: form.value.key,
|
||||
name: form.value.name,
|
||||
descricao: form.value.descricao
|
||||
};
|
||||
|
||||
if (isEdit.value) {
|
||||
const { error } = await supabase.from('features').update(payload).eq('id', form.value.id);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Ok', detail: 'Recurso atualizado.', life: 2500 });
|
||||
} else {
|
||||
const { error } = await supabase.from('features').insert(payload);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Ok', detail: 'Recurso criado.', life: 2500 });
|
||||
}
|
||||
|
||||
showDlg.value = false;
|
||||
await fetchAll();
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) {
|
||||
toast.add({ severity: 'warn', summary: 'Key já existente', detail: 'Já existe um recurso com essa key.', life: 3500 });
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 4500 });
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function askDelete (row) {
|
||||
confirm.require({
|
||||
message: `Excluir o recurso "${row.key}"?`,
|
||||
header: 'Confirmar exclusão',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: () => doDelete(row)
|
||||
})
|
||||
function askDelete(row) {
|
||||
confirm.require({
|
||||
message: `Excluir o recurso "${row.key}"?`,
|
||||
header: 'Confirmar exclusão',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: () => doDelete(row)
|
||||
});
|
||||
}
|
||||
|
||||
async function doDelete (row) {
|
||||
try {
|
||||
const { error } = await supabase.from('features').delete().eq('id', row.id)
|
||||
if (error) throw error
|
||||
toast.add({ severity: 'success', summary: 'Ok', detail: 'Recurso excluído.', life: 2500 })
|
||||
await fetchAll()
|
||||
} catch (e) {
|
||||
const hint = isFkViolation(e)
|
||||
? 'Este recurso está vinculado a planos ou módulos. Remova o vínculo antes de excluir.'
|
||||
: ''
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro',
|
||||
detail: hint ? `${e?.message} — ${hint}` : (e?.message || String(e)),
|
||||
life: 5200
|
||||
})
|
||||
}
|
||||
async function doDelete(row) {
|
||||
try {
|
||||
const { error } = await supabase.from('features').delete().eq('id', row.id);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Ok', detail: 'Recurso excluído.', life: 2500 });
|
||||
await fetchAll();
|
||||
} catch (e) {
|
||||
const hint = isFkViolation(e) ? 'Este recurso está vinculado a planos ou módulos. Remova o vínculo antes de excluir.' : '';
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro',
|
||||
detail: hint ? `${e?.message} — ${hint}` : e?.message || String(e),
|
||||
life: 5200
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hero sticky ───────────────────────────────────────────
|
||||
const heroEl = ref(null)
|
||||
const heroSentinelRef = ref(null)
|
||||
const heroMenuRef = ref(null)
|
||||
const heroStuck = ref(false)
|
||||
let disconnectStickyObserver = null
|
||||
const heroEl = ref(null);
|
||||
const heroSentinelRef = ref(null);
|
||||
const heroMenuRef = ref(null);
|
||||
const heroStuck = ref(false);
|
||||
let disconnectStickyObserver = null;
|
||||
|
||||
const heroMenuItems = computed(() => [
|
||||
{ label: 'Atualizar', icon: 'pi pi-refresh', command: fetchAll, disabled: loading.value || saving.value },
|
||||
{ label: 'Adicionar recurso', icon: 'pi pi-plus', command: openCreate, disabled: saving.value }
|
||||
])
|
||||
{ label: 'Atualizar', icon: 'pi pi-refresh', command: fetchAll, disabled: loading.value || saving.value },
|
||||
{ label: 'Adicionar recurso', icon: 'pi pi-plus', command: openCreate, disabled: saving.value }
|
||||
]);
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchAll()
|
||||
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()
|
||||
}
|
||||
})
|
||||
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 {}
|
||||
})
|
||||
try {
|
||||
disconnectStickyObserver?.();
|
||||
} catch {}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDialog />
|
||||
<ConfirmDialog />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref="heroSentinelRef" class="h-px" />
|
||||
<!-- 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-fuchsia-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)]">Recursos do Sistema</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Cadastre os recursos (features) que os planos podem habilitar.</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações desktop (≥ 1200px) -->
|
||||
<div class="hidden xl:flex items-center gap-2 flex-wrap">
|
||||
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
|
||||
<Button label="Adicionar recurso" icon="pi pi-plus" size="small" :disabled="saving" @click="openCreate" />
|
||||
</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="features_hero_menu"
|
||||
@click="(e) => heroMenuRef.toggle(e)"
|
||||
/>
|
||||
<Menu ref="heroMenuRef" id="features_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-[380px]">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="q" id="features_search" class="w-full pr-10" variant="filled" autocomplete="off" :disabled="loading || saving" />
|
||||
</IconField>
|
||||
<label for="features_search">Buscar por key, nome ou descrição</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<DataTable :value="filteredRows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
|
||||
<Column header="Domínio" style="width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="featureDomain(data.key)" :severity="domainSeverity(featureDomain(data.key))" rounded />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="key" header="Key" sortable style="min-width: 18rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium font-mono text-[1rem]">{{ data.key }}</span>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">ID: {{ data.id }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="name" header="Nome" sortable style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<span>{{ data.name || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="descricao" header="Descrição" sortable style="min-width: 22rem">
|
||||
<template #body="{ data }">
|
||||
<div class="max-w-[600px] whitespace-nowrap overflow-hidden text-ellipsis text-[var(--text-color-secondary)]" :title="data.descricao || ''">
|
||||
{{ data.descricao || '—' }}
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="created_at" header="Criado em" sortable style="width: 13rem" />
|
||||
|
||||
<Column header="Ações" style="width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined @click="openEdit(data)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined @click="askDelete(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="showDlg"
|
||||
modal
|
||||
:header="isEdit ? 'Editar recurso' : 'Novo recurso'"
|
||||
:style="{ width: '640px' }"
|
||||
:closable="!saving"
|
||||
:dismissableMask="!saving"
|
||||
:draggable="false"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Key -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-tag" />
|
||||
<InputText
|
||||
id="cr-key"
|
||||
v-model.trim="form.key"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="saving"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
@blur="form.key = slugifyKey(form.key)"
|
||||
@keydown.enter.prevent="save"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="cr-key">Key *</label>
|
||||
</FloatLabel>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
|
||||
Ex.: <span class="font-mono">agenda.view</span> ou <span class="font-mono">online_scheduling.manage</span>.
|
||||
Espaços e acentos são normalizados automaticamente.
|
||||
<!-- 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-fuchsia-400/10" />
|
||||
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nome -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-bookmark" />
|
||||
<InputText
|
||||
id="cr-name"
|
||||
v-model.trim="form.name"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="saving"
|
||||
autocomplete="off"
|
||||
@keydown.enter.prevent="save"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="cr-name">Nome *</label>
|
||||
</FloatLabel>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
|
||||
Nome exibido para o usuário na página de upgrade e nas listagens.
|
||||
</div>
|
||||
</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)]">Recursos do Sistema</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Cadastre os recursos (features) que os planos podem habilitar.</div>
|
||||
</div>
|
||||
|
||||
<!-- Descrição PT-BR -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<Textarea
|
||||
id="cr-desc-pt"
|
||||
v-model.trim="form.descricao"
|
||||
class="w-full"
|
||||
rows="3"
|
||||
autoResize
|
||||
:disabled="saving"
|
||||
/>
|
||||
<label for="cr-desc-pt">Descrição</label>
|
||||
</FloatLabel>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
|
||||
Explique o que o recurso habilita e para quem se aplica.
|
||||
<!-- Ações desktop (≥ 1200px) -->
|
||||
<div class="hidden xl:flex items-center gap-2 flex-wrap">
|
||||
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
|
||||
<Button label="Adicionar recurso" icon="pi pi-plus" size="small" :disabled="saving" @click="openCreate" />
|
||||
</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="features_hero_menu" @click="(e) => heroMenuRef.toggle(e)" />
|
||||
<Menu ref="heroMenuRef" id="features_hero_menu" :model="heroMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" severity="secondary" outlined @click="showDlg = false" :disabled="saving" />
|
||||
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<!-- 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-[380px]">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="q" id="features_search" class="w-full pr-10" variant="filled" autocomplete="off" :disabled="loading || saving" />
|
||||
</IconField>
|
||||
<label for="features_search">Buscar por key, nome ou descrição</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<DataTable :value="filteredRows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
|
||||
<Column header="Domínio" style="width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="featureDomain(data.key)" :severity="domainSeverity(featureDomain(data.key))" rounded />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="key" header="Key" sortable style="min-width: 18rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium font-mono text-[1rem]">{{ data.key }}</span>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">ID: {{ data.id }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="name" header="Nome" sortable style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<span>{{ data.name || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="descricao" header="Descrição" sortable style="min-width: 22rem">
|
||||
<template #body="{ data }">
|
||||
<div class="max-w-[600px] whitespace-nowrap overflow-hidden text-ellipsis text-[var(--text-color-secondary)]" :title="data.descricao || ''">
|
||||
{{ data.descricao || '—' }}
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="created_at" header="Criado em" sortable style="width: 13rem" />
|
||||
|
||||
<Column header="Ações" style="width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined @click="openEdit(data)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined @click="askDelete(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="showDlg" modal :header="isEdit ? 'Editar recurso' : 'Novo recurso'" :style="{ width: '640px' }" :closable="!saving" :dismissableMask="!saving" :draggable="false">
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Key -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-tag" />
|
||||
<InputText id="cr-key" v-model.trim="form.key" class="w-full" variant="filled" :disabled="saving" autocomplete="off" autofocus @blur="form.key = slugifyKey(form.key)" @keydown.enter.prevent="save" />
|
||||
</IconField>
|
||||
<label for="cr-key">Key *</label>
|
||||
</FloatLabel>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Ex.: <span class="font-mono">agenda.view</span> ou <span class="font-mono">online_scheduling.manage</span>. Espaços e acentos são normalizados automaticamente.</div>
|
||||
</div>
|
||||
|
||||
<!-- Nome -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-bookmark" />
|
||||
<InputText id="cr-name" v-model.trim="form.name" class="w-full" variant="filled" :disabled="saving" autocomplete="off" @keydown.enter.prevent="save" />
|
||||
</IconField>
|
||||
<label for="cr-name">Nome *</label>
|
||||
</FloatLabel>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Nome exibido para o usuário na página de upgrade e nas listagens.</div>
|
||||
</div>
|
||||
|
||||
<!-- Descrição PT-BR -->
|
||||
<div>
|
||||
<FloatLabel variant="on">
|
||||
<Textarea id="cr-desc-pt" v-model.trim="form.descricao" class="w-full" rows="3" autoResize :disabled="saving" />
|
||||
<label for="cr-desc-pt">Descrição</label>
|
||||
</FloatLabel>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Explique o que o recurso habilita e para quem se aplica.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" severity="secondary" outlined @click="showDlg = false" :disabled="saving" />
|
||||
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user