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:
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user