a7f6bcbe66
- 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>
281 lines
11 KiB
Vue
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>
|