CRM WhatsApp Grupo 3 completo + Marco A/B (Asaas) + admin SaaS + refactors polimórficos

Sessão 11+: fechamento do CRM de WhatsApp com dois providers (Evolution/Twilio),
sistema de créditos com Asaas/PIX, polimorfismo de telefones/emails, e integração
admin SaaS no /saas/addons existente.

═══════════════════════════════════════════════════════════════════════════
GRUPO 3 — WORKFLOW / CRM (completo)
═══════════════════════════════════════════════════════════════════════════

3.1 Tags · migration conversation_tags + seed de 5 system tags · composable
useConversationTags.js · popover + pills no drawer e nos cards do Kanban.

3.2 Atribuição de conversa a terapeuta · migration 20260421000012 com PK
(tenant_id, thread_key), UPSERT, RLS que valida assignee como membro ativo
do mesmo tenant · view conversation_threads expandida com assigned_to +
assigned_at · composable useConversationAssignment.js · drawer com Select
filtrável + botão "Assumir" · inbox com filtro aside (Todas/Minhas/Não
atribuídas) e chip do responsável em cada card (destaca "Eu" em azul).

3.3 Notas internas · migration conversation_notes · composable + seção
colapsável no drawer · apenas o criador pode editar/apagar (RLS).

3.5 Converter desconhecido em paciente · botão + dialog quick-cadastro ·
"Vincular existente" com Select filter de até 500 pacientes · cria
telefone WhatsApp (vinculado) via upsertWhatsappForExisting.

3.6 Histórico de conversa no prontuário · nova aba "Conversas" em
PatientProntuario.vue · PatientConversationsTab.vue com stats (total /
recebidas / enviadas / primeira / última), SelectButton de filtro, timeline
com bolhas por direção, mídia inline (imagem/áudio/vídeo/doc via signed
URL), indicadores ✓ ✓✓ de delivery, botão "Abrir no CRM".

═══════════════════════════════════════════════════════════════════════════
MARCO A — UNIFICAÇÃO WHATSAPP (dois providers mutuamente exclusivos)
═══════════════════════════════════════════════════════════════════════════

- Página chooser ConfiguracoesWhatsappChooserPage.vue com 2 cards (Pessoal/
  Oficial), deactivate via edge function deactivate-notification-channel
- send-whatsapp-message refatorada com roteamento por provider; Twilio deduz
  1 crédito antes do envio e refunda em falha
- Paridade Twilio (novo): módulo compartilhado supabase/functions/_shared/
  whatsapp-hooks.ts com lógica provider-agnóstica (opt-in, opt-out, auto-
  reply, schedule helpers em TZ São Paulo, makeTwilioCreditedSendFn que
  envolve envio em dedução atômica + rollback). Consumido por Evolution E
  Twilio inbound. Evolution refatorado (~290 linhas duplicadas removidas).
- Bucket privado whatsapp-media · decrypt via Evolution getBase64From
  MediaMessage · upload com path tenant/yyyy/mm · signed URLs on-demand

═══════════════════════════════════════════════════════════════════════════
MARCO B — SISTEMA DE CRÉDITOS WHATSAPP + ASAAS
═══════════════════════════════════════════════════════════════════════════

Banco:
- Migration 20260421000007_whatsapp_credits (4 tabelas: balance,
  transactions, packages, purchases) + RPCs add_whatsapp_credits e
  deduct_whatsapp_credits (atômicas com SELECT FOR UPDATE)
- Migration 20260421000013_tenant_cpf_cnpj (coluna em tenants com CHECK
  de 11 ou 14 dígitos)

Edge functions:
- create-whatsapp-credit-charge · Asaas v3 (sandbox + prod) · PIX com
  QR code · getOrCreateAsaasCustomer patcha customer existente com CPF
  quando está faltando
- asaas-webhook · recebe PAYMENT_RECEIVED/CONFIRMED e credita balance

Frontend (tenant):
- Página /configuracoes/creditos-whatsapp com saldo + loja + histórico
- Dialog de confirmação com CPF/CNPJ (validação via isValidCPF/CNPJ de
  utils/validators, formatação on-blur, pré-fill de tenants.cpf_cnpj,
  persiste no primeiro uso) · fallback sandbox 24971563792 REMOVIDO
- Composable useWhatsappCredits extrai erros amigáveis via
  error.context.json()

Frontend (SaaS admin):
- Em /saas/addons (reuso do pattern existente, não criou página paralela):
  - Aba 4 "Pacotes WhatsApp" — CRUD whatsapp_credit_packages com DataTable,
    toggle is_active inline, dialog de edição com validação
  - Aba 5 "Topup WhatsApp" — tenant Select com saldo ao vivo · RPC
    add_whatsapp_credits com p_admin_id = auth.uid() (auditoria) · histórico
    das últimas 20 transações topup/adjustment/refund

═══════════════════════════════════════════════════════════════════════════
GRUPO 2 — AUTOMAÇÃO
═══════════════════════════════════════════════════════════════════════════

2.3 Auto-reply · conversation_autoreply_settings + conversation_autoreply_
log · 3 modos de schedule (agenda das regras semanais, business_hours
custom, custom_window) · cooldown por thread · respeita opt-out · agora
funciona em Evolution E Twilio (hooks compartilhados)

2.4 Lembretes de sessão · conversation_session_reminders_settings +
_logs · edge send-session-reminders (cron) · janelas 24h e 2h antes ·
Twilio deduz crédito com rollback em falha

═══════════════════════════════════════════════════════════════════════════
GRUPO 5 — COMPLIANCE (LGPD Art. 18 §2)
═══════════════════════════════════════════════════════════════════════════

5.2 Opt-out · conversation_optouts + conversation_optout_keywords (10 system
seed + custom por tenant) · detecção por regex word-boundary e normalização
(lowercase + strip acentos + pontuação) · ack automático (deduz crédito em
Twilio) · opt-in via "voltar", "retornar", "reativar", "restart" ·
página /configuracoes/conversas-optouts com CRUD de keywords

═══════════════════════════════════════════════════════════════════════════
REFACTOR POLIMÓRFICO — TELEFONES + EMAILS
═══════════════════════════════════════════════════════════════════════════

- contact_types + contact_phones (entity_type + entity_id) — migration
  20260421000008 · contact_email_types + contact_emails — 20260421000011
- Componentes ContactPhonesEditor.vue e ContactEmailsEditor.vue (add/edit/
  remove com confirm, primary selector, WhatsApp linked badge)
- Composables useContactPhones.js + useContactEmails.js com
  unsetOtherPrimaries() e validação
- Trocado em PatientsCadastroPage.vue e MedicosPage.vue (removidos campos
  legados telefone/telefone_alternativo e email_principal/email_alternativo)
- Migration retroativa v2 (20260421000010) detecta conversation_messages
  e cria/atualiza phone como WhatsApp vinculado

═══════════════════════════════════════════════════════════════════════════
POLIMENTO VISUAL + INFRA
═══════════════════════════════════════════════════════════════════════════

- Skeletons simplificados no dashboard do terapeuta
- Animações fade-up com stagger via [--delay:Xms] (fix specificity sobre
  .dash-card box-shadow transition)
- ConfirmDialog com group="conversation-drawer" (evita montagem duplicada)
- Image preview PrimeVue com botão de download injetado via MutationObserver
  (fetch + blob para funcionar cross-origin)
- Áudio/vídeo com preload="metadata" e controles de velocidade do browser
- friendlySendError() mapeia códigos do edge pra mensagens pt-BR via
  error.context.json()
- Teleport #cfg-page-actions para ações globais de Configurações
- Brotli/Gzip + auto-import Vue/PrimeVue + bundle analyzer
- AppLayout consolidado (removidas duplicatas por área) + RouterPassthrough
- Removido console.trace debug que estava em watch de router e queries
  Supabase (degradava perf pra todos)
- Realtime em conversation_messages via publication supabase_realtime
- Notifier global flutuante com beep + toggle mute (4 camadas: badge +
  sino + popup + browser notification)

═══════════════════════════════════════════════════════════════════════════
MIGRATIONS NOVAS (13)
═══════════════════════════════════════════════════════════════════════════

20260420000001_patient_intake_invite_info_rpc
20260420000002_audit_logs_lgpd
20260420000003_audit_logs_unified_view
20260420000004_lgpd_export_patient_rpc
20260420000005_conversation_messages
20260420000005_search_global_rpc
20260420000006_conv_messages_notifications
20260420000007_notif_channels_saas_admin_insert
20260420000008_conv_messages_realtime
20260420000009_conv_messages_delivery_status
20260421000001_whatsapp_media_bucket
20260421000002_conversation_notes
20260421000003_conversation_tags
20260421000004_conversation_autoreply
20260421000005_conversation_optouts
20260421000006_session_reminders
20260421000007_whatsapp_credits
20260421000008_contact_phones
20260421000009_retroactive_whatsapp_link
20260421000010_retroactive_whatsapp_link_v2
20260421000011_contact_emails
20260421000012_conversation_assignments
20260421000013_tenant_cpf_cnpj

═══════════════════════════════════════════════════════════════════════════
EDGE FUNCTIONS NOVAS / MODIFICADAS
═══════════════════════════════════════════════════════════════════════════

Novas:
- _shared/whatsapp-hooks.ts (módulo compartilhado)
- asaas-webhook
- create-whatsapp-credit-charge
- deactivate-notification-channel
- evolution-webhook-provision
- evolution-whatsapp-inbound
- get-intake-invite-info
- notification-webhook
- send-session-reminders
- send-whatsapp-message
- submit-patient-intake
- twilio-whatsapp-inbound

═══════════════════════════════════════════════════════════════════════════
FRONTEND — RESUMO
═══════════════════════════════════════════════════════════════════════════

Composables novos: useAddonExtrato, useAuditoria, useAutoReplySettings,
useClinicKPIs, useContactEmails, useContactPhones, useConversationAssignment,
useConversationNotes, useConversationOptouts, useConversationTags,
useConversations, useLgpdExport, useSessionReminders, useWhatsappCredits

