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:
Leonardo
2026-03-24 21:26:58 -03:00
parent a89d1f5560
commit 53a4980396
453 changed files with 121427 additions and 174407 deletions
+745
View File
@@ -0,0 +1,745 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/views/pages/saas/SaasAddonsPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
const toast = useToast();
const confirm = useConfirm();
const activeTab = ref(0);
// ══════════════════════════════════════════════════════════════
// Tenants (para selecionar ao adicionar créditos)
// ══════════════════════════════════════════════════════════════
const tenants = ref([]);
const tenantMap = ref({});
const loadingTenants = ref(false);
async function loadTenants() {
loadingTenants.value = true;
try {
const { data, error } = await supabase.from('tenants').select('id, name, kind').order('name', { ascending: true });
if (error) throw error;
const list = data || [];
tenantMap.value = Object.fromEntries(list.map((t) => [t.id, t.name || t.id]));
tenants.value = list.map((t) => ({
value: t.id,
label: `${t.name} (${t.kind ?? 'tenant'})`
}));
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar tenants', detail: e?.message, life: 4000 });
} finally {
loadingTenants.value = false;
}
}
function tenantName(id) {
return tenantMap.value[id] || id?.slice(0, 8) + '…';
}
// ══════════════════════════════════════════════════════════════
// ABA 1 — Produtos (CRUD addon_products)
// ══════════════════════════════════════════════════════════════
const products = ref([]);
const productsLoading = ref(false);
const productDialog = ref(false);
const editingProduct = ref(null);
const emptyProduct = () => ({
slug: '',
name: '',
description: '',
addon_type: 'sms',
icon: 'pi pi-comment',
credits_amount: 0,
price_reais: 0,
is_active: true,
is_visible: true,
sort_order: 0
});
const productForm = ref(emptyProduct());
const addonTypes = [
{ label: 'SMS', value: 'sms' },
{ label: 'E-mail', value: 'email' },
{ label: 'Servidor', value: 'server' },
{ label: 'Domínio', value: 'domain' }
];
async function loadProducts() {
productsLoading.value = true;
const { data } = await supabase.from('addon_products').select('*').is('deleted_at', null).order('addon_type').order('sort_order');
productsLoading.value = false;
if (data) products.value = data;
}
function openNewProduct() {
editingProduct.value = null;
productForm.value = emptyProduct();
productDialog.value = true;
}
function openEditProduct(p) {
editingProduct.value = p;
productForm.value = { ...p, price_reais: (p.price_cents || 0) / 100 };
productDialog.value = true;
}
async function saveProduct() {
const f = productForm.value;
if (!f.slug || !f.name || !f.addon_type) {
toast.add({ severity: 'warn', summary: 'Preencha slug, nome e tipo', life: 3000 });
return;
}
const payload = {
slug: f.slug,
name: f.name,
description: f.description,
addon_type: f.addon_type,
icon: f.icon,
credits_amount: f.credits_amount || 0,
price_cents: Math.round((f.price_reais || 0) * 100),
is_active: f.is_active,
is_visible: f.is_visible,
sort_order: f.sort_order || 0,
updated_at: new Date().toISOString()
};
let error;
if (editingProduct.value) {
({ error } = await supabase.from('addon_products').update(payload).eq('id', editingProduct.value.id));
} else {
({ error } = await supabase.from('addon_products').insert(payload));
}
if (error) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: error.message, life: 4000 });
return;
}
toast.add({ severity: 'success', summary: 'Produto salvo', life: 3000 });
productDialog.value = false;
await loadProducts();
}
function deleteProduct(p) {
confirm.require({
group: 'headless',
header: 'Remover produto',
message: `Deseja remover "${p.name}"?`,
icon: 'pi-trash',
color: '#ef4444',
accept: async () => {
await supabase.from('addon_products').update({ deleted_at: new Date().toISOString() }).eq('id', p.id);
toast.add({ severity: 'success', summary: 'Produto removido', life: 3000 });
await loadProducts();
}
});
}
function formatPrice(cents) {
if (!cents) return '—';
return (cents / 100).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
// ══════════════════════════════════════════════════════════════
// ABA 2 — Créditos por Tenant
// ══════════════════════════════════════════════════════════════
const credits = ref([]);
const creditsLoading = ref(false);
// Dialog para adicionar créditos
const creditDialog = ref(false);
const creditForm = ref({ tenant_id: null, addon_type: 'sms', amount: 0, product_id: null, description: '', price_reais: 0 });
async function loadCredits() {
creditsLoading.value = true;
const { data } = await supabase
.from('addon_credits')
.select(
`
id, tenant_id, addon_type, balance, total_purchased, total_consumed,
low_balance_threshold, daily_limit, hourly_limit,
from_number_override, expires_at, is_active, created_at
`
)
.order('addon_type')
.order('balance', { ascending: true });
creditsLoading.value = false;
if (data) credits.value = data;
}
const productOptions = computed(() => products.value.filter((p) => p.is_active && p.addon_type === creditForm.value.addon_type).map((p) => ({ value: p.id, label: `${p.name} (${p.credits_amount} créd · ${formatPrice(p.price_cents)})`, product: p })));
function onProductSelect(evt) {
const opt = productOptions.value.find((o) => o.value === creditForm.value.product_id);
if (opt?.product) {
creditForm.value.amount = opt.product.credits_amount;
creditForm.value.price_reais = opt.product.price_cents / 100;
creditForm.value.description = opt.product.name;
}
}
function openAddCredit() {
creditForm.value = { tenant_id: null, addon_type: 'sms', amount: 100, product_id: null, description: 'Crédito manual', price_reais: 0 };
creditDialog.value = true;
}
function openAddCreditFor(tenantId) {
creditForm.value = { tenant_id: tenantId, addon_type: 'sms', amount: 100, product_id: null, description: 'Crédito manual', price_reais: 0 };
creditDialog.value = true;
}
// Agrupa créditos por tenant e vincula as transações de cada um
const tenantGroups = computed(() => {
const groups = {};
for (const c of credits.value) {
if (!groups[c.tenant_id]) {
groups[c.tenant_id] = { tenant_id: c.tenant_id, credits: [], transactions: [] };
}
groups[c.tenant_id].credits.push(c);
}
for (const tx of transactions.value) {
if (groups[tx.tenant_id]) {
groups[tx.tenant_id].transactions.push(tx);
}
}
// Ordena por nome do tenant
return Object.values(groups).sort((a, b) =>
tenantName(a.tenant_id).localeCompare(tenantName(b.tenant_id))
);
});
async function addCredit() {
const f = creditForm.value;
if (!f.tenant_id || !f.amount || f.amount <= 0) {
toast.add({ severity: 'warn', summary: 'Selecione um tenant e informe a quantidade', life: 3000 });
return;
}
const { data, error } = await supabase.rpc('admin_credit_addon', {
p_tenant_id: f.tenant_id,
p_addon_type: f.addon_type,
p_amount: f.amount,
p_product_id: f.product_id || null,
p_description: f.description || 'Crédito manual',
p_payment_method: 'manual',
p_price_cents: Math.round((f.price_reais || 0) * 100)
});
if (error) {
toast.add({ severity: 'error', summary: 'Erro ao creditar', detail: error.message, life: 5000 });
return;
}
toast.add({
severity: 'success',
summary: 'Créditos adicionados!',
detail: `Saldo: ${data?.balance_before}${data?.balance_after}`,
life: 5000
});
creditDialog.value = false;
await Promise.all([loadCredits(), loadTransactions()]);
}
// ══════════════════════════════════════════════════════════════
// ABA 3 — Transações recentes
// ══════════════════════════════════════════════════════════════
const transactions = ref([]);
const txLoading = ref(false);
async function loadTransactions() {
txLoading.value = true;
const { data } = await supabase.from('addon_transactions').select('*').order('created_at', { ascending: false }).limit(500);
txLoading.value = false;
if (data) transactions.value = data;
}
function txTypeLabel(type) {
const map = { purchase: 'Compra', consume: 'Consumo', adjustment: 'Ajuste', refund: 'Reembolso', expiration: 'Expiração' };
return map[type] || type;
}
function txTypeSeverity(type) {
const map = { purchase: 'success', consume: 'secondary', adjustment: 'info', refund: 'warn', expiration: 'danger' };
return map[type] || 'secondary';
}
function formatDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
}
// ══════════════════════════════════════════════════════════════
// Init
// ══════════════════════════════════════════════════════════════
onMounted(() => {
loadTenants();
loadProducts();
loadCredits();
loadTransactions();
});
</script>
<template>
<div class="flex flex-col gap-4">
<ConfirmDialog group="headless">
<template #container="{ message, acceptCallback, rejectCallback }">
<div class="flex flex-col items-center p-8 bg-surface-0 dark:bg-surface-900 rounded-xl shadow-xl">
<div class="rounded-full inline-flex justify-center items-center h-24 w-24 -mt-20" :style="{ background: message.color || 'var(--p-primary-color)', color: '#fff' }">
<i :class="`pi ${message.icon || 'pi-question'} !text-4xl`"></i>
</div>
<span class="font-bold text-2xl block mb-2 mt-6">{{ message.header }}</span>
<p class="mb-0 text-center text-[var(--text-color-secondary)]">{{ message.message }}</p>
<div class="flex items-center gap-2 mt-6">
<Button label="Confirmar" class="rounded-full" :style="{ background: message.color || 'var(--p-primary-color)', borderColor: message.color || 'var(--p-primary-color)' }" @click="acceptCallback" />
<Button label="Cancelar" variant="outlined" class="rounded-full" @click="rejectCallback" />
</div>
</div>
</template>
</ConfirmDialog>
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold m-0">Recursos Extras (Add-ons)</h2>
</div>
<!-- Próximos passos -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<Card class="border-l-4" style="border-left-color: var(--p-yellow-500)">
<template #content>
<div class="flex items-start gap-3">
<i class="pi pi-bell text-2xl" style="color: var(--p-yellow-500)" />
<div>
<h4 class="font-semibold m-0 mb-1">Alerta de saldo baixo</h4>
<p class="text-sm text-surface-500 m-0">
Próximo passo: Notificar tenants automaticamente quando o saldo de créditos estiver abaixo do limite configurado. O campo <code>low_balance_threshold</code> existe no banco falta a Edge Function de verificação
periódica.
</p>
<Tag value="Planejado" severity="warn" class="mt-2" />
</div>
</div>
</template>
</Card>
<Card class="border-l-4" style="border-left-color: var(--p-blue-500)">
<template #content>
<div class="flex items-start gap-3">
<i class="pi pi-credit-card text-2xl" style="color: var(--p-blue-500)" />
<div>
<h4 class="font-semibold m-0 mb-1">Compra online (Gateway)</h4>
<p class="text-sm text-surface-500 m-0">
Próximo passo: Integrar gateway de pagamento (PIX, cartão) para que tenants comprem créditos diretamente pela plataforma. Os campos <code>payment_method</code> e <code>payment_reference</code> estão prontos no
banco.
</p>
<Tag value="Planejado" severity="info" class="mt-2" />
</div>
</div>
</template>
</Card>
</div>
<Tabs v-model:value="activeTab">
<TabList>
<Tab :value="0">Produtos</Tab>
<Tab :value="1">Recursos Extras por Tenant</Tab>
<Tab :value="2">Transações</Tab>
</TabList>
<TabPanels>
<!-- ABA 1: Produtos -->
<TabPanel :value="0">
<div class="flex justify-end mb-3">
<Button label="Novo produto" icon="pi pi-plus" size="small" @click="openNewProduct" />
</div>
<DataTable :value="products" :loading="productsLoading" size="small" stripedRows emptyMessage="Nenhum produto cadastrado.">
<Column field="slug" header="Slug" style="width: 130px" />
<Column field="name" header="Nome" />
<Column field="addon_type" header="Tipo" style="width: 90px">
<template #body="{ data }">
<Tag :value="data.addon_type.toUpperCase()" />
</template>
</Column>
<Column field="credits_amount" header="Créditos" style="width: 90px" />
<Column field="price_cents" header="Preço" style="width: 110px">
<template #body="{ data }">{{ formatPrice(data.price_cents) }}</template>
</Column>
<Column field="is_active" header="Ativo" style="width: 70px">
<template #body="{ data }">
<i :class="data.is_active ? 'pi pi-check text-green-500' : 'pi pi-times text-red-500'" />
</template>
</Column>
<Column field="is_visible" header="Visível" style="width: 70px">
<template #body="{ data }">
<i :class="data.is_visible ? 'pi pi-eye text-green-500' : 'pi pi-eye-slash text-surface-400'" />
</template>
</Column>
<Column header="Ações" style="width: 100px">
<template #body="{ data }">
<div class="flex gap-1">
<Button icon="pi pi-pencil" text rounded size="small" @click="openEditProduct(data)" />
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="deleteProduct(data)" />
</div>
</template>
</Column>
</DataTable>
</TabPanel>
<!-- ABA 2: Créditos por Tenant -->
<TabPanel :value="1">
<div class="flex justify-between items-center mb-3">
<span class="text-sm text-surface-500">
{{ tenantGroups.length }} tenant(s) com recursos extras
</span>
<div class="flex gap-2">
<Button icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="creditsLoading" @click="() => { loadCredits(); loadTransactions(); }" />
<Button label="Adicionar créditos" icon="pi pi-plus" size="small" @click="openAddCredit" />
</div>
</div>
<!-- Loading -->
<div v-if="creditsLoading" class="flex justify-center py-10">
<ProgressSpinner style="width:36px;height:36px" />
</div>
<!-- Vazio -->
<div v-else-if="!tenantGroups.length" class="text-center py-10 text-surface-400 text-sm">
<i class="pi pi-inbox block text-3xl opacity-30 mb-2" />
Nenhum tenant com recursos extras ainda.
</div>
<!-- Accordion por tenant -->
<Accordion v-else>
<AccordionPanel
v-for="group in tenantGroups"
:key="group.tenant_id"
:value="group.tenant_id"
>
<AccordionHeader>
<div class="flex items-center gap-3 w-full min-w-0 pr-3">
<!-- Nome do tenant -->
<span class="font-semibold text-sm truncate flex-1">
{{ tenantName(group.tenant_id) }}
</span>
<!-- Chips de saldo por addon_type -->
<div class="flex gap-1.5 flex-wrap shrink-0">
<Tag
v-for="c in group.credits"
:key="c.id"
:value="`${c.addon_type.toUpperCase()} · ${c.balance}`"
:severity="c.balance <= 0 ? 'danger' : c.balance <= (c.low_balance_threshold || 10) ? 'warn' : 'success'"
class="text-[0.68rem]"
/>
</div>
<!-- Botão + créditos inline -->
<Button
icon="pi pi-plus"
label="Creditar"
size="small"
severity="secondary"
outlined
class="shrink-0 text-xs !py-1"
@click.stop="openAddCreditFor(group.tenant_id)"
/>
</div>
</AccordionHeader>
<AccordionContent>
<div class="pt-1 pb-2">
<!-- Resumo do saldo por addon_type -->
<div class="flex flex-wrap gap-3 mb-4">
<div
v-for="c in group.credits"
:key="c.id"
class="flex flex-col gap-0.5 border border-[var(--surface-border)] rounded-lg px-3 py-2 min-w-[120px]"
>
<span class="text-[0.65rem] font-bold uppercase text-[var(--text-color-secondary)]">{{ c.addon_type }}</span>
<span
class="text-2xl font-bold leading-none"
:class="c.balance <= 0 ? 'text-red-500' : c.balance <= (c.low_balance_threshold || 10) ? 'text-yellow-500' : 'text-green-500'"
>{{ c.balance }}</span>
<span class="text-[0.68rem] text-[var(--text-color-secondary)]">
comprado {{ c.total_purchased }} · consumido {{ c.total_consumed }}
</span>
</div>
</div>
<!-- Histórico de adições -->
<div class="text-xs font-semibold text-[var(--text-color-secondary)] uppercase tracking-wide mb-2">
Histórico de movimentações
</div>
<DataTable
:value="group.transactions"
size="small"
striped-rows
empty-message="Nenhuma movimentação registrada."
class="text-sm"
>
<Column header="Data" style="width: 135px">
<template #body="{ data }">
<span class="text-xs">{{ formatDate(data.created_at) }}</span>
</template>
</Column>
<Column field="addon_type" header="Tipo" style="width: 65px">
<template #body="{ data }">
<span class="text-[0.65rem] font-semibold uppercase">{{ data.addon_type }}</span>
</template>
</Column>
<Column header="Operação" style="width: 100px">
<template #body="{ data }">
<Tag
:value="txTypeLabel(data.type)"
:severity="txTypeSeverity(data.type)"
class="text-[0.65rem]"
/>
</template>
</Column>
<Column header="Qtd" style="width: 70px">
<template #body="{ data }">
<span
class="font-semibold text-sm"
:class="data.amount > 0 ? 'text-green-500' : 'text-red-400'"
>
{{ data.amount > 0 ? '+' : '' }}{{ data.amount }}
</span>
</template>
</Column>
<Column header="Saldo após" style="width: 85px">
<template #body="{ data }">
<span class="text-sm font-semibold">{{ data.balance_after }}</span>
</template>
</Column>
<Column field="description" header="Descrição" />
<Column header="Valor pago" style="width: 100px">
<template #body="{ data }">
{{ data.price_cents ? formatPrice(data.price_cents) : '—' }}
</template>
</Column>
</DataTable>
</div>
</AccordionContent>
</AccordionPanel>
</Accordion>
</TabPanel>
<!-- ABA 3: Transações -->
<TabPanel :value="2">
<div class="flex justify-end mb-3">
<Button icon="pi pi-refresh" text rounded size="small" @click="loadTransactions" :loading="txLoading" />
</div>
<DataTable :value="transactions" :loading="txLoading" size="small" stripedRows emptyMessage="Nenhuma transação encontrada.">
<Column field="created_at" header="Data" style="width: 140px">
<template #body="{ data }">{{ formatDate(data.created_at) }}</template>
</Column>
<Column field="tenant_id" header="Tenant" style="max-width: 160px">
<template #body="{ data }">
<span class="text-sm" :title="data.tenant_id">{{ tenantName(data.tenant_id) }}</span>
</template>
</Column>
<Column field="addon_type" header="Tipo" style="width: 70px">
<template #body="{ data }"><Tag :value="data.addon_type" /></template>
</Column>
<Column field="type" header="Operação" style="width: 100px">
<template #body="{ data }">
<Tag :value="txTypeLabel(data.type)" :severity="txTypeSeverity(data.type)" />
</template>
</Column>
<Column field="amount" header="Qtd" style="width: 70px">
<template #body="{ data }">
<span :class="data.amount > 0 ? 'text-green-500 font-semibold' : 'text-red-500'"> {{ data.amount > 0 ? '+' : '' }}{{ data.amount }} </span>
</template>
</Column>
<Column field="balance_after" header="Saldo" style="width: 70px" />
<Column field="description" header="Descrição" />
<Column field="payment_method" header="Pgto" style="width: 80px">
<template #body="{ data }">{{ data.payment_method || '—' }}</template>
</Column>
</DataTable>
</TabPanel>
</TabPanels>
</Tabs>
<!-- Dialog: Novo/Editar Produto -->
<Dialog
v-model:visible="productDialog"
modal
:draggable="false"
:closable="true"
:dismissableMask="true"
maximizable
class="dc-dialog w-[36rem]"
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<div class="min-w-0">
<div class="text-base font-semibold truncate">{{ editingProduct ? 'Editar Produto' : 'Novo Produto' }}</div>
<div class="text-xs opacity-50">Configurar produto de recurso extra</div>
</div>
</div>
</div>
</template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Slug</label>
<InputText v-model="productForm.slug" placeholder="sms_100" :disabled="!!editingProduct" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Nome</label>
<InputText v-model="productForm.name" placeholder="SMS 100 créditos" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Descrição</label>
<Textarea v-model="productForm.description" rows="2" class="w-full" />
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Tipo</label>
<Select v-model="productForm.addon_type" :options="addonTypes" optionLabel="label" optionValue="value" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Ícone</label>
<InputText v-model="productForm.icon" placeholder="pi pi-comment" class="w-full" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Créditos</label>
<InputNumber v-model="productForm.credits_amount" :min="0" fluid />
</div>
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Preço (R$)</label>
<InputNumber
v-model="productForm.price_reais"
:min="0"
:min-fraction-digits="2"
:max-fraction-digits="2"
mode="currency"
currency="BRL"
locale="pt-BR"
fluid
/>
</div>
</div>
<div class="flex flex-col gap-1 w-32">
<label class="font-medium text-sm">Ordem de exibição</label>
<InputNumber v-model="productForm.sort_order" :min="0" fluid />
</div>
<div class="flex gap-4">
<div class="flex items-center gap-2">
<ToggleSwitch v-model="productForm.is_active" />
<label class="text-sm">Ativo</label>
</div>
<div class="flex items-center gap-2">
<ToggleSwitch v-model="productForm.is_visible" />
<label class="text-sm">Visível na vitrine</label>
</div>
</div>
</div>
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<Button label="Cancelar" severity="secondary" text class="rounded-full hover:!text-red-500" @click="productDialog = false" />
<Button label="Salvar" icon="pi pi-save" class="rounded-full" @click="saveProduct" />
</div>
</template>
</Dialog>
<!-- Dialog: Adicionar Créditos -->
<Dialog
v-model:visible="creditDialog"
modal
:draggable="false"
:closable="true"
:dismissableMask="true"
maximizable
class="dc-dialog w-[36rem]"
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<div class="min-w-0">
<div class="text-base font-semibold truncate">Adicionar Créditos</div>
<div class="text-xs opacity-50">Creditar recursos extras para um tenant</div>
</div>
</div>
</div>
</template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Selecionar terapeuta / clínica</label>
<Select v-model="creditForm.tenant_id" :options="tenants" optionLabel="label" optionValue="value" placeholder="Escolha um tenant..." filter :loading="loadingTenants" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Tipo de recurso</label>
<Select v-model="creditForm.addon_type" :options="addonTypes" optionLabel="label" optionValue="value" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Pacote (opcional preenche automaticamente)</label>
<Select v-model="creditForm.product_id" :options="productOptions" optionLabel="label" optionValue="value" placeholder="Selecione um pacote ou preencha manualmente" showClear class="w-full" @change="onProductSelect" />
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Quantidade de créditos</label>
<InputNumber v-model="creditForm.amount" :min="1" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Valor pago (R$)</label>
<InputNumber v-model="creditForm.price_reais" :min="0" :minFractionDigits="2" :maxFractionDigits="2" mode="currency" currency="BRL" locale="pt-BR" class="w-full" />
</div>
</div>
<div class="flex flex-col gap-1">
<label class="font-medium text-sm">Descrição</label>
<InputText v-model="creditForm.description" placeholder="Crédito manual" class="w-full" />
</div>
</div>
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<Button label="Cancelar" severity="secondary" text class="rounded-full hover:!text-red-500" @click="creditDialog = false" />
<Button label="Creditar" icon="pi pi-plus" class="rounded-full" @click="addCredit" />
</div>
</template>
</Dialog>
</div>
</template>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+397 -292
View File
@@ -15,354 +15,459 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import Editor from 'primevue/editor'
import { supabase } from '@/lib/supabase/client'
import { renderEmail } from '@/lib/email/emailTemplateService'
import { MOCK_DATA, TEMPLATE_DOMAINS } from '@/lib/email/emailTemplateConstants'
import { ref, computed, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import Editor from 'primevue/editor';
import { supabase } from '@/lib/supabase/client';
import { renderEmail } from '@/lib/email/emailTemplateService';
import { MOCK_DATA, TEMPLATE_DOMAINS } from '@/lib/email/emailTemplateConstants';
const toast = useToast()
const toast = useToast();
// ── Perfil (logo no preview) ───────────────────────────────────
const profileLogoUrl = ref(null)
const profileLogoUrl = ref(null);
async function loadProfile() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
const { data } = await supabase
.from('profiles')
.select('avatar_url')
.eq('id', user.id)
.maybeSingle()
profileLogoUrl.value = data?.avatar_url || null
const {
data: { user }
} = await supabase.auth.getUser();
if (!user) return;
const { data } = await supabase.from('profiles').select('avatar_url').eq('id', user.id).maybeSingle();
profileLogoUrl.value = data?.avatar_url || null;
}
// ── Lista ──────────────────────────────────────────────────────
const templates = ref([])
const loading = ref(false)
const filterDomain = ref(null)
const templates = ref([]);
const loading = ref(false);
const filterDomain = ref(null);
async function load() {
loading.value = true
try {
const { data, error } = await supabase
.from('email_templates_global')
.select('*')
.order('domain')
.order('key')
if (error) throw error
templates.value = data
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message, life: 4000 })
} finally {
loading.value = false
}
loading.value = true;
try {
const { data, error } = await supabase.from('email_templates_global').select('*').order('domain').order('key');
if (error) throw error;
templates.value = data;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message, life: 4000 });
} finally {
loading.value = false;
}
}
const filtered = computed(() => {
if (!filterDomain.value) return templates.value
return templates.value.filter(t => t.domain === filterDomain.value)
})
if (!filterDomain.value) return templates.value;
return templates.value.filter((t) => t.domain === filterDomain.value);
});
const DOMAIN_OPTIONS = [
{ label: 'Todos', value: null },
{ label: 'Sessão', value: TEMPLATE_DOMAINS.SESSION },
{ label: 'Triagem', value: TEMPLATE_DOMAINS.INTAKE },
{ label: 'Sistema', value: TEMPLATE_DOMAINS.SYSTEM },
{ label: 'Financeiro',value: TEMPLATE_DOMAINS.BILLING },
]
{ label: 'Todos', value: null },
{ label: 'Sessão', value: TEMPLATE_DOMAINS.SESSION },
{ label: 'Triagem', value: TEMPLATE_DOMAINS.INTAKE },
{ label: 'Sistema', value: TEMPLATE_DOMAINS.SYSTEM },
{ label: 'Financeiro', value: TEMPLATE_DOMAINS.BILLING }
];
const DOMAIN_LABEL = { session: 'Sessão', intake: 'Triagem', billing: 'Financeiro', system: 'Sistema' }
const DOMAIN_SEVERITY = { session: 'info', intake: 'warning', billing: 'success', system: 'secondary' }
const DOMAIN_SELECT_OPTIONS = [
{ label: 'Sessão', value: TEMPLATE_DOMAINS.SESSION },
{ label: 'Triagem', value: TEMPLATE_DOMAINS.INTAKE },
{ label: 'Sistema', value: TEMPLATE_DOMAINS.SYSTEM },
{ label: 'Financeiro', value: TEMPLATE_DOMAINS.BILLING }
];
// ── Dialog edição ──────────────────────────────────────────────
const dlg = ref({ open: false, saving: false, id: null })
const form = ref({})
const editorRef = ref(null)
const DOMAIN_LABEL = { session: 'Sessão', intake: 'Triagem', billing: 'Financeiro', system: 'Sistema' };
const DOMAIN_SEVERITY = { session: 'info', intake: 'warning', billing: 'success', system: 'secondary' };
function openEdit(t) {
form.value = {
key: t.key,
subject: t.subject,
body_html: t.body_html,
body_text: t.body_text ?? '',
version: t.version,
is_active: t.is_active,
variables: t.variables || {},
}
dlg.value = { open: true, saving: false, id: t.id }
// ── Variables editor helpers ───────────────────────────────────
// Trabalha internamente como array de {key, description} para o editor dinâmico
const varRows = ref([]); // [{ key: string, description: string }]
function _objectToRows(obj) {
return Object.entries(obj || {}).map(([key, description]) => ({ key, description: String(description) }));
}
function closeDlg() { dlg.value.open = false }
function _rowsToObject(rows) {
const obj = {};
rows.forEach((r) => {
const k = r.key?.trim();
if (k) obj[k] = r.description || '';
});
return obj;
}
function addVarRow() {
varRows.value.push({ key: '', description: '' });
}
function removeVarRow(idx) {
varRows.value.splice(idx, 1);
}
// ── Dialog edição / criação ────────────────────────────────────
const dlg = ref({ open: false, saving: false, id: null, isNew: false });
const form = ref({});
const editorRef = ref(null);
function openNew() {
form.value = {
key: '',
domain: TEMPLATE_DOMAINS.SESSION,
subject: '',
body_html: '',
body_text: '',
is_active: true
};
varRows.value = [];
dlg.value = { open: true, saving: false, id: null, isNew: true };
}
function openEdit(t) {
form.value = {
key: t.key,
domain: t.domain,
subject: t.subject,
body_html: t.body_html,
body_text: t.body_text ?? '',
version: t.version,
is_active: t.is_active
};
varRows.value = _objectToRows(t.variables);
dlg.value = { open: true, saving: false, id: t.id, isNew: false };
}
function closeDlg() {
dlg.value.open = false;
}
const dlgHeader = computed(() => (dlg.value.isNew ? 'Novo Template Global' : `Editar — ${form.value.key}`));
// Chaves disponíveis para inserção rápida no editor
const formVariables = computed(() => {
const keys = Object.keys(form.value.variables || {})
if (!keys.includes('clinic_logo_url')) keys.push('clinic_logo_url')
return keys
})
const keys = varRows.value.map((r) => r.key).filter(Boolean);
if (!keys.includes('clinic_logo_url')) keys.push('clinic_logo_url');
return keys;
});
// Insere {{varName}} na posição do cursor no Editor (Quill)
function insertVar(varName) {
const snippet = `{{${varName}}}`
const quill = editorRef.value?.quill
if (!quill) {
form.value.body_html = (form.value.body_html || '') + snippet
return
}
const range = quill.getSelection(true)
const index = range ? range.index : quill.getLength() - 1
quill.insertText(index, snippet, 'user')
quill.setSelection(index + snippet.length, 0)
const snippet = `{{${varName}}}`;
const quill = editorRef.value?.quill;
if (!quill) {
form.value.body_html = (form.value.body_html || '') + snippet;
return;
}
const range = quill.getSelection(true);
const index = range ? range.index : quill.getLength() - 1;
quill.insertText(index, snippet, 'user');
quill.setSelection(index + snippet.length, 0);
}
async function save() {
if (!form.value.subject?.trim() || !form.value.body_html?.trim()) {
toast.add({ severity: 'warn', summary: 'Subject e body são obrigatórios', life: 3000 })
return
}
dlg.value.saving = true
try {
const { error } = await supabase
.from('email_templates_global')
.update({
subject: form.value.subject,
body_html: form.value.body_html,
body_text: form.value.body_text || null,
version: form.value.version,
is_active: form.value.is_active,
})
.eq('id', dlg.value.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Template salvo', life: 3000 })
closeDlg()
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 })
} finally {
dlg.value.saving = false
}
if (!form.value.subject?.trim()) {
toast.add({ severity: 'warn', summary: 'Subject é obrigatório', life: 3000 });
return;
}
if (!form.value.body_html?.trim()) {
toast.add({ severity: 'warn', summary: 'Body HTML é obrigatório', life: 3000 });
return;
}
if (dlg.value.isNew && !form.value.key?.trim()) {
toast.add({ severity: 'warn', summary: 'Key é obrigatória', life: 3000 });
return;
}
const variables = _rowsToObject(varRows.value);
dlg.value.saving = true;
try {
if (dlg.value.isNew) {
// INSERT — version começa em 1
const { error } = await supabase.from('email_templates_global').insert({
key: form.value.key.trim(),
domain: form.value.domain,
channel: 'email',
subject: form.value.subject.trim(),
body_html: form.value.body_html,
body_text: form.value.body_text || null,
version: 1,
is_active: form.value.is_active,
variables
});
if (error) throw error;
toast.add({ severity: 'success', summary: 'Template criado', detail: 'v1', life: 3000 });
} else {
// UPDATE — incrementa version automaticamente
const nextVersion = (form.value.version || 1) + 1;
const { error } = await supabase
.from('email_templates_global')
.update({
subject: form.value.subject.trim(),
body_html: form.value.body_html,
body_text: form.value.body_text || null,
version: nextVersion,
is_active: form.value.is_active,
variables
})
.eq('id', dlg.value.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Template salvo', detail: `Versão ${nextVersion}`, life: 3000 });
}
closeDlg();
await load();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 });
} finally {
dlg.value.saving = false;
}
}
async function toggleActive(t) {
try {
const { error } = await supabase
.from('email_templates_global')
.update({ is_active: !t.is_active })
.eq('id', t.id)
if (error) throw error
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
}
try {
const { error } = await supabase.from('email_templates_global').update({ is_active: !t.is_active }).eq('id', t.id);
if (error) throw error;
await load();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
}
}
// ── Dialog preview ─────────────────────────────────────────────
const preview = ref({ open: false, subject: '', body_html: '', key: '' })
const preview = ref({ open: false, subject: '', body_html: '', key: '' });
function openPreview(t) {
const mock = {
..._mockForDomain(t.domain),
clinic_logo_url: profileLogoUrl.value || null,
}
const rendered = renderEmail(t, mock)
preview.value = { open: true, ...rendered, key: t.key }
const mock = {
..._mockForDomain(t.domain),
clinic_logo_url: profileLogoUrl.value || null
};
const rendered = renderEmail(t, mock);
preview.value = { open: true, ...rendered, key: t.key };
}
function _mockForDomain(domain) {
if (domain === TEMPLATE_DOMAINS.SESSION) return { ...MOCK_DATA.session }
if (domain === TEMPLATE_DOMAINS.INTAKE) return { ...MOCK_DATA.intake }
return { ...MOCK_DATA.system }
if (domain === TEMPLATE_DOMAINS.SESSION) return { ...MOCK_DATA.session };
if (domain === TEMPLATE_DOMAINS.INTAKE) return { ...MOCK_DATA.intake };
return { ...MOCK_DATA.system };
}
onMounted(() => {
load()
loadProfile()
})
load();
loadProfile();
});
</script>
<template>
<div class="p-4 md:p-6 max-w-[1100px] mx-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
<div>
<h1 class="text-xl font-bold m-0">Templates de E-mail Globais</h1>
<p class="text-sm text-[var(--text-color-secondary)] mt-1 mb-0">
Templates base do sistema. Tenants podem criar overrides sem alterar estes.
</p>
</div>
<Button icon="pi pi-refresh" severity="secondary" text :loading="loading" @click="load" />
</div>
<!-- Filtro -->
<div class="flex gap-2 mb-4 flex-wrap">
<Button
v-for="opt in DOMAIN_OPTIONS"
:key="String(opt.value)"
:label="opt.label"
size="small"
:severity="filterDomain === opt.value ? 'primary' : 'secondary'"
:outlined="filterDomain !== opt.value"
@click="filterDomain = opt.value"
/>
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-12">
<ProgressSpinner />
</div>
<!-- Lista -->
<div v-else class="flex flex-col gap-2">
<div
v-for="t in filtered"
:key="t.id"
class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] px-4 py-3 flex gap-3 items-center"
:class="{ 'opacity-50': !t.is_active }"
>
<Tag
:value="DOMAIN_LABEL[t.domain] ?? t.domain"
:severity="DOMAIN_SEVERITY[t.domain] ?? 'secondary'"
class="text-[0.7rem] shrink-0"
/>
<div class="flex-1 min-w-0">
<div class="font-mono text-xs text-[var(--text-color-secondary)] mb-0.5">{{ t.key }}</div>
<div class="text-sm truncate font-medium">{{ t.subject }}</div>
</div>
<div class="hidden md:flex items-center gap-3 text-xs text-[var(--text-color-secondary)] shrink-0">
<span>v{{ t.version }}</span>
<Tag :value="t.channel" severity="secondary" class="text-[0.65rem]" />
</div>
<div class="flex items-center gap-1 shrink-0">
<Button icon="pi pi-eye" text rounded size="small" severity="secondary" title="Preview" @click="openPreview(t)" />
<Button icon="pi pi-pencil" text rounded size="small" title="Editar" @click="openEdit(t)" />
<Button
:icon="t.is_active ? 'pi pi-eye-slash' : 'pi pi-eye'"
text rounded size="small"
:severity="t.is_active ? 'secondary' : 'success'"
:title="t.is_active ? 'Desativar' : 'Ativar'"
@click="toggleActive(t)"
/>
</div>
</div>
<div v-if="!filtered.length" class="text-center py-12 text-[var(--text-color-secondary)]">
<i class="pi pi-envelope text-4xl opacity-30 block mb-3" />
Nenhum template neste domínio.
</div>
</div>
<!-- Dialog Edição -->
<Dialog
v-model:visible="dlg.open"
:header="`Editar — ${form.key}`"
modal
:style="{ width: '860px', maxWidth: '96vw' }"
:draggable="false"
>
<div class="flex flex-col gap-4 py-2">
<!-- Subject -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Subject *</label>
<InputText v-model="form.subject" class="w-full" />
</div>
<!-- Body HTML Editor Quill -->
<div class="flex flex-col gap-2">
<label class="text-xs font-semibold">Body HTML *</label>
<Editor
ref="editorRef"
v-model="form.body_html"
editor-style="min-height: 260px; font-size: 0.85rem;"
/>
<!-- Botões de variáveis -->
<div class="flex flex-col gap-1.5 mt-1">
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span>
<div class="flex flex-wrap gap-1.5">
<Button
v-for="v in formVariables"
:key="v"
:label="`{{${v}}}`"
size="small"
severity="secondary"
outlined
class="font-mono !text-[0.68rem] !py-1 !px-2"
@click="insertVar(v)"
/>
<div class="p-4 md:p-6 max-w-[1100px] mx-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
<div>
<h1 class="text-xl font-bold m-0">Templates de E-mail Globais</h1>
<p class="text-sm text-[var(--text-color-secondary)] mt-1 mb-0">Templates base do sistema. Tenants podem criar overrides sem alterar estes.</p>
</div>
<div class="flex gap-2">
<Button icon="pi pi-refresh" severity="secondary" text :loading="loading" @click="load" />
<Button label="Novo template" icon="pi pi-plus" @click="openNew" />
</div>
</div>
<p class="text-xs text-[var(--text-color-secondary)] m-0">
Suporta <code>&#123;&#123;variavel&#125;&#125;</code> e
<code>&#123;&#123;#if variavel&#125;&#125;...&#123;&#123;/if&#125;&#125;</code>
</p>
</div>
<!-- Body Text -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">
Body Text
<span class="font-normal opacity-60">(opcional gerado do HTML se vazio)</span>
</label>
<Textarea v-model="form.body_text" rows="2" class="w-full text-xs" auto-resize />
<!-- Filtro por domain -->
<div class="flex gap-2 mb-4 flex-wrap">
<Button
v-for="opt in DOMAIN_OPTIONS"
:key="String(opt.value)"
:label="opt.label"
size="small"
:severity="filterDomain === opt.value ? 'primary' : 'secondary'"
:outlined="filterDomain !== opt.value"
@click="filterDomain = opt.value"
/>
</div>
<!-- Versão (esquerda) + Ativo (direita) -->
<div class="flex items-end justify-between">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Versão</label>
<InputNumber v-model="form.version" :min="1" style="width:110px" />
</div>
<div class="flex items-center gap-2">
<ToggleSwitch v-model="form.is_active" inputId="sw-active-global" />
<label for="sw-active-global" class="text-sm cursor-pointer select-none">Ativo</label>
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-12">
<ProgressSpinner />
</div>
</div>
<!-- Lista -->
<div v-else class="flex flex-col gap-2">
<div
v-for="t in filtered"
:key="t.id"
class="border border-(--surface-border) rounded-xl bg-(--surface-card) px-4 py-3 flex gap-3 items-center"
:class="{ 'opacity-50': !t.is_active }"
>
<Tag :value="DOMAIN_LABEL[t.domain] ?? t.domain" :severity="DOMAIN_SEVERITY[t.domain] ?? 'secondary'" class="text-[0.7rem] shrink-0" />
<div class="flex-1 min-w-0">
<div class="font-mono text-xs text-(--text-color-secondary) mb-0.5">{{ t.key }}</div>
<div class="text-sm truncate font-medium">{{ t.subject }}</div>
</div>
<div class="hidden md:flex items-center gap-3 text-xs text-(--text-color-secondary) shrink-0">
<span>v{{ t.version }}</span>
<Tag :value="t.channel" severity="secondary" class="text-[0.65rem]" />
</div>
<div class="flex items-center gap-1 shrink-0">
<Button icon="pi pi-eye" text rounded size="small" severity="secondary" title="Preview" @click="openPreview(t)" />
<Button icon="pi pi-pencil" text rounded size="small" title="Editar" @click="openEdit(t)" />
<Button
:icon="t.is_active ? 'pi pi-eye-slash' : 'pi pi-check-circle'"
text
rounded
size="small"
:severity="t.is_active ? 'secondary' : 'success'"
:title="t.is_active ? 'Desativar' : 'Ativar'"
@click="toggleActive(t)"
/>
</div>
</div>
<template #footer>
<Button label="Cancelar" text @click="closeDlg" :disabled="dlg.saving" />
<Button label="Salvar" icon="pi pi-check" :loading="dlg.saving" @click="save" />
</template>
</Dialog>
<!-- Dialog Preview -->
<Dialog
v-model:visible="preview.open"
:header="`Preview — ${preview.key}`"
modal
:style="{ width: '700px', maxWidth: '96vw' }"
:draggable="false"
>
<div class="flex flex-col gap-3">
<div class="border border-[var(--surface-border)] rounded-lg p-3 bg-[var(--surface-ground)]">
<span class="text-xs font-semibold text-[var(--text-color-secondary)] uppercase tracking-widest">Subject</span>
<p class="mt-1 mb-0 font-medium">{{ preview.subject }}</p>
<div v-if="!filtered.length" class="text-center py-12 text-(--text-color-secondary)">
<i class="pi pi-envelope text-4xl opacity-30 block mb-3" />
Nenhum template neste domínio.
</div>
</div>
<div class="border border-[var(--surface-border)] rounded-lg p-5 bg-white text-gray-800">
<div v-if="profileLogoUrl" class="mb-4 text-center">
<img :src="profileLogoUrl" alt="Logo" class="h-10 object-contain inline-block" />
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="text-sm leading-relaxed" v-html="preview.body_html" />
</div>
<!-- Dialog Edição / Criação -->
<Dialog v-model:visible="dlg.open" :header="dlgHeader" modal :style="{ width: '880px', maxWidth: '96vw' }" :draggable="false">
<div class="flex flex-col gap-5 py-2">
<p class="text-xs text-[var(--text-color-secondary)] m-0 text-center">
Preview com dados de exemplo.
<span v-if="profileLogoUrl"> Logo do seu perfil.</span>
<span v-else> Sem logo configure em <b>Meu Perfil Avatar</b>.</span>
</p>
</div>
<!-- Key + Domain (linha no modo criação; no edit: readonly labels) -->
<div v-if="dlg.isNew" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Key *<span class="font-normal opacity-60 ml-1">(ex: session.reminder.email)</span></label>
<InputText v-model="form.key" placeholder="domain.nome.email" class="w-full font-mono" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Domain *</label>
<Select v-model="form.domain" :options="DOMAIN_SELECT_OPTIONS" option-label="label" option-value="value" class="w-full" />
</div>
</div>
<template #footer>
<Button label="Fechar" @click="preview.open = false" />
</template>
</Dialog>
<div v-else class="flex items-center gap-3 text-sm">
<Tag :value="DOMAIN_LABEL[form.domain] ?? form.domain" :severity="DOMAIN_SEVERITY[form.domain] ?? 'secondary'" />
<span class="font-mono text-(--text-color-secondary)">{{ form.key }}</span>
<span class="ml-auto text-xs text-(--text-color-secondary) opacity-70">v{{ form.version }} v{{ (form.version || 1) + 1 }} ao salvar</span>
</div>
</div>
<!-- Subject -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Subject *</label>
<InputText v-model="form.subject" class="w-full" placeholder="Assunto do e-mail — suporta {{variavel}}" />
</div>
<!-- Body HTML -->
<div class="flex flex-col gap-2">
<label class="text-xs font-semibold">Body HTML *</label>
<Editor ref="editorRef" v-model="form.body_html" editor-style="min-height: 240px; font-size: 0.85rem;" />
<!-- Botões de inserção de variáveis -->
<div v-if="formVariables.length" class="flex flex-col gap-1.5">
<span class="text-xs text-(--text-color-secondary)">Inserir variável no cursor:</span>
<div class="flex flex-wrap gap-1.5">
<Button
v-for="v in formVariables"
:key="v"
:label="`{{${v}}}`"
size="small"
severity="secondary"
outlined
class="font-mono text-[0.68rem]! py-1! px-2!"
@click="insertVar(v)"
/>
</div>
</div>
<p class="text-xs text-(--text-color-secondary) m-0">
Suporta <code>&#123;&#123;variavel&#125;&#125;</code> e
<code>&#123;&#123;#if variavel&#125;&#125;...&#123;&#123;/if&#125;&#125;</code>
</p>
</div>
<!-- Body Text -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">
Body Text
<span class="font-normal opacity-60">(opcional gerado do HTML se vazio)</span>
</label>
<Textarea v-model="form.body_text" rows="2" class="w-full text-xs" auto-resize />
</div>
<!-- Variables editor -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-semibold">Variáveis disponíveis</label>
<Button label="Adicionar variável" icon="pi pi-plus" size="small" severity="secondary" text @click="addVarRow" />
</div>
<div v-if="varRows.length === 0" class="text-xs text-(--text-color-secondary) italic py-1">
Nenhuma variável definida. Clique em "Adicionar variável" para incluir.
</div>
<div v-else class="flex flex-col gap-2">
<div v-for="(row, idx) in varRows" :key="idx" class="flex gap-2 items-center">
<InputText
v-model="row.key"
placeholder="chave"
class="font-mono flex-[0_0_200px] text-sm"
size="small"
/>
<InputText
v-model="row.description"
placeholder="Descrição da variável"
class="flex-1 text-sm"
size="small"
/>
<Button icon="pi pi-times" text rounded size="small" severity="danger" @click="removeVarRow(idx)" />
</div>
</div>
<p class="text-xs text-(--text-color-secondary) m-0 mt-1">
Estas chaves ficam salvas no campo <code>variables</code> e guiam os tenants ao customizar o template.
</p>
</div>
<!-- Ativo toggle -->
<div class="flex items-center gap-2 pt-1 border-t border-(--surface-border)">
<ToggleSwitch v-model="form.is_active" inputId="sw-active-global" />
<label for="sw-active-global" class="text-sm cursor-pointer select-none">Template ativo</label>
</div>
</div>
<template #footer>
<Button label="Cancelar" text @click="closeDlg" :disabled="dlg.saving" />
<Button
:label="dlg.isNew ? 'Criar template' : 'Salvar'"
icon="pi pi-check"
:loading="dlg.saving"
@click="save"
/>
</template>
</Dialog>
<!-- Dialog Preview -->
<Dialog v-model:visible="preview.open" :header="`Preview — ${preview.key}`" modal :style="{ width: '700px', maxWidth: '96vw' }" :draggable="false">
<div class="flex flex-col gap-3">
<div class="border border-(--surface-border) rounded-lg p-3 bg-(--surface-ground)">
<span class="text-xs font-semibold text-(--text-color-secondary) uppercase tracking-widest">Subject</span>
<p class="mt-1 mb-0 font-medium">{{ preview.subject }}</p>
</div>
<div class="border border-(--surface-border) rounded-lg p-5 bg-white text-gray-800">
<div v-if="profileLogoUrl" class="mb-4 text-center">
<img :src="profileLogoUrl" alt="Logo" class="h-10 object-contain inline-block" />
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="text-sm leading-relaxed" v-html="preview.body_html" />
</div>
<p class="text-xs text-(--text-color-secondary) m-0 text-center">
Preview com dados de exemplo.
<span v-if="profileLogoUrl"> Logo do seu perfil.</span>
<span v-else> Sem logo configure em <b>Meu Perfil Avatar</b>.</span>
</p>
</div>
<template #footer>
<Button label="Fechar" @click="preview.open = false" />
</template>
</Dialog>
</div>
</template>
+226 -238
View File
@@ -15,307 +15,295 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { useDocsAdmin } from '@/composables/useDocsAdmin'
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
import { useDocsAdmin } from '@/composables/useDocsAdmin';
const router = useRouter()
const { requestEditDoc } = useDocsAdmin()
const router = useRouter();
const { requestEditDoc } = useDocsAdmin();
function editarDoc (docId) {
requestEditDoc(docId)
router.push('/saas/docs')
function editarDoc(docId) {
requestEditDoc(docId);
router.push('/saas/docs');
}
// ── Estado ────────────────────────────────────────────────────
const loading = ref(false)
const docs = ref([]) // docs com exibir_no_faq = true
const faqItens = ref([]) // todos os itens FAQ dos docs acima
const loading = ref(false);
const docs = ref([]); // docs com exibir_no_faq = true
const faqItens = ref([]); // todos os itens FAQ dos docs acima
const busca = ref('')
const catAtiva = ref(null) // categoria selecionada no sidebar
const busca = ref('');
const catAtiva = ref(null); // categoria selecionada no sidebar
// Controla quais perguntas estão abertas { [itemId]: boolean }
const abertos = ref({})
const abertos = ref({});
// ── Load ──────────────────────────────────────────────────────
async function load () {
loading.value = true
try {
// Busca docs habilitados no FAQ
const { data: docsData, error: docsErr } = await supabase
.from('saas_docs')
.select('id, titulo, categoria, ordem, pagina_path')
.eq('ativo', true)
.eq('exibir_no_faq', true)
.order('categoria')
.order('ordem')
if (docsErr) throw docsErr
async function load() {
loading.value = true;
try {
// Busca docs habilitados no FAQ
const { data: docsData, error: docsErr } = await supabase.from('saas_docs').select('id, titulo, categoria, ordem, pagina_path').eq('ativo', true).eq('exibir_no_faq', true).order('categoria').order('ordem');
if (docsErr) throw docsErr;
docs.value = docsData || []
docs.value = docsData || [];
if (!docs.value.length) return
if (!docs.value.length) return;
// Busca todos os itens FAQ desses docs
const docIds = docs.value.map(d => d.id)
const { data: itensData, error: itensErr } = await supabase
.from('saas_faq_itens')
.select('id, doc_id, pergunta, resposta, ordem')
.in('doc_id', docIds)
.eq('ativo', true)
.order('ordem')
if (itensErr) throw itensErr
// Busca todos os itens FAQ desses docs
const docIds = docs.value.map((d) => d.id);
const { data: itensData, error: itensErr } = await supabase.from('saas_faq_itens').select('id, doc_id, pergunta, resposta, ordem').in('doc_id', docIds).eq('ativo', true).order('ordem');
if (itensErr) throw itensErr;
faqItens.value = itensData || []
} finally {
loading.value = false
}
faqItens.value = itensData || [];
} finally {
loading.value = false;
}
}
onMounted(load)
onMounted(load);
// ── Categorias disponíveis ────────────────────────────────────
const categorias = computed(() => {
const set = new Set(docs.value.map(d => d.categoria).filter(Boolean))
return [...set].sort()
})
const set = new Set(docs.value.map((d) => d.categoria).filter(Boolean));
return [...set].sort();
});
// ── Docs filtrados pela categoria ativa ───────────────────────
const docsFiltrados = computed(() => {
if (!catAtiva.value) return docs.value
return docs.value.filter(d => d.categoria === catAtiva.value)
})
if (!catAtiva.value) return docs.value;
return docs.value.filter((d) => d.categoria === catAtiva.value);
});
// ── Itens de um doc, aplicando busca ─────────────────────────
function itensDo (docId) {
const q = busca.value.trim().toLowerCase()
return faqItens.value.filter(f => {
if (f.doc_id !== docId) return false
if (!q) return true
return (
f.pergunta.toLowerCase().includes(q) ||
(f.resposta || '').toLowerCase().includes(q)
)
})
function itensDo(docId) {
const q = busca.value.trim().toLowerCase();
return faqItens.value.filter((f) => {
if (f.doc_id !== docId) return false;
if (!q) return true;
return f.pergunta.toLowerCase().includes(q) || (f.resposta || '').toLowerCase().includes(q);
});
}
// ── Docs que têm resultado na busca ──────────────────────────
const docsComResultado = computed(() => {
return docsFiltrados.value.filter(d => itensDo(d.id).length > 0)
})
return docsFiltrados.value.filter((d) => itensDo(d.id).length > 0);
});
// Total de resultados para feedback
const totalResultados = computed(() => {
if (!busca.value.trim()) return null
return docsComResultado.value.reduce((acc, d) => acc + itensDo(d.id).length, 0)
})
if (!busca.value.trim()) return null;
return docsComResultado.value.reduce((acc, d) => acc + itensDo(d.id).length, 0);
});
// ── Toggle pergunta ───────────────────────────────────────────
function toggle (id) {
abertos.value[id] = !abertos.value[id]
function toggle(id) {
abertos.value[id] = !abertos.value[id];
}
// Abre todas as perguntas dos resultados quando há busca ativa
function expandirResultados () {
docsComResultado.value.forEach(d => {
itensDo(d.id).forEach(item => {
abertos.value[item.id] = true
})
})
function expandirResultados() {
docsComResultado.value.forEach((d) => {
itensDo(d.id).forEach((item) => {
abertos.value[item.id] = true;
});
});
}
// Observa busca: expande automaticamente quando tem busca
watch(busca, (val) => {
if (val.trim()) expandirResultados()
})
if (val.trim()) expandirResultados();
});
// ── Selecionar categoria ──────────────────────────────────────
function selecionarCat (cat) {
catAtiva.value = catAtiva.value === cat ? null : cat
busca.value = ''
abertos.value = {}
function selecionarCat(cat) {
catAtiva.value = catAtiva.value === cat ? null : cat;
busca.value = '';
abertos.value = {};
}
</script>
<template>
<!-- Sentinel -->
<div class="h-px" />
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div
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-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
</div>
<div class="relative z-10 flex flex-col gap-3">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex items-center justify-center flex-shrink-0">
<i class="pi pi-comments text-xl text-[var(--text-color)]" />
<!-- Hero sticky -->
<div 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-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
</div>
<div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Central de Ajuda</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Encontre respostas para as dúvidas mais comuns</div>
</div>
</div>
<!-- Busca -->
<div>
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="busca"
placeholder="Buscar pergunta…"
class="w-full"
/>
<InputIcon v-if="busca" class="pi pi-times cursor-pointer" @click="busca = ''" />
</IconField>
<div v-if="totalResultados !== null" class="text-[1rem] text-[var(--text-color-secondary)] opacity-70 mt-1 ml-1">
{{ totalResultados }} resultado{{ totalResultados !== 1 ? 's' : '' }} encontrado{{ totalResultados !== 1 ? 's' : '' }}
</div>
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-16">
<i class="pi pi-spinner pi-spin text-2xl opacity-30" />
</div>
<template v-else>
<div class="flex gap-4 items-start flex-col sm:flex-row">
<!-- Sidebar de categorias -->
<aside v-if="categorias.length" class="w-full sm:w-48 flex-shrink-0 sticky top-[calc(var(--layout-sticky-top,56px)+8rem)] flex flex-col sm:flex-col flex-row flex-wrap gap-1">
<div class="text-[1rem] font-bold uppercase tracking-wider text-[var(--text-color-secondary)] opacity-60 px-2 mb-1 hidden sm:block">Categorias</div>
<button
class="flex items-center w-full px-2.5 py-1.5 rounded-md text-[1rem] text-[var(--text-color-secondary)] bg-transparent border-none cursor-pointer text-left transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-color)]"
:class="{ 'bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] font-semibold': !catAtiva }"
@click="selecionarCat(null)"
>
<i class="pi pi-th-large mr-2 opacity-60" />
Todas
<span class="ml-auto opacity-50 text-[1rem]">{{ faqItens.length }}</span>
</button>
<button
v-for="cat in categorias"
:key="cat"
class="flex items-center w-full px-2.5 py-1.5 rounded-md text-[1rem] text-[var(--text-color-secondary)] bg-transparent border-none cursor-pointer text-left transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-color)]"
:class="{ 'bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] font-semibold': catAtiva === cat }"
@click="selecionarCat(cat)"
>
<i class="pi pi-tag mr-2 opacity-60" />
{{ cat }}
<span class="ml-auto opacity-50 text-[1rem]">
{{ faqItens.filter(f => docs.find(d => d.id === f.doc_id && d.categoria === cat)).length }}
</span>
</button>
</aside>
<!-- Conteúdo principal -->
<div class="flex-1 min-w-0 flex flex-col gap-4">
<!-- Sem resultados -->
<div v-if="docsComResultado.length === 0" class="flex flex-col items-center py-12 text-center">
<i class="pi pi-search text-3xl opacity-20 mb-3" />
<div class="text-[var(--text-color-secondary)] text-[1rem]">Nenhuma pergunta encontrada.</div>
<button v-if="busca || catAtiva" class="text-[var(--primary-color)] text-[1rem] mt-2 underline" @click="busca = ''; catAtiva = null; abertos = {}">
Limpar filtros
</button>
</div>
<!-- Grupos de docs -->
<div
v-for="doc in docsComResultado"
:key="doc.id"
class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden"
>
<!-- Cabeçalho do grupo (doc) -->
<div class="group flex items-center gap-3 px-5 py-3.5 border-b border-[var(--surface-border)] bg-[var(--surface-ground)]">
<div class="w-8 h-8 rounded-md bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] flex items-center justify-center flex-shrink-0">
<i class="pi pi-file-edit" />
</div>
<div class="flex-1 min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)] leading-tight">{{ doc.titulo }}</div>
<div v-if="doc.categoria" class="text-[1rem] text-[var(--text-color-secondary)] opacity-60 mt-px">{{ doc.categoria }}</div>
</div>
<button
class="flex items-center justify-center w-7 h-7 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] text-[var(--text-color-secondary)] cursor-pointer flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[var(--surface-hover)] hover:text-[var(--primary-color)]"
v-tooltip.top="'Editar documento'"
@click="editarDoc(doc.id)"
>
<i class="pi pi-pencil" />
</button>
<div class="relative z-10 flex flex-col gap-3">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex items-center justify-center flex-shrink-0">
<i class="pi pi-comments text-xl text-[var(--text-color)]" />
</div>
<div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Central de Ajuda</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Encontre respostas para as dúvidas mais comuns</div>
</div>
</div>
<!-- Itens FAQ do grupo -->
<div class="flex flex-col">
<div
v-for="item in itensDo(doc.id)"
:key="item.id"
class="border-b border-[var(--surface-border)] last:border-b-0 transition-colors"
:class="abertos[item.id] ? 'bg-[color-mix(in_srgb,var(--primary-color)_3%,transparent)]' : ''"
>
<button
class="w-full flex items-center justify-between gap-4 px-5 py-3.5 bg-transparent border-none cursor-pointer text-left transition-colors hover:bg-[var(--surface-hover)]"
@click="toggle(item.id)"
>
<span class="text-[1rem] font-medium text-[var(--text-color)] leading-snug">{{ item.pergunta }}</span>
<i
class="pi shrink-0 opacity-40 transition-transform duration-200"
:class="abertos[item.id] ? 'pi-chevron-up' : 'pi-chevron-down'"
/>
</button>
<Transition name="faq-expand">
<div
v-if="abertos[item.id] && item.resposta"
class="px-5 pb-4 text-[1rem] text-[var(--text-color-secondary)] leading-relaxed break-words ql-content"
v-html="item.resposta"
/>
</Transition>
</div>
<!-- Busca -->
<div>
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="busca" placeholder="Buscar pergunta…" class="w-full" />
<InputIcon v-if="busca" class="pi pi-times cursor-pointer" @click="busca = ''" />
</IconField>
<div v-if="totalResultados !== null" class="text-[1rem] text-[var(--text-color-secondary)] opacity-70 mt-1 ml-1">
{{ totalResultados }} resultado{{ totalResultados !== 1 ? 's' : '' }} encontrado{{ totalResultados !== 1 ? 's' : '' }}
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-16">
<i class="pi pi-spinner pi-spin text-2xl opacity-30" />
</div>
<template v-else>
<div class="flex gap-4 items-start flex-col sm:flex-row">
<!-- Sidebar de categorias -->
<aside v-if="categorias.length" class="w-full sm:w-48 flex-shrink-0 sticky top-[calc(var(--layout-sticky-top,56px)+8rem)] flex flex-col sm:flex-col flex-row flex-wrap gap-1">
<div class="text-[1rem] font-bold uppercase tracking-wider text-[var(--text-color-secondary)] opacity-60 px-2 mb-1 hidden sm:block">Categorias</div>
<button
class="flex items-center w-full px-2.5 py-1.5 rounded-md text-[1rem] text-[var(--text-color-secondary)] bg-transparent border-none cursor-pointer text-left transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-color)]"
:class="{ 'bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] font-semibold': !catAtiva }"
@click="selecionarCat(null)"
>
<i class="pi pi-th-large mr-2 opacity-60" />
Todas
<span class="ml-auto opacity-50 text-[1rem]">{{ faqItens.length }}</span>
</button>
<button
v-for="cat in categorias"
:key="cat"
class="flex items-center w-full px-2.5 py-1.5 rounded-md text-[1rem] text-[var(--text-color-secondary)] bg-transparent border-none cursor-pointer text-left transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-color)]"
:class="{ 'bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] font-semibold': catAtiva === cat }"
@click="selecionarCat(cat)"
>
<i class="pi pi-tag mr-2 opacity-60" />
{{ cat }}
<span class="ml-auto opacity-50 text-[1rem]">
{{ faqItens.filter((f) => docs.find((d) => d.id === f.doc_id && d.categoria === cat)).length }}
</span>
</button>
</aside>
<!-- Conteúdo principal -->
<div class="flex-1 min-w-0 flex flex-col gap-4">
<!-- Sem resultados -->
<div v-if="docsComResultado.length === 0" class="flex flex-col items-center py-12 text-center">
<i class="pi pi-search text-3xl opacity-20 mb-3" />
<div class="text-[var(--text-color-secondary)] text-[1rem]">Nenhuma pergunta encontrada.</div>
<button
v-if="busca || catAtiva"
class="text-[var(--primary-color)] text-[1rem] mt-2 underline"
@click="
busca = '';
catAtiva = null;
abertos = {};
"
>
Limpar filtros
</button>
</div>
<!-- Grupos de docs -->
<div v-for="doc in docsComResultado" :key="doc.id" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<!-- Cabeçalho do grupo (doc) -->
<div class="group flex items-center gap-3 px-5 py-3.5 border-b border-[var(--surface-border)] bg-[var(--surface-ground)]">
<div class="w-8 h-8 rounded-md bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] flex items-center justify-center flex-shrink-0">
<i class="pi pi-file-edit" />
</div>
<div class="flex-1 min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)] leading-tight">{{ doc.titulo }}</div>
<div v-if="doc.categoria" class="text-[1rem] text-[var(--text-color-secondary)] opacity-60 mt-px">{{ doc.categoria }}</div>
</div>
<button
class="flex items-center justify-center w-7 h-7 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] text-[var(--text-color-secondary)] cursor-pointer flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[var(--surface-hover)] hover:text-[var(--primary-color)]"
v-tooltip.top="'Editar documento'"
@click="editarDoc(doc.id)"
>
<i class="pi pi-pencil" />
</button>
</div>
<!-- Itens FAQ do grupo -->
<div class="flex flex-col">
<div
v-for="item in itensDo(doc.id)"
:key="item.id"
class="border-b border-[var(--surface-border)] last:border-b-0 transition-colors"
:class="abertos[item.id] ? 'bg-[color-mix(in_srgb,var(--primary-color)_3%,transparent)]' : ''"
>
<button class="w-full flex items-center justify-between gap-4 px-5 py-3.5 bg-transparent border-none cursor-pointer text-left transition-colors hover:bg-[var(--surface-hover)]" @click="toggle(item.id)">
<span class="text-[1rem] font-medium text-[var(--text-color)] leading-snug">{{ item.pergunta }}</span>
<i class="pi shrink-0 opacity-40 transition-transform duration-200" :class="abertos[item.id] ? 'pi-chevron-up' : 'pi-chevron-down'" />
</button>
<Transition name="faq-expand">
<div v-if="abertos[item.id] && item.resposta" class="px-5 pb-4 text-[1rem] text-[var(--text-color-secondary)] leading-relaxed break-words ql-content" v-html="item.resposta" />
</Transition>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
/* Quill content */
.ql-content :deep(p) { margin: 0 0 0.5rem; }
.ql-content :deep(p:last-child) { margin-bottom: 0; }
.ql-content :deep(strong) { font-weight: 600; color: var(--text-color); }
.ql-content :deep(em) { font-style: italic; }
.ql-content :deep(p) {
margin: 0 0 0.5rem;
}
.ql-content :deep(p:last-child) {
margin-bottom: 0;
}
.ql-content :deep(strong) {
font-weight: 600;
color: var(--text-color);
}
.ql-content :deep(em) {
font-style: italic;
}
.ql-content :deep(ul),
.ql-content :deep(ol) { padding-left: 1.25rem; margin: 0.4rem 0; }
.ql-content :deep(li) { margin-bottom: 0.2rem; }
.ql-content :deep(a) { color: var(--primary-color); text-decoration: underline; }
.ql-content :deep(blockquote) {
border-left: 3px solid var(--surface-border);
margin: 0.5rem 0;
padding: 0.25rem 0.75rem;
font-style: italic;
.ql-content :deep(ol) {
padding-left: 1.25rem;
margin: 0.4rem 0;
}
.ql-content :deep(li) {
margin-bottom: 0.2rem;
}
.ql-content :deep(a) {
color: var(--primary-color);
text-decoration: underline;
}
.ql-content :deep(blockquote) {
border-left: 3px solid var(--surface-border);
margin: 0.5rem 0;
padding: 0.25rem 0.75rem;
font-style: italic;
}
/* Animação expand */
.faq-expand-enter-active,
.faq-expand-leave-active {
transition: opacity 0.2s ease, max-height 0.25s ease;
max-height: 800px;
overflow: hidden;
transition:
opacity 0.2s ease,
max-height 0.25s ease;
max-height: 800px;
overflow: hidden;
}
.faq-expand-enter-from,
.faq-expand-leave-to {
opacity: 0;
max-height: 0;
opacity: 0;
max-height: 0;
}
</style>
</style>
+311 -356
View File
@@ -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>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+403 -477
View File
@@ -15,410 +15,361 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Editor from 'primevue/editor'
import { ref, computed, onMounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import Editor from 'primevue/editor';
const toast = useToast()
const confirm = useConfirm()
const toast = useToast();
const confirm = useConfirm();
// ─── Estado ───────────────────────────────────────────────────────────────────
const slides = ref([])
const loading = ref(false)
const saving = ref(false)
const previewIdx = ref(0)
const slides = ref([]);
const loading = ref(false);
const saving = ref(false);
const previewIdx = ref(0);
const dialogOpen = ref(false)
const editingSlide = ref(null) // null = novo
const dialogOpen = ref(false);
const editingSlide = ref(null); // null = novo
const form = ref({ title: '', body: '', icon: '', ordem: 0, ativo: true })
const form = ref({ title: '', body: '', icon: '', ordem: 0, ativo: true });
// ─── Ícones disponíveis (subset PrimeIcons relevantes) ────────────────────────
const ICONS = [
{ value: 'pi-calendar-clock', label: 'Agenda' },
{ value: 'pi-users', label: 'Equipe' },
{ value: 'pi-globe', label: 'Online' },
{ value: 'pi-shield', label: 'Segurança' },
{ value: 'pi-heart-fill', label: 'Saúde' },
{ value: 'pi-chart-line', label: 'Estatísticas' },
{ value: 'pi-bell', label: 'Notificações' },
{ value: 'pi-lock', label: 'Privacidade' },
{ value: 'pi-mobile', label: 'Mobile' },
{ value: 'pi-sync', label: 'Sincronização' },
{ value: 'pi-star', label: 'Destaque' },
{ value: 'pi-check-circle', label: 'Aprovação' },
{ value: 'pi-comments', label: 'Comunicação' },
{ value: 'pi-file-edit', label: 'Prontuário' },
{ value: 'pi-briefcase', label: 'Profissional' },
{ value: 'pi-bolt', label: 'Performance' },
]
{ value: 'pi-calendar-clock', label: 'Agenda' },
{ value: 'pi-users', label: 'Equipe' },
{ value: 'pi-globe', label: 'Online' },
{ value: 'pi-shield', label: 'Segurança' },
{ value: 'pi-heart-fill', label: 'Saúde' },
{ value: 'pi-chart-line', label: 'Estatísticas' },
{ value: 'pi-bell', label: 'Notificações' },
{ value: 'pi-lock', label: 'Privacidade' },
{ value: 'pi-mobile', label: 'Mobile' },
{ value: 'pi-sync', label: 'Sincronização' },
{ value: 'pi-star', label: 'Destaque' },
{ value: 'pi-check-circle', label: 'Aprovação' },
{ value: 'pi-comments', label: 'Comunicação' },
{ value: 'pi-file-edit', label: 'Prontuário' },
{ value: 'pi-briefcase', label: 'Profissional' },
{ value: 'pi-bolt', label: 'Performance' }
];
// ─── Computed ─────────────────────────────────────────────────────────────────
const slidesAtivos = computed(() => slides.value.filter(s => s.ativo).sort((a, b) => a.ordem - b.ordem))
const previewSlide = computed(() => slidesAtivos.value[previewIdx.value] ?? slidesAtivos.value[0] ?? null)
const slidesAtivos = computed(() => slides.value.filter((s) => s.ativo).sort((a, b) => a.ordem - b.ordem));
const previewSlide = computed(() => slidesAtivos.value[previewIdx.value] ?? slidesAtivos.value[0] ?? null);
// ─── Supabase ─────────────────────────────────────────────────────────────────
async function load () {
loading.value = true
try {
const { data, error } = await supabase
.from('login_carousel_slides')
.select('*')
.order('ordem', { ascending: true })
if (error) throw error
slides.value = data || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar slides.', life: 4000 })
} finally {
loading.value = false
}
}
function stripHtml (s) {
return String(s || '').replace(/<[^>]+>/g, '').trim()
}
async function saveSlide () {
if (!stripHtml(form.value.title) || !stripHtml(form.value.body)) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Título e conteúdo são obrigatórios.', life: 3000 })
return
}
saving.value = true
try {
const payload = {
title: form.value.title,
body: form.value.body,
icon: form.value.icon || 'pi-star',
ordem: form.value.ordem,
ativo: form.value.ativo,
async function load() {
loading.value = true;
try {
const { data, error } = await supabase.from('login_carousel_slides').select('*').order('ordem', { ascending: true });
if (error) throw error;
slides.value = data || [];
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar slides.', life: 4000 });
} finally {
loading.value = false;
}
if (editingSlide.value) {
const { error } = await supabase
.from('login_carousel_slides')
.update(payload)
.eq('id', editingSlide.value.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Slide atualizado com sucesso.', life: 3000 })
} else {
const maxOrdem = slides.value.length ? Math.max(...slides.value.map(s => s.ordem)) + 1 : 0
payload.ordem = maxOrdem
const { error } = await supabase
.from('login_carousel_slides')
.insert(payload)
if (error) throw error
toast.add({ severity: 'success', summary: 'Criado', detail: 'Slide adicionado com sucesso.', life: 3000 })
}
function stripHtml(s) {
return String(s || '')
.replace(/<[^>]+>/g, '')
.trim();
}
async function saveSlide() {
if (!stripHtml(form.value.title) || !stripHtml(form.value.body)) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Título e conteúdo são obrigatórios.', life: 3000 });
return;
}
saving.value = true;
try {
const payload = {
title: form.value.title,
body: form.value.body,
icon: form.value.icon || 'pi-star',
ordem: form.value.ordem,
ativo: form.value.ativo
};
if (editingSlide.value) {
const { error } = await supabase.from('login_carousel_slides').update(payload).eq('id', editingSlide.value.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Slide atualizado com sucesso.', life: 3000 });
} else {
const maxOrdem = slides.value.length ? Math.max(...slides.value.map((s) => s.ordem)) + 1 : 0;
payload.ordem = maxOrdem;
const { error } = await supabase.from('login_carousel_slides').insert(payload);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Criado', detail: 'Slide adicionado com sucesso.', life: 3000 });
}
dialogOpen.value = false;
await load();
previewIdx.value = 0;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar.', life: 4000 });
} finally {
saving.value = false;
}
dialogOpen.value = false
await load()
previewIdx.value = 0
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar.', life: 4000 })
} finally {
saving.value = false
}
}
async function toggleAtivo (slide) {
try {
const { error } = await supabase
.from('login_carousel_slides')
.update({ ativo: !slide.ativo })
.eq('id', slide.id)
if (error) throw error
slide.ativo = !slide.ativo
toast.add({ severity: 'info', summary: slide.ativo ? 'Ativado' : 'Desativado', detail: `"${slide.title}"`, life: 2500 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
}
async function toggleAtivo(slide) {
try {
const { error } = await supabase.from('login_carousel_slides').update({ ativo: !slide.ativo }).eq('id', slide.id);
if (error) throw error;
slide.ativo = !slide.ativo;
toast.add({ severity: 'info', summary: slide.ativo ? 'Ativado' : 'Desativado', detail: `"${slide.title}"`, life: 2500 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 });
}
}
async function deleteSlide (slide) {
confirm.require({
message: `Remover o slide "${slide.title}"? Esta ação não pode ser desfeita.`,
header: 'Confirmar remoção',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Remover',
rejectLabel: 'Cancelar',
accept: async () => {
try {
const { error } = await supabase.from('login_carousel_slides').delete().eq('id', slide.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Removido', detail: `Slide removido.`, life: 2500 })
await load()
previewIdx.value = 0
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
}
},
})
async function deleteSlide(slide) {
confirm.require({
message: `Remover o slide "${slide.title}"? Esta ação não pode ser desfeita.`,
header: 'Confirmar remoção',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Remover',
rejectLabel: 'Cancelar',
accept: async () => {
try {
const { error } = await supabase.from('login_carousel_slides').delete().eq('id', slide.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Removido', detail: `Slide removido.`, life: 2500 });
await load();
previewIdx.value = 0;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 });
}
}
});
}
async function moveSlide (slide, dir) {
const sorted = [...slides.value].sort((a, b) => a.ordem - b.ordem)
const idx = sorted.findIndex(s => s.id === slide.id)
const swapIdx = idx + dir
if (swapIdx < 0 || swapIdx >= sorted.length) return
async function moveSlide(slide, dir) {
const sorted = [...slides.value].sort((a, b) => a.ordem - b.ordem);
const idx = sorted.findIndex((s) => s.id === slide.id);
const swapIdx = idx + dir;
if (swapIdx < 0 || swapIdx >= sorted.length) return;
const a = sorted[idx]
const b = sorted[swapIdx]
const tempOrdem = a.ordem
const a = sorted[idx];
const b = sorted[swapIdx];
const tempOrdem = a.ordem;
try {
await supabase.from('login_carousel_slides').update({ ordem: b.ordem }).eq('id', a.id)
await supabase.from('login_carousel_slides').update({ ordem: tempOrdem }).eq('id', b.id)
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
}
try {
await supabase.from('login_carousel_slides').update({ ordem: b.ordem }).eq('id', a.id);
await supabase.from('login_carousel_slides').update({ ordem: tempOrdem }).eq('id', b.id);
await load();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 });
}
}
// ─── Dialog helpers ───────────────────────────────────────────────────────────
function openNew () {
editingSlide.value = null
form.value = { title: '', body: '', icon: 'pi-calendar-clock', ordem: 0, ativo: true }
dialogOpen.value = true
function openNew() {
editingSlide.value = null;
form.value = { title: '', body: '', icon: 'pi-calendar-clock', ordem: 0, ativo: true };
dialogOpen.value = true;
}
function openEdit (slide) {
editingSlide.value = slide
form.value = { title: slide.title, body: slide.body, icon: slide.icon || 'pi-star', ordem: slide.ordem, ativo: slide.ativo }
dialogOpen.value = true
function openEdit(slide) {
editingSlide.value = slide;
form.value = { title: slide.title, body: slide.body, icon: slide.icon || 'pi-star', ordem: slide.ordem, ativo: slide.ativo };
dialogOpen.value = true;
}
onMounted(load)
onMounted(load);
</script>
<template>
<ConfirmDialog />
<ConfirmDialog />
<!-- Sentinel -->
<div class="h-px" />
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div
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-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-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)] flex items-center gap-2">
<i class="pi pi-images text-indigo-500" />
Carrossel do Login
<!-- Hero sticky -->
<div 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-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">
Gerencie os slides exibidos na tela de login do sistema
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button
icon="pi pi-refresh"
severity="secondary"
outlined
title="Recarregar"
:loading="loading"
@click="load"
/>
<Button
icon="pi pi-plus"
label="Novo slide"
@click="openNew"
/>
</div>
</div>
</div>
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<div class="grid grid-cols-1 xl:grid-cols-[1fr_340px] gap-6 items-start">
<!-- Tabela de slides -->
<div class="bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border)] overflow-hidden">
<!-- Loading skeleton -->
<div v-if="loading" class="flex flex-col divide-y divide-[var(--surface-border)]">
<div v-for="i in 4" :key="i" class="flex items-center gap-4 px-5 py-4 animate-pulse">
<div class="w-10 h-10 rounded-md bg-[var(--surface-ground)] flex-shrink-0" />
<div class="flex-1 space-y-2">
<div class="h-3.5 w-40 rounded bg-[var(--surface-ground)]" />
<div class="h-3 w-64 rounded bg-[var(--surface-ground)]" />
</div>
</div>
</div>
<!-- Lista vazia -->
<div v-else-if="!slides.length" class="flex flex-col items-center justify-center py-16 gap-3 text-[var(--text-color-secondary)]">
<i class="pi pi-images text-4xl opacity-30" />
<span class="text-[1rem]">Nenhum slide cadastrado ainda.</span>
<Button label="Criar primeiro slide" size="small" @click="openNew" />
</div>
<!-- Rows -->
<div v-else class="divide-y divide-[var(--surface-border)]">
<!-- Header -->
<div class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-4 px-5 py-2.5 bg-[var(--surface-ground)] text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)]">
<span class="w-10" />
<span>Slide</span>
<span class="text-center w-[60px]">Status</span>
<span class="w-[96px]" />
</div>
<div
v-for="(slide, i) in [...slides].sort((a,b) => a.ordem - b.ordem)"
:key="slide.id"
class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-4 px-5 py-3.5 transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)] group"
>
<!-- Ícone + ordem -->
<div class="relative flex-shrink-0">
<div
class="w-10 h-10 rounded-md flex items-center justify-center text-lg"
:class="slide.ativo ? 'bg-indigo-500/10 text-indigo-500' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'"
>
<i :class="['pi', slide.icon || 'pi-star']" />
</div>
<span class="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-[var(--surface-border)] text-[0.58rem] font-bold flex items-center justify-center text-[var(--text-color-secondary)]">
{{ slide.ordem + 1 }}
</span>
</div>
<!-- Conteúdo -->
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)] truncate [&_*]:inline" :class="!slide.ativo && 'opacity-40 line-through'" v-html="slide.title" />
<div class="text-[1rem] text-[var(--text-color-secondary)] truncate mt-0.5 [&_*]:inline" :class="!slide.ativo && 'opacity-40'" v-html="slide.body" />
</div>
<!-- Toggle ativo -->
<div class="flex justify-center w-[60px]">
<InputSwitch
:modelValue="slide.ativo"
@update:modelValue="() => toggleAtivo(slide)"
/>
</div>
<!-- Ações -->
<div class="flex items-center gap-1 w-[96px] justify-end opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--text-color)] transition-colors duration-100 disabled:opacity-30"
:disabled="i === 0"
title="Mover para cima"
@click="moveSlide(slide, -1)"
>
<i class="pi pi-chevron-up text-xs" />
</button>
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--text-color)] transition-colors duration-100 disabled:opacity-30"
:disabled="i === slides.length - 1"
title="Mover para baixo"
@click="moveSlide(slide, 1)"
>
<i class="pi pi-chevron-down text-xs" />
</button>
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-indigo-50 hover:text-indigo-600 transition-colors duration-100"
title="Editar"
@click="openEdit(slide)"
>
<i class="pi pi-pencil text-xs" />
</button>
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-red-50 hover:text-red-500 transition-colors duration-100"
title="Remover"
@click="deleteSlide(slide)"
>
<i class="pi pi-trash text-xs" />
</button>
</div>
</div>
</div>
</div>
<!-- Preview -->
<div class="sticky top-6 flex flex-col gap-3">
<div class="text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)] flex items-center gap-1.5 px-1">
<i class="pi pi-eye" /> Pré-visualização
</div>
<!-- Mock da tela de login lado esquerdo -->
<div class="relative overflow-hidden rounded-md aspect-[9/16] max-h-[480px] w-full select-none shadow-xl">
<div class="absolute inset-0 bg-gradient-to-br from-indigo-600 via-violet-600 to-purple-700" />
<!-- Grade decorativa -->
<div
class="absolute inset-0 opacity-[0.08]"
style="background-image: linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px); background-size: 32px 32px;"
/>
<!-- Orbs -->
<div class="absolute -top-20 -left-20 h-64 w-64 rounded-full bg-white/10 blur-3xl pointer-events-none" />
<div class="absolute bottom-0 right-0 h-48 w-48 rounded-full bg-violet-300/20 blur-3xl pointer-events-none" />
<div class="relative z-10 flex flex-col h-full p-6">
<!-- Brand mock -->
<div class="flex items-center gap-2">
<div class="grid h-7 w-7 place-items-center rounded-lg bg-white/20 border border-white/20">
<i class="pi pi-heart-fill text-white text-[0.6rem]" />
</div>
<span class="text-white/90 font-bold text-xs tracking-tight">Agência PSI</span>
</div>
<!-- Slide content -->
<div class="flex-1 flex flex-col justify-center gap-4">
<Transition name="prev-fade" mode="out-in">
<div v-if="previewSlide" :key="previewSlide.id ?? previewIdx" class="space-y-4">
<div class="grid h-11 w-11 place-items-center rounded-md bg-white/15 border border-white/20 shadow-lg">
<i :class="['pi', previewSlide.icon || 'pi-star', 'text-white text-lg']" />
</div>
<div class="space-y-2">
<div class="text-xl font-bold text-white leading-tight prose prose-invert prose-sm max-w-none" v-html="previewSlide.title" />
<div class="text-[1rem] text-white/70 leading-relaxed prose prose-invert prose-sm max-w-none" v-html="previewSlide.body" />
</div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] flex items-center gap-2">
<i class="pi pi-images text-indigo-500" />
Carrossel do Login
</div>
<div v-else class="flex flex-col items-center justify-center gap-2 text-white/30 text-xs">
<i class="pi pi-ban text-2xl" />
Nenhum slide ativo
</div>
</Transition>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Gerencie os slides exibidos na tela de login do sistema</div>
</div>
<!-- Dots -->
<div class="flex items-center gap-1.5">
<button
v-for="(s, i) in slidesAtivos"
:key="s.id"
class="transition-all duration-300 rounded-full"
:class="i === previewIdx ? 'w-5 h-1.5 bg-white shadow' : 'w-1.5 h-1.5 bg-white/35 hover:bg-white/60'"
@click="previewIdx = i"
/>
<span v-if="slidesAtivos.length" class="ml-2 text-[0.6rem] text-white/40 tabular-nums">
{{ previewIdx + 1 }}/{{ slidesAtivos.length }}
</span>
<div class="flex items-center gap-2 shrink-0">
<Button icon="pi pi-refresh" severity="secondary" outlined title="Recarregar" :loading="loading" @click="load" />
<Button icon="pi pi-plus" label="Novo slide" @click="openNew" />
</div>
</div>
</div>
<!-- Info -->
<div class="rounded-lg border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3 text-xs text-[var(--text-color-secondary)] flex items-start gap-2">
<i class="pi pi-info-circle text-indigo-500 mt-px flex-shrink-0" />
<span>Clique nos pontos para navegar entre os slides ativos. A ordem e visibilidade refletem o que o usuário verá no login.</span>
</div>
</div>
</div>
<!-- SQL Helper -->
<div class="bg-[var(--surface-card)] rounded-md border border-[var(--surface-border)] px-5 py-4">
<div class="flex items-center gap-2 mb-3">
<i class="pi pi-database text-amber-500 text-[1rem]" />
<span class="text-xs font-bold text-[var(--text-color)] uppercase tracking-widest">SQL de referência</span>
<span class="ml-auto text-xs text-[var(--text-color-secondary)]">Execute no Supabase caso a tabela não exista</span>
</div>
<pre class="text-[0.7rem] bg-[var(--surface-ground)] rounded-lg p-3.5 overflow-x-auto text-[var(--text-color-secondary)] leading-relaxed whitespace-pre-wrap"><code>create table if not exists public.login_carousel_slides (
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<div class="grid grid-cols-1 xl:grid-cols-[1fr_340px] gap-6 items-start">
<!-- Tabela de slides -->
<div class="bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border)] overflow-hidden">
<!-- Loading skeleton -->
<div v-if="loading" class="flex flex-col divide-y divide-[var(--surface-border)]">
<div v-for="i in 4" :key="i" class="flex items-center gap-4 px-5 py-4 animate-pulse">
<div class="w-10 h-10 rounded-md bg-[var(--surface-ground)] flex-shrink-0" />
<div class="flex-1 space-y-2">
<div class="h-3.5 w-40 rounded bg-[var(--surface-ground)]" />
<div class="h-3 w-64 rounded bg-[var(--surface-ground)]" />
</div>
</div>
</div>
<!-- Lista vazia -->
<div v-else-if="!slides.length" class="flex flex-col items-center justify-center py-16 gap-3 text-[var(--text-color-secondary)]">
<i class="pi pi-images text-4xl opacity-30" />
<span class="text-[1rem]">Nenhum slide cadastrado ainda.</span>
<Button label="Criar primeiro slide" size="small" @click="openNew" />
</div>
<!-- Rows -->
<div v-else class="divide-y divide-[var(--surface-border)]">
<!-- Header -->
<div class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-4 px-5 py-2.5 bg-[var(--surface-ground)] text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)]">
<span class="w-10" />
<span>Slide</span>
<span class="text-center w-[60px]">Status</span>
<span class="w-[96px]" />
</div>
<div
v-for="(slide, i) in [...slides].sort((a, b) => a.ordem - b.ordem)"
:key="slide.id"
class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-4 px-5 py-3.5 transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)] group"
>
<!-- Ícone + ordem -->
<div class="relative flex-shrink-0">
<div class="w-10 h-10 rounded-md flex items-center justify-center text-lg" :class="slide.ativo ? 'bg-indigo-500/10 text-indigo-500' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'">
<i :class="['pi', slide.icon || 'pi-star']" />
</div>
<span class="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-[var(--surface-border)] text-[0.58rem] font-bold flex items-center justify-center text-[var(--text-color-secondary)]">
{{ slide.ordem + 1 }}
</span>
</div>
<!-- Conteúdo -->
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)] truncate [&_*]:inline" :class="!slide.ativo && 'opacity-40 line-through'" v-html="slide.title" />
<div class="text-[1rem] text-[var(--text-color-secondary)] truncate mt-0.5 [&_*]:inline" :class="!slide.ativo && 'opacity-40'" v-html="slide.body" />
</div>
<!-- Toggle ativo -->
<div class="flex justify-center w-[60px]">
<InputSwitch :modelValue="slide.ativo" @update:modelValue="() => toggleAtivo(slide)" />
</div>
<!-- Ações -->
<div class="flex items-center gap-1 w-[96px] justify-end opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--text-color)] transition-colors duration-100 disabled:opacity-30"
:disabled="i === 0"
title="Mover para cima"
@click="moveSlide(slide, -1)"
>
<i class="pi pi-chevron-up text-xs" />
</button>
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--text-color)] transition-colors duration-100 disabled:opacity-30"
:disabled="i === slides.length - 1"
title="Mover para baixo"
@click="moveSlide(slide, 1)"
>
<i class="pi pi-chevron-down text-xs" />
</button>
<button class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-indigo-50 hover:text-indigo-600 transition-colors duration-100" title="Editar" @click="openEdit(slide)">
<i class="pi pi-pencil text-xs" />
</button>
<button class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-red-50 hover:text-red-500 transition-colors duration-100" title="Remover" @click="deleteSlide(slide)">
<i class="pi pi-trash text-xs" />
</button>
</div>
</div>
</div>
</div>
<!-- Preview -->
<div class="sticky top-6 flex flex-col gap-3">
<div class="text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)] flex items-center gap-1.5 px-1"><i class="pi pi-eye" /> Pré-visualização</div>
<!-- Mock da tela de login lado esquerdo -->
<div class="relative overflow-hidden rounded-md aspect-[9/16] max-h-[480px] w-full select-none shadow-xl">
<div class="absolute inset-0 bg-gradient-to-br from-indigo-600 via-violet-600 to-purple-700" />
<!-- Grade decorativa -->
<div class="absolute inset-0 opacity-[0.08]" style="background-image: linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px); background-size: 32px 32px" />
<!-- Orbs -->
<div class="absolute -top-20 -left-20 h-64 w-64 rounded-full bg-white/10 blur-3xl pointer-events-none" />
<div class="absolute bottom-0 right-0 h-48 w-48 rounded-full bg-violet-300/20 blur-3xl pointer-events-none" />
<div class="relative z-10 flex flex-col h-full p-6">
<!-- Brand mock -->
<div class="flex items-center gap-2">
<div class="grid h-7 w-7 place-items-center rounded-lg bg-white/20 border border-white/20">
<i class="pi pi-heart-fill text-white text-[0.6rem]" />
</div>
<span class="text-white/90 font-bold text-xs tracking-tight">Agência PSI</span>
</div>
<!-- Slide content -->
<div class="flex-1 flex flex-col justify-center gap-4">
<Transition name="prev-fade" mode="out-in">
<div v-if="previewSlide" :key="previewSlide.id ?? previewIdx" class="space-y-4">
<div class="grid h-11 w-11 place-items-center rounded-md bg-white/15 border border-white/20 shadow-lg">
<i :class="['pi', previewSlide.icon || 'pi-star', 'text-white text-lg']" />
</div>
<div class="space-y-2">
<div class="text-xl font-bold text-white leading-tight prose prose-invert prose-sm max-w-none" v-html="previewSlide.title" />
<div class="text-[1rem] text-white/70 leading-relaxed prose prose-invert prose-sm max-w-none" v-html="previewSlide.body" />
</div>
</div>
<div v-else class="flex flex-col items-center justify-center gap-2 text-white/30 text-xs">
<i class="pi pi-ban text-2xl" />
Nenhum slide ativo
</div>
</Transition>
</div>
<!-- Dots -->
<div class="flex items-center gap-1.5">
<button
v-for="(s, i) in slidesAtivos"
:key="s.id"
class="transition-all duration-300 rounded-full"
:class="i === previewIdx ? 'w-5 h-1.5 bg-white shadow' : 'w-1.5 h-1.5 bg-white/35 hover:bg-white/60'"
@click="previewIdx = i"
/>
<span v-if="slidesAtivos.length" class="ml-2 text-[0.6rem] text-white/40 tabular-nums"> {{ previewIdx + 1 }}/{{ slidesAtivos.length }} </span>
</div>
</div>
</div>
<!-- Info -->
<div class="rounded-lg border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3 text-xs text-[var(--text-color-secondary)] flex items-start gap-2">
<i class="pi pi-info-circle text-indigo-500 mt-px flex-shrink-0" />
<span>Clique nos pontos para navegar entre os slides ativos. A ordem e visibilidade refletem o que o usuário verá no login.</span>
</div>
</div>
</div>
<!-- SQL Helper -->
<div class="bg-[var(--surface-card)] rounded-md border border-[var(--surface-border)] px-5 py-4">
<div class="flex items-center gap-2 mb-3">
<i class="pi pi-database text-amber-500 text-[1rem]" />
<span class="text-xs font-bold text-[var(--text-color)] uppercase tracking-widest">SQL de referência</span>
<span class="ml-auto text-xs text-[var(--text-color-secondary)]">Execute no Supabase caso a tabela não exista</span>
</div>
<pre class="text-[0.7rem] bg-[var(--surface-ground)] rounded-lg p-3.5 overflow-x-auto text-[var(--text-color-secondary)] leading-relaxed whitespace-pre-wrap"><code>create table if not exists public.login_carousel_slides (
id uuid primary key default gen_random_uuid(),
title text not null,
body text not null,
@@ -443,137 +394,112 @@ create policy "saas_admin_full" on public.login_carousel_slides
-- Leitura pública (login não tem usuário autenticado)
create policy "public_read" on public.login_carousel_slides
for select using (ativo = true);</code></pre>
</div>
</div>
</div>
<!-- /px-3 content wrapper -->
<!-- /px-3 content wrapper -->
<!-- Dialog: Criar / Editar slide -->
<Dialog v-model:visible="dialogOpen" modal :header="editingSlide ? 'Editar slide' : 'Novo slide'" :draggable="false" :style="{ width: '46rem', maxWidth: '96vw' }">
<div class="flex flex-col gap-4 pt-1">
<!-- Título -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Título <span class="text-red-500">*</span></label>
<Editor v-model="form.title" :pt="{ toolbar: { style: 'display:none' } }" style="height: 72px" editorStyle="font-size: 1rem; font-weight: 600;" placeholder="Ex: Gestão clínica simplificada">
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" />
<button class="ql-italic" />
<button class="ql-underline" />
</span>
</template>
</Editor>
</div>
<!-- Dialog: Criar / Editar slide -->
<Dialog
v-model:visible="dialogOpen"
modal
:header="editingSlide ? 'Editar slide' : 'Novo slide'"
:draggable="false"
:style="{ width: '46rem', maxWidth: '96vw' }"
>
<div class="flex flex-col gap-4 pt-1">
<!-- Conteúdo -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Conteúdo <span class="text-red-500">*</span></label>
<Editor v-model="form.body" style="height: 160px" editorStyle="font-size: 1rem;">
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" />
<button class="ql-italic" />
<button class="ql-underline" />
</span>
<span class="ql-formats">
<button class="ql-list" value="ordered" />
<button class="ql-list" value="bullet" />
</span>
<span class="ql-formats">
<button class="ql-link" />
<button class="ql-clean" />
</span>
</template>
</Editor>
</div>
<!-- Título -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Título <span class="text-red-500">*</span></label>
<Editor
v-model="form.title"
:pt="{ toolbar: { style: 'display:none' } }"
style="height: 72px"
editorStyle="font-size: 1rem; font-weight: 600;"
placeholder="Ex: Gestão clínica simplificada"
>
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" />
<button class="ql-italic" />
<button class="ql-underline" />
</span>
</template>
</Editor>
</div>
<!-- Ícone -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Ícone</label>
<div class="grid grid-cols-4 sm:grid-cols-8 gap-1.5">
<button
v-for="ic in ICONS"
:key="ic.value"
type="button"
class="flex flex-col items-center justify-center gap-1 py-2 rounded-lg border text-xs transition-all duration-100"
:class="
form.icon === ic.value
? 'border-indigo-500 bg-indigo-50 text-indigo-600 shadow-sm'
: 'border-[var(--surface-border)] bg-[var(--surface-ground)] text-[var(--text-color-secondary)] hover:border-indigo-300 hover:text-indigo-500'
"
:title="ic.label"
@click="form.icon = ic.value"
>
<i :class="['pi', ic.value, 'text-base']" />
<span class="text-[0.6rem] leading-none">{{ ic.label }}</span>
</button>
</div>
</div>
<!-- Conteúdo -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Conteúdo <span class="text-red-500">*</span></label>
<Editor
v-model="form.body"
style="height: 160px"
editorStyle="font-size: 1rem;"
>
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" />
<button class="ql-italic" />
<button class="ql-underline" />
</span>
<span class="ql-formats">
<button class="ql-list" value="ordered" />
<button class="ql-list" value="bullet" />
</span>
<span class="ql-formats">
<button class="ql-link" />
<button class="ql-clean" />
</span>
</template>
</Editor>
</div>
<!-- Ativo -->
<div class="flex items-center gap-3">
<InputSwitch v-model="form.ativo" inputId="slide-ativo" />
<label for="slide-ativo" class="text-[1rem] text-[var(--text-color)] cursor-pointer select-none"> Slide ativo (visível no carrossel) </label>
</div>
<!-- Ícone -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Ícone</label>
<div class="grid grid-cols-4 sm:grid-cols-8 gap-1.5">
<button
v-for="ic in ICONS"
:key="ic.value"
type="button"
class="flex flex-col items-center justify-center gap-1 py-2 rounded-lg border text-xs transition-all duration-100"
:class="form.icon === ic.value
? 'border-indigo-500 bg-indigo-50 text-indigo-600 shadow-sm'
: 'border-[var(--surface-border)] bg-[var(--surface-ground)] text-[var(--text-color-secondary)] hover:border-indigo-300 hover:text-indigo-500'"
:title="ic.label"
@click="form.icon = ic.value"
>
<i :class="['pi', ic.value, 'text-base']" />
<span class="text-[0.6rem] leading-none">{{ ic.label }}</span>
</button>
<!-- Mini preview -->
<div class="relative overflow-hidden rounded-md p-5 flex items-center gap-4" style="background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)">
<div class="grid h-12 w-12 flex-shrink-0 place-items-center rounded-md bg-white/15 border border-white/20 shadow">
<i :class="['pi', form.icon || 'pi-star', 'text-white text-xl']" />
</div>
<div class="min-w-0 overflow-hidden">
<div class="text-[1rem] font-bold text-white line-clamp-2 prose prose-invert prose-sm max-w-none" v-html="stripHtml(form.title) ? form.title : 'Título do slide'" />
<div class="text-[1rem] text-white/70 mt-0.5 line-clamp-3 prose prose-invert prose-sm max-w-none" v-html="stripHtml(form.body) ? form.body : 'Conteúdo descritivo...'" />
</div>
</div>
<!-- Ações -->
<div class="flex justify-end gap-2 pt-1">
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="dialogOpen = false" />
<Button :label="editingSlide ? 'Salvar alterações' : 'Criar slide'" icon="pi pi-check" :loading="saving" @click="saveSlide" />
</div>
</div>
</div>
<!-- Ativo -->
<div class="flex items-center gap-3">
<InputSwitch v-model="form.ativo" inputId="slide-ativo" />
<label for="slide-ativo" class="text-[1rem] text-[var(--text-color)] cursor-pointer select-none">
Slide ativo (visível no carrossel)
</label>
</div>
<!-- Mini preview -->
<div
class="relative overflow-hidden rounded-md p-5 flex items-center gap-4"
style="background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)"
>
<div class="grid h-12 w-12 flex-shrink-0 place-items-center rounded-md bg-white/15 border border-white/20 shadow">
<i :class="['pi', form.icon || 'pi-star', 'text-white text-xl']" />
</div>
<div class="min-w-0 overflow-hidden">
<div class="text-[1rem] font-bold text-white line-clamp-2 prose prose-invert prose-sm max-w-none" v-html="stripHtml(form.title) ? form.title : 'Título do slide'" />
<div class="text-[1rem] text-white/70 mt-0.5 line-clamp-3 prose prose-invert prose-sm max-w-none" v-html="stripHtml(form.body) ? form.body : 'Conteúdo descritivo...'" />
</div>
</div>
<!-- Ações -->
<div class="flex justify-end gap-2 pt-1">
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="dialogOpen = false" />
<Button
:label="editingSlide ? 'Salvar alterações' : 'Criar slide'"
icon="pi pi-check"
:loading="saving"
@click="saveSlide"
/>
</div>
</div>
</Dialog>
</Dialog>
</template>
<style scoped>
.prev-fade-enter-active,
.prev-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.prev-fade-enter-from {
opacity: 0;
transform: translateY(12px);
opacity: 0;
transform: translateY(12px);
}
.prev-fade-leave-to {
opacity: 0;
transform: translateY(-8px);
opacity: 0;
transform: translateY(-8px);
}
</style>
@@ -0,0 +1,443 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/views/pages/saas/SaasNotificationTemplatesPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
const toast = useToast();
const confirm = useConfirm();
// ── Constantes ──────────────────────────────────────────────────
const CHANNELS = [
{ label: 'WhatsApp', value: 'whatsapp', icon: 'pi pi-whatsapp' },
{ label: 'SMS', value: 'sms', icon: 'pi pi-mobile' }
];
const DOMAIN_OPTIONS = [
{ label: 'Sessão', value: 'session' },
{ label: 'Triagem', value: 'intake' },
{ label: 'Financeiro', value: 'billing' },
{ label: 'Sistema', value: 'system' }
];
const EVENT_TYPE_OPTIONS = [
{ label: 'Lembrete de sessão', value: 'lembrete_sessao' },
{ label: 'Confirmação de sessão', value: 'confirmacao_sessao' },
{ label: 'Cancelamento de sessão', value: 'cancelamento_sessao' },
{ label: 'Reagendamento', value: 'reagendamento' },
{ label: 'Cobrança pendente', value: 'cobranca_pendente' },
{ label: 'Boas-vindas paciente', value: 'boas_vindas_paciente' },
{ label: 'Intake recebido', value: 'intake_recebido' },
{ label: 'Intake aprovado', value: 'intake_aprovado' },
{ label: 'Intake rejeitado', value: 'intake_rejeitado' }
];
const EVENT_TYPE_LABELS = Object.fromEntries(EVENT_TYPE_OPTIONS.map((e) => [e.value, e.label]));
const DOMAIN_LABELS = { session: 'Sessão', intake: 'Triagem', billing: 'Financeiro', system: 'Sistema' };
const DOMAIN_SEVERITY = { session: 'info', intake: 'warning', billing: 'success', system: 'secondary' };
const VARS_BY_EVENT = {
lembrete_sessao: ['patient_name', 'session_date', 'session_time', 'therapist_name', 'session_modality', 'session_link'],
confirmacao_sessao: ['patient_name', 'session_date', 'session_time', 'therapist_name', 'session_modality'],
cancelamento_sessao: ['patient_name', 'session_date', 'session_time', 'therapist_name', 'cancellation_reason'],
reagendamento: ['patient_name', 'session_date', 'session_time', 'therapist_name', 'session_modality'],
cobranca_pendente: ['patient_name', 'therapist_name', 'valor', 'vencimento'],
boas_vindas_paciente: ['patient_name', 'clinic_name', 'therapist_name', 'portal_link'],
intake_recebido: ['patient_name', 'clinic_name', 'therapist_name'],
intake_aprovado: ['patient_name', 'therapist_name', 'session_date', 'session_time'],
intake_rejeitado: ['patient_name', 'therapist_name', 'rejection_reason']
};
// ── Estado ──────────────────────────────────────────────────────
const activeChannel = ref('whatsapp');
const templates = ref([]);
const loading = ref(false);
// ── Load ────────────────────────────────────────────────────────
async function load() {
loading.value = true;
try {
const { data, error } = await supabase.from('notification_templates').select('*').is('tenant_id', null).eq('is_default', true).is('deleted_at', null).order('domain').order('event_type');
if (error) throw error;
templates.value = data || [];
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message, life: 4000 });
} finally {
loading.value = false;
}
}
const filtered = computed(() => templates.value.filter((t) => t.channel === activeChannel.value));
// ── Dialog ──────────────────────────────────────────────────────
const dlg = ref({ open: false, saving: false, id: null, isNew: false });
const form = ref({});
const bodyTextareaRef = ref(null);
function _emptyForm() {
return {
key: '',
domain: 'session',
channel: activeChannel.value,
event_type: 'lembrete_sessao',
body_text: '',
is_active: true
};
}
function openNew() {
form.value = _emptyForm();
dlg.value = { open: true, saving: false, id: null, isNew: true };
}
function openEdit(t) {
form.value = {
key: t.key,
domain: t.domain,
channel: t.channel,
event_type: t.event_type,
body_text: t.body_text,
is_active: t.is_active
};
dlg.value = { open: true, saving: false, id: t.id, isNew: false };
}
function closeDlg() {
dlg.value.open = false;
}
// Variáveis disponíveis para o event_type selecionado
const availableVars = computed(() => VARS_BY_EVENT[form.value.event_type] || []);
// Variáveis detectadas no body_text
const detectedVars = computed(() => {
const matches = (form.value.body_text || '').matchAll(/\{\{(\w+)\}\}/g);
return [...new Set([...matches].map((m) => m[1]))];
});
// Insere variável no cursor do textarea
function insertVar(varName) {
const snippet = `{{${varName}}}`;
const ta = bodyTextareaRef.value?.$el?.querySelector('textarea');
if (!ta) {
form.value.body_text = (form.value.body_text || '') + snippet;
return;
}
const start = ta.selectionStart ?? ta.value.length;
const end = ta.selectionEnd ?? start;
const val = form.value.body_text || '';
form.value.body_text = val.slice(0, start) + snippet + val.slice(end);
nextTick(() => {
const pos = start + snippet.length;
ta.focus();
ta.setSelectionRange(pos, pos);
});
}
// ── Save ────────────────────────────────────────────────────────
async function save() {
if (!form.value.body_text?.trim()) {
toast.add({ severity: 'warn', summary: 'Mensagem é obrigatória', life: 3000 });
return;
}
if (dlg.value.isNew && !form.value.key?.trim()) {
toast.add({ severity: 'warn', summary: 'Key é obrigatória', life: 3000 });
return;
}
dlg.value.saving = true;
try {
if (dlg.value.isNew) {
// Detecta variáveis usadas
const vars = [...(form.value.body_text || '').matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]);
const { error } = await supabase.from('notification_templates').insert({
tenant_id: null,
owner_id: null,
key: form.value.key,
domain: form.value.domain,
channel: form.value.channel,
event_type: form.value.event_type,
body_text: form.value.body_text,
variables: vars,
is_default: true,
is_active: form.value.is_active
});
if (error) throw error;
toast.add({ severity: 'success', summary: 'Template criado', life: 3000 });
} else {
const vars = [...(form.value.body_text || '').matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]);
const currentVersion = templates.value.find((t) => t.id === dlg.value.id)?.version || 0;
const { error } = await supabase
.from('notification_templates')
.update({
body_text: form.value.body_text,
domain: form.value.domain,
event_type: form.value.event_type,
variables: vars,
is_active: form.value.is_active,
version: currentVersion + 1
})
.eq('id', dlg.value.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Template atualizado', life: 3000 });
}
closeDlg();
await load();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 });
} finally {
dlg.value.saving = false;
}
}
// ── Toggle ativo ────────────────────────────────────────────────
async function toggleActive(t) {
try {
const { error } = await supabase.from('notification_templates').update({ is_active: !t.is_active }).eq('id', t.id);
if (error) throw error;
t.is_active = !t.is_active;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
}
}
// ── Soft delete ─────────────────────────────────────────────────
function deleteTemplate(t) {
confirm.require({
group: 'headless',
message: `Excluir o template "${t.key}"?`,
header: 'Confirmar exclusão',
icon: 'pi-trash',
color: '#ef4444',
accept: async () => {
try {
const { error } = await supabase.from('notification_templates').update({ deleted_at: new Date().toISOString() }).eq('id', t.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Template excluído', life: 3000 });
await load();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
}
}
});
}
// ── Truncate ────────────────────────────────────────────────────
function truncate(str, len = 80) {
if (!str) return '';
return str.length > len ? str.slice(0, len) + '…' : str;
}
onMounted(load);
</script>
<template>
<ConfirmDialog group="headless">
<template #container="{ message, acceptCallback, rejectCallback }">
<div class="flex flex-col items-center p-8 bg-surface-0 dark:bg-surface-900 rounded-xl shadow-xl">
<div class="rounded-full inline-flex justify-center items-center h-24 w-24 -mt-20" :style="{ background: message.color || 'var(--p-primary-color)', color: '#fff' }">
<i :class="`pi ${message.icon || 'pi-question'} !text-4xl`"></i>
</div>
<span class="font-bold text-2xl block mb-2 mt-6">{{ message.header }}</span>
<p class="mb-0 text-center text-[var(--text-color-secondary)]">{{ message.message }}</p>
<div class="flex items-center gap-2 mt-6">
<Button label="Confirmar" class="rounded-full" :style="{ background: message.color || 'var(--p-primary-color)', borderColor: message.color || 'var(--p-primary-color)' }" @click="acceptCallback" />
<Button label="Cancelar" variant="outlined" class="rounded-full" @click="rejectCallback" />
</div>
</div>
</template>
</ConfirmDialog>
<div class="p-4 md:p-6 max-w-[1200px] mx-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
<div>
<h1 class="text-xl font-bold m-0">Templates de Notificação</h1>
<p class="text-sm text-[var(--text-color-secondary)] mt-1 mb-0">Templates globais de WhatsApp e SMS. Tenants herdam automaticamente.</p>
</div>
<div class="flex gap-2">
<Button label="Novo template" icon="pi pi-plus" size="small" @click="openNew" />
<Button icon="pi pi-refresh" severity="secondary" text :loading="loading" @click="load" />
</div>
</div>
<!-- Tabs canal -->
<div class="flex gap-2 mb-5">
<Button v-for="ch in CHANNELS" :key="ch.value" :label="ch.label" :icon="ch.icon" size="small" :severity="activeChannel === ch.value ? 'primary' : 'secondary'" :outlined="activeChannel !== ch.value" @click="activeChannel = ch.value" />
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-12">
<ProgressSpinner />
</div>
<!-- DataTable -->
<DataTable v-else :value="filtered" stripedRows size="small" class="text-sm" :rowClass="(data) => (!data.is_active ? 'opacity-50' : '')">
<Column field="key" header="Key" sortable style="min-width: 200px">
<template #body="{ data }">
<code class="font-mono text-xs">{{ data.key }}</code>
</template>
</Column>
<Column field="domain" header="Domínio" sortable style="width: 110px">
<template #body="{ data }">
<Tag :value="DOMAIN_LABELS[data.domain] ?? data.domain" :severity="DOMAIN_SEVERITY[data.domain] ?? 'secondary'" class="text-[0.65rem]" />
</template>
</Column>
<Column field="event_type" header="Evento" sortable style="min-width: 160px">
<template #body="{ data }">
<span class="text-xs">{{ EVENT_TYPE_LABELS[data.event_type] ?? data.event_type }}</span>
</template>
</Column>
<Column field="body_text" header="Mensagem" style="min-width: 200px">
<template #body="{ data }">
<span class="text-xs text-[var(--text-color-secondary)]">{{ truncate(data.body_text) }}</span>
</template>
</Column>
<Column field="version" header="v" sortable style="width: 50px" class="text-center">
<template #body="{ data }">
<span class="text-xs text-[var(--text-color-secondary)]">{{ data.version }}</span>
</template>
</Column>
<Column header="Ativo" style="width: 70px" class="text-center">
<template #body="{ data }">
<ToggleSwitch :modelValue="data.is_active" @update:modelValue="() => toggleActive(data)" />
</template>
</Column>
<Column header="" style="width: 90px">
<template #body="{ data }">
<div class="flex gap-1">
<Button icon="pi pi-pencil" text rounded size="small" @click="openEdit(data)" />
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="deleteTemplate(data)" />
</div>
</template>
</Column>
<template #empty>
<div class="text-center py-8 text-[var(--text-color-secondary)]">
<i class="pi pi-comment text-3xl opacity-30 block mb-2" />
Nenhum template {{ activeChannel === 'sms' ? 'SMS' : 'WhatsApp' }} cadastrado.
</div>
</template>
</DataTable>
<!-- Dialog Cadastro / Edição -->
<Dialog
v-model:visible="dlg.open"
modal
:draggable="false"
:closable="!dlg.saving"
:dismissableMask="!dlg.saving"
maximizable
class="dc-dialog w-[50rem]"
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<div class="min-w-0">
<div class="text-base font-semibold truncate">{{ dlg.isNew ? 'Novo Template' : 'Editar Template' }}</div>
<div class="text-xs opacity-50">{{ dlg.isNew ? 'Cadastrar novo template de notificação' : form.key }}</div>
</div>
</div>
</div>
</template>
<div class="flex flex-col gap-4 py-2">
<!-- Key + Channel (somente no cadastro) -->
<div v-if="dlg.isNew" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Key *</label>
<InputText v-model="form.key" class="w-full font-mono text-sm" placeholder="ex: session.reminder.sms" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Canal</label>
<Select v-model="form.channel" :options="CHANNELS" option-label="label" option-value="value" class="w-full" />
</div>
</div>
<div v-else class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Key</label>
<InputText :model-value="form.key" class="w-full font-mono text-sm" disabled />
</div>
<!-- Domain + Event Type -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Domínio</label>
<Select v-model="form.domain" :options="DOMAIN_OPTIONS" option-label="label" option-value="value" class="w-full" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Tipo de evento</label>
<Select v-model="form.event_type" :options="EVENT_TYPE_OPTIONS" option-label="label" option-value="value" class="w-full" />
</div>
</div>
<!-- Body text -->
<div class="flex flex-col gap-2">
<label class="text-xs font-semibold">Mensagem *</label>
<Textarea ref="bodyTextareaRef" v-model="form.body_text" rows="6" auto-resize class="w-full text-sm" />
<!-- Chips de variáveis -->
<div v-if="availableVars.length" class="flex flex-col gap-1.5">
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span>
<div class="flex flex-wrap gap-1.5">
<Button v-for="v in availableVars" :key="v" :label="`{{${v}}}`" size="small" severity="secondary" outlined class="font-mono !text-[0.68rem] !py-1 !px-2" @click="insertVar(v)" />
</div>
</div>
<!-- Variáveis detectadas -->
<div v-if="detectedVars.length" class="flex items-center gap-1.5 flex-wrap">
<span class="text-xs text-[var(--text-color-secondary)]">Variáveis usadas:</span>
<Tag v-for="v in detectedVars" :key="v" :value="v" severity="info" class="!text-[0.6rem] font-mono" />
</div>
</div>
<!-- Ativo -->
<div class="flex items-center gap-2 pt-1">
<ToggleSwitch v-model="form.is_active" inputId="sw-active-tpl" />
<label for="sw-active-tpl" class="text-sm cursor-pointer select-none">Ativo</label>
</div>
</div>
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<Button label="Cancelar" severity="secondary" text class="rounded-full hover:!text-red-500" :disabled="dlg.saving" @click="closeDlg" />
<Button :label="dlg.isNew ? 'Criar template' : 'Salvar'" icon="pi pi-check" class="rounded-full" :loading="dlg.saving" @click="save" />
</div>
</template>
</Dialog>
</div>
</template>
+18 -21
View File
@@ -15,27 +15,24 @@
|--------------------------------------------------------------------------
-->
<template>
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div
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-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div 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-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-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)]">Em construção</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Esta área do Admin SaaS ainda será implementada.</div>
</div>
</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)]">Em construção</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Esta área do Admin SaaS ainda será implementada.</div>
</div>
</div>
</div>
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- conteúdo futuro -->
</div>
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- conteúdo futuro -->
</div>
</template>
@@ -15,230 +15,232 @@
|--------------------------------------------------------------------------
-->
<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 Checkbox from 'primevue/checkbox'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Checkbox from 'primevue/checkbox';
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) // carregamento geral (fetch)
const saving = ref(false) // salvando pendências
const hasPending = 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([]) // estado atual (reflete UI)
const originalLinks = ref([]) // snapshot do banco (para diff / cancelar)
const plans = ref([]);
const features = ref([]);
const links = ref([]); // estado atual (reflete UI)
const originalLinks = ref([]); // snapshot do banco (para diff / cancelar)
const q = ref('')
const q = ref('');
const targetFilter = ref('all') // 'all' | 'clinic' | 'therapist' | 'supervisor'
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' }
]
{ 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())
const busySet = ref(new Set());
function cellKey (planId, featureId) {
return `${planId}::${featureId}`
function cellKey(planId, featureId) {
return `${planId}::${featureId}`;
}
function isBusy (planId, featureId) {
return busySet.value.has(cellKey(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 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')
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(() => {
const s = new Set()
for (const r of links.value) s.add(`${r.plan_id}::${r.feature_id}`)
return s
})
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 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 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 => {
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)
})
})
const term = String(q.value || '')
.trim()
.toLowerCase();
if (!term) return features.value;
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 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 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 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
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
if (a.size !== b.size) {
hasPending.value = true;
return;
}
}
hasPending.value = false
for (const k of a) {
if (!b.has(k)) {
hasPending.value = true;
return;
}
}
hasPending.value = false;
}
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')
])
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
if (ep) throw ep;
if (ef) throw ef;
if (epf) throw epf;
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 })
} finally {
loading.value = false
busySet.value = new Set()
}
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 });
} finally {
loading.value = false;
busySet.value = new Set();
}
}
function isEnabled (planId, featureId) {
return enabledSet.value.has(`${planId}::${featureId}`)
function isEnabled(planId, featureId) {
return enabledSet.value.has(`${planId}::${featureId}`);
}
/**
* 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
function toggleLocal(planId, featureId, nextValue) {
if (loading.value || saving.value) return;
if (isBusy(planId, featureId)) return;
setBusy(planId, featureId, true)
setBusy(planId, featureId, true);
try {
if (nextValue) {
if (!enabledSet.value.has(`${planId}::${featureId}`)) {
links.value = [...links.value, { plan_id: planId, feature_id: featureId }]
}
} else {
links.value = links.value.filter(x => !(x.plan_id === planId && x.feature_id === featureId))
try {
if (nextValue) {
if (!enabledSet.value.has(`${planId}::${featureId}`)) {
links.value = [...links.value, { plan_id: planId, feature_id: featureId }];
}
} else {
links.value = links.value.filter((x) => !(x.plan_id === planId && x.feature_id === featureId));
}
markDirtyIfNeeded();
} finally {
setBusy(planId, featureId, false);
}
markDirtyIfNeeded()
} finally {
setBusy(planId, featureId, false)
}
}
/**
* Ação em massa local (sem salvar)
*/
function setAllForPlanLocal (planId, mode) {
if (!planId) return
if (loading.value || saving.value) return
function setAllForPlanLocal(planId, mode) {
if (!planId) return;
if (loading.value || saving.value) return;
const feats = filteredFeatures.value || []
if (!feats.length) 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}`))
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++
}
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;
}
links.value = next
markDirtyIfNeeded()
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;
if (!changed) {
toast.add({ severity: 'info', summary: 'Sem mudanças', detail: 'Todos os recursos filtrados já estavam marcados.', life: 2200 })
markDirtyIfNeeded();
if (!changed) {
toast.add({ severity: 'info', summary: 'Sem mudanças', detail: 'Nenhum recurso filtrado estava marcado para remover.', 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 })
}
}
}
/**
@@ -246,55 +248,55 @@ function setAllForPlanLocal (planId, mode) {
* (1) confirma ação
* (2) confirma impacto (quantidade)
*/
function confirmMassAction (plan, mode) {
if (!plan?.id) return
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)
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
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 })
}
})
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 });
}
});
}
/**
@@ -302,259 +304,236 @@ function confirmReset () {
* - 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 })
}
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;
}
for (const k of wasSet) {
if (!nowSet.has(k)) {
const [plan_id, feature_id] = k.split('::')
deletes.push({ plan_id, feature_id })
}
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;
}
// 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 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 }
}))
}
])
{ 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()
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-emerald-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)]">Controle de Recursos</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Defina quais recursos cada plano habilita. Mudanças ficam pendentes até salvar.</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 || 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="flex xl:hidden">
<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>
<!-- 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-[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="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] 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 />
<!-- 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-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
Dica: use a busca para reduzir a lista e aplique ações em massa com confirmação.
<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)]">Controle de Recursos</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Defina quais recursos cada plano habilita. Mudanças ficam pendentes até salvar.</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 || 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="flex xl:hidden">
<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>
</div>
<Divider class="my-0" />
<!-- 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-[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>
<DataTable
:value="filteredFeatures"
dataKey="id"
:loading="loading"
stripedRows
responsiveLayout="scroll"
:scrollable="true"
scrollHeight="70vh"
>
<Column header="" frozen style="min-width: 28rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.key }}</span>
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-snug mt-1">
{{ data.descricao || data.description || '—' }}
</div>
</div>
</template>
</Column>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] 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>
<Column
v-for="p in filteredPlans"
:key="p.id"
: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 class="text-[1rem] text-[var(--text-color-secondary)]">Dica: use a busca para reduzir a lista e aplique ações em massa com confirmação.</div>
</div>
<div class="flex items-center justify-center gap-1 flex-wrap">
<div class="text-[1rem] text-[var(--text-color-secondary)] truncate" :title="p.key">{{ p.key }}</div>
<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>
</div>
<template #body="{ data }">
<div class="flex justify-center">
<Checkbox
:binary="true"
:modelValue="isEnabled(p.id, data.id)"
: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>
</template>
<Divider class="my-0" />
<DataTable :value="filteredFeatures" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll" :scrollable="true" scrollHeight="70vh">
<Column header="" frozen style="min-width: 28rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.key }}</span>
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-snug mt-1">
{{ data.descricao || data.description || '—' }}
</div>
</div>
</template>
</Column>
<Column v-for="p in filteredPlans" :key="p.id" :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">
<div class="text-[1rem] text-[var(--text-color-secondary)] truncate" :title="p.key">{{ p.key }}</div>
<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)"
: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>
</template>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -15,527 +15,462 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
import { useToast } from 'primevue/usetoast'
import { useToast } from 'primevue/usetoast';
const route = useRoute()
const router = useRouter()
const toast = useToast()
const route = useRoute();
const router = useRouter();
const toast = useToast();
const loading = ref(false)
const isFetching = ref(false)
const loading = ref(false);
const isFetching = ref(false);
const events = ref([])
const plans = ref([])
const profiles = ref([])
const events = ref([]);
const plans = ref([]);
const profiles = ref([]);
const q = ref('')
const q = ref('');
// filtro por tipo de owner (clinic/therapist/all)
const ownerType = ref('all') // 'all' | 'clinic' | 'therapist'
const ownerType = ref('all'); // 'all' | 'clinic' | 'therapist'
const ownerTypeOptions = [
{ label: 'Todos', value: 'all' },
{ label: 'Clínica', value: 'clinic' },
{ label: 'Terapeuta', value: 'therapist' }
]
{ label: 'Todos', value: 'all' },
{ label: 'Clínica', value: 'clinic' },
{ label: 'Terapeuta', value: 'therapist' }
];
const isFocused = computed(() => {
return typeof route.query?.q === 'string' && route.query.q.trim().length > 0
})
return typeof route.query?.q === 'string' && route.query.q.trim().length > 0;
});
// ---------- helpers: plano ----------
const planKeyById = computed(() => {
const m = new Map()
for (const p of plans.value) m.set(p.id, p.key)
return m
})
const m = new Map();
for (const p of plans.value) m.set(p.id, p.key);
return m;
});
function planKey (planId) {
if (!planId) return '—'
return planKeyById.value.get(planId) || planId
function planKey(planId) {
if (!planId) return '—';
return planKeyById.value.get(planId) || planId;
}
// ---------- helpers: datas ----------
function formatWhen (iso) {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return String(iso)
return d.toLocaleString('pt-BR')
function formatWhen(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return d.toLocaleString('pt-BR');
}
// ---------- helpers: owner ----------
function normalizeOwnerType (t) {
const k = String(t || '').toLowerCase()
if (k === 'clinic' || k === 'therapist') return k
return 'unknown'
function normalizeOwnerType(t) {
const k = String(t || '').toLowerCase();
if (k === 'clinic' || k === 'therapist') return k;
return 'unknown';
}
function ownerKeyFromEvent (ev) {
const t = normalizeOwnerType(ev.owner_type)
const r = String(ev.owner_ref || '').trim()
if ((t === 'clinic' || t === 'therapist') && r) return `${t}:${r}`
const legacy = String(ev.owner_id || '').trim()
return legacy || 'unknown'
function ownerKeyFromEvent(ev) {
const t = normalizeOwnerType(ev.owner_type);
const r = String(ev.owner_ref || '').trim();
if ((t === 'clinic' || t === 'therapist') && r) return `${t}:${r}`;
const legacy = String(ev.owner_id || '').trim();
return legacy || 'unknown';
}
function parseOwnerKey (raw) {
const s = String(raw || '').trim()
if (!s) return { kind: 'unknown', id: null, raw: '' }
function parseOwnerKey(raw) {
const s = String(raw || '').trim();
if (!s) return { kind: 'unknown', id: null, raw: '' };
const m = s.match(/^(clinic|therapist)\s*:\s*([0-9a-fA-F-]{8,})$/)
if (m) return { kind: m[1].toLowerCase(), id: m[2], raw: s }
const m = s.match(/^(clinic|therapist)\s*:\s*([0-9a-fA-F-]{8,})$/);
if (m) return { kind: m[1].toLowerCase(), id: m[2], raw: s };
return { kind: 'unknown', id: s, raw: s }
return { kind: 'unknown', id: s, raw: s };
}
function ownerTagLabel (t) {
if (t === 'clinic') return 'Clínica'
if (t === 'therapist') return 'Terapeuta'
return '—'
function ownerTagLabel(t) {
if (t === 'clinic') return 'Clínica';
if (t === 'therapist') return 'Terapeuta';
return '—';
}
function ownerTagSeverity (t) {
if (t === 'clinic') return 'info'
if (t === 'therapist') return 'success'
return 'secondary'
function ownerTagSeverity(t) {
if (t === 'clinic') return 'info';
if (t === 'therapist') return 'success';
return 'secondary';
}
// ---------- helpers: profiles ----------
const profileById = computed(() => {
const m = new Map()
for (const p of profiles.value) m.set(p.id, p)
return m
})
const m = new Map();
for (const p of profiles.value) m.set(p.id, p);
return m;
});
function displayUser (userId) {
if (!userId) return '—'
const p = profileById.value.get(userId)
if (!p) return userId
function displayUser(userId) {
if (!userId) return '—';
const p = profileById.value.get(userId);
if (!p) return userId;
const name = p.nome || p.name || p.full_name || p.display_name || p.username || null
const email = p.email || p.email_principal || p.user_email || null
const name = p.nome || p.name || p.full_name || p.display_name || p.username || null;
const email = p.email || p.email_principal || p.user_email || null;
if (name && email) return `${name} <${email}>`
if (name) return name
if (email) return email
return userId
if (name && email) return `${name} <${email}>`;
if (name) return name;
if (email) return email;
return userId;
}
function displayOwner (ev) {
const t = normalizeOwnerType(ev.owner_type)
const ref = String(ev.owner_ref || '').trim()
if (t === 'therapist' && ref) return displayUser(ref)
return ownerKeyFromEvent(ev)
function displayOwner(ev) {
const t = normalizeOwnerType(ev.owner_type);
const ref = String(ev.owner_ref || '').trim();
if (t === 'therapist' && ref) return displayUser(ref);
return ownerKeyFromEvent(ev);
}
// ---------- evento ----------
function eventLabel (t) {
const k = String(t || '').toLowerCase()
if (k === 'plan_changed') return 'Plano alterado'
if (k === 'canceled') return 'Cancelada'
if (k === 'reactivated') return 'Reativada'
return t || '—'
function eventLabel(t) {
const k = String(t || '').toLowerCase();
if (k === 'plan_changed') return 'Plano alterado';
if (k === 'canceled') return 'Cancelada';
if (k === 'reactivated') return 'Reativada';
return t || '—';
}
function eventSeverity (t) {
const k = String(t || '').toLowerCase()
if (k === 'plan_changed') return 'info'
if (k === 'canceled') return 'danger'
if (k === 'reactivated') return 'success'
return 'secondary'
function eventSeverity(t) {
const k = String(t || '').toLowerCase();
if (k === 'plan_changed') return 'info';
if (k === 'canceled') return 'danger';
if (k === 'reactivated') return 'success';
return 'secondary';
}
// ---------- navegação ----------
function goToSubscriptions (ev) {
const key = ownerKeyFromEvent(ev)
if (!key || key === 'unknown') return
router.push({ path: '/saas/subscriptions', query: { q: key } })
function goToSubscriptions(ev) {
const key = ownerKeyFromEvent(ev);
if (!key || key === 'unknown') return;
router.push({ path: '/saas/subscriptions', query: { q: key } });
}
// ---------- fetch ----------
async function fetchAll () {
if (isFetching.value) return
isFetching.value = true
loading.value = true
async function fetchAll() {
if (isFetching.value) return;
isFetching.value = true;
loading.value = true;
try {
const [{ data: p, error: ep }, { data: e, error: ee }] = await Promise.all([
supabase.from('plans').select('id,key').order('key', { ascending: true }),
supabase
.from('subscription_events')
.select('*')
.order('created_at', { ascending: false })
.limit(500)
])
try {
const [{ data: p, error: ep }, { data: e, error: ee }] = await Promise.all([
supabase.from('plans').select('id,key').order('key', { ascending: true }),
supabase.from('subscription_events').select('*').order('created_at', { ascending: false }).limit(500)
]);
if (ep) throw ep
if (ee) throw ee
if (ep) throw ep;
if (ee) throw ee;
plans.value = p || []
events.value = e || []
plans.value = p || [];
events.value = e || [];
// profiles: created_by e owners therapist
const ids = new Set()
for (const ev of (events.value || [])) {
const createdBy = String(ev.created_by || '').trim()
if (createdBy) ids.add(createdBy)
// profiles: created_by e owners therapist
const ids = new Set();
for (const ev of events.value || []) {
const createdBy = String(ev.created_by || '').trim();
if (createdBy) ids.add(createdBy);
const t = normalizeOwnerType(ev.owner_type)
const ref = String(ev.owner_ref || '').trim()
if (t === 'therapist' && ref) ids.add(ref)
const t = normalizeOwnerType(ev.owner_type);
const ref = String(ev.owner_ref || '').trim();
if (t === 'therapist' && ref) ids.add(ref);
}
if (ids.size) {
const { data: pr, error: epr } = await supabase.from('profiles').select('id,nome,name,full_name,display_name,username,email,email_principal,user_email').in('id', Array.from(ids));
profiles.value = epr ? [] : pr || [];
} else {
profiles.value = [];
}
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err.message || String(err), life: 5000 });
} finally {
loading.value = false;
isFetching.value = false;
}
if (ids.size) {
const { data: pr, error: epr } = await supabase
.from('profiles')
.select('id,nome,name,full_name,display_name,username,email,email_principal,user_email')
.in('id', Array.from(ids))
profiles.value = epr ? [] : (pr || [])
} else {
profiles.value = []
}
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err.message || String(err), life: 5000 })
} finally {
loading.value = false
isFetching.value = false
}
}
// ---------- filtro ----------
const filtered = computed(() => {
const term = String(q.value || '').trim().toLowerCase()
let list = events.value || []
const term = String(q.value || '')
.trim()
.toLowerCase();
let list = events.value || [];
if (ownerType.value !== 'all') {
list = list.filter(ev => normalizeOwnerType(ev.owner_type) === ownerType.value)
}
// foco por query (?q=clinic:... / therapist:...)
const focus = String(route.query?.q || '').trim()
if (focus) {
const parsed = parseOwnerKey(focus)
if (parsed.kind === 'clinic' || parsed.kind === 'therapist') {
list = list.filter(ev =>
normalizeOwnerType(ev.owner_type) === parsed.kind &&
String(ev.owner_ref || '') === String(parsed.id || '')
)
} else {
const f = focus.toLowerCase()
list = list.filter(ev =>
ownerKeyFromEvent(ev).toLowerCase().includes(f) ||
String(ev.owner_id || '').toLowerCase().includes(f)
)
if (ownerType.value !== 'all') {
list = list.filter((ev) => normalizeOwnerType(ev.owner_type) === ownerType.value);
}
}
if (!term) return list
// foco por query (?q=clinic:... / therapist:...)
const focus = String(route.query?.q || '').trim();
if (focus) {
const parsed = parseOwnerKey(focus);
if (parsed.kind === 'clinic' || parsed.kind === 'therapist') {
list = list.filter((ev) => normalizeOwnerType(ev.owner_type) === parsed.kind && String(ev.owner_ref || '') === String(parsed.id || ''));
} else {
const f = focus.toLowerCase();
list = list.filter(
(ev) =>
ownerKeyFromEvent(ev).toLowerCase().includes(f) ||
String(ev.owner_id || '')
.toLowerCase()
.includes(f)
);
}
}
return list.filter(ev => {
const oldKey = planKey(ev.old_plan_id)
const newKey = planKey(ev.new_plan_id)
if (!term) return list;
const ok = ownerKeyFromEvent(ev)
const ownerDisp = String(displayOwner(ev) || '')
const subId = String(ev.subscription_id || '')
const eventType = String(ev.event_type || '')
const reason = String(ev.reason || '')
return list.filter((ev) => {
const oldKey = planKey(ev.old_plan_id);
const newKey = planKey(ev.new_plan_id);
const meta = ev.metadata ? JSON.stringify(ev.metadata) : ''
const ok = ownerKeyFromEvent(ev);
const ownerDisp = String(displayOwner(ev) || '');
const subId = String(ev.subscription_id || '');
const eventType = String(ev.event_type || '');
const reason = String(ev.reason || '');
const by = String(ev.created_by || '')
const byDisp = String(displayUser(by) || '')
const meta = ev.metadata ? JSON.stringify(ev.metadata) : '';
return (
ok.toLowerCase().includes(term) ||
ownerDisp.toLowerCase().includes(term) ||
subId.toLowerCase().includes(term) ||
eventType.toLowerCase().includes(term) ||
reason.toLowerCase().includes(term) ||
meta.toLowerCase().includes(term) ||
String(oldKey || '').toLowerCase().includes(term) ||
String(newKey || '').toLowerCase().includes(term) ||
by.toLowerCase().includes(term) ||
byDisp.toLowerCase().includes(term)
)
})
})
const by = String(ev.created_by || '');
const byDisp = String(displayUser(by) || '');
const totalCount = computed(() => (filtered.value || []).length)
const changedCount = computed(() => (filtered.value || []).filter(x => String(x?.event_type || '').toLowerCase() === 'plan_changed').length)
return (
ok.toLowerCase().includes(term) ||
ownerDisp.toLowerCase().includes(term) ||
subId.toLowerCase().includes(term) ||
eventType.toLowerCase().includes(term) ||
reason.toLowerCase().includes(term) ||
meta.toLowerCase().includes(term) ||
String(oldKey || '')
.toLowerCase()
.includes(term) ||
String(newKey || '')
.toLowerCase()
.includes(term) ||
by.toLowerCase().includes(term) ||
byDisp.toLowerCase().includes(term)
);
});
});
function clearFocus () {
router.push({ path: route.path, query: {} })
const totalCount = computed(() => (filtered.value || []).length);
const changedCount = computed(() => (filtered.value || []).filter((x) => String(x?.event_type || '').toLowerCase() === 'plan_changed').length);
function clearFocus() {
router.push({ path: route.path, query: {} });
}
// -------------------------
// Hero sticky
// -------------------------
const heroRef = ref(null)
const sentinelRef = ref(null)
const heroStuck = ref(false)
let heroObserver = null
const mobileMenuRef = ref(null)
const heroRef = ref(null);
const sentinelRef = ref(null);
const heroStuck = ref(false);
let heroObserver = null;
const mobileMenuRef = ref(null);
const heroMenuItems = computed(() => [
{
label: 'Voltar para assinaturas',
icon: 'pi pi-arrow-left',
command: () => router.push('/saas/subscriptions'),
disabled: loading.value
},
{
label: 'Recarregar',
icon: 'pi pi-refresh',
command: fetchAll,
disabled: loading.value
},
{ separator: true },
{
label: 'Filtros',
items: ownerTypeOptions.map(o => ({
label: o.label,
icon: ownerType.value === o.value ? 'pi pi-check' : 'pi pi-circle',
command: () => { ownerType.value = o.value }
}))
}
])
{
label: 'Voltar para assinaturas',
icon: 'pi pi-arrow-left',
command: () => router.push('/saas/subscriptions'),
disabled: loading.value
},
{
label: 'Recarregar',
icon: 'pi pi-refresh',
command: fetchAll,
disabled: loading.value
},
{ separator: true },
{
label: 'Filtros',
items: ownerTypeOptions.map((o) => ({
label: o.label,
icon: ownerType.value === o.value ? 'pi pi-check' : 'pi pi-circle',
command: () => {
ownerType.value = o.value;
}
}))
}
]);
onMounted(async () => {
const initialQ = route.query?.q
if (typeof initialQ === 'string' && initialQ.trim()) {
q.value = initialQ.trim()
const parsed = parseOwnerKey(initialQ)
if (parsed.kind === 'clinic') ownerType.value = 'clinic'
if (parsed.kind === 'therapist') ownerType.value = 'therapist'
}
await fetchAll()
const initialQ = route.query?.q;
if (typeof initialQ === 'string' && initialQ.trim()) {
q.value = initialQ.trim();
const parsed = parseOwnerKey(initialQ);
if (parsed.kind === 'clinic') ownerType.value = 'clinic';
if (parsed.kind === 'therapist') ownerType.value = 'therapist';
}
await fetchAll();
if (sentinelRef.value) {
heroObserver = new IntersectionObserver(
([entry]) => { heroStuck.value = !entry.isIntersecting },
{ rootMargin: `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`, threshold: 0 }
)
heroObserver.observe(sentinelRef.value)
}
})
if (sentinelRef.value) {
heroObserver = new IntersectionObserver(
([entry]) => {
heroStuck.value = !entry.isIntersecting;
},
{ rootMargin: `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`, threshold: 0 }
);
heroObserver.observe(sentinelRef.value);
}
});
onBeforeUnmount(() => {
heroObserver?.disconnect()
})
heroObserver?.disconnect();
});
</script>
<template>
<!-- Sentinel -->
<div ref="sentinelRef" class="h-px" />
<!-- Sentinel -->
<div ref="sentinelRef" class="h-px" />
<!-- Hero sticky -->
<div
ref="heroRef"
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-amber-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-orange-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)]">Histórico de assinaturas</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Auditoria read-only das mudanças de plano e status.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<Button
label="Voltar para assinaturas"
icon="pi pi-arrow-left"
severity="secondary"
outlined
size="small"
:disabled="loading"
@click="router.push('/saas/subscriptions')"
/>
<SelectButton
v-model="ownerType"
:options="ownerTypeOptions"
optionLabel="label"
optionValue="value"
size="small"
:disabled="loading"
/>
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
outlined
size="small"
:loading="loading"
@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"
@click="(e) => mobileMenuRef.toggle(e)"
/>
<Menu ref="mobileMenuRef" :model="heroMenuItems" popup />
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Card foco -->
<div
v-if="isFocused"
class="overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)]"
>
<div class="flex flex-wrap items-center justify-between gap-3 p-5">
<div class="min-w-0">
<div class="text-[1rem] font-semibold leading-none text-[var(--text-color)]">Eventos em foco</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<Tag value="Filtro ativo" severity="warning" rounded />
<div class="text-[1rem] text-[var(--text-color-secondary)] break-all">
{{ route.query.q }}
</div>
</div>
<!-- Hero sticky -->
<div ref="heroRef" 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-amber-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-orange-400/10" />
</div>
<Button
label="Limpar filtro"
icon="pi pi-times"
severity="danger"
class="font-semibold"
raised
:disabled="loading"
@click="clearFocus"
/>
</div>
</div>
<!-- busca -->
<div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="q"
id="events_search"
class="w-full pr-10"
variant="filled"
:disabled="loading"
/>
</IconField>
<label for="events_search">Buscar owner, subscription, plano, tipo, usuário</label>
</FloatLabel>
</div>
<DataTable
:value="filtered"
dataKey="id"
:loading="loading"
stripedRows
responsiveLayout="scroll"
:rowHover="true"
paginator
:rows="15"
:rowsPerPageOptions="[10,15,25,50]"
currentPageReportTemplate="{first}{last} de {totalRecords}"
paginatorTemplate="RowsPerPageDropdown FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport"
>
<Column header="Quando" style="min-width: 14rem">
<template #body="{ data }">
{{ formatWhen(data.created_at) }}
</template>
</Column>
<!-- Tipo = tipo do OWNER (clínica/terapeuta) -->
<Column header="Owner tipo" style="width: 11rem">
<template #body="{ data }">
<Tag
:value="ownerTagLabel(normalizeOwnerType(data.owner_type))"
:severity="ownerTagSeverity(normalizeOwnerType(data.owner_type))"
rounded
/>
</template>
</Column>
<Column header="Owner" style="min-width: 24rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ ownerKeyFromEvent(data) }}</span>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ displayOwner(data) }}
<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)]">Histórico de assinaturas</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Auditoria read-only das mudanças de plano e status.</div>
</div>
</div>
</template>
</Column>
<!-- Evento = event_type (plan_changed/canceled/reactivated) -->
<Column header="Evento" style="min-width: 12rem">
<template #body="{ data }">
<Tag :value="eventLabel(data.event_type)" :severity="eventSeverity(data.event_type)" />
</template>
</Column>
<!-- Ações desktop ( 1200px) -->
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<Button label="Voltar para assinaturas" icon="pi pi-arrow-left" severity="secondary" outlined size="small" :disabled="loading" @click="router.push('/saas/subscriptions')" />
<SelectButton v-model="ownerType" :options="ownerTypeOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" @click="fetchAll" />
</div>
<Column header="De → Para" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex items-center gap-2 flex-wrap">
<Tag :value="planKey(data.old_plan_id)" severity="secondary" />
<i class="pi pi-arrow-right text-color-secondary" />
<Tag :value="planKey(data.new_plan_id)" severity="success" />
</div>
</template>
</Column>
<Column field="subscription_id" header="Subscription" style="min-width: 22rem">
<template #body="{ data }">
<span class="font-mono text-[1rem]">{{ data.subscription_id }}</span>
</template>
</Column>
<Column header="Alterado por" style="min-width: 22rem">
<template #body="{ data }">
{{ displayUser(data.created_by) }}
</template>
</Column>
<Column header="Ações" style="width: 12rem">
<template #body="{ data }">
<Button
label="Ver assinatura"
icon="pi pi-external-link"
size="small"
severity="secondary"
outlined
class="w-full md:w-auto"
:disabled="ownerKeyFromEvent(data) === 'unknown'"
@click="goToSubscriptions(data)"
/>
</template>
</Column>
<template #empty>
<div class="p-4 text-[var(--text-color-secondary)]">
Nenhum evento encontrado com os filtros atuais.
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="warn" size="small" @click="(e) => mobileMenuRef.toggle(e)" />
<Menu ref="mobileMenuRef" :model="heroMenuItems" popup />
</div>
</div>
</template>
</DataTable>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
Mostrando até 500 eventos mais recentes.
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Card foco -->
<div v-if="isFocused" class="overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="flex flex-wrap items-center justify-between gap-3 p-5">
<div class="min-w-0">
<div class="text-[1rem] font-semibold leading-none text-[var(--text-color)]">Eventos em foco</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<Tag value="Filtro ativo" severity="warning" rounded />
<div class="text-[1rem] text-[var(--text-color-secondary)] break-all">
{{ route.query.q }}
</div>
</div>
</div>
<Button label="Limpar filtro" icon="pi pi-times" severity="danger" class="font-semibold" raised :disabled="loading" @click="clearFocus" />
</div>
</div>
<!-- busca -->
<div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="events_search" class="w-full pr-10" variant="filled" :disabled="loading" />
</IconField>
<label for="events_search">Buscar owner, subscription, plano, tipo, usuário</label>
</FloatLabel>
</div>
<DataTable
:value="filtered"
dataKey="id"
:loading="loading"
stripedRows
responsiveLayout="scroll"
:rowHover="true"
paginator
:rows="15"
:rowsPerPageOptions="[10, 15, 25, 50]"
currentPageReportTemplate="{first}{last} de {totalRecords}"
paginatorTemplate="RowsPerPageDropdown FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport"
>
<Column header="Quando" style="min-width: 14rem">
<template #body="{ data }">
{{ formatWhen(data.created_at) }}
</template>
</Column>
<!-- Tipo = tipo do OWNER (clínica/terapeuta) -->
<Column header="Owner tipo" style="width: 11rem">
<template #body="{ data }">
<Tag :value="ownerTagLabel(normalizeOwnerType(data.owner_type))" :severity="ownerTagSeverity(normalizeOwnerType(data.owner_type))" rounded />
</template>
</Column>
<Column header="Owner" style="min-width: 24rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ ownerKeyFromEvent(data) }}</span>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ displayOwner(data) }}
</div>
</div>
</template>
</Column>
<!-- Evento = event_type (plan_changed/canceled/reactivated) -->
<Column header="Evento" style="min-width: 12rem">
<template #body="{ data }">
<Tag :value="eventLabel(data.event_type)" :severity="eventSeverity(data.event_type)" />
</template>
</Column>
<Column header="De → Para" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex items-center gap-2 flex-wrap">
<Tag :value="planKey(data.old_plan_id)" severity="secondary" />
<i class="pi pi-arrow-right text-color-secondary" />
<Tag :value="planKey(data.new_plan_id)" severity="success" />
</div>
</template>
</Column>
<Column field="subscription_id" header="Subscription" style="min-width: 22rem">
<template #body="{ data }">
<span class="font-mono text-[1rem]">{{ data.subscription_id }}</span>
</template>
</Column>
<Column header="Alterado por" style="min-width: 22rem">
<template #body="{ data }">
{{ displayUser(data.created_by) }}
</template>
</Column>
<Column header="Ações" style="width: 12rem">
<template #body="{ data }">
<Button label="Ver assinatura" icon="pi pi-external-link" size="small" severity="secondary" outlined class="w-full md:w-auto" :disabled="ownerKeyFromEvent(data) === 'unknown'" @click="goToSubscriptions(data)" />
</template>
</Column>
<template #empty>
<div class="p-4 text-[var(--text-color-secondary)]">Nenhum evento encontrado com os filtros atuais.</div>
</template>
</DataTable>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Mostrando até 500 eventos mais recentes.</div>
</div>
</template>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+407 -481
View File
@@ -15,558 +15,484 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import {
createSupportSession,
listActiveSupportSessions,
listSessionHistory,
revokeSupportSession,
buildSupportUrl,
} from '@/support/supportSessionService'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { createSupportSession, listActiveSupportSessions, listSessionHistory, revokeSupportSession, buildSupportUrl } from '@/support/supportSessionService';
const TAG = '[SaasSupportPage]'
const toast = useToast()
const TAG = '[SaasSupportPage]';
const toast = useToast();
// Tabs
const activeTab = ref(0)
const activeTab = ref(0);
// Estado Nova Sessão
const selectedTenantId = ref(null)
const ttlMinutes = ref(60)
const sessionNote = ref('')
const creating = ref(false)
const generatedUrl = ref(null)
const generatedData = ref(null)
const selectedTenantId = ref(null);
const ttlMinutes = ref(60);
const sessionNote = ref('');
const creating = ref(false);
const generatedUrl = ref(null);
const generatedData = ref(null);
// Estado Listas
const loadingTenants = ref(false)
const loadingSessions = ref(false)
const loadingHistory = ref(false)
const revokingToken = ref(null)
const loadingTenants = ref(false);
const loadingSessions = ref(false);
const loadingHistory = ref(false);
const revokingToken = ref(null);
const tenants = ref([])
const tenantMap = ref({})
const activeSessions = ref([])
const sessionHistory = ref([])
const tenants = ref([]);
const tenantMap = ref({});
const activeSessions = ref([]);
const sessionHistory = ref([]);
// TTL Options
const ttlOptions = [
{ label: '15 minutos', value: 15 },
{ label: '30 minutos', value: 30 },
{ label: '1 hora', value: 60 },
{ label: '2 horas', value: 120 },
]
{ label: '15 minutos', value: 15 },
{ label: '30 minutos', value: 30 },
{ label: '1 hora', value: 60 },
{ label: '2 horas', value: 120 }
];
// Countdown tick
const _now = ref(Date.now())
let _tickTimer = null
const _now = ref(Date.now());
let _tickTimer = null;
function startTick () {
if (_tickTimer) return
_tickTimer = setInterval(() => { _now.value = Date.now() }, 10_000)
function startTick() {
if (_tickTimer) return;
_tickTimer = setInterval(() => {
_now.value = Date.now();
}, 10_000);
}
onBeforeUnmount(() => { if (_tickTimer) clearInterval(_tickTimer) })
onBeforeUnmount(() => {
if (_tickTimer) clearInterval(_tickTimer);
});
// Computed
const expiresLabel = computed(() => {
if (!generatedData.value?.expires_at) return ''
return new Date(generatedData.value.expires_at).toLocaleString('pt-BR')
})
if (!generatedData.value?.expires_at) return '';
return new Date(generatedData.value.expires_at).toLocaleString('pt-BR');
});
const tokenPreview = computed(() => {
if (!generatedData.value?.token) return ''
const t = generatedData.value.token
return `${t.slice(0, 8)}${t.slice(-8)}`
})
if (!generatedData.value?.token) return '';
const t = generatedData.value.token;
return `${t.slice(0, 8)}${t.slice(-8)}`;
});
const activeSessionCount = computed(() => activeSessions.value.length)
const activeSessionCount = computed(() => activeSessions.value.length);
// Lifecycle
onMounted(async () => {
console.log(`${TAG} montado`)
await loadTenants()
await loadActiveSessions()
startTick()
})
console.log(`${TAG} montado`);
await loadTenants();
await loadActiveSessions();
startTick();
});
// Tenants
async function loadTenants () {
loadingTenants.value = true
console.log(`${TAG} loadTenants`)
try {
const { data, error } = await supabase
.from('tenants')
.select('id, name, kind')
.order('name', { ascending: true })
async function loadTenants() {
loadingTenants.value = true;
console.log(`${TAG} loadTenants`);
try {
const { data, error } = await supabase.from('tenants').select('id, name, kind').order('name', { ascending: true });
if (error) throw error
if (error) throw error;
const list = data || []
console.log(`${TAG} ${list.length} tenant(s) carregado(s)`)
const list = data || [];
console.log(`${TAG} ${list.length} tenant(s) carregado(s)`);
tenantMap.value = Object.fromEntries(list.map(t => [t.id, t.name || t.id]))
tenants.value = list.map(t => ({
value: t.id,
label: `${t.name} (${t.kind ?? 'tenant'})`,
}))
} catch (e) {
console.error(`${TAG} loadTenants ERRO`, e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
} finally {
loadingTenants.value = false
}
tenantMap.value = Object.fromEntries(list.map((t) => [t.id, t.name || t.id]));
tenants.value = list.map((t) => ({
value: t.id,
label: `${t.name} (${t.kind ?? 'tenant'})`
}));
} catch (e) {
console.error(`${TAG} loadTenants ERRO`, e);
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
} finally {
loadingTenants.value = false;
}
}
// Sessões ativas
async function loadActiveSessions () {
loadingSessions.value = true
console.log(`${TAG} loadActiveSessions`)
try {
activeSessions.value = await listActiveSupportSessions()
console.log(`${TAG} ${activeSessions.value.length} sessão(ões) ativa(s)`)
} catch (e) {
console.error(`${TAG} loadActiveSessions ERRO`, e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
} finally {
loadingSessions.value = false
}
async function loadActiveSessions() {
loadingSessions.value = true;
console.log(`${TAG} loadActiveSessions`);
try {
activeSessions.value = await listActiveSupportSessions();
console.log(`${TAG} ${activeSessions.value.length} sessão(ões) ativa(s)`);
} catch (e) {
console.error(`${TAG} loadActiveSessions ERRO`, e);
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
} finally {
loadingSessions.value = false;
}
}
// Histórico
async function loadHistory () {
if (loadingHistory.value) return
loadingHistory.value = true
console.log(`${TAG} loadHistory`)
try {
sessionHistory.value = await listSessionHistory(100)
console.log(`${TAG} histórico: ${sessionHistory.value.length} registro(s)`)
} catch (e) {
console.error(`${TAG} loadHistory ERRO`, e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
} finally {
loadingHistory.value = false
}
async function loadHistory() {
if (loadingHistory.value) return;
loadingHistory.value = true;
console.log(`${TAG} loadHistory`);
try {
sessionHistory.value = await listSessionHistory(100);
console.log(`${TAG} histórico: ${sessionHistory.value.length} registro(s)`);
} catch (e) {
console.error(`${TAG} loadHistory ERRO`, e);
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
} finally {
loadingHistory.value = false;
}
}
// Criar sessão
async function handleCreate () {
if (!selectedTenantId.value) return
creating.value = true
generatedUrl.value = null
generatedData.value = null
async function handleCreate() {
if (!selectedTenantId.value) return;
creating.value = true;
generatedUrl.value = null;
generatedData.value = null;
console.log(`${TAG} handleCreate`, { tenantId: selectedTenantId.value, ttlMinutes: ttlMinutes.value, note: sessionNote.value || '(sem nota)' })
console.log(`${TAG} handleCreate`, { tenantId: selectedTenantId.value, ttlMinutes: ttlMinutes.value, note: sessionNote.value || '(sem nota)' });
try {
const result = await createSupportSession(selectedTenantId.value, ttlMinutes.value, sessionNote.value)
generatedData.value = result
generatedUrl.value = buildSupportUrl(result.token)
try {
const result = await createSupportSession(selectedTenantId.value, ttlMinutes.value, sessionNote.value);
generatedData.value = result;
generatedUrl.value = buildSupportUrl(result.token);
console.log(`${TAG} sessão criada com sucesso`, { token: `${result.token.slice(0,8)}`, expires_at: result.expires_at })
console.log(`${TAG} sessão criada com sucesso`, { token: `${result.token.slice(0, 8)}`, expires_at: result.expires_at });
toast.add({ severity: 'success', summary: 'Sessão criada', detail: 'URL de suporte gerada com sucesso.', life: 4000 })
await loadActiveSessions()
} catch (e) {
console.error(`${TAG} handleCreate ERRO`, e)
toast.add({ severity: 'error', summary: 'Erro ao criar sessão', detail: e?.message, life: 5000 })
} finally {
creating.value = false
}
toast.add({ severity: 'success', summary: 'Sessão criada', detail: 'URL de suporte gerada com sucesso.', life: 4000 });
await loadActiveSessions();
} catch (e) {
console.error(`${TAG} handleCreate ERRO`, e);
toast.add({ severity: 'error', summary: 'Erro ao criar sessão', detail: e?.message, life: 5000 });
} finally {
creating.value = false;
}
}
// Revogar
async function handleRevoke (token) {
revokingToken.value = token
console.log(`${TAG} handleRevoke token=${token.slice(0, 8)}`)
try {
await revokeSupportSession(token)
toast.add({ severity: 'success', summary: 'Sessão revogada', life: 3000 })
if (generatedData.value?.token === token) {
generatedUrl.value = null
generatedData.value = null
async function handleRevoke(token) {
revokingToken.value = token;
console.log(`${TAG} handleRevoke token=${token.slice(0, 8)}`);
try {
await revokeSupportSession(token);
toast.add({ severity: 'success', summary: 'Sessão revogada', life: 3000 });
if (generatedData.value?.token === token) {
generatedUrl.value = null;
generatedData.value = null;
}
await loadActiveSessions();
if (sessionHistory.value.length) await loadHistory();
} catch (e) {
console.error(`${TAG} handleRevoke ERRO`, e);
toast.add({ severity: 'error', summary: 'Erro ao revogar', detail: e?.message, life: 4000 });
} finally {
revokingToken.value = null;
}
await loadActiveSessions()
if (sessionHistory.value.length) await loadHistory()
} catch (e) {
console.error(`${TAG} handleRevoke ERRO`, e)
toast.add({ severity: 'error', summary: 'Erro ao revogar', detail: e?.message, life: 4000 })
} finally {
revokingToken.value = null
}
}
// Copiar
function copyUrl (url) {
if (!url) return
navigator.clipboard.writeText(url)
console.log(`${TAG} URL copiada`)
toast.add({ severity: 'info', summary: 'Copiado!', detail: 'URL copiada para a área de transferência.', life: 2000 })
function copyUrl(url) {
if (!url) return;
navigator.clipboard.writeText(url);
console.log(`${TAG} URL copiada`);
toast.add({ severity: 'info', summary: 'Copiado!', detail: 'URL copiada para a área de transferência.', life: 2000 });
}
// Tab change
function onTabChange (e) {
const idx = e.index ?? e
activeTab.value = idx
console.log(`${TAG} tab mudou para ${idx}`)
if (idx === 2 && sessionHistory.value.length === 0) loadHistory()
function onTabChange(e) {
const idx = e.index ?? e;
activeTab.value = idx;
console.log(`${TAG} tab mudou para ${idx}`);
if (idx === 2 && sessionHistory.value.length === 0) loadHistory();
}
// Helpers
function tenantName (id) {
return tenantMap.value[id] || id
function tenantName(id) {
return tenantMap.value[id] || id;
}
function formatDate (iso) {
if (!iso) return '-'
return new Date(iso).toLocaleString('pt-BR')
function formatDate(iso) {
if (!iso) return '-';
return new Date(iso).toLocaleString('pt-BR');
}
function remainingLabel (iso) {
_now.value // dependência reativa
if (!iso) return '-'
const diff = new Date(iso) - Date.now()
if (diff <= 0) return 'Expirada'
const min = Math.floor(diff / 60000)
const h = Math.floor(min / 60)
const m = min % 60
if (h > 0) return `${h}h ${m}min`
return `${min} min`
function remainingLabel(iso) {
_now.value; // dependência reativa
if (!iso) return '-';
const diff = new Date(iso) - Date.now();
if (diff <= 0) return 'Expirada';
const min = Math.floor(diff / 60000);
const h = Math.floor(min / 60);
const m = min % 60;
if (h > 0) return `${h}h ${m}min`;
return `${min} min`;
}
function isExpiringSoon (iso) {
if (!iso) return false
const diff = (new Date(iso) - Date.now()) / 60000
return diff > 0 && diff < 15
function isExpiringSoon(iso) {
if (!iso) return false;
const diff = (new Date(iso) - Date.now()) / 60000;
return diff > 0 && diff < 15;
}
function sessionStatusSeverity (session) {
if (session._expired) return 'danger'
if (isExpiringSoon(session.expires_at)) return 'warning'
return 'success'
function sessionStatusSeverity(session) {
if (session._expired) return 'danger';
if (isExpiringSoon(session.expires_at)) return 'warning';
return 'success';
}
function sessionStatusLabel (session) {
if (session._expired) return 'Expirada'
if (isExpiringSoon(session.expires_at)) return 'Expirando'
return 'Ativa'
function sessionStatusLabel(session) {
if (session._expired) return 'Expirada';
if (isExpiringSoon(session.expires_at)) return 'Expirando';
return 'Ativa';
}
</script>
<template>
<!-- Sentinel -->
<div class="h-px" />
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div
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-fuchsia-400/10" />
<!-- Hero sticky -->
<div 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-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center justify-center w-10 h-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex-shrink-0">
<i class="pi pi-headphones text-[var(--text-color)]" />
</div>
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Suporte Técnico</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Gere e gerencie links seguros de acesso em modo debug</div>
</div>
</div>
<Tag v-if="activeSessionCount > 0" :value="`${activeSessionCount} sessão${activeSessionCount > 1 ? 'ões' : ''} ativa${activeSessionCount > 1 ? 's' : ''}`" severity="warning" />
</div>
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center justify-center w-10 h-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex-shrink-0">
<i class="pi pi-headphones text-[var(--text-color)]" />
</div>
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Suporte Técnico</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Gere e gerencie links seguros de acesso em modo debug</div>
</div>
</div>
<Tag
v-if="activeSessionCount > 0"
:value="`${activeSessionCount} sessão${activeSessionCount > 1 ? 'ões' : ''} ativa${activeSessionCount > 1 ? 's' : ''}`"
severity="warning"
/>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Tabs -->
<TabView @tab-change="onTabChange">
<!-- Tab 0: Nova Sessão -->
<TabPanel header="Nova Sessão">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5 pt-2">
<!-- Formulário -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="text-[1rem] font-semibold flex items-center gap-2 mb-4">
<i class="pi pi-plus-circle text-[var(--primary-color)]" />
Configurar acesso de suporte
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-[1rem] font-medium">Selecionar Cliente (Tenant)</label>
<Select
v-model="selectedTenantId"
:options="tenants"
option-label="label"
option-value="value"
placeholder="Buscar tenant..."
filter
:loading="loadingTenants"
class="w-full"
empty-filter-message="Nenhum tenant encontrado"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-[1rem] font-medium">Duração do Acesso</label>
<Select v-model="ttlMinutes" :options="ttlOptions" option-label="label" option-value="value" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="text-[1rem] font-medium">
Nota / Motivo
<span class="text-[var(--text-color-secondary)] font-normal">(opcional)</span>
</label>
<InputText v-model="sessionNote" placeholder="Ex: cliente reportou erro na agenda de recorrência" class="w-full" />
</div>
<Button label="Ativar Modo Suporte" icon="pi pi-shield" severity="warning" :loading="creating" :disabled="!selectedTenantId" class="w-full" @click="handleCreate" />
</div>
</div>
<!-- URL Gerada -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="text-[1rem] font-semibold flex items-center gap-2 mb-4">
<i class="pi pi-link text-[var(--primary-color)]" />
URL Gerada
</div>
<div v-if="generatedUrl" class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-[1rem] font-medium">Link de Acesso</label>
<div class="flex gap-2">
<InputText :value="generatedUrl" readonly class="flex-1 font-mono text-[1rem]" />
<Button icon="pi pi-copy" severity="secondary" outlined v-tooltip.top="'Copiar URL'" @click="copyUrl(generatedUrl)" />
</div>
</div>
<div class="flex items-center gap-2 text-[1rem]">
<i class="pi pi-clock text-orange-500" />
<span class="text-[var(--text-color-secondary)]">Expira em:</span>
<strong>{{ expiresLabel }}</strong>
</div>
<div class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)] font-mono">
<i class="pi pi-key" />
<span>{{ tokenPreview }}</span>
</div>
<div v-if="sessionNote" class="flex items-start gap-2 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-comment mt-0.5 flex-shrink-0" />
<span class="italic">{{ sessionNote }}</span>
</div>
<Message severity="info" :closable="false">
<div class="text-[1rem]">Envie este link ao terapeuta ou acesse diretamente para monitorar os logs da agenda em tempo real.</div>
</Message>
</div>
<div v-else class="flex flex-col items-center justify-center py-12 text-[var(--text-color-secondary)] gap-3">
<i class="pi pi-shield text-4xl opacity-25" />
<div class="text-[1rem]">Nenhuma sessão gerada ainda</div>
</div>
</div>
</div>
</TabPanel>
<!-- Tab 1: Sessões Ativas -->
<TabPanel>
<template #header>
<span class="flex items-center gap-2">
Sessões Ativas
<Badge v-if="activeSessionCount > 0" :value="String(activeSessionCount)" severity="warning" />
</span>
</template>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 mt-2">
<div class="flex items-center justify-between mb-4">
<div class="text-[1rem] font-semibold flex items-center gap-2">
<i class="pi pi-circle-fill text-green-500" />
Sessões em vigor
</div>
<Button icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loadingSessions" label="Atualizar" @click="loadActiveSessions" />
</div>
<DataTable :value="activeSessions" :loading="loadingSessions" empty-message="Nenhuma sessão ativa no momento" size="small" striped-rows>
<Column header="Tenant" style="min-width: 200px">
<template #body="{ data }">
<div class="flex flex-col gap-0.5">
<span class="font-medium text-[1rem]">{{ tenantName(data.tenant_id) }}</span>
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_id }}</span>
</div>
</template>
</Column>
<Column header="Token">
<template #body="{ data }">
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.token.slice(0, 12) }}</span>
</template>
</Column>
<Column header="Restante" style="min-width: 100px">
<template #body="{ data }">
<span :class="isExpiringSoon(data.expires_at) ? 'text-orange-500 font-semibold' : ''">
{{ remainingLabel(data.expires_at) }}
</span>
</template>
</Column>
<Column header="Criada em">
<template #body="{ data }">
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.created_at) }}</span>
</template>
</Column>
<Column header="Nota">
<template #body="{ data }">
<span v-if="data._note" class="text-[1rem] italic text-[var(--text-color-secondary)]">{{ data._note }}</span>
<span v-else class="text-[1rem] text-[var(--text-color-secondary)]"></span>
</template>
</Column>
<Column header="Ações" style="width: 110px">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-copy" size="small" severity="secondary" outlined v-tooltip.top="'Copiar URL'" @click="copyUrl(buildSupportUrl(data.token))" />
<Button icon="pi pi-trash" size="small" severity="danger" outlined v-tooltip.top="'Revogar'" :loading="revokingToken === data.token" @click="handleRevoke(data.token)" />
</div>
</template>
</Column>
</DataTable>
</div>
</TabPanel>
<!-- Tab 2: Histórico -->
<TabPanel header="Histórico">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 mt-2">
<div class="flex items-center justify-between mb-4">
<div class="text-[1rem] font-semibold flex items-center gap-2">
<i class="pi pi-history text-[var(--primary-color)]" />
Últimas 100 sessões
</div>
<Button icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loadingHistory" label="Carregar" @click="loadHistory" />
</div>
<DataTable :value="sessionHistory" :loading="loadingHistory" empty-message="Clique em Carregar para ver o histórico" size="small" striped-rows paginator :rows="20">
<Column header="Status" style="width: 110px">
<template #body="{ data }">
<Tag :value="sessionStatusLabel(data)" :severity="sessionStatusSeverity(data)" />
</template>
</Column>
<Column header="Tenant" style="min-width: 180px">
<template #body="{ data }">
<div class="flex flex-col gap-0.5">
<span class="font-medium text-[1rem]">{{ tenantName(data.tenant_id) }}</span>
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_id }}</span>
</div>
</template>
</Column>
<Column header="Token">
<template #body="{ data }">
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.token.slice(0, 12) }}</span>
</template>
</Column>
<Column header="Criada em" sortable field="created_at">
<template #body="{ data }">
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.created_at) }}</span>
</template>
</Column>
<Column header="Expirava em">
<template #body="{ data }">
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.expires_at) }}</span>
</template>
</Column>
<Column header="Nota">
<template #body="{ data }">
<span v-if="data._note" class="text-[1rem] italic text-[var(--text-color-secondary)]">{{ data._note }}</span>
<span v-else class="text-[1rem] text-[var(--text-color-secondary)]"></span>
</template>
</Column>
<Column header="" style="width: 60px">
<template #body="{ data }">
<Button v-if="!data._expired" icon="pi pi-trash" size="small" severity="danger" outlined v-tooltip.top="'Revogar'" :loading="revokingToken === data.token" @click="handleRevoke(data.token)" />
</template>
</Column>
</DataTable>
</div>
</TabPanel>
</TabView>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Tabs -->
<TabView @tab-change="onTabChange">
<!-- Tab 0: Nova Sessão -->
<TabPanel header="Nova Sessão">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5 pt-2">
<!-- Formulário -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="text-[1rem] font-semibold flex items-center gap-2 mb-4">
<i class="pi pi-plus-circle text-[var(--primary-color)]" />
Configurar acesso de suporte
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-[1rem] font-medium">Selecionar Cliente (Tenant)</label>
<Select
v-model="selectedTenantId"
:options="tenants"
option-label="label"
option-value="value"
placeholder="Buscar tenant..."
filter
:loading="loadingTenants"
class="w-full"
empty-filter-message="Nenhum tenant encontrado"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-[1rem] font-medium">Duração do Acesso</label>
<Select
v-model="ttlMinutes"
:options="ttlOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-[1rem] font-medium">
Nota / Motivo
<span class="text-[var(--text-color-secondary)] font-normal">(opcional)</span>
</label>
<InputText
v-model="sessionNote"
placeholder="Ex: cliente reportou erro na agenda de recorrência"
class="w-full"
/>
</div>
<Button
label="Ativar Modo Suporte"
icon="pi pi-shield"
severity="warning"
:loading="creating"
:disabled="!selectedTenantId"
class="w-full"
@click="handleCreate"
/>
</div>
</div>
<!-- URL Gerada -->
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="text-[1rem] font-semibold flex items-center gap-2 mb-4">
<i class="pi pi-link text-[var(--primary-color)]" />
URL Gerada
</div>
<div v-if="generatedUrl" class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-[1rem] font-medium">Link de Acesso</label>
<div class="flex gap-2">
<InputText :value="generatedUrl" readonly class="flex-1 font-mono text-[1rem]" />
<Button icon="pi pi-copy" severity="secondary" outlined v-tooltip.top="'Copiar URL'" @click="copyUrl(generatedUrl)" />
</div>
</div>
<div class="flex items-center gap-2 text-[1rem]">
<i class="pi pi-clock text-orange-500" />
<span class="text-[var(--text-color-secondary)]">Expira em:</span>
<strong>{{ expiresLabel }}</strong>
</div>
<div class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)] font-mono">
<i class="pi pi-key" />
<span>{{ tokenPreview }}</span>
</div>
<div v-if="sessionNote" class="flex items-start gap-2 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-comment mt-0.5 flex-shrink-0" />
<span class="italic">{{ sessionNote }}</span>
</div>
<Message severity="info" :closable="false">
<div class="text-[1rem]">Envie este link ao terapeuta ou acesse diretamente para monitorar os logs da agenda em tempo real.</div>
</Message>
</div>
<div v-else class="flex flex-col items-center justify-center py-12 text-[var(--text-color-secondary)] gap-3">
<i class="pi pi-shield text-4xl opacity-25" />
<div class="text-[1rem]">Nenhuma sessão gerada ainda</div>
</div>
</div>
</div>
</TabPanel>
<!-- Tab 1: Sessões Ativas -->
<TabPanel>
<template #header>
<span class="flex items-center gap-2">
Sessões Ativas
<Badge v-if="activeSessionCount > 0" :value="String(activeSessionCount)" severity="warning" />
</span>
</template>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 mt-2">
<div class="flex items-center justify-between mb-4">
<div class="text-[1rem] font-semibold flex items-center gap-2">
<i class="pi pi-circle-fill text-green-500" />
Sessões em vigor
</div>
<Button
icon="pi pi-refresh"
severity="secondary"
outlined
size="small"
:loading="loadingSessions"
label="Atualizar"
@click="loadActiveSessions"
/>
</div>
<DataTable
:value="activeSessions"
:loading="loadingSessions"
empty-message="Nenhuma sessão ativa no momento"
size="small"
striped-rows
>
<Column header="Tenant" style="min-width: 200px">
<template #body="{ data }">
<div class="flex flex-col gap-0.5">
<span class="font-medium text-[1rem]">{{ tenantName(data.tenant_id) }}</span>
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_id }}</span>
</div>
</template>
</Column>
<Column header="Token">
<template #body="{ data }">
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.token.slice(0, 12) }}</span>
</template>
</Column>
<Column header="Restante" style="min-width: 100px">
<template #body="{ data }">
<span :class="isExpiringSoon(data.expires_at) ? 'text-orange-500 font-semibold' : ''">
{{ remainingLabel(data.expires_at) }}
</span>
</template>
</Column>
<Column header="Criada em">
<template #body="{ data }">
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.created_at) }}</span>
</template>
</Column>
<Column header="Nota">
<template #body="{ data }">
<span v-if="data._note" class="text-[1rem] italic text-[var(--text-color-secondary)]">{{ data._note }}</span>
<span v-else class="text-[1rem] text-[var(--text-color-secondary)]"></span>
</template>
</Column>
<Column header="Ações" style="width: 110px">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-copy" size="small" severity="secondary" outlined v-tooltip.top="'Copiar URL'" @click="copyUrl(buildSupportUrl(data.token))" />
<Button icon="pi pi-trash" size="small" severity="danger" outlined v-tooltip.top="'Revogar'" :loading="revokingToken === data.token" @click="handleRevoke(data.token)" />
</div>
</template>
</Column>
</DataTable>
</div>
</TabPanel>
<!-- Tab 2: Histórico -->
<TabPanel header="Histórico">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 mt-2">
<div class="flex items-center justify-between mb-4">
<div class="text-[1rem] font-semibold flex items-center gap-2">
<i class="pi pi-history text-[var(--primary-color)]" />
Últimas 100 sessões
</div>
<Button
icon="pi pi-refresh"
severity="secondary"
outlined
size="small"
:loading="loadingHistory"
label="Carregar"
@click="loadHistory"
/>
</div>
<DataTable
:value="sessionHistory"
:loading="loadingHistory"
empty-message="Clique em Carregar para ver o histórico"
size="small"
striped-rows
paginator
:rows="20"
>
<Column header="Status" style="width: 110px">
<template #body="{ data }">
<Tag :value="sessionStatusLabel(data)" :severity="sessionStatusSeverity(data)" />
</template>
</Column>
<Column header="Tenant" style="min-width: 180px">
<template #body="{ data }">
<div class="flex flex-col gap-0.5">
<span class="font-medium text-[1rem]">{{ tenantName(data.tenant_id) }}</span>
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_id }}</span>
</div>
</template>
</Column>
<Column header="Token">
<template #body="{ data }">
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.token.slice(0, 12) }}</span>
</template>
</Column>
<Column header="Criada em" sortable field="created_at">
<template #body="{ data }">
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.created_at) }}</span>
</template>
</Column>
<Column header="Expirava em">
<template #body="{ data }">
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.expires_at) }}</span>
</template>
</Column>
<Column header="Nota">
<template #body="{ data }">
<span v-if="data._note" class="text-[1rem] italic text-[var(--text-color-secondary)]">{{ data._note }}</span>
<span v-else class="text-[1rem] text-[var(--text-color-secondary)]"></span>
</template>
</Column>
<Column header="" style="width: 60px">
<template #body="{ data }">
<Button
v-if="!data._expired"
icon="pi pi-trash"
size="small"
severity="danger"
outlined
v-tooltip.top="'Revogar'"
:loading="revokingToken === data.token"
@click="handleRevoke(data.token)"
/>
</template>
</Column>
</DataTable>
</div>
</TabPanel>
</TabView>
</div>
</template>
+605
View File
@@ -0,0 +1,605 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/views/pages/saas/SaasWhatsappPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
const toast = useToast();
const confirm = useConfirm();
// Tenants
const tenants = ref([]);
const tenantMap = ref({});
const loadingTenants = ref(false);
const selectedTenantId = ref(null);
async function loadTenants() {
loadingTenants.value = true;
try {
const { data, error } = await supabase.from('tenants').select('id, name, kind').order('name', { ascending: true });
if (error) throw error;
const list = data || [];
tenantMap.value = Object.fromEntries(list.map((t) => [t.id, t.name || t.id]));
tenants.value = list.map((t) => ({
value: t.id,
label: `${t.name} (${t.kind ?? 'tenant'})`
}));
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar tenants', detail: e?.message, life: 4000 });
} finally {
loadingTenants.value = false;
}
}
// Canal WhatsApp do tenant selecionado
const channel = ref(null);
const credentials = ref({ api_url: '', api_key: '', instance_name: '' });
const hasCredentials = ref(false);
const loadingChannel = ref(false);
const savingCredentials = ref(false);
// Status de conexão
const connectionStatus = ref(null);
const connectionLoading = ref(false);
// QR Code
const qrDialog = ref(false);
const qrCodeBase64 = ref(null);
const qrLoading = ref(false);
const qrCountdown = ref(0);
let qrTimer = null;
let isMounted = true;
const connectionTag = computed(() => {
if (!hasCredentials.value) return { label: 'Não configurado', severity: 'secondary' };
if (channel.value && !channel.value.is_active) return { label: 'Desativado', severity: 'secondary' };
if (connectionLoading.value) return { label: 'Verificando...', severity: 'secondary' };
switch (connectionStatus.value) {
case 'open':
return { label: 'Conectado', severity: 'success' };
case 'connecting':
return { label: 'Conectando...', severity: 'warn' };
default:
return { label: 'Desconectado', severity: 'danger' };
}
});
const selectedTenantName = computed(() => {
if (!selectedTenantId.value) return '';
return tenantMap.value[selectedTenantId.value] || selectedTenantId.value;
});
// Ao selecionar um tenant, carregar canal
async function onTenantSelect() {
resetChannelState();
if (!selectedTenantId.value) return;
await loadChannel();
}
function resetChannelState() {
channel.value = null;
credentials.value = { api_url: '', api_key: '', instance_name: '' };
hasCredentials.value = false;
connectionStatus.value = null;
clearQrTimer();
qrCodeBase64.value = null;
}
async function loadChannel() {
if (!selectedTenantId.value) return;
loadingChannel.value = true;
try {
// Buscar pelo owner_id do tenant (que é o user_id do dono)
const { data, error } = await supabase.from('notification_channels').select('*').eq('tenant_id', selectedTenantId.value).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
if (error) throw error;
channel.value = data;
if (data?.credentials) {
credentials.value = {
api_url: data.credentials.api_url || '',
api_key: data.credentials.api_key || '',
instance_name: data.credentials.instance_name || ''
};
hasCredentials.value = true;
// Só verificar conexão se o canal estiver ativo
if (data.is_active) await checkConnectionStatus();
}
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar canal', detail: e.message, life: 4000 });
} finally {
loadingChannel.value = false;
}
}
// Salvar credenciais
async function saveCredentials() {
if (!selectedTenantId.value || savingCredentials.value) return;
if (!credentials.value.api_url || !credentials.value.api_key || !credentials.value.instance_name) {
toast.add({ severity: 'warn', summary: 'Preencha todos os campos', life: 3000 });
return;
}
// Validação básica de URL
try {
new URL(credentials.value.api_url);
} catch {
toast.add({ severity: 'warn', summary: 'URL inválida', detail: 'A URL deve começar com http:// ou https://', life: 4000 });
return;
}
savingCredentials.value = true;
try {
// Buscar o owner_id (dono do tenant)
const { data: members, error: memErr } = await supabase.from('tenant_members').select('user_id').eq('tenant_id', selectedTenantId.value).in('role', ['tenant_admin', 'admin']).limit(1).single();
if (memErr) throw memErr;
const ownerId = members.user_id;
const creds = { ...credentials.value };
// Remover barra final da URL
creds.api_url = creds.api_url.replace(/\/+$/, '');
if (channel.value?.id) {
// Atualizar existente
const { error } = await supabase
.from('notification_channels')
.update({
credentials: creds,
display_name: `WhatsApp — ${selectedTenantName.value}`,
is_active: true
})
.eq('id', channel.value.id);
if (error) throw error;
} else {
// Inserir novo recarregar para evitar duplicata por race condition
const { data: existing } = await supabase.from('notification_channels').select('id').eq('owner_id', ownerId).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
if (existing?.id) {
// Já existe (criado por outra aba/sessão) atualizar
const { error } = await supabase
.from('notification_channels')
.update({
credentials: creds,
display_name: `WhatsApp — ${selectedTenantName.value}`,
is_active: true,
provider: 'evolution_api'
})
.eq('id', existing.id);
if (error) throw error;
} else {
const { data, error } = await supabase
.from('notification_channels')
.insert({
owner_id: ownerId,
tenant_id: selectedTenantId.value,
channel: 'whatsapp',
provider: 'evolution_api',
is_active: true,
display_name: `WhatsApp — ${selectedTenantName.value}`,
credentials: creds
})
.select('*')
.single();
if (error) throw error;
channel.value = data;
}
}
hasCredentials.value = true;
toast.add({ severity: 'success', summary: 'Credenciais salvas', detail: `WhatsApp configurado para ${selectedTenantName.value}`, life: 3000 });
// Recarregar canal para sincronizar estado
await loadChannel();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 });
} finally {
savingCredentials.value = false;
}
}
// Desativar WhatsApp do tenant
function confirmDeactivate() {
confirm.require({
message: `Desativar o WhatsApp de "${selectedTenantName.value}"? O terapeuta não poderá mais enviar mensagens.`,
header: 'Desativar WhatsApp',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: async () => {
try {
const { error } = await supabase.from('notification_channels').update({ is_active: false }).eq('id', channel.value.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'WhatsApp desativado', life: 3000 });
await loadChannel();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
}
}
});
}
// Status da conexão via Evolution API
async function checkConnectionStatus() {
if (!hasCredentials.value) return;
connectionLoading.value = true;
try {
const res = await fetch(`${credentials.value.api_url}/instance/fetchInstances`, {
headers: { apikey: credentials.value.api_key }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const instances = await res.json();
const inst = Array.isArray(instances) ? instances.find((i) => i.instance?.instanceName === credentials.value.instance_name) : null;
connectionStatus.value = inst?.instance?.status || 'close';
} catch (e) {
connectionStatus.value = 'close';
toast.add({
severity: 'warn',
summary: 'Não foi possível conectar à Evolution API',
detail: 'Verifique a URL e a chave de API.',
life: 5000
});
} finally {
connectionLoading.value = false;
}
}
// QR Code
async function fetchQrCode() {
if (!isMounted) return;
qrLoading.value = true;
qrCodeBase64.value = null;
clearQrTimer();
try {
const res = await fetch(`${credentials.value.api_url}/instance/connect/${credentials.value.instance_name}`, { headers: { apikey: credentials.value.api_key } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const base64 = data?.base64;
if (!base64) {
if (data?.instance?.status === 'open') {
connectionStatus.value = 'open';
toast.add({ severity: 'success', summary: 'WhatsApp já está conectado!', life: 3000 });
qrDialog.value = false;
return;
}
throw new Error('QR Code não retornado pela API.');
}
qrCodeBase64.value = base64.startsWith('data:') ? base64 : `data:image/png;base64,${base64}`;
startQrCountdown();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao gerar QR Code', detail: e.message, life: 5000 });
} finally {
qrLoading.value = false;
}
}
function startQrCountdown() {
qrCountdown.value = 30;
qrTimer = setInterval(() => {
qrCountdown.value--;
if (qrCountdown.value <= 0) {
clearQrTimer();
fetchQrCode();
}
}, 1000);
}
function clearQrTimer() {
if (qrTimer) {
clearInterval(qrTimer);
qrTimer = null;
}
qrCountdown.value = 0;
}
function openQrDialog() {
qrDialog.value = true;
fetchQrCode();
}
function closeQrDialog() {
qrDialog.value = false;
clearQrTimer();
qrCodeBase64.value = null;
checkConnectionStatus();
}
// Visão geral: todos os canais WhatsApp
const allChannels = ref([]);
const loadingAll = ref(false);
async function loadAllChannels() {
loadingAll.value = true;
try {
const { data, error } = await supabase.from('notification_channels').select('*').eq('channel', 'whatsapp').is('deleted_at', null).order('created_at', { ascending: false });
if (error) throw error;
allChannels.value = data || [];
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar canais', detail: e.message, life: 4000 });
} finally {
loadingAll.value = false;
}
}
function channelStatusTag(ch) {
if (!ch.is_active) return { label: 'Desativado', severity: 'secondary' };
switch (ch.connection_status) {
case 'connected':
return { label: 'Conectado', severity: 'success' };
case 'connecting':
return { label: 'Conectando', severity: 'warn' };
case 'qr_pending':
return { label: 'Aguardando QR', severity: 'warn' };
case 'error':
return { label: 'Erro', severity: 'danger' };
default:
return { label: 'Desconectado', severity: 'danger' };
}
}
function formatDate(dt) {
if (!dt) return '—';
const d = new Date(dt);
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' }) + ' ' + d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function selectTenantFromTable(tenantId) {
selectedTenantId.value = tenantId;
activeTab.value = 1;
onTenantSelect();
}
// Tabs
const activeTab = ref(0);
// Inicialização
onMounted(async () => {
await Promise.all([loadTenants(), loadAllChannels()]);
});
onBeforeUnmount(() => {
isMounted = false;
clearQrTimer();
});
</script>
<template>
<div class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-comments" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Gerenciar WhatsApp</div>
<div class="cfg-subheader__sub">Configure a integração WhatsApp para cada terapeuta ou clínica</div>
</div>
</div>
<!-- Abas -->
<Tabs :value="activeTab" @update:value="activeTab = $event">
<TabList>
<Tab :value="0"><i class="pi pi-list mr-2" />Visão geral</Tab>
<Tab :value="1"><i class="pi pi-cog mr-2" />Configurar tenant</Tab>
</TabList>
<TabPanels>
<!-- ABA 1 Visão geral -->
<TabPanel :value="0">
<div class="flex flex-col gap-3 pt-3">
<div class="flex items-center gap-2">
<span class="text-sm text-[var(--text-color-secondary)]"> {{ allChannels.length }} canal(is) WhatsApp configurado(s) </span>
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="loadingAll" v-tooltip.bottom="'Atualizar'" class="ml-auto" @click="loadAllChannels" />
</div>
<DataTable :value="allChannels" :loading="loadingAll" responsive-layout="scroll" striped-rows class="text-sm">
<Column header="Tenant" style="min-width: 180px">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-semibold">{{ tenantMap[data.tenant_id] || '—' }}</span>
<span class="text-xs text-[var(--text-color-secondary)] font-mono">{{ data.tenant_id?.slice(0, 8) }}</span>
</div>
</template>
</Column>
<Column header="Instância" style="min-width: 140px">
<template #body="{ data }">
<span class="font-mono text-xs">{{ data.credentials?.instance_name || '—' }}</span>
</template>
</Column>
<Column header="Status" style="min-width: 120px">
<template #body="{ data }">
<Tag :value="channelStatusTag(data).label" :severity="channelStatusTag(data).severity" class="text-[0.7rem]" />
</template>
</Column>
<Column header="Ativo" style="min-width: 80px">
<template #body="{ data }">
<Tag :value="data.is_active ? 'Sim' : 'Não'" :severity="data.is_active ? 'success' : 'secondary'" class="text-[0.7rem]" />
</template>
</Column>
<Column header="Criado em" style="min-width: 140px">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
<Column header="" style="width: 60px">
<template #body="{ data }">
<Button icon="pi pi-pencil" text rounded size="small" severity="secondary" v-tooltip.left="'Configurar'" @click="selectTenantFromTable(data.tenant_id)" />
</template>
</Column>
<template #empty>
<div class="text-center py-6 text-sm text-[var(--text-color-secondary)]">Nenhum canal WhatsApp configurado ainda.</div>
</template>
</DataTable>
</div>
</TabPanel>
<!-- ABA 2 Configurar tenant -->
<TabPanel :value="1">
<div class="flex flex-col gap-4 pt-3">
<!-- Seletor de tenant -->
<div class="border border-[var(--surface-border)] rounded-lg p-4 bg-[var(--surface-card)]">
<div class="flex flex-col gap-2">
<label class="text-xs font-semibold">Selecionar terapeuta / clínica</label>
<div class="flex gap-2">
<Select v-model="selectedTenantId" :options="tenants" option-label="label" option-value="value" placeholder="Escolha um tenant..." filter :loading="loadingTenants" class="flex-1" @change="onTenantSelect" />
</div>
</div>
</div>
<!-- Nenhum tenant selecionado -->
<div v-if="!selectedTenantId" class="border border-dashed border-[var(--surface-border)] rounded-lg p-6 text-center bg-[var(--surface-ground)]">
<i class="pi pi-arrow-up text-2xl text-[var(--text-color-secondary)] opacity-40 mb-2" />
<p class="text-sm text-[var(--text-color-secondary)] m-0">Selecione um tenant acima para configurar o WhatsApp.</p>
</div>
<!-- Loading -->
<div v-else-if="loadingChannel" class="flex justify-center py-8">
<ProgressSpinner style="width: 40px; height: 40px" />
</div>
<!-- Painel do tenant selecionado -->
<template v-else>
<!-- Status da conexão -->
<div class="border border-[var(--surface-border)] rounded-lg p-4 bg-[var(--surface-card)]">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3">
<div class="grid place-items-center w-10 h-10 rounded-full" :class="connectionStatus === 'open' ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-500'">
<i class="pi pi-comments text-lg" />
</div>
<div>
<div class="font-semibold text-sm">{{ selectedTenantName }}</div>
<Tag :value="connectionTag.label" :severity="connectionTag.severity" class="text-xs mt-1" />
</div>
</div>
<div class="flex gap-2">
<Button :label="connectionStatus === 'open' ? 'Reconectar' : 'Conectar'" icon="pi pi-qrcode" size="small" :disabled="!hasCredentials" @click="openQrDialog" />
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="connectionLoading" :disabled="!hasCredentials" v-tooltip.bottom="'Verificar status'" @click="checkConnectionStatus" />
<Button v-if="hasCredentials && channel?.is_active" icon="pi pi-power-off" size="small" severity="danger" outlined v-tooltip.bottom="'Desativar'" @click="confirmDeactivate" />
</div>
</div>
</div>
<!-- Formulário de credenciais -->
<div class="border border-[var(--surface-border)] rounded-lg overflow-hidden">
<div class="flex items-center gap-2 px-4 py-3 bg-[var(--surface-ground)]">
<i class="pi pi-key text-sm opacity-60" />
<span class="text-sm font-semibold">Credenciais da Evolution API</span>
</div>
<div class="px-4 py-4 flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">URL da Evolution API</label>
<InputText v-model="credentials.api_url" placeholder="https://evolution.seudominio.com" class="w-full" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">API Key</label>
<InputText v-model="credentials.api_key" placeholder="Chave de API da Evolution" class="w-full" type="password" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Nome da instância</label>
<InputText v-model="credentials.instance_name" placeholder="nome-da-instancia" class="w-full" />
</div>
<div class="flex justify-end">
<Button label="Salvar credenciais" icon="pi pi-save" size="small" :loading="savingCredentials" :disabled="savingCredentials" @click="saveCredentials" />
</div>
</div>
</div>
</template>
</div>
</TabPanel>
</TabPanels>
</Tabs>
<!-- Dialog QR Code -->
<Dialog v-model:visible="qrDialog" :header="`Conectar WhatsApp — ${selectedTenantName}`" modal :style="{ width: '420px', maxWidth: '96vw' }" :draggable="false" @hide="closeQrDialog">
<div class="flex flex-col items-center gap-4 py-2">
<p class="text-sm text-[var(--text-color-secondary)] text-center m-0">Escaneie o QR Code com o WhatsApp do celular do terapeuta para conectar.</p>
<div v-if="qrLoading" class="flex flex-col items-center gap-3 py-6">
<ProgressSpinner style="width: 48px; height: 48px" />
<span class="text-xs text-[var(--text-color-secondary)]">Gerando QR Code...</span>
</div>
<div v-else-if="qrCodeBase64" class="flex flex-col items-center gap-3">
<img :src="qrCodeBase64" alt="QR Code WhatsApp" class="w-64 h-64 rounded-lg border border-[var(--surface-border)]" />
<div class="flex items-center gap-2 text-xs text-[var(--text-color-secondary)]">
<i class="pi pi-clock" />
<span
>Atualiza automaticamente em <strong>{{ qrCountdown }}s</strong></span
>
</div>
</div>
<div v-else class="text-center py-6">
<i class="pi pi-exclamation-circle text-3xl text-[var(--text-color-secondary)] opacity-40 mb-2" />
<p class="text-sm text-[var(--text-color-secondary)] m-0">Não foi possível gerar o QR Code.</p>
</div>
<Button label="Atualizar QR Code" icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="qrLoading" @click="fetchQrCode" />
</div>
<template #footer>
<Button label="Fechar" text @click="closeQrDialog" />
</template>
</Dialog>
<ConfirmDialog />
</div>
</template>
<style scoped>
.cfg-subheader {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.875rem 1rem;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
position: relative;
overflow: hidden;
}
.cfg-subheader::before {
content: '';
position: absolute;
top: -20px;
right: -20px;
width: 80px;
height: 80px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
filter: blur(20px);
pointer-events: none;
}
.cfg-subheader__icon {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 6px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.85rem;
}
.cfg-subheader__title {
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--primary-color, #6366f1);
}
.cfg-subheader__sub {
font-size: 0.75rem;
color: var(--text-color-secondary);
opacity: 0.85;
}
</style>
File diff suppressed because it is too large Load Diff