Files
agenciapsilmno/src/layout/configuracoes/ConfiguracoesCanaisPage.vue
T
Leonardo a7f6bcbe66 F3 schema-per-tenant: frontend usa tenantDb() pra tabelas tenant
- useTenantDb composable + lib/supabase/tenantClient (tenantDb/tenantSchemaName)
- tenantStore: getters activeTenantSlug/activeTenantSchema; my_tenants() RPC
  passa a devolver slug+nome (migration 07)
- codemod scripts/codemod-tenant-db.py: supabase.from('<84 tabelas + 6 views
  tenant>') -> tenantDb().from(...) em 139 arquivos (777 chamadas), remove
  .eq('tenant_id') das cadeias tenant (173)
- passada manual (4 agentes): remove tenant_id de payloads insert/upsert/update,
  selects, .or/.is de defaults; onConflict ajustado pros uniques sem tenant_id
  (singletons usam 'singleton'); realtime de tabelas tenant aponta pro schema
  do tenant ativo; repos dropam tenant_id defensivamente de payloads externos
- agendaSelects: tenant_id fora do AGENDA_EVENT_SELECT (quebraria PostgREST)
- zero embeds cross-schema (todos FK embeds sao tenant->tenant ou global->global)
- build de producao passa; 67 .js checados

Pendente (fora do escopo F3, sao cross-tenant/anon -> F4/F6):
- AgendadorPublicoPage (anon, resolve tenant por link_slug)
- Saas{Feriados,NotificationTemplates,DocumentTemplates,Whatsapp}Page
  (gerenciam defaults do sistema / views cross-tenant)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 04:44:59 -03:00

281 lines
11 KiB
Vue

<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/layout/configuracoes/ConfiguracoesCanaisPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
const router = useRouter();
const toast = useToast();
const tenantStore = useTenantStore();
const userId = ref(null);
const tenantId = ref(null);
const loading = ref(true);
// ── WhatsApp ──────────────────────────────────────────────────
const whatsapp = ref({
configured: false,
status: null, // 'open' | 'close' | 'connecting' | null
checking: false,
credentials: null
});
// ── SMS ───────────────────────────────────────────────────────
const sms = ref({
hasCredits: false,
balance: 0,
isActive: false
});
// ── Email ─────────────────────────────────────────────────────
const email = ref({
templatesCount: 0
});
// ── Computed status cards ─────────────────────────────────────
const channels = computed(() => [
{
key: 'whatsapp',
label: 'WhatsApp',
icon: 'pi pi-whatsapp',
description: 'Envie lembretes e confirmações via WhatsApp automaticamente.',
route: '/configuracoes/whatsapp',
tag: whatsappTag.value,
details: whatsappDetails.value
},
{
key: 'sms',
label: 'SMS',
icon: 'pi pi-comment',
description: 'Envie SMS para pacientes com créditos pré-pagos.',
route: '/configuracoes/sms',
tag: smsTag.value,
details: smsDetails.value
},
{
key: 'email',
label: 'E-mail',
icon: 'pi pi-envelope',
description: 'Personalize os e-mails enviados automaticamente aos pacientes.',
route: '/configuracoes/email-templates',
tag: emailTag.value,
details: emailDetails.value
}
]);
const whatsappTag = computed(() => {
if (whatsapp.value.checking) return { label: 'Verificando...', severity: 'secondary', icon: 'pi pi-spin pi-spinner' };
if (!whatsapp.value.configured) return { label: 'Não configurado', severity: 'secondary', icon: 'pi pi-info-circle' };
switch (whatsapp.value.status) {
case 'open':
return { label: 'Conectado', severity: 'success', icon: 'pi pi-check-circle' };
case 'connecting':
return { label: 'Conectando...', severity: 'warn', icon: 'pi pi-spin pi-spinner' };
default:
return { label: 'Desconectado', severity: 'danger', icon: 'pi pi-times-circle' };
}
});
const whatsappDetails = computed(() => {
if (!whatsapp.value.configured) return 'Configure as credenciais da Evolution API para conectar.';
if (whatsapp.value.status === 'open') return 'Canal ativo e enviando mensagens.';
return 'Canal configurado mas desconectado. Reconecte pelo QR Code.';
});
const smsTag = computed(() => {
if (!sms.value.hasCredits) return { label: 'Sem créditos', severity: 'secondary', icon: 'pi pi-info-circle' };
if (sms.value.balance <= 0) return { label: 'Sem saldo', severity: 'danger', icon: 'pi pi-times-circle' };
if (sms.value.balance <= 10) return { label: `${sms.value.balance} créditos`, severity: 'warn', icon: 'pi pi-exclamation-triangle' };
return { label: `${sms.value.balance} créditos`, severity: 'success', icon: 'pi pi-check-circle' };
});
const smsDetails = computed(() => {
if (!sms.value.hasCredits) return 'Adquira créditos SMS em Recursos Extras para ativar o canal.';
if (sms.value.balance <= 0) return 'Saldo zerado. Os envios de SMS estão pausados.';
return `Saldo de ${sms.value.balance} créditos disponíveis para envio.`;
});
const emailTag = computed(() => {
return { label: 'Ativo', severity: 'success', icon: 'pi pi-check-circle' };
});
const emailDetails = computed(() => {
if (email.value.templatesCount > 0) {
return `${email.value.templatesCount} template(s) personalizados.`;
}
return 'Usando templates padrão. Personalize se desejar.';
});
// ── Load ──────────────────────────────────────────────────────
async function loadUser() {
const {
data: { user }
} = await supabase.auth.getUser();
if (!user) return;
userId.value = user.id;
tenantId.value = tenantStore.activeTenantId || user.id;
}
async function loadWhatsApp() {
if (!tenantId.value) return;
let { data } = await tenantDb().from('notification_channels').select('credentials, connection_status').eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
// Fallback owner_id
if (!data && userId.value && userId.value !== tenantId.value) {
const fb = await tenantDb().from('notification_channels').select('credentials, connection_status').eq('owner_id', userId.value).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
data = fb.data;
}
if (data?.credentials) {
whatsapp.value.configured = true;
whatsapp.value.credentials = data.credentials;
// Tenta verificar status real via Evolution API
whatsapp.value.checking = true;
try {
const res = await fetch(`${data.credentials.api_url}/instance/fetchInstances`, {
headers: { apikey: data.credentials.api_key }
});
if (res.ok) {
const instances = await res.json();
const inst = Array.isArray(instances) ? instances.find((i) => i.instance?.instanceName === data.credentials.instance_name) : null;
whatsapp.value.status = inst?.instance?.status || 'close';
} else {
whatsapp.value.status = 'close';
}
} catch {
whatsapp.value.status = 'close';
} finally {
whatsapp.value.checking = false;
}
}
}
async function loadSms() {
if (!tenantId.value) return;
const { data } = await supabase.from('addon_credits').select('balance, is_active').eq('tenant_id', tenantId.value).eq('addon_type', 'sms').eq('is_active', true).maybeSingle();
if (data) {
sms.value.hasCredits = true;
sms.value.balance = data.balance || 0;
sms.value.isActive = data.is_active;
}
}
async function loadEmail() {
if (!tenantId.value) return;
const { count } = await tenantDb().from('email_templates_tenant').select('id', { count: 'exact', head: true });
email.value.templatesCount = count || 0;
}
function goTo(route) {
router.push(route);
}
// ── Init ──────────────────────────────────────────────────────
onMounted(async () => {
await loadUser();
await Promise.all([loadWhatsApp(), loadSms(), loadEmail()]);
loading.value = false;
});
</script>
<template>
<div class="flex flex-col gap-5">
<!-- Header -->
<Card>
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-bell text-xl" />
Canais de Notificação
</div>
</template>
<template #subtitle>Visão geral dos canais de comunicação com seus pacientes.</template>
</Card>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-12 text-surface-500"><i class="pi pi-spin pi-spinner mr-2 text-xl" /> Verificando canais...</div>
<!-- Channel Cards -->
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="ch in channels" :key="ch.key" class="border border-surface rounded-xl p-5 flex flex-col gap-4 cursor-pointer hover:shadow-md transition-shadow" @click="goTo(ch.route)">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<i :class="ch.icon" class="text-xl text-primary" />
</div>
<span class="font-semibold text-lg">{{ ch.label }}</span>
</div>
<Tag :value="ch.tag.label" :severity="ch.tag.severity" :icon="ch.tag.icon" />
</div>
<!-- Descrição -->
<p class="text-sm text-surface-500 m-0">{{ ch.description }}</p>
<!-- Status detalhe -->
<div class="text-xs text-surface-400 mt-auto">
{{ ch.details }}
</div>
<!-- Link -->
<div class="flex justify-end">
<Button label="Configurar" icon="pi pi-arrow-right" iconPos="right" size="small" text @click.stop="goTo(ch.route)" />
</div>
</div>
</div>
<!-- Resumo rápido -->
<Card>
<template #title>Como funciona</template>
<template #content>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 font-semibold">
<i class="pi pi-whatsapp text-green-500" />
WhatsApp
</div>
<p class="text-surface-500 m-0">Conecte via Evolution API e QR Code. Mensagens automáticas de lembrete, confirmação e cancelamento.</p>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 font-semibold">
<i class="pi pi-comment text-blue-500" />
SMS
</div>
<p class="text-surface-500 m-0">Funciona com créditos pré-pagos. Adquira pacotes em Recursos Extras. Ideal para pacientes sem WhatsApp.</p>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 font-semibold">
<i class="pi pi-envelope text-orange-500" />
E-mail
</div>
<p class="text-surface-500 m-0">Ativo por padrão. Personalize os templates de e-mail com o visual da sua clínica.</p>
</div>
</div>
</template>
</Card>
</div>
</template>