Stores: conversationDrawerStore

Componentes novos: ConversationDrawer, GlobalInboundNotifier, GlobalSearch,
ContactEmailsEditor, ContactPhonesEditor

Páginas novas: CRMConversasPage, PatientConversationsTab, AddonsExtratoPage,
AuditoriaPage, NotificationsHistoryPage, ConfiguracoesWhatsappChooserPage,
ConfiguracoesConversasAutoreplyPage, ConfiguracoesConversasOptoutsPage,
ConfiguracoesConversasTagsPage, ConfiguracoesCreditosWhatsappPage,
ConfiguracoesLembretesSessaoPage

Utils novos: addonExtratoExport, auditoriaExport, excelExport,
lgpdExportFormats

Páginas existentes alteradas: ClinicDashboard, PatientsCadastroPage,
PatientCadastroDialog, PatientsListPage, MedicosPage, PatientProntuario,
ConfiguracoesWhatsappPage, SaasWhatsappPage, ConfiguracoesRecursosExtrasPage,
ConfiguracoesPage, AgendaTerapeutaPage, AgendaClinicaPage, NotificationItem,
NotificationDrawer, AppLayout, AppTopbar, useMenuBadges,
patientsRepository, SaasAddonsPage (aba 4 + 5 WhatsApp)

Routes: routes.clinic, routes.configs, routes.therapist atualizados
Menus: clinic.menu, therapist.menu, saas.menu atualizados

═══════════════════════════════════════════════════════════════════════════
NOTAS

- Após subir, rodar supabase functions serve --no-verify-jwt
  --env-file supabase/functions/.env pra carregar o módulo _shared
- WHATSAPP_SETUP.md reescrito (~400 linhas) com setup completo dos 3
  providers + troubleshooting + LGPD
- HANDOFF.md atualizado com estado atual e próximos passos

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-04-23 07:05:24 -03:00
parent 037ba3721f
commit 2644e60bb6
191 changed files with 38629 additions and 3756 deletions
+209
View File
@@ -0,0 +1,209 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useAddonExtrato.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
// ─── helpers de período ─────────────────────────────────────────────────────
function startOfDay(d) {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
function endOfDay(d) {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x;
}
function resolveDateRange(preset, customRange) {
const now = new Date();
if (preset === 'thisMonth') {
const start = new Date(now.getFullYear(), now.getMonth(), 1);
return { from: startOfDay(start), to: endOfDay(now) };
}
if (preset === 'lastMonth') {
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const end = new Date(now.getFullYear(), now.getMonth(), 0);
return { from: startOfDay(start), to: endOfDay(end) };
}
if (preset === 'last90') {
const start = new Date(now);
start.setDate(start.getDate() - 90);
return { from: startOfDay(start), to: endOfDay(now) };
}
if (preset === 'custom' && Array.isArray(customRange) && customRange[0] && customRange[1]) {
return { from: startOfDay(customRange[0]), to: endOfDay(customRange[1]) };
}
// fallback: thisMonth
const start = new Date(now.getFullYear(), now.getMonth(), 1);
return { from: startOfDay(start), to: endOfDay(now) };
}
// ─── sanitização de busca ───────────────────────────────────────────────────
function sanitizeSearch(raw) {
if (typeof raw !== 'string') return '';
const trimmed = raw.trim();
if (!trimmed) return '';
// limite defensivo para não enviar termos absurdos pro client-side filter
return trimmed.slice(0, 120).toLowerCase();
}
// ─── composable ─────────────────────────────────────────────────────────────
export function useAddonExtrato() {
const tenantStore = useTenantStore();
const transactions = ref([]);
const balances = ref({}); // { sms: {balance, total_purchased, total_consumed}, ... }
const loading = ref(false);
const error = ref(null);
const filters = ref({
periodPreset: 'thisMonth',
customRange: null, // [Date, Date]
addonTypes: [], // [] = todos
movementTypes: [], // [] = todos
search: ''
});
const dateRange = computed(() => resolveDateRange(filters.value.periodPreset, filters.value.customRange));
async function load() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) {
transactions.value = [];
error.value = new Error('Tenant ativo inválido.');
return;
}
loading.value = true;
error.value = null;
try {
const { from, to } = dateRange.value;
let query = supabase
.from('addon_transactions')
.select('id, created_at, addon_type, type, amount, balance_before, balance_after, description, payment_method, payment_reference, price_cents, currency, queue_id')
.eq('tenant_id', tenantId)
.gte('created_at', from.toISOString())
.lte('created_at', to.toISOString())
.order('created_at', { ascending: false })
.limit(5000);
if (filters.value.addonTypes.length > 0) {
query = query.in('addon_type', filters.value.addonTypes);
}
if (filters.value.movementTypes.length > 0) {
query = query.in('type', filters.value.movementTypes);
}
const { data, error: qErr } = await query;
if (qErr) throw qErr;
transactions.value = data ?? [];
} catch (err) {
error.value = err;
transactions.value = [];
} finally {
loading.value = false;
}
}
async function loadBalances() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) {
balances.value = {};
return;
}
const { data } = await supabase
.from('addon_credits')
.select('addon_type, balance, total_purchased, total_consumed, low_balance_threshold, expires_at')
.eq('tenant_id', tenantId)
.eq('is_active', true);
const map = {};
for (const c of data ?? []) map[c.addon_type] = c;
balances.value = map;
}
// filtro client-side de busca textual (sobre server-side result)
const rows = computed(() => {
const q = sanitizeSearch(filters.value.search);
if (!q) return transactions.value;
return transactions.value.filter((r) => {
const ref = (r.payment_reference || '').toLowerCase();
const desc = (r.description || '').toLowerCase();
const method = (r.payment_method || '').toLowerCase();
return ref.includes(q) || desc.includes(q) || method.includes(q);
});
});
const summary = computed(() => {
let purchasedCredits = 0;
let purchasedCents = 0;
let consumedCredits = 0;
let adjustedCredits = 0;
let refundedCredits = 0;
for (const r of rows.value) {
const amt = Number(r.amount) || 0;
switch (r.type) {
case 'purchase':
purchasedCredits += Math.abs(amt);
purchasedCents += Number(r.price_cents) || 0;
break;
case 'consumption':
consumedCredits += Math.abs(amt);
break;
case 'adjustment':
adjustedCredits += amt; // mantém sinal
break;
case 'refund':
refundedCredits += Math.abs(amt);
break;
default:
break;
}
}
return {
purchasedCredits,
purchasedCents,
consumedCredits,
adjustedCredits,
refundedCredits,
totalRows: rows.value.length
};
});
return {
transactions,
rows,
balances,
filters,
dateRange,
loading,
error,
summary,
load,
loadBalances
};
}
+206
View File
@@ -0,0 +1,206 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useAuditoria.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
// ─── helpers ────────────────────────────────────────────────────────────────
function startOfDay(d) {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
function endOfDay(d) {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x;
}
function resolveDateRange(preset, customRange) {
const now = new Date();
if (preset === 'today') {
return { from: startOfDay(now), to: endOfDay(now) };
}
if (preset === 'last7') {
const start = new Date(now);
start.setDate(start.getDate() - 6);
return { from: startOfDay(start), to: endOfDay(now) };
}
if (preset === 'last30') {
const start = new Date(now);
start.setDate(start.getDate() - 29);
return { from: startOfDay(start), to: endOfDay(now) };
}
if (preset === 'last90') {
const start = new Date(now);
start.setDate(start.getDate() - 89);
return { from: startOfDay(start), to: endOfDay(now) };
}
if (preset === 'custom' && Array.isArray(customRange) && customRange[0] && customRange[1]) {
return { from: startOfDay(customRange[0]), to: endOfDay(customRange[1]) };
}
const start = new Date(now);
start.setDate(start.getDate() - 6);
return { from: startOfDay(start), to: endOfDay(now) };
}
function sanitizeSearch(raw) {
if (typeof raw !== 'string') return '';
return raw.trim().slice(0, 120).toLowerCase();
}
// ─── composable ─────────────────────────────────────────────────────────────
export function useAuditoria() {
const tenantStore = useTenantStore();
const events = ref([]);
const usersMap = ref({}); // { uid: { email, display_name } }
const loading = ref(false);
const error = ref(null);
const filters = ref({
periodPreset: 'last7',
customRange: null,
sources: [], // [] = todas
entityTypes: [], // [] = todos
actions: [], // [] = todas
userId: null,
search: ''
});
const dateRange = computed(() => resolveDateRange(filters.value.periodPreset, filters.value.customRange));
async function load() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) {
events.value = [];
error.value = new Error('Tenant ativo inválido.');
return;
}
loading.value = true;
error.value = null;
try {
const { from, to } = dateRange.value;
let query = supabase
.from('audit_log_unified')
.select('uid, tenant_id, user_id, entity_type, entity_id, action, description, occurred_at, source, details')
.eq('tenant_id', tenantId)
.gte('occurred_at', from.toISOString())
.lte('occurred_at', to.toISOString())
.order('occurred_at', { ascending: false })
.limit(5000);
if (filters.value.sources.length > 0) {
query = query.in('source', filters.value.sources);
}
if (filters.value.entityTypes.length > 0) {
query = query.in('entity_type', filters.value.entityTypes);
}
if (filters.value.actions.length > 0) {
query = query.in('action', filters.value.actions);
}
if (filters.value.userId) {
query = query.eq('user_id', filters.value.userId);
}
const { data, error: qErr } = await query;
if (qErr) throw qErr;
events.value = data ?? [];
await resolveUserNames();
} catch (err) {
error.value = err;
events.value = [];
} finally {
loading.value = false;
}
}
async function resolveUserNames() {
const uids = [...new Set(events.value.map((e) => e.user_id).filter(Boolean))];
const unknown = uids.filter((u) => !usersMap.value[u]);
if (!unknown.length) return;
const { data } = await supabase.from('profiles').select('id, full_name, nickname').in('id', unknown);
const next = { ...usersMap.value };
for (const u of unknown) next[u] = { email: '', display_name: '' };
for (const p of data ?? []) {
next[p.id] = { email: '', display_name: p.full_name || p.nickname || '' };
}
usersMap.value = next;
}
function userDisplay(userId) {
if (!userId) return 'Sistema';
const u = usersMap.value[userId];
if (!u) return 'Usuário desconhecido';
return u.display_name || u.email || userId.slice(0, 8);
}
// filtro client-side: busca textual
const rows = computed(() => {
const q = sanitizeSearch(filters.value.search);
if (!q) return events.value;
return events.value.filter((e) => {
const desc = (e.description || '').toLowerCase();
const entity = (e.entity_type || '').toLowerCase();
const action = (e.action || '').toLowerCase();
const src = (e.source || '').toLowerCase();
const user = userDisplay(e.user_id).toLowerCase();
return desc.includes(q) || entity.includes(q) || action.includes(q) || src.includes(q) || user.includes(q);
});
});
const summary = computed(() => {
const bySource = {};
const byAction = {};
const byUser = new Set();
for (const e of rows.value) {
bySource[e.source] = (bySource[e.source] || 0) + 1;
byAction[e.action] = (byAction[e.action] || 0) + 1;
if (e.user_id) byUser.add(e.user_id);
}
return {
totalRows: rows.value.length,
bySource,
byAction,
distinctUsers: byUser.size
};
});
return {
events,
rows,
filters,
dateRange,
loading,
error,
summary,
userDisplay,
load
};
}
+127
View File
@@ -0,0 +1,127 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useAutoReplySettings.js
| Data: 2026-04-21
|
| Settings de auto-reply fora do horário (CRM Grupo 2.3).
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const DEFAULT_SETTINGS = {
enabled: false,
message: 'Olá! Nosso horário de atendimento acabou. Retornaremos sua mensagem assim que possível. Obrigado!',
cooldown_minutes: 180,
schedule_mode: 'agenda',
business_hours: [],
custom_window: []
};
export function useAutoReplySettings() {
const tenantStore = useTenantStore();
const settings = ref({ ...DEFAULT_SETTINGS });
const loading = ref(false);
const saving = ref(false);
const error = ref(null);
const lastLoadedAt = ref(null);
async function load() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return;
loading.value = true;
error.value = null;
try {
const { data, error: err } = await supabase
.from('conversation_autoreply_settings')
.select('enabled, message, cooldown_minutes, schedule_mode, business_hours, custom_window')
.eq('tenant_id', tenantId)
.maybeSingle();
if (err) throw err;
if (data) {
settings.value = {
enabled: !!data.enabled,
message: data.message ?? DEFAULT_SETTINGS.message,
cooldown_minutes: Number(data.cooldown_minutes ?? DEFAULT_SETTINGS.cooldown_minutes),
schedule_mode: data.schedule_mode ?? 'agenda',
business_hours: Array.isArray(data.business_hours) ? data.business_hours : [],
custom_window: Array.isArray(data.custom_window) ? data.custom_window : []
};
} else {
settings.value = { ...DEFAULT_SETTINGS, business_hours: [], custom_window: [] };
}
lastLoadedAt.value = new Date().toISOString();
} catch (e) {
error.value = e?.message || 'Falha ao carregar settings';
} finally {
loading.value = false;
}
}
async function save(partial = null) {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return { ok: false, error: 'no_tenant' };
const payload = partial ? { ...settings.value, ...partial } : { ...settings.value };
// Sanitização básica
payload.message = String(payload.message || '').trim().slice(0, 2000);
if (!payload.message) return { ok: false, error: 'mensagem vazia' };
payload.cooldown_minutes = Math.max(0, Math.min(43200, Number(payload.cooldown_minutes) || 0));
if (!['agenda', 'business_hours', 'custom'].includes(payload.schedule_mode)) {
payload.schedule_mode = 'agenda';
}
payload.business_hours = Array.isArray(payload.business_hours) ? payload.business_hours : [];
payload.custom_window = Array.isArray(payload.custom_window) ? payload.custom_window : [];
saving.value = true;
try {
const { error: err } = await supabase
.from('conversation_autoreply_settings')
.upsert({ tenant_id: tenantId, ...payload }, { onConflict: 'tenant_id' });
if (err) throw err;
settings.value = payload;
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'Falha ao salvar' };
} finally {
saving.value = false;
}
}
// Busca a agenda_regras_semanais do tenant — pra mostrar preview "seguindo agenda"
async function loadAgendaWindows() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return [];
try {
const { data } = await supabase
.from('agenda_regras_semanais')
.select('dia_semana, hora_inicio, hora_fim, ativo')
.eq('tenant_id', tenantId)
.eq('ativo', true)
.order('dia_semana');
return (data || []).map((r) => ({
dow: r.dia_semana,
start: String(r.hora_inicio).slice(0, 5),
end: String(r.hora_fim).slice(0, 5)
}));
} catch {
return [];
}
}
return {
settings,
loading,
saving,
error,
lastLoadedAt,
load,
save,
loadAgendaWindows
};
}
+239
View File
@@ -0,0 +1,239 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useClinicKPIs.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
function startOfMonth(d = new Date()) {
return new Date(d.getFullYear(), d.getMonth(), 1, 0, 0, 0, 0);
}
function endOfMonth(d = new Date()) {
return new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999);
}
function addMonths(d, n) {
return new Date(d.getFullYear(), d.getMonth() + n, 1);
}
function monthLabel(d) {
return d.toLocaleDateString('pt-BR', { month: 'short', year: '2-digit' });
}
export function useClinicKPIs() {
const tenantStore = useTenantStore();
const loading = ref(false);
const error = ref(null);
// totais do mês corrente
const mrrCurrentCents = ref(0); // receita recebida no mês
const overdueCents = ref(0);
const overdueCount = ref(0);
const pendingCents = ref(0);
// pacientes
const activePatients = ref(0);
const inactivePatients = ref(0);
const totalPatients = ref(0);
// sessões
const sessionsDone = ref(0);
const sessionsCancelled = ref(0);
const sessionsNoShow = ref(0);
const sessionsScheduled = ref(0);
// receita últimos 6 meses
const revenueSeries = ref([]); // [{ label, received, due }]
// top 5 pacientes (por valor recebido últimos 6 meses)
const topPatients = ref([]); // [{ patient_id, nome_completo, total }]
async function load() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) {
error.value = new Error('Tenant ativo inválido');
return;
}
loading.value = true;
error.value = null;
const now = new Date();
const monthStart = startOfMonth(now).toISOString();
const monthEnd = endOfMonth(now).toISOString();
const sixMonthsAgo = startOfMonth(addMonths(now, -5)).toISOString();
try {
const [finRes, pendRes, patRes, eventRes, finSeriesRes] = await Promise.all([
// 1) financial_records PAGO no mês (para MRR)
supabase
.from('financial_records')
.select('final_amount, patient_id')
.eq('tenant_id', tenantId)
.eq('status', 'paid')
.gte('paid_at', monthStart)
.lte('paid_at', monthEnd),
// 2) financial_records pending/overdue (qualquer data)
supabase
.from('financial_records')
.select('status, final_amount')
.eq('tenant_id', tenantId)
.in('status', ['pending', 'overdue']),
// 3) patients por status
supabase
.from('patients')
.select('status')
.eq('tenant_id', tenantId),
// 4) eventos de agenda no mês (para realizado/cancelado/faltou)
supabase
.from('agenda_eventos')
.select('status, tipo')
.eq('tenant_id', tenantId)
.gte('inicio_em', monthStart)
.lte('inicio_em', monthEnd)
.neq('tipo', 'bloqueio'),
// 5) financial_records pagos últimos 6 meses (série + top pacientes)
supabase
.from('financial_records')
.select('final_amount, paid_at, patient_id, patients!patient_id(nome_completo)')
.eq('tenant_id', tenantId)
.eq('status', 'paid')
.gte('paid_at', sixMonthsAgo)
.lte('paid_at', monthEnd)
]);
if (finRes.error) throw finRes.error;
if (pendRes.error) throw pendRes.error;
if (patRes.error) throw patRes.error;
if (eventRes.error) throw eventRes.error;
if (finSeriesRes.error) throw finSeriesRes.error;
// MRR do mês (em centavos ou reais? seguindo financial_records.amount está em number/int; tratar como BRL)
mrrCurrentCents.value = 0;
for (const r of finRes.data ?? []) {
mrrCurrentCents.value += Number(r.final_amount) || 0;
}
// pending / overdue
overdueCents.value = 0;
overdueCount.value = 0;
pendingCents.value = 0;
for (const r of pendRes.data ?? []) {
const v = Number(r.final_amount) || 0;
if (r.status === 'overdue') {
overdueCents.value += v;
overdueCount.value += 1;
} else if (r.status === 'pending') {
pendingCents.value += v;
}
}
// pacientes
totalPatients.value = (patRes.data ?? []).length;
activePatients.value = 0;
inactivePatients.value = 0;
for (const p of patRes.data ?? []) {
if (p.status === 'Ativo') activePatients.value += 1;
else if (p.status === 'Inativo' || p.status === 'Arquivado') inactivePatients.value += 1;
}
// sessões
sessionsDone.value = 0;
sessionsCancelled.value = 0;
sessionsNoShow.value = 0;
sessionsScheduled.value = 0;
for (const ev of eventRes.data ?? []) {
sessionsScheduled.value += 1;
if (ev.status === 'realizado') sessionsDone.value += 1;
else if (ev.status === 'cancelado') sessionsCancelled.value += 1;
else if (ev.status === 'faltou') sessionsNoShow.value += 1;
}
// série 6 meses + top 5 pacientes
const monthBuckets = {};
for (let i = 5; i >= 0; i--) {
const d = startOfMonth(addMonths(now, -i));
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
monthBuckets[key] = { label: monthLabel(d), received: 0 };
}
const patientTotals = new Map();
for (const r of finSeriesRes.data ?? []) {
if (!r.paid_at) continue;
const d = new Date(r.paid_at);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
const v = Number(r.final_amount) || 0;
if (monthBuckets[key]) {
monthBuckets[key].received += v;
}
if (r.patient_id) {
const prev = patientTotals.get(r.patient_id) || { nome: r.patients?.nome_completo || '—', total: 0 };
patientTotals.set(r.patient_id, { nome: prev.nome, total: prev.total + v });
}
}
revenueSeries.value = Object.values(monthBuckets);
topPatients.value = [...patientTotals.entries()]
.map(([patient_id, v]) => ({ patient_id, nome_completo: v.nome, total: v.total }))
.sort((a, b) => b.total - a.total)
.slice(0, 5);
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
const avgTicket = computed(() => {
if (sessionsDone.value === 0) return 0;
return mrrCurrentCents.value / sessionsDone.value;
});
const noShowRate = computed(() => {
const closed = sessionsDone.value + sessionsCancelled.value + sessionsNoShow.value;
if (closed === 0) return null;
return Math.round((sessionsNoShow.value / closed) * 100);
});
return {
loading,
error,
mrrCurrentCents,
overdueCents,
overdueCount,
pendingCents,
activePatients,
inactivePatients,
totalPatients,
sessionsDone,
sessionsCancelled,
sessionsNoShow,
sessionsScheduled,
avgTicket,
noShowRate,
revenueSeries,
topPatients,
load
};
}
+185
View File
@@ -0,0 +1,185 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useContactEmails.js
| Data: 2026-04-21
|
| Gerenciamento polimórfico de emails (patients, medicos, etc).
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const EMAIL_RE = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i;
function normalizeEmail(raw) {
return String(raw || '').trim().toLowerCase();
}
export function useContactEmails() {
const tenantStore = useTenantStore();
const types = ref([]);
const emails = ref([]);
const loading = ref(false);
const saving = ref(false);
async function loadTypes() {
try {
const { data } = await supabase
.from('contact_email_types')
.select('id, tenant_id, name, slug, icon, is_system, position')
.order('position', { ascending: true })
.order('name', { ascending: true });
types.value = data || [];
} catch (e) {
console.error('[useContactEmails] loadTypes:', e?.message);
types.value = [];
}
}
async function loadEmails(entityType, entityId) {
if (!entityType || !entityId) {
emails.value = [];
return;
}
loading.value = true;
try {
const { data, error } = await supabase
.from('contact_emails')
.select('id, contact_email_type_id, email, is_primary, notes, position, created_at')
.eq('entity_type', entityType)
.eq('entity_id', entityId)
.order('is_primary', { ascending: false })
.order('position', { ascending: true });
if (error) throw error;
emails.value = data || [];
} catch (e) {
console.error('[useContactEmails] loadEmails:', e?.message);
emails.value = [];
} finally {
loading.value = false;
}
}
async function unsetOtherPrimaries(entityType, entityId, exceptId = null) {
const q = supabase
.from('contact_emails')
.update({ is_primary: false })
.eq('entity_type', entityType)
.eq('entity_id', entityId)
.eq('is_primary', true);
if (exceptId) q.neq('id', exceptId);
await q;
}
async function addEmail(entityType, entityId, { contact_email_type_id, email, is_primary = false, notes = null }) {
const tenantId = tenantStore.activeTenantId;
const clean = normalizeEmail(email);
if (!tenantId || !entityType || !entityId) return { ok: false, error: 'invalid_context' };
if (!contact_email_type_id) return { ok: false, error: 'Tipo obrigatório' };
if (!clean || !EMAIL_RE.test(clean)) return { ok: false, error: 'Email inválido' };
saving.value = true;
try {
if (is_primary) {
await unsetOtherPrimaries(entityType, entityId);
} else if (emails.value.length === 0) {
is_primary = true;
}
const maxPos = emails.value.reduce((m, e) => Math.max(m, e.position || 0), 0);
const { data, error } = await supabase
.from('contact_emails')
.insert({
tenant_id: tenantId,
entity_type: entityType,
entity_id: entityId,
contact_email_type_id,
email: clean,
is_primary,
notes,
position: maxPos + 10
})
.select()
.single();
if (error) throw error;
await loadEmails(entityType, entityId);
return { ok: true, email: data };
} catch (e) {
return { ok: false, error: e?.message || 'add_failed' };
} finally {
saving.value = false;
}
}
async function updateEmail(entityType, entityId, id, patch) {
saving.value = true;
try {
const sanitized = { ...patch };
if (sanitized.email !== undefined) {
sanitized.email = normalizeEmail(sanitized.email);
if (!sanitized.email || !EMAIL_RE.test(sanitized.email)) {
return { ok: false, error: 'Email inválido' };
}
}
if (sanitized.is_primary === true) {
await unsetOtherPrimaries(entityType, entityId, id);
}
const { error } = await supabase.from('contact_emails').update(sanitized).eq('id', id);
if (error) throw error;
await loadEmails(entityType, entityId);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'update_failed' };
} finally {
saving.value = false;
}
}
async function removeEmail(entityType, entityId, id) {
saving.value = true;
try {
const wasPrimary = emails.value.find((e) => e.id === id)?.is_primary;
const { error } = await supabase.from('contact_emails').delete().eq('id', id);
if (error) throw error;
if (wasPrimary) {
const remaining = emails.value.filter((e) => e.id !== id).sort((a, b) => (a.position || 0) - (b.position || 0));
if (remaining.length > 0) {
await supabase.from('contact_emails').update({ is_primary: true }).eq('id', remaining[0].id);
}
}
await loadEmails(entityType, entityId);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'remove_failed' };
} finally {
saving.value = false;
}
}
async function setPrimary(entityType, entityId, id) {
return updateEmail(entityType, entityId, id, { is_primary: true });
}
function typeBySlug(slug) { return types.value.find((t) => t.slug === slug); }
function typeById(id) { return types.value.find((t) => t.id === id); }
return {
types,
emails,
loading,
saving,
loadTypes,
loadEmails,
addEmail,
updateEmail,
removeEmail,
setPrimary,
typeBySlug,
typeById
};
}
+199
View File
@@ -0,0 +1,199 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useContactPhones.js
| Data: 2026-04-21
|
| Gerenciamento polimórfico de telefones (patients, medicos, etc).
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
function normalizeDigits(raw) {
return String(raw || '').replace(/\D/g, '');
}
export function useContactPhones() {
const tenantStore = useTenantStore();
const types = ref([]); // contact_types (system + custom do tenant)
const phones = ref([]); // contact_phones da entidade atual
const loading = ref(false);
const saving = ref(false);
async function loadTypes() {
try {
const { data } = await supabase
.from('contact_types')
.select('id, tenant_id, name, slug, icon, is_mobile, is_system, position')
.order('position', { ascending: true })
.order('name', { ascending: true });
types.value = data || [];
} catch (e) {
console.error('[useContactPhones] loadTypes:', e?.message);
types.value = [];
}
}
async function loadPhones(entityType, entityId) {
if (!entityType || !entityId) {
phones.value = [];
return;
}
loading.value = true;
try {
const { data, error } = await supabase
.from('contact_phones')
.select('id, contact_type_id, number, is_primary, whatsapp_linked_at, notes, position, created_at')
.eq('entity_type', entityType)
.eq('entity_id', entityId)
.order('is_primary', { ascending: false })
.order('position', { ascending: true });
if (error) throw error;
phones.value = data || [];
} catch (e) {
console.error('[useContactPhones] loadPhones:', e?.message);
phones.value = [];
} finally {
loading.value = false;
}
}
// Ensure só 1 primary por entidade — seta outros pra false antes de inserir/atualizar
async function unsetOtherPrimaries(entityType, entityId, exceptId = null) {
const q = supabase
.from('contact_phones')
.update({ is_primary: false })
.eq('entity_type', entityType)
.eq('entity_id', entityId)
.eq('is_primary', true);
if (exceptId) q.neq('id', exceptId);
await q;
}
async function addPhone(entityType, entityId, { contact_type_id, number, is_primary = false, whatsapp_linked_at = null, notes = null }) {
const tenantId = tenantStore.activeTenantId;
const digits = normalizeDigits(number);
if (!tenantId || !entityType || !entityId) return { ok: false, error: 'invalid_context' };
if (!contact_type_id) return { ok: false, error: 'Tipo de contato obrigatório' };
if (!digits || digits.length < 8 || digits.length > 15) return { ok: false, error: 'Telefone inválido' };
saving.value = true;
try {
// Se marcou como primary, desmarca outros
if (is_primary) {
await unsetOtherPrimaries(entityType, entityId);
} else if (phones.value.length === 0) {
// Primeiro telefone → vira primary automaticamente
is_primary = true;
}
const maxPos = phones.value.reduce((m, p) => Math.max(m, p.position || 0), 0);
const { data, error } = await supabase
.from('contact_phones')
.insert({
tenant_id: tenantId,
entity_type: entityType,
entity_id: entityId,
contact_type_id,
number: digits,
is_primary,
whatsapp_linked_at,
notes,
position: maxPos + 10
})
.select()
.single();
if (error) throw error;
await loadPhones(entityType, entityId);
return { ok: true, phone: data };
} catch (e) {
return { ok: false, error: e?.message || 'add_failed' };
} finally {
saving.value = false;
}
}
async function updatePhone(entityType, entityId, id, patch) {
saving.value = true;
try {
const sanitized = { ...patch };
if (sanitized.number !== undefined) sanitized.number = normalizeDigits(sanitized.number);
if (sanitized.number && (sanitized.number.length < 8 || sanitized.number.length > 15)) {
return { ok: false, error: 'Telefone inválido' };
}
if (sanitized.is_primary === true) {
await unsetOtherPrimaries(entityType, entityId, id);
}
const { error } = await supabase
.from('contact_phones')
.update(sanitized)
.eq('id', id);
if (error) throw error;
await loadPhones(entityType, entityId);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'update_failed' };
} finally {
saving.value = false;
}
}
async function removePhone(entityType, entityId, id) {
saving.value = true;
try {
const wasPrimary = phones.value.find((p) => p.id === id)?.is_primary;
const { error } = await supabase.from('contact_phones').delete().eq('id', id);
if (error) throw error;
// Se removeu o primary, promove o próximo pra primary
if (wasPrimary) {
const remaining = phones.value.filter((p) => p.id !== id).sort((a, b) => (a.position || 0) - (b.position || 0));
if (remaining.length > 0) {
await supabase
.from('contact_phones')
.update({ is_primary: true })
.eq('id', remaining[0].id);
}
}
await loadPhones(entityType, entityId);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'remove_failed' };
} finally {
saving.value = false;
}
}
async function setPrimary(entityType, entityId, id) {
return updatePhone(entityType, entityId, id, { is_primary: true });
}
function typeBySlug(slug) {
return types.value.find((t) => t.slug === slug);
}
function typeById(id) {
return types.value.find((t) => t.id === id);
}
return {
types,
phones,
loading,
saving,
loadTypes,
loadPhones,
addPhone,
updatePhone,
removePhone,
setPrimary,
typeBySlug,
typeById
};
}
@@ -0,0 +1,154 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useConversationAssignment.js
| Data: 2026-04-21
|
| Atribuicao de thread de conversa a um terapeuta/membro do tenant.
| Uma linha por (tenant_id, thread_key). Reatribuir = UPSERT.
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
export function useConversationAssignment() {
const tenantStore = useTenantStore();
const assignment = ref(null); // { assigned_to, assigned_by, assigned_at, _assignee_name }
const members = ref([]); // lista de membros do tenant (pra Select)
const loading = ref(false);
const saving = ref(false);
const error = ref(null);
async function loadMembers() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) { members.value = []; return; }
try {
const { data, error: err } = await supabase
.from('v_tenant_members_with_profiles')
.select('user_id, full_name, email, role')
.eq('tenant_id', tenantId)
.in('role', ['tenant_admin', 'therapist', 'secretary'])
.eq('status', 'active')
.order('full_name', { ascending: true });
if (err) throw err;
members.value = (data || []).map((m) => ({
user_id: m.user_id,
label: m.full_name || m.email || m.user_id,
email: m.email,
role: m.role
}));
} catch (e) {
error.value = e?.message || 'Falha ao carregar membros';
members.value = [];
}
}
async function load(threadKey) {
if (!threadKey) { assignment.value = null; return; }
const tenantId = tenantStore.activeTenantId;
if (!tenantId) { assignment.value = null; return; }
loading.value = true;
error.value = null;
try {
const { data, error: err } = await supabase
.from('conversation_assignments')
.select('tenant_id, thread_key, patient_id, contact_number, assigned_to, assigned_by, assigned_at')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey)
.maybeSingle();
if (err) throw err;
if (!data || !data.assigned_to) {
assignment.value = data || null;
return;
}
// Resolve nome do assignee via membros (se carregado) ou profiles
let assigneeName = null;
const hit = members.value.find((m) => m.user_id === data.assigned_to);
if (hit) assigneeName = hit.label;
if (!assigneeName) {
const { data: u } = await supabase.from('profiles').select('full_name').eq('id', data.assigned_to).maybeSingle();
assigneeName = u?.full_name || null;
}
assignment.value = { ...data, _assignee_name: assigneeName };
} catch (e) {
error.value = e?.message || 'Falha ao carregar atribuicao';
assignment.value = null;
} finally {
loading.value = false;
}
}
async function assign({ threadKey, patientId = null, contactNumber = null, assignedTo }) {
const tenantId = tenantStore.activeTenantId;
if (!tenantId || !threadKey) return { ok: false, error: 'invalid_params' };
saving.value = true;
try {
const { data: authData } = await supabase.auth.getUser();
const userId = authData?.user?.id;
if (!userId) return { ok: false, error: 'not_authenticated' };
const payload = {
tenant_id: tenantId,
thread_key: threadKey,
patient_id: patientId || null,
contact_number: contactNumber || null,
assigned_to: assignedTo || null,
assigned_by: userId,
assigned_at: new Date().toISOString()
};
const { data, error: err } = await supabase
.from('conversation_assignments')
.upsert(payload, { onConflict: 'tenant_id,thread_key' })
.select('tenant_id, thread_key, patient_id, contact_number, assigned_to, assigned_by, assigned_at')
.single();
if (err) throw err;
let assigneeName = null;
if (data.assigned_to) {
const hit = members.value.find((m) => m.user_id === data.assigned_to);
assigneeName = hit?.label || null;
if (!assigneeName) {
const { data: u } = await supabase.from('profiles').select('full_name').eq('id', data.assigned_to).maybeSingle();
assigneeName = u?.full_name || null;
}
}
assignment.value = { ...data, _assignee_name: assigneeName };
return { ok: true, assignment: assignment.value };
} catch (e) {
return { ok: false, error: e?.message || 'assign_failed' };
} finally {
saving.value = false;
}
}
async function unassign({ threadKey, patientId = null, contactNumber = null }) {
return assign({ threadKey, patientId, contactNumber, assignedTo: null });
}
function clear() {
assignment.value = null;
error.value = null;
}
return {
assignment,
members,
loading,
saving,
error,
loadMembers,
load,
assign,
unassign,
clear
};
}
+178
View File
@@ -0,0 +1,178 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useConversationNotes.js
| Data: 2026-04-21
|
| Notas internas por thread de conversa. Carregadas sob demanda quando o
| drawer da conversa abre.
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
function sanitizeBody(raw) {
if (typeof raw !== 'string') return '';
return raw.trim().slice(0, 4000);
}
export function useConversationNotes() {
const tenantStore = useTenantStore();
const notes = ref([]);
const loading = ref(false);
const saving = ref(false);
const error = ref(null);
const count = computed(() => notes.value.length);
async function load(threadKey) {
if (!threadKey) {
notes.value = [];
return;
}
const tenantId = tenantStore.activeTenantId;
if (!tenantId) {
notes.value = [];
return;
}
loading.value = true;
error.value = null;
try {
const { data, error: err } = await supabase
.from('conversation_notes')
.select('id, thread_key, patient_id, contact_number, body, created_by, created_at, updated_at')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey)
.is('deleted_at', null)
.order('created_at', { ascending: false });
if (err) throw err;
// Busca nomes dos criadores (1 query só)
const rows = data || [];
const userIds = [...new Set(rows.map((r) => r.created_by).filter(Boolean))];
let nameMap = {};
if (userIds.length) {
const { data: users } = await supabase
.from('profiles')
.select('id, full_name')
.in('id', userIds);
nameMap = Object.fromEntries((users || []).map((u) => [u.id, u.full_name || '']));
}
notes.value = rows.map((r) => ({ ...r, _author_name: nameMap[r.created_by] || null }));
} catch (e) {
error.value = e?.message || 'Falha ao carregar notas';
notes.value = [];
} finally {
loading.value = false;
}
}
async function create({ threadKey, patientId = null, contactNumber = null, body }) {
const tenantId = tenantStore.activeTenantId;
const clean = sanitizeBody(body);
if (!tenantId || !threadKey || !clean) return { ok: false, error: 'invalid_params' };
saving.value = true;
try {
const { data: authData } = await supabase.auth.getUser();
const userId = authData?.user?.id;
if (!userId) return { ok: false, error: 'not_authenticated' };
const { data, error: err } = await supabase
.from('conversation_notes')
.insert({
tenant_id: tenantId,
thread_key: threadKey,
patient_id: patientId,
contact_number: contactNumber,
body: clean,
created_by: userId
})
.select('id, thread_key, patient_id, contact_number, body, created_by, created_at, updated_at')
.single();
if (err) throw err;
// Prepend (mais recente primeiro)
notes.value = [{ ...data, _author_name: null }, ...notes.value];
// Recarrega o display_name do autor novo
const { data: u } = await supabase
.from('profiles')
.select('full_name')
.eq('id', data.created_by)
.maybeSingle();
if (u?.full_name) {
const item = notes.value.find((n) => n.id === data.id);
if (item) item._author_name = u.full_name;
}
return { ok: true, note: data };
} catch (e) {
return { ok: false, error: e?.message || 'insert_failed' };
} finally {
saving.value = false;
}
}
async function update(id, body) {
const clean = sanitizeBody(body);
if (!id || !clean) return { ok: false, error: 'invalid_params' };
saving.value = true;
try {
const { error: err } = await supabase
.from('conversation_notes')
.update({ body: clean })
.eq('id', id);
if (err) throw err;
const item = notes.value.find((n) => n.id === id);
if (item) {
item.body = clean;
item.updated_at = new Date().toISOString();
}
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'update_failed' };
} finally {
saving.value = false;
}
}
async function remove(id) {
if (!id) return { ok: false, error: 'invalid_id' };
saving.value = true;
try {
const { error: err } = await supabase
.from('conversation_notes')
.update({ deleted_at: new Date().toISOString() })
.eq('id', id);
if (err) throw err;
notes.value = notes.value.filter((n) => n.id !== id);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'delete_failed' };
} finally {
saving.value = false;
}
}
function clear() {
notes.value = [];
error.value = null;
}
return {
notes,
count,
loading,
saving,
error,
load,
create,
update,
remove,
clear
};
}
+203
View File
@@ -0,0 +1,203 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useConversationOptouts.js
| Data: 2026-04-21
|
| Gerencia opt-outs do CRM WhatsApp (LGPD Art. 18 Sec.2).
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
function normalizePhoneBR(raw) {
if (!raw) return '';
const digits = String(raw).replace(/\D/g, '');
// Sem DDI 55 → agrega
if (digits.length === 10 || digits.length === 11) return '55' + digits;
return digits;
}
export function useConversationOptouts() {
const tenantStore = useTenantStore();
const optouts = ref([]);
const keywords = ref([]);
const loading = ref(false);
const saving = ref(false);
const activeOptouts = computed(() => optouts.value.filter((o) => !o.opted_back_in_at));
const historyOptouts = computed(() => optouts.value.filter((o) => o.opted_back_in_at));
async function load() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return;
loading.value = true;
try {
const [optsRes, kwsRes] = await Promise.all([
supabase
.from('conversation_optouts')
.select('id, phone, patient_id, source, keyword_matched, original_message, notes, opted_out_at, opted_back_in_at, blocked_by')
.eq('tenant_id', tenantId)
.order('opted_out_at', { ascending: false }),
supabase
.from('conversation_optout_keywords')
.select('id, tenant_id, keyword, enabled, is_system')
.or(`tenant_id.is.null,tenant_id.eq.${tenantId}`)
.order('is_system', { ascending: false })
.order('keyword', { ascending: true })
]);
optouts.value = optsRes.data || [];
keywords.value = kwsRes.data || [];
// Enriquece com nome do paciente
const patIds = [...new Set(optouts.value.map((o) => o.patient_id).filter(Boolean))];
if (patIds.length) {
const { data: pats } = await supabase.from('patients').select('id, nome_completo').in('id', patIds);
const patMap = Object.fromEntries((pats || []).map((p) => [p.id, p.nome_completo]));
optouts.value = optouts.value.map((o) => ({ ...o, _patient_name: patMap[o.patient_id] || null }));
}
} catch (e) {
console.error('[useConversationOptouts] load:', e?.message);
} finally {
loading.value = false;
}
}
async function addManual({ phone, patientId = null, notes = null }) {
const tenantId = tenantStore.activeTenantId;
const cleanPhone = normalizePhoneBR(phone);
if (!tenantId || !cleanPhone) return { ok: false, error: 'invalid_params' };
if (!/^\d{6,15}$/.test(cleanPhone)) return { ok: false, error: 'invalid_phone_format' };
saving.value = true;
try {
const { data: authData } = await supabase.auth.getUser();
const userId = authData?.user?.id;
// Verifica se já existe ativo
const { data: existing } = await supabase
.from('conversation_optouts')
.select('id')
.eq('tenant_id', tenantId)
.eq('phone', cleanPhone)
.is('opted_back_in_at', null)
.maybeSingle();
if (existing) return { ok: false, error: 'already_opted_out' };
const { data, error } = await supabase
.from('conversation_optouts')
.insert({
tenant_id: tenantId,
phone: cleanPhone,
patient_id: patientId,
source: 'manual',
notes,
blocked_by: userId
})
.select('id, phone, patient_id, source, notes, opted_out_at, blocked_by')
.single();
if (error) throw error;
optouts.value = [{ ...data, _patient_name: null }, ...optouts.value];
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'add_failed' };
} finally {
saving.value = false;
}
}
async function restore(id) {
if (!id) return { ok: false, error: 'invalid_id' };
saving.value = true;
try {
const now = new Date().toISOString();
const { error } = await supabase
.from('conversation_optouts')
.update({ opted_back_in_at: now })
.eq('id', id);
if (error) throw error;
const item = optouts.value.find((o) => o.id === id);
if (item) item.opted_back_in_at = now;
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'restore_failed' };
} finally {
saving.value = false;
}
}
async function addKeyword(keyword) {
const tenantId = tenantStore.activeTenantId;
const clean = String(keyword || '').trim().slice(0, 100);
if (!tenantId || !clean) return { ok: false, error: 'invalid_params' };
saving.value = true;
try {
const { data, error } = await supabase
.from('conversation_optout_keywords')
.insert({ tenant_id: tenantId, keyword: clean, is_system: false, enabled: true })
.select('id, tenant_id, keyword, enabled, is_system')
.single();
if (error) throw error;
keywords.value = [...keywords.value, data];
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'add_keyword_failed' };
} finally {
saving.value = false;
}
}
async function toggleKeyword(id, enabled) {
saving.value = true;
try {
const { error } = await supabase
.from('conversation_optout_keywords')
.update({ enabled })
.eq('id', id);
if (error) throw error;
const item = keywords.value.find((k) => k.id === id);
if (item) item.enabled = enabled;
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'toggle_failed' };
} finally {
saving.value = false;
}
}
async function deleteKeyword(id) {
saving.value = true;
try {
const { error } = await supabase
.from('conversation_optout_keywords')
.delete()
.eq('id', id);
if (error) throw error;
keywords.value = keywords.value.filter((k) => k.id !== id);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'delete_failed' };
} finally {
saving.value = false;
}
}
return {
optouts,
keywords,
activeOptouts,
historyOptouts,
loading,
saving,
load,
addManual,
restore,
addKeyword,
toggleKeyword,
deleteKeyword
};
}
+260
View File
@@ -0,0 +1,260 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useConversationTags.js
| Data: 2026-04-21
|
| Tags aplicáveis a threads de conversa (urgente, primeira consulta, etc).
| Combina tags do sistema (tenant_id NULL) com custom do tenant.
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
function sanitizeName(raw) {
if (typeof raw !== 'string') return '';
return raw.trim().slice(0, 40);
}
function toSlug(name) {
return sanitizeName(name)
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
}
export function useConversationTags() {
const tenantStore = useTenantStore();
const allTags = ref([]); // todas as tags disponíveis (system + custom)
const threadTagIds = ref(new Set()); // tag_ids aplicados na thread atual
const loading = ref(false);
const saving = ref(false);
const threadTags = computed(() =>
allTags.value.filter((t) => threadTagIds.value.has(t.id))
);
// ── Carrega todas as tags visíveis (system + custom do tenant) ────
async function loadAllTags() {
const tenantId = tenantStore.activeTenantId;
loading.value = true;
try {
// RLS filtra automaticamente: system (tenant_id IS NULL) + custom do tenant ativo
const { data, error } = await supabase
.from('conversation_tags')
.select('id, tenant_id, name, slug, color, icon, position, is_system')
.order('position', { ascending: true })
.order('name', { ascending: true });
if (error) throw error;
allTags.value = data || [];
} catch (e) {
console.error('[useConversationTags] loadAllTags:', e?.message);
allTags.value = [];
} finally {
loading.value = false;
}
}
// ── Carrega tags aplicadas em MÚLTIPLAS threads (batch) ────
// Retorna Map<thread_key, tag_id[]> pra renderização em Kanban
async function loadForThreads(threadKeys) {
const tenantId = tenantStore.activeTenantId;
if (!tenantId || !Array.isArray(threadKeys) || !threadKeys.length) return new Map();
try {
const { data, error } = await supabase
.from('conversation_thread_tags')
.select('thread_key, tag_id')
.eq('tenant_id', tenantId)
.in('thread_key', threadKeys);
if (error) throw error;
const map = new Map();
for (const row of data || []) {
if (!map.has(row.thread_key)) map.set(row.thread_key, []);
map.get(row.thread_key).push(row.tag_id);
}
return map;
} catch (e) {
console.error('[useConversationTags] loadForThreads:', e?.message);
return new Map();
}
}
// ── Carrega tags aplicadas em uma thread ────
async function loadForThread(threadKey) {
const tenantId = tenantStore.activeTenantId;
if (!tenantId || !threadKey) {
threadTagIds.value = new Set();
return;
}
try {
const { data, error } = await supabase
.from('conversation_thread_tags')
.select('tag_id')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey);
if (error) throw error;
threadTagIds.value = new Set((data || []).map((r) => r.tag_id));
} catch (e) {
console.error('[useConversationTags] loadForThread:', e?.message);
threadTagIds.value = new Set();
}
}
// ── Toggle: adiciona ou remove tag da thread ────
async function toggleOnThread(threadKey, tagId) {
const tenantId = tenantStore.activeTenantId;
if (!tenantId || !threadKey || !tagId) return { ok: false, error: 'invalid_params' };
saving.value = true;
const hasTag = threadTagIds.value.has(tagId);
try {
if (hasTag) {
const { error } = await supabase
.from('conversation_thread_tags')
.delete()
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey)
.eq('tag_id', tagId);
if (error) throw error;
const next = new Set(threadTagIds.value);
next.delete(tagId);
threadTagIds.value = next;
} else {
const { data: authData } = await supabase.auth.getUser();
const userId = authData?.user?.id;
if (!userId) return { ok: false, error: 'not_authenticated' };
const { error } = await supabase
.from('conversation_thread_tags')
.insert({
tenant_id: tenantId,
thread_key: threadKey,
tag_id: tagId,
tagged_by: userId
});
if (error) throw error;
const next = new Set(threadTagIds.value);
next.add(tagId);
threadTagIds.value = next;
}
return { ok: true, added: !hasTag };
} catch (e) {
return { ok: false, error: e?.message || 'toggle_failed' };
} finally {
saving.value = false;
}
}
// ── Cria tag custom ────
async function createCustomTag({ name, color = '#6366f1', icon = null }) {
const tenantId = tenantStore.activeTenantId;
const cleanName = sanitizeName(name);
if (!tenantId || !cleanName) return { ok: false, error: 'invalid_params' };
const slug = toSlug(cleanName);
if (!slug) return { ok: false, error: 'invalid_slug' };
saving.value = true;
try {
const { data, error } = await supabase
.from('conversation_tags')
.insert({
tenant_id: tenantId,
name: cleanName,
slug,
color,
icon,
is_system: false
})
.select('id, tenant_id, name, slug, color, icon, position, is_system')
.single();
if (error) throw error;
allTags.value = [...allTags.value, data].sort((a, b) => (a.position - b.position) || a.name.localeCompare(b.name));
return { ok: true, tag: data };
} catch (e) {
return { ok: false, error: e?.message || 'create_failed' };
} finally {
saving.value = false;
}
}
// ── Atualiza tag custom ────
async function updateCustomTag(id, { name, color, icon }) {
if (!id) return { ok: false, error: 'invalid_id' };
const patch = {};
if (name !== undefined) {
const clean = sanitizeName(name);
if (!clean) return { ok: false, error: 'invalid_name' };
patch.name = clean;
patch.slug = toSlug(clean);
if (!patch.slug) return { ok: false, error: 'invalid_slug' };
}
if (color !== undefined) patch.color = color;
if (icon !== undefined) patch.icon = icon;
if (!Object.keys(patch).length) return { ok: false, error: 'nothing_to_update' };
saving.value = true;
try {
const { data, error } = await supabase
.from('conversation_tags')
.update(patch)
.eq('id', id)
.select('id, tenant_id, name, slug, color, icon, position, is_system')
.single();
if (error) throw error;
allTags.value = allTags.value
.map((t) => (t.id === id ? data : t))
.sort((a, b) => (a.position - b.position) || a.name.localeCompare(b.name));
return { ok: true, tag: data };
} catch (e) {
return { ok: false, error: e?.message || 'update_failed' };
} finally {
saving.value = false;
}
}
// ── Remove tag custom (system bloqueada por RLS) ────
async function deleteCustomTag(id) {
if (!id) return { ok: false, error: 'invalid_id' };
saving.value = true;
try {
const { error } = await supabase.from('conversation_tags').delete().eq('id', id);
if (error) throw error;
allTags.value = allTags.value.filter((t) => t.id !== id);
const next = new Set(threadTagIds.value);
next.delete(id);
threadTagIds.value = next;
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'delete_failed' };
} finally {
saving.value = false;
}
}
function clear() {
threadTagIds.value = new Set();
}
return {
allTags,
threadTags,
threadTagIds,
loading,
saving,
loadAllTags,
loadForThread,
loadForThreads,
toggleOnThread,
createCustomTag,
updateCustomTag,
deleteCustomTag,
clear
};
}
+268
View File
@@ -0,0 +1,268 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useConversations.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref, computed, onUnmounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const KANBAN_ORDER = ['urgent', 'awaiting_us', 'awaiting_patient', 'resolved'];
function sanitizeSearch(raw) {
if (typeof raw !== 'string') return '';
return raw.trim().slice(0, 120).toLowerCase();
}
export function useConversations() {
const tenantStore = useTenantStore();
const threads = ref([]);
const loading = ref(false);
const error = ref(null);
const filters = ref({
search: '',
channel: null, // null = todos
unreadOnly: false,
assigned: null // null = todas | 'me' | 'unassigned' | <uuid>
});
const currentUserId = ref(null);
supabase.auth.getUser().then(({ data }) => { currentUserId.value = data?.user?.id ?? null; });
let realtimeChannel = null;
async function load() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) {
threads.value = [];
error.value = new Error('Tenant ativo inválido');
return;
}
loading.value = true;
error.value = null;
try {
const { data, error: qErr } = await supabase
.from('conversation_threads')
.select('*')
.eq('tenant_id', tenantId)
.order('last_message_at', { ascending: false })
.limit(500);
if (qErr) throw qErr;
threads.value = data ?? [];
} catch (err) {
error.value = err;
threads.value = [];
} finally {
loading.value = false;
}
}
function subscribeRealtime() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return;
if (realtimeChannel) {
supabase.removeChannel(realtimeChannel);
}
realtimeChannel = supabase
.channel(`conv_msg_tenant_${tenantId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'conversation_messages',
filter: `tenant_id=eq.${tenantId}`
},
(payload) => {
// refetch da lista (view agrega tudo)
load();
// se o drawer esta aberto numa thread desta msg, appenda
const newMsg = payload.new;
if (currentThread.value && messageBelongsToThread(newMsg, currentThread.value)) {
const alreadyThere = threadMessages.value.some((m) => m.id === newMsg.id);
if (!alreadyThere) threadMessages.value.push(newMsg);
}
}
)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'conversation_messages',
filter: `tenant_id=eq.${tenantId}`
},
(payload) => {
load();
const updated = payload.new;
if (currentThread.value && messageBelongsToThread(updated, currentThread.value)) {
const idx = threadMessages.value.findIndex((m) => m.id === updated.id);
if (idx >= 0) threadMessages.value[idx] = updated;
}
}
)
.subscribe();
}
function unsubscribeRealtime() {
if (realtimeChannel) {
supabase.removeChannel(realtimeChannel);
realtimeChannel = null;
}
}
onUnmounted(() => unsubscribeRealtime());
const filteredThreads = computed(() => {
const q = sanitizeSearch(filters.value.search);
const assignFilter = filters.value.assigned;
const uid = currentUserId.value;
return threads.value.filter((t) => {
if (filters.value.channel && t.channel !== filters.value.channel) return false;
if (filters.value.unreadOnly && (t.unread_count || 0) === 0) return false;
if (assignFilter === 'me') {
if (!uid || t.assigned_to !== uid) return false;
} else if (assignFilter === 'unassigned') {
if (t.assigned_to) return false;
} else if (assignFilter && typeof assignFilter === 'string') {
if (t.assigned_to !== assignFilter) return false;
}
if (q) {
const name = (t.patient_name || '').toLowerCase();
const num = (t.contact_number || '').toLowerCase();
const body = (t.last_message_body || '').toLowerCase();
if (!name.includes(q) && !num.includes(q) && !body.includes(q)) return false;
}
return true;
});
});
const byKanban = computed(() => {
const map = { urgent: [], awaiting_us: [], awaiting_patient: [], resolved: [] };
for (const t of filteredThreads.value) {
const k = KANBAN_ORDER.includes(t.kanban_status) ? t.kanban_status : 'awaiting_us';
map[k].push(t);
}
return map;
});
const summary = computed(() => ({
total: threads.value.length,
urgent: byKanban.value.urgent.length,
awaiting_us: byKanban.value.awaiting_us.length,
awaiting_patient: byKanban.value.awaiting_patient.length,
resolved: byKanban.value.resolved.length,
unreadTotal: threads.value.reduce((s, t) => s + (t.unread_count || 0), 0)
}));
// Mensagens de uma thread especifica (drawer)
const threadMessages = ref([]);
const threadLoading = ref(false);
const currentThread = ref(null);
function messageBelongsToThread(msg, thread) {
if (!thread || !msg) return false;
if (thread.patient_id) return msg.patient_id === thread.patient_id;
// thread anônima
if (msg.patient_id) return false;
return (
msg.from_number === thread.contact_number ||
msg.to_number === thread.contact_number
);
}
async function loadThreadMessages(thread) {
currentThread.value = thread;
if (!thread) {
threadMessages.value = [];
return;
}
threadLoading.value = true;
try {
let q = supabase
.from('conversation_messages')
.select('*')
.eq('tenant_id', tenantStore.activeTenantId)
.order('created_at', { ascending: true })
.limit(500);
if (thread.patient_id) {
q = q.eq('patient_id', thread.patient_id);
} else {
// anônimo — filtra por from_number ou to_number
q = q.or(`from_number.eq.${thread.contact_number},to_number.eq.${thread.contact_number}`).is('patient_id', null);
}
const { data, error: qErr } = await q;
if (qErr) throw qErr;
threadMessages.value = data ?? [];
} finally {
threadLoading.value = false;
}
}
async function markThreadRead(thread) {
if (!thread) return;
// Marca unread do inbound como lido
const nowIso = new Date().toISOString();
const tenantId = tenantStore.activeTenantId;
let q = supabase
.from('conversation_messages')
.update({ read_at: nowIso })
.eq('tenant_id', tenantId)
.eq('direction', 'inbound')
.is('read_at', null);
if (thread.patient_id) q = q.eq('patient_id', thread.patient_id);
else q = q.eq('from_number', thread.contact_number).is('patient_id', null);
await q;
load();
}
async function setKanbanStatus(thread, newStatus) {
if (!['urgent', 'awaiting_us', 'awaiting_patient', 'resolved'].includes(newStatus)) return;
const tenantId = tenantStore.activeTenantId;
const patch = { kanban_status: newStatus };
if (newStatus === 'resolved') patch.resolved_at = new Date().toISOString();
let q = supabase.from('conversation_messages').update(patch).eq('tenant_id', tenantId);
if (thread.patient_id) q = q.eq('patient_id', thread.patient_id);
else q = q.eq('from_number', thread.contact_number).is('patient_id', null);
await q;
load();
}
return {
threads,
filteredThreads,
byKanban,
summary,
filters,
loading,
error,
load,
subscribeRealtime,
unsubscribeRealtime,
threadMessages,
threadLoading,
loadThreadMessages,
markThreadRead,
setKanbanStatus
};
}
+108
View File
@@ -0,0 +1,108 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useLgpdExport.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { downloadLgpdPDF } from '@/utils/lgpdExportFormats';
function slugify(s) {
if (!s) return 'paciente';
return (
String(s)
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
.slice(0, 40) || 'paciente'
);
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function useLgpdExport() {
const loading = ref(false);
const error = ref(null);
const lastPayload = ref(null);
async function fetchExport(patientId) {
if (!patientId) {
throw new Error('patientId obrigatório');
}
const { data, error: rpcErr } = await supabase.rpc('export_patient_data', { p_patient_id: patientId });
if (rpcErr) throw rpcErr;
return data;
}
async function exportJSON(patientId, patientName) {
loading.value = true;
error.value = null;
try {
const payload = await fetchExport(patientId);
lastPayload.value = payload;
const ts = new Date().toISOString().slice(0, 10);
const filename = `lgpd-export-${slugify(patientName)}-${ts}.json`;
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' });
downloadBlob(blob, filename);
return payload;
} catch (err) {
error.value = err;
throw err;
} finally {
loading.value = false;
}
}
async function exportPDF(patientId, patientName, tenantName) {
loading.value = true;
error.value = null;
try {
const payload = await fetchExport(patientId);
lastPayload.value = payload;
const ts = new Date().toISOString().slice(0, 10);
const filename = `lgpd-export-${slugify(patientName)}-${ts}.pdf`;
await downloadLgpdPDF(payload, tenantName, filename);
return payload;
} catch (err) {
error.value = err;
throw err;
} finally {
loading.value = false;
}
}
return {
loading,
error,
lastPayload,
fetchExport,
exportJSON,
exportPDF
};
}
+43 -1
View File
@@ -23,9 +23,11 @@ import { useTenantStore } from '@/stores/tenantStore';
const agendaHoje = ref(0);
const cadastrosRecebidos = ref(0);
const agendamentosRecebidos = ref(0);
const conversasUnread = ref(0);
let _timer = null;
let _started = false;
let _realtimeChannel = null;
async function _refresh() {
try {
@@ -69,23 +71,63 @@ async function _refresh() {
const { count } = await q;
agendamentosRecebidos.value = count || 0;
}
// 4. Conversas não lidas (mensagens inbound sem read_at)
if (tenantId) {
const { count } = await supabase
.from('conversation_messages')
.select('id', { count: 'exact', head: true })
.eq('tenant_id', tenantId)
.eq('direction', 'inbound')
.is('read_at', null);
conversasUnread.value = count || 0;
}
} catch {
// badge falhar não deve quebrar a navegação
}
}
// Subscribe Realtime pra atualizar badge ao vivo
function _subscribeRealtime() {
try {
const tenantStore = useTenantStore();
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || null;
if (!tenantId) return;
if (_realtimeChannel) {
supabase.removeChannel(_realtimeChannel);
}
_realtimeChannel = supabase
.channel(`menu_badges_conv_${tenantId}`)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'conversation_messages', filter: `tenant_id=eq.${tenantId}` },
() => _refresh()
)
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'conversation_messages', filter: `tenant_id=eq.${tenantId}` },
() => _refresh()
)
.subscribe();
} catch {
// Realtime falhar não deve quebrar
}
}
// ─── API pública ───────────────────────────────────────────
export function useMenuBadges() {
if (!_started) {
_started = true;
_refresh();
_timer = setInterval(_refresh, 5 * 60 * 1000); // atualiza a cada 5 min
_subscribeRealtime();
_timer = setInterval(_refresh, 5 * 60 * 1000); // atualiza a cada 5 min (fallback)
}
return {
agendaHoje,
cadastrosRecebidos,
agendamentosRecebidos,
conversasUnread,
refresh: _refresh
};
}
+123
View File
@@ -0,0 +1,123 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useSessionReminders.js
| Data: 2026-04-21
|
| Lembretes automáticos de sessão (CRM Grupo 2.4).
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const DEFAULTS = {
enabled: false,
send_24h: true,
send_2h: true,
template_24h: 'Oi {{nome_paciente}}! 👋 Lembrando da sua sessão amanhã, {{data_sessao}} às {{hora_sessao}}. Até lá!',
template_2h: 'Oi {{nome_paciente}}! Sua sessão começa em 2 horas, às {{hora_sessao}}. Te espero! 😊',
quiet_hours_enabled: true,
quiet_hours_start: '22:00',
quiet_hours_end: '08:00',
respect_opt_out: true
};
export function useSessionReminders() {
const tenantStore = useTenantStore();
const settings = ref({ ...DEFAULTS });
const recentLogs = ref([]);
const loading = ref(false);
const saving = ref(false);
async function load() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return;
loading.value = true;
try {
const [settingsRes, logsRes] = await Promise.all([
supabase
.from('session_reminder_settings')
.select('*')
.eq('tenant_id', tenantId)
.maybeSingle(),
supabase
.from('session_reminder_logs')
.select('id, event_id, reminder_type, sent_at, provider, skip_reason, to_phone')
.eq('tenant_id', tenantId)
.order('sent_at', { ascending: false })
.limit(30)
]);
if (settingsRes.data) {
settings.value = {
enabled: !!settingsRes.data.enabled,
send_24h: !!settingsRes.data.send_24h,
send_2h: !!settingsRes.data.send_2h,
template_24h: settingsRes.data.template_24h || DEFAULTS.template_24h,
template_2h: settingsRes.data.template_2h || DEFAULTS.template_2h,
quiet_hours_enabled: !!settingsRes.data.quiet_hours_enabled,
quiet_hours_start: String(settingsRes.data.quiet_hours_start || DEFAULTS.quiet_hours_start).slice(0, 5),
quiet_hours_end: String(settingsRes.data.quiet_hours_end || DEFAULTS.quiet_hours_end).slice(0, 5),
respect_opt_out: !!settingsRes.data.respect_opt_out
};
} else {
settings.value = { ...DEFAULTS };
}
recentLogs.value = logsRes.data || [];
} catch (e) {
console.error('[useSessionReminders] load:', e?.message);
} finally {
loading.value = false;
}
}
async function save() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return { ok: false, error: 'no_tenant' };
const payload = { ...settings.value };
payload.template_24h = String(payload.template_24h || '').trim().slice(0, 2000);
payload.template_2h = String(payload.template_2h || '').trim().slice(0, 2000);
if (!payload.template_24h || !payload.template_2h) {
return { ok: false, error: 'Templates não podem ficar vazios' };
}
saving.value = true;
try {
const { error } = await supabase
.from('session_reminder_settings')
.upsert({ tenant_id: tenantId, ...payload }, { onConflict: 'tenant_id' });
if (error) throw error;
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'Falha ao salvar' };
} finally {
saving.value = false;
}
}
// Dispara manualmente (pra teste ou pra catch-up de eventos perdidos)
async function runNow() {
try {
const { data, error } = await supabase.functions.invoke('send-session-reminders', { body: {} });
if (error) throw error;
return { ok: true, stats: data?.stats || null };
} catch (e) {
return { ok: false, error: e?.message || 'Falha ao executar' };
}
}
return {
settings,
recentLogs,
loading,
saving,
load,
save,
runNow
};
}
+144
View File
@@ -0,0 +1,144 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useWhatsappCredits.js
| Data: 2026-04-21
|
| Sistema de créditos WhatsApp (Marco B).
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
export function useWhatsappCredits() {
const tenantStore = useTenantStore();
const balance = ref(null); // { balance, lifetime_purchased, lifetime_used, low_balance_threshold }
const transactions = ref([]); // últimos extratos
const packages = ref([]); // pacotes ativos (loja)
const purchases = ref([]); // minhas ordens de compra
const tenantCpfCnpj = ref(''); // CPF/CNPJ armazenado no tenant (pra prefill)
const loading = ref(false);
const creating = ref(false);
const currentBalance = computed(() => balance.value?.balance ?? 0);
const isLow = computed(() => {
if (!balance.value) return false;
return balance.value.balance <= balance.value.low_balance_threshold;
});
async function loadAll() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return;
loading.value = true;
try {
const [balRes, txRes, pkgRes, purRes, tenRes] = await Promise.all([
supabase
.from('whatsapp_credits_balance')
.select('*')
.eq('tenant_id', tenantId)
.maybeSingle(),
supabase
.from('whatsapp_credits_transactions')
.select('id, kind, amount, balance_after, note, created_at, purchase_id, admin_id')
.eq('tenant_id', tenantId)
.order('created_at', { ascending: false })
.limit(50),
supabase
.from('whatsapp_credit_packages')
.select('*')
.eq('is_active', true)
.order('position', { ascending: true })
.order('price_brl', { ascending: true }),
supabase
.from('whatsapp_credit_purchases')
.select('id, package_name, credits, amount_brl, status, paid_at, expires_at, created_at, asaas_pix_qrcode, asaas_pix_copy_paste, asaas_payment_link')
.eq('tenant_id', tenantId)
.order('created_at', { ascending: false })
.limit(20),
supabase
.from('tenants')
.select('cpf_cnpj')
.eq('id', tenantId)
.maybeSingle()
]);
balance.value = balRes.data || { balance: 0, lifetime_purchased: 0, lifetime_used: 0, low_balance_threshold: 20 };
transactions.value = txRes.data || [];
packages.value = pkgRes.data || [];
purchases.value = purRes.data || [];
tenantCpfCnpj.value = tenRes.data?.cpf_cnpj || '';
} catch (e) {
console.error('[useWhatsappCredits] loadAll:', e?.message);
} finally {
loading.value = false;
}
}
async function createPurchase(packageId, cpfCnpj = null) {
if (!packageId) return { ok: false, error: 'package_missing' };
creating.value = true;
try {
const cleanDoc = (cpfCnpj || '').replace(/\D/g, '') || null;
const body = cleanDoc ? { package_id: packageId, cpf_cnpj: cleanDoc } : { package_id: packageId };
const { data, error } = await supabase.functions.invoke('create-whatsapp-credit-charge', { body });
if (error) {
// Edge function errors (non-2xx) vêm em error.context.json() no SDK novo
let parsed = null;
try {
parsed = typeof error.context?.json === 'function'
? await error.context.json()
: null;
} catch (_) { /* swallow */ }
return {
ok: false,
error: parsed?.error || error.message || 'invoke_failed',
message: parsed?.message || null
};
}
if (!data?.ok) return { ok: false, error: data?.error || 'unknown', message: data?.message || null };
// Atualiza CPF armazenado se a compra foi com um novo
if (cleanDoc) tenantCpfCnpj.value = cleanDoc;
await loadAll();
return { ok: true, purchase: data.purchase };
} catch (e) {
return { ok: false, error: e?.message || 'create_failed' };
} finally {
creating.value = false;
}
}
async function updateLowBalanceThreshold(newThreshold) {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return { ok: false, error: 'no_tenant' };
const v = Math.max(0, Math.min(10000, Number(newThreshold) || 0));
try {
const { error } = await supabase
.from('whatsapp_credits_balance')
.upsert({ tenant_id: tenantId, low_balance_threshold: v }, { onConflict: 'tenant_id' });
if (error) throw error;
if (balance.value) balance.value.low_balance_threshold = v;
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'update_failed' };
}
}
return {
balance,
transactions,
packages,
purchases,
tenantCpfCnpj,
currentBalance,
isLow,
loading,
creating,
loadAll,
createPurchase,
updateLowBalanceThreshold
};
}