Files
agenciapsilmno/src/components/conversations/ConversationDrawer.vue
T
Leonardo 2644e60bb6 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>
2026-04-23 07:05:24 -03:00

1178 lines
54 KiB
Vue

<!--
|--------------------------------------------------------------------------
| Agência PSI Drawer global de conversa
|--------------------------------------------------------------------------
| Uso: <ConversationDrawer /> no AppLayout. Controlado via store:
| const store = useConversationDrawerStore();
| store.openForPatient(patientId);
| store.openForThread(thread);
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import { useConversationNotes } from '@/composables/useConversationNotes';
import { useConversationTags } from '@/composables/useConversationTags';
import { useConversationAssignment } from '@/composables/useConversationAssignment';
import { supabase } from '@/lib/supabase/client';
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
const toast = useToast();
const confirm = useConfirm();
const store = useConversationDrawerStore();
const notesApi = useConversationNotes();
const tagsApi = useConversationTags();
const assignApi = useConversationAssignment();
const tagsPopover = ref(null);
// ── Current user (pra saber se pode editar/deletar notas próprias) ────
const currentUserId = ref(null);
supabase.auth.getUser().then(({ data }) => { currentUserId.value = data?.user?.id ?? null; });
// ── Notas internas ────────────────────────────────────────────
const notesOpen = ref(false);
const newNoteText = ref('');
const editingNoteId = ref(null);
const editingText = ref('');
// ── Converter número desconhecido em paciente (CRM 3.5) ────
const quickPatientDialog = ref(false);
const linkPatientDialog = ref(false);
const linkPatientOptions = ref([]);
const linkPatientLoading = ref(false);
const linkPatientSelected = ref(null);
const linkingPatient = ref(false);
// Strip "55" DDI prefix se tiver (InputMask espera 10-11 dígitos BR)
function phoneForForm(raw) {
const d = String(raw || '').replace(/\D/g, '');
if (d.length === 13 && d.startsWith('55')) return d.slice(2);
if (d.length === 12 && d.startsWith('55')) return d.slice(2);
return d;
}
function openConvertToPatient() {
quickPatientDialog.value = true;
}
function openLinkPatient() {
linkPatientSelected.value = null;
linkPatientDialog.value = true;
loadPatients();
}
async function loadPatients() {
const tenantId = store.thread?.tenant_id;
if (!tenantId) return;
linkPatientLoading.value = true;
try {
// Carrega todos os pacientes do tenant (até 500) — filter é client-side
const { data, error } = await supabase
.from('patients')
.select('id, nome_completo, telefone, email_principal, status')
.eq('tenant_id', tenantId)
.order('nome_completo', { ascending: true })
.limit(500);
if (error) throw error;
// Formata label combinado pra filter achar
linkPatientOptions.value = (data || []).map((p) => ({
...p,
_label: `${p.nome_completo}${p.telefone ? ' · ' + formatPhoneShort(p.telefone) : ''}${p.email_principal ? ' · ' + p.email_principal : ''}`
}));
} catch (e) {
console.error('[ConversationDrawer] load patients:', e?.message);
linkPatientOptions.value = [];
toast.add({ severity: 'error', summary: 'Erro', detail: 'Falha ao carregar pacientes: ' + e?.message, life: 4000 });
} finally {
linkPatientLoading.value = false;
}
}
async function confirmLinkPatient() {
const patient = linkPatientSelected.value;
if (!patient?.id || !store.thread?.contact_number) return;
linkingPatient.value = true;
try {
const phone = store.thread.contact_number;
const tenantId = store.thread.tenant_id;
// 1) Vincula conversation_messages
const { error } = await supabase
.from('conversation_messages')
.update({ patient_id: patient.id })
.or(`from_number.eq.${phone},to_number.eq.${phone}`)
.is('patient_id', null);
if (error) throw error;
// 2) Adiciona telefone WhatsApp no contact_phones (se não existir ainda)
await upsertWhatsappForExisting(tenantId, patient.id, phone);
store.thread.patient_id = patient.id;
store.thread.patient_name = patient.nome_completo;
store.thread.thread_key = patient.id;
for (const m of store.messages) {
if (!m.patient_id) m.patient_id = patient.id;
}
linkPatientDialog.value = false;
toast.add({
severity: 'success',
summary: 'Conversa vinculada',
detail: `${patient.nome_completo} agora está ligado a essa conversa. Número salvo como WhatsApp (vinculado).`,
life: 3500
});
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao vincular', detail: e?.message, life: 4000 });
} finally {
linkingPatient.value = false;
}
}
// Helper: adiciona telefone WhatsApp (vinculado) ao paciente existente.
// Se já tem um telefone com o mesmo número, só marca como vinculado em vez de duplicar.
async function upsertWhatsappForExisting(tenantId, patientId, threadPhone) {
if (!tenantId || !patientId || !threadPhone) return;
try {
const phoneDigits = String(threadPhone).replace(/\D/g, '');
// Busca se já tem esse número cadastrado
const { data: existing } = await supabase
.from('contact_phones')
.select('id, contact_type_id, whatsapp_linked_at')
.eq('entity_type', 'patient')
.eq('entity_id', patientId)
.eq('number', phoneDigits)
.limit(1)
.maybeSingle();
if (existing) {
// Atualiza vinculado_at se ainda não tinha
if (!existing.whatsapp_linked_at) {
await supabase
.from('contact_phones')
.update({ whatsapp_linked_at: new Date().toISOString() })
.eq('id', existing.id);
}
return;
}
// Não tem — cria novo com type='whatsapp'
const { data: types } = await supabase
.from('contact_types')
.select('id, slug')
.is('tenant_id', null)
.eq('slug', 'whatsapp')
.maybeSingle();
const whatsappTypeId = types?.id;
if (!whatsappTypeId) return;
await supabase.from('contact_phones').insert({
tenant_id: tenantId,
entity_type: 'patient',
entity_id: patientId,
contact_type_id: whatsappTypeId,
number: phoneDigits,
is_primary: false,
whatsapp_linked_at: new Date().toISOString(),
position: 100
});
} catch (e) {
console.warn('[ConversationDrawer] upsert whatsapp contact_phones:', e?.message);
}
}
function formatPhoneShort(p) {
const s = String(p || '').replace(/\D/g, '');
if (s.length === 11) return `(${s.slice(0, 2)}) ${s.slice(2, 7)}-${s.slice(7)}`;
if (s.length === 10) return `(${s.slice(0, 2)}) ${s.slice(2, 6)}-${s.slice(6)}`;
if (s.length === 13 && s.startsWith('55')) return `(${s.slice(2, 4)}) ${s.slice(4, 9)}-${s.slice(9)}`;
return p;
}
// Após criar paciente: vincula conversation_messages + registra telefone WhatsApp em contact_phones
async function onPatientCreated(row) {
const newPatientId = row?.id;
const phone = store.thread?.contact_number;
const tenantId = store.thread?.tenant_id;
if (!newPatientId || !phone) {
toast.add({ severity: 'warn', summary: 'Paciente criado', detail: 'Mas não foi possível vincular a conversa. Recarregue.', life: 4000 });
return;
}
try {
// 1) Vincula TODAS as mensagens do thread (anon) a esse patient_id
const { error: msgErr } = await supabase
.from('conversation_messages')
.update({ patient_id: newPatientId })
.or(`from_number.eq.${phone},to_number.eq.${phone}`)
.is('patient_id', null);
if (msgErr) throw msgErr;
// 2) Insere telefone WhatsApp em contact_phones (se ainda não tem)
// O Celular primary já foi criado pelo trigger quando cadastrou via ComponentCadastroRapido? NÃO.
// ComponentCadastroRapido insere em patients.telefone direto. Vou adicionar contact_phones AGORA.
await insertWhatsappContactPhone(tenantId, newPatientId, phone, row.telefone);
// 3) Atualiza o store localmente (thread_key vai mudar pra UUID)
store.thread.patient_id = newPatientId;
store.thread.patient_name = row.nome_completo || row.nome || null;
store.thread.thread_key = newPatientId;
for (const m of store.messages) {
if (!m.patient_id) m.patient_id = newPatientId;
}
toast.add({
severity: 'success',
summary: 'Paciente criado e vinculado',
detail: `${store.thread.patient_name} agora está ligado a essa conversa.`,
life: 3500
});
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao vincular', detail: e?.message || 'Falha ao atualizar mensagens', life: 4000 });
}
}
// Helper: insere entries em contact_phones pra novo paciente criado via ComponentCadastroRapido.
// 1) Celular (primary) = telefone do form (se tiver, já diferente do whatsapp)
// 2) WhatsApp com whatsapp_linked_at = phone da thread (se não for igual ao celular)
async function insertWhatsappContactPhone(tenantId, patientId, threadPhone, formPhone) {
if (!tenantId || !patientId || !threadPhone) return;
try {
// Busca tipos system
const { data: types } = await supabase
.from('contact_types')
.select('id, slug')
.is('tenant_id', null);
const celularType = types?.find((t) => t.slug === 'celular');
const whatsappType = types?.find((t) => t.slug === 'whatsapp');
const phoneDigits = String(threadPhone).replace(/\D/g, '');
const formDigits = String(formPhone || '').replace(/\D/g, '');
// formPhone vem do quick create sem DDI (10-11 dig); threadPhone com DDI (13 dig).
// Normaliza: remove DDI 55 pra comparar
const formNoDdi = formDigits.length >= 12 && formDigits.startsWith('55') ? formDigits.slice(2) : formDigits;
const threadNoDdi = phoneDigits.length >= 12 && phoneDigits.startsWith('55') ? phoneDigits.slice(2) : phoneDigits;
const rows = [];
// Celular primary (from form — o que o user digitou no cadastro rápido)
if (celularType && formDigits && formDigits.length >= 8) {
rows.push({
tenant_id: tenantId,
entity_type: 'patient',
entity_id: patientId,
contact_type_id: celularType.id,
number: formDigits.length < 12 ? '55' + formDigits : formDigits, // garante DDI
is_primary: true,
position: 10
});
}
// WhatsApp linked (from thread) — só se diferente do celular
if (whatsappType && phoneDigits && formNoDdi !== threadNoDdi) {
rows.push({
tenant_id: tenantId,
entity_type: 'patient',
entity_id: patientId,
contact_type_id: whatsappType.id,
number: phoneDigits,
is_primary: false,
whatsapp_linked_at: new Date().toISOString(),
position: 20
});
} else if (whatsappType && phoneDigits && formNoDdi === threadNoDdi && rows.length > 0) {
// Mesmo número → marca o celular como também vinculado WhatsApp
rows[0].whatsapp_linked_at = new Date().toISOString();
}
if (rows.length > 0) {
await supabase.from('contact_phones').insert(rows);
}
} catch (e) {
console.warn('[ConversationDrawer] insert whatsapp contact_phones:', e?.message);
// Não bloqueia o fluxo — paciente já foi criado
}
}
// ── Resolução de URLs de mídia ─────────────────────────────
// Mensagens de WhatsApp vêm com media_url em dois formatos:
// 1) URL externa (http/https) — Twilio e outras; usa direto
// 2) Path interno do bucket privado whatsapp-media — precisa signed URL
const mediaUrls = ref({}); // { [msgId]: resolvedUrl }
async function ensureMediaUrl(m) {
if (!m?.id || !m.media_url) return;
if (mediaUrls.value[m.id]) return;
const raw = m.media_url;
if (/^https?:\/\//i.test(raw)) {
mediaUrls.value[m.id] = raw;
return;
}
// Path interno — gera signed URL (expira em 1h, re-resolvido ao reabrir drawer)
const { data, error } = await supabase.storage.from('whatsapp-media').createSignedUrl(raw, 3600);
if (error) {
console.error('[ConversationDrawer] signed URL error:', error.message);
mediaUrls.value[m.id] = null;
return;
}
mediaUrls.value[m.id] = data?.signedUrl || null;
}
const composeText = ref('');
const composeTextareaRef = ref(null);
const messagesContainerRef = ref(null);
const templatesPopover = ref(null);
const emojiPopover = ref(null);
const isOpen = computed({
get: () => store.isOpen,
set: (v) => { if (!v) store.close(); }
});
const KANBAN_COLUMNS = [
{ key: 'urgent', label: 'Urgente', icon: 'pi pi-exclamation-triangle' },
{ key: 'awaiting_us', label: 'Aguardando resposta', icon: 'pi pi-inbox' },
{ key: 'awaiting_patient', label: 'Aguardando paciente', icon: 'pi pi-hourglass' },
{ key: 'resolved', label: 'Resolvido', icon: 'pi pi-check' }
];
// ── Formatters ─────────────────────────────────────────────
function fmtDateTime(iso) {
if (!iso) return '';
return new Date(iso).toLocaleString('pt-BR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
function channelIcon(ch) {
const map = { whatsapp: 'pi-whatsapp', sms: 'pi-comment', email: 'pi-envelope' };
return map[ch] || 'pi-comment';
}
function isImage(mime) { return !!mime && mime.startsWith('image/'); }
function isAudio(mime) { return !!mime && mime.startsWith('audio/'); }
function isVideo(mime) { return !!mime && mime.startsWith('video/'); }
function contactLabel() {
return store.thread?.patient_name || store.thread?.contact_number || 'Desconhecido';
}
function whatsappLink() {
const num = (store.thread?.contact_number || '').replace(/\D/g, '');
if (!num) return '';
return `https://wa.me/${num}`;
}
// ── Auto-scroll ────────────────────────────────────────────
function scrollToBottom() {
nextTick(() => {
const el = messagesContainerRef.value;
if (el) el.scrollTop = el.scrollHeight;
});
}
watch(() => store.messages.length, () => {
if (store.isOpen) scrollToBottom();
// Resolve URLs de novas mensagens com mídia
for (const m of store.messages) {
if (m.media_url && !mediaUrls.value[m.id]) ensureMediaUrl(m);
}
});
watch(() => store.isOpen, (open) => {
if (open) {
composeText.value = '';
scrollToBottom();
// Re-resolve URLs ao abrir (signed URLs podem ter expirado)
mediaUrls.value = {};
for (const m of store.messages) {
if (m.media_url) ensureMediaUrl(m);
}
// Carrega notas, tags e atribuição da thread
if (store.thread?.thread_key) {
notesApi.load(store.thread.thread_key);
tagsApi.loadAllTags();
tagsApi.loadForThread(store.thread.thread_key);
assignApi.loadMembers().then(() => assignApi.load(store.thread.thread_key));
}
} else {
notesApi.clear();
tagsApi.clear();
assignApi.clear();
notesOpen.value = false;
editingNoteId.value = null;
}
});
// Recarrega notas + tags + atribuição ao trocar de thread sem fechar drawer
watch(() => store.thread?.thread_key, (key) => {
if (key && store.isOpen) {
notesApi.load(key);
tagsApi.loadForThread(key);
assignApi.load(key);
}
});
// ── Handlers de atribuição ──────────────────────────────────
async function onAssignChange(newUserId) {
if (!store.thread?.thread_key) return;
const res = await assignApi.assign({
threadKey: store.thread.thread_key,
patientId: store.thread.patient_id || null,
contactNumber: store.thread.contact_number || null,
assignedTo: newUserId || null
});
if (res.ok) {
toast.add({
severity: 'success',
summary: newUserId ? 'Conversa atribuída' : 'Atribuição removida',
life: 2000
});
} else {
toast.add({ severity: 'error', summary: 'Erro', detail: res.error || 'Falha ao atribuir', life: 3500 });
}
}
async function assignToMe() {
const uid = currentUserId.value;
if (!uid) return;
await onAssignChange(uid);
}
// ── Handlers de tags ────────────────────────────────────────
function openTagsPopover(ev) {
tagsPopover.value?.toggle(ev);
}
async function onToggleTag(tagId) {
if (!store.thread?.thread_key) return;
const res = await tagsApi.toggleOnThread(store.thread.thread_key, tagId);
if (!res.ok) {
toast.add({ severity: 'error', summary: 'Erro', detail: res.error || 'Falha ao atualizar tag', life: 3500 });
}
}
// ── Handlers de notas ─────────────────────────────────────────
async function addNote() {
const text = newNoteText.value.trim();
if (!text || !store.thread) return;
const res = await notesApi.create({
threadKey: store.thread.thread_key,
patientId: store.thread.patient_id || null,
contactNumber: store.thread.contact_number || null,
body: text
});
if (res.ok) {
newNoteText.value = '';
toast.add({ severity: 'success', summary: 'Nota adicionada', life: 1800 });
} else {
toast.add({ severity: 'error', summary: 'Erro', detail: res.error || 'Falha ao salvar nota', life: 3500 });
}
}
function startEditNote(note) {
editingNoteId.value = note.id;
editingText.value = note.body;
}
function cancelEditNote() {
editingNoteId.value = null;
editingText.value = '';
}
async function saveEditNote(id) {
const res = await notesApi.update(id, editingText.value);
if (res.ok) {
editingNoteId.value = null;
editingText.value = '';
toast.add({ severity: 'success', summary: 'Nota atualizada', life: 1800 });
} else {
toast.add({ severity: 'error', summary: 'Erro', detail: res.error || 'Falha ao atualizar', life: 3500 });
}
}
function confirmDeleteNote(id) {
confirm.require({
group: 'conversation-drawer',
message: 'Remover esta nota? Essa ação não pode ser desfeita.',
header: 'Remover nota',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Remover',
rejectLabel: 'Cancelar',
acceptClass: 'p-button-danger',
accept: async () => {
const res = await notesApi.remove(id);
if (res.ok) {
toast.add({ severity: 'success', summary: 'Nota removida', life: 1800 });
} else {
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
}
}
});
}
function fmtNoteDate(iso) {
if (!iso) return '';
const d = new Date(iso);
const today = new Date();
const isToday = d.toDateString() === today.toDateString();
if (isToday) {
return 'Hoje, ' + d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}
// ── Injeta botão de download no toolbar do <Image preview> ────
// PrimeVue Image não expõe slot pra adicionar buttons no preview,
// então observamos o DOM: quando o toolbar aparece, inserimos o botão.
let _toolbarObserver = null;
function triggerImageDownload(src) {
if (!src) return;
// Fetch + blob pra forçar download (atributo 'download' não funciona cross-origin)
fetch(src)
.then((r) => r.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// Extrai nome do arquivo da URL (último segmento antes de ?)
const filename = (src.split('?')[0].split('/').pop() || 'imagem').slice(0, 120);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
})
.catch((err) => {
console.error('[ConversationDrawer] download error:', err);
toast.add({ severity: 'error', summary: 'Erro', detail: 'Falha ao baixar imagem.', life: 3500 });
});
}
function injectDownloadButton(toolbar) {
if (!toolbar || toolbar.querySelector('[data-download-btn]')) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.setAttribute('aria-label', 'Baixar imagem');
btn.setAttribute('data-download-btn', '1');
btn.setAttribute('data-pc-group-section', 'action');
// Herda a classe p-image-closeButton pra pegar o mesmo styling do toolbar
btn.className = 'p-image-close-button';
btn.innerHTML = '<span class="pi pi-download" style="font-size: 1rem;"></span>';
btn.addEventListener('click', (e) => {
e.stopPropagation();
// Escopa o lookup ao mask específico deste botão (seguro se houver múltiplos previews)
const mask = btn.closest('.p-image-mask');
const img = mask?.querySelector('img.p-image-original, img')
?? document.querySelector('.p-image-original');
triggerImageDownload(img?.src);
});
// Insere antes do close (último)
const closeBtn = toolbar.querySelector('[aria-label*="lose" i]') || toolbar.lastElementChild;
toolbar.insertBefore(btn, closeBtn);
}
onMounted(() => {
_toolbarObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
const toolbar = node.matches?.('.p-image-toolbar') ? node : node.querySelector?.('.p-image-toolbar');
if (toolbar) injectDownloadButton(toolbar);
}
}
});
_toolbarObserver.observe(document.body, { childList: true, subtree: true });
});
onBeforeUnmount(() => {
_toolbarObserver?.disconnect();
_toolbarObserver = null;
});
// ── Compose ────────────────────────────────────────────────
async function sendMessage() {
const text = composeText.value.trim();
if (!text) return;
const result = await store.sendMessage(text);
if (result.ok) {
composeText.value = '';
toast.add({ severity: 'success', summary: 'Mensagem enviada', life: 2000 });
} else {
toast.add({ severity: 'error', summary: 'Falha ao enviar', detail: result.error, life: 5000 });
}
}
function onComposeKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
async function onMoveTo(newStatus) {
try {
await store.setKanbanStatus(newStatus);
const label = KANBAN_COLUMNS.find(c => c.key === newStatus)?.label;
toast.add({ severity: 'success', summary: 'Movido', detail: `Conversa movida para ${label}.`, life: 2000 });
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao mover.', life: 4000 });
}
}
// ── Templates ──────────────────────────────────────────────
function templateFriendlyLabel(key) {
const map = {
'session.lembrete': 'Lembrete de sessão (24h antes)',
'session.lembrete_2h': 'Lembrete de sessão (2h antes)',
'session.confirmacao': 'Confirmação de agendamento',
'session.cancelamento': 'Sessão cancelada',
'session.reagendamento': 'Sessão reagendada',
'cobranca.pendente': 'Cobrança pendente',
'sistema.boas_vindas': 'Boas-vindas ao paciente'
};
const stripped = key.replace('.whatsapp', '');
const prefix = stripped.split('.').slice(0, 2).join('.');
return map[prefix] || stripped;
}
async function applyTemplate(tpl) {
const { text, missing } = await store.resolveTemplate(tpl);
composeText.value = composeText.value ? composeText.value + '\n' + text : text;
templatesPopover.value?.hide();
nextTick(() => {
const el = composeTextareaRef.value?.$el?.querySelector('textarea') ?? null;
if (el?.focus) el.focus();
});
if (missing.length) {
toast.add({
severity: 'warn',
summary: 'Variáveis sem valor',
detail: `Não consegui preencher: ${missing.join(', ')}. Edita a mensagem antes de enviar.`,
life: 5000
});
}
}
function openTemplatesPopover(ev) {
if (!store.templatesLoaded) store.loadTemplates();
templatesPopover.value?.toggle(ev);
}
// ── Emoji ──────────────────────────────────────────────────
const emojiList = [
'😀','😃','😄','😁','😆','😅','🤣','😂','🙂','🙃','😉','😊',
'😍','🥰','😘','😗','😙','😚','😋','😛','😜','🤪','😝','🤗',
'🤔','🤨','😐','😑','😶','🙄','😏','😒','😔','😪','🤤','😴',
'😎','🤓','🧐','🤯','😳','🥵','🥶','😱','😨','😰','😥','😓',
'👍','👎','👌','✌️','🤞','🤟','🤘','👈','👉','👆','🖕','👇',
'👋','🙌','👏','🤝','🙏','💪','🫶','❤️','🧡','💛','💚','💙',
'💜','🖤','🤍','🤎','💔','❣️','💕','💞','💓','💗','💖','💘',
'✨','⭐','🌟','💫','⚡','🔥','💯','✅','❌','⚠️','💬','💭'
];
function toggleEmojiPopover(ev) {
emojiPopover.value?.toggle(ev);
}
function insertEmoji(emoji) {
const el = composeTextareaRef.value?.$el?.querySelector('textarea') ?? null;
if (el && typeof el.selectionStart === 'number') {
const start = el.selectionStart;
const end = el.selectionEnd;
const before = composeText.value.slice(0, start);
const after = composeText.value.slice(end);
composeText.value = before + emoji + after;
nextTick(() => {
el.focus();
const pos = start + emoji.length;
el.setSelectionRange(pos, pos);
});
} else {
composeText.value += emoji;
}
}
</script>
<template>
<!-- group="conversation-drawer" isola esse confirm das páginas (evita 2x modal). -->
<ConfirmDialog group="conversation-drawer" />
<Drawer v-model:visible="isOpen" position="right" class="!w-full md:!w-[520px]">
<template #header>
<div v-if="store.thread" class="flex items-center gap-2">
<i :class="['pi', channelIcon(store.thread.channel)]" />
<div class="flex flex-col">
<span class="font-semibold">{{ contactLabel() }}</span>
<span v-if="store.thread.contact_number" class="text-xs text-[var(--text-color-secondary)]">{{ store.thread.contact_number }}</span>
</div>
</div>
</template>
<div v-if="store.thread" class="flex flex-col gap-3 h-full">
<!-- Ações: responder no WhatsApp Web + converter em paciente -->
<div class="flex items-center gap-1.5 flex-wrap border-b border-[var(--surface-border)] pb-3">
<a v-if="whatsappLink()" :href="whatsappLink()" target="_blank" rel="noopener">
<Button label="Abrir no WhatsApp Web" icon="pi pi-external-link" severity="secondary" outlined size="small" />
</a>
<template v-if="!store.thread.patient_id && store.thread.contact_number">
<Button
label="Novo paciente"
icon="pi pi-user-plus"
severity="primary"
size="small"
class="rounded-full"
v-tooltip.top="'Cadastra este número como novo paciente e vincula a conversa'"
@click="openConvertToPatient"
/>
<Button
label="Vincular existente"
icon="pi pi-link"
severity="secondary"
outlined
size="small"
class="rounded-full"
v-tooltip.top="'Liga essa conversa a um paciente cadastrado'"
@click="openLinkPatient"
/>
</template>
</div>
<!-- Cadastro rápido pré-preenchido com telefone da thread -->
<ComponentCadastroRapido
v-model="quickPatientDialog"
title="Cadastrar novo paciente"
:initial-data="{
nome_completo: store.thread.patient_name || '',
telefone: phoneForForm(store.thread.contact_number)
}"
@created="onPatientCreated"
/>
<!-- Dialog: vincular a paciente existente -->
<Dialog
v-model:visible="linkPatientDialog"
header="Vincular a paciente existente"
modal
:style="{ width: 'min(480px, 95vw)' }"
>
<div class="flex flex-col gap-3">
<div class="text-xs text-[var(--text-color-secondary)]">
Escolha ou busque um paciente pra vincular ao número
<strong class="font-mono">{{ formatPhoneShort(store.thread?.contact_number) }}</strong>.
</div>
<Select
v-model="linkPatientSelected"
:options="linkPatientOptions"
optionLabel="_label"
:filter="true"
filterPlaceholder="Buscar por nome, telefone ou email…"
:filterFields="['nome_completo', 'telefone', 'email_principal', '_label']"
placeholder="Selecione um paciente"
:loading="linkPatientLoading"
class="w-full"
:emptyFilterMessage="'Nenhum paciente encontrado'"
:emptyMessage="'Nenhum paciente cadastrado ainda'"
appendTo="body"
>
<template #value="slotProps">
<div v-if="slotProps.value" class="flex items-center gap-2">
<div class="w-7 h-7 rounded-full grid place-items-center bg-indigo-500/10 text-indigo-500 font-bold text-[0.65rem] shrink-0">
{{ (slotProps.value.nome_completo || '?').slice(0, 2).toUpperCase() }}
</div>
<div class="flex-1 min-w-0 text-sm">
<div class="font-semibold truncate">{{ slotProps.value.nome_completo }}</div>
<div v-if="slotProps.value.telefone" class="text-[0.68rem] text-[var(--text-color-secondary)] font-mono">{{ formatPhoneShort(slotProps.value.telefone) }}</div>
</div>
</div>
<span v-else class="text-[var(--text-color-secondary)]">{{ slotProps.placeholder }}</span>
</template>
<template #option="slotProps">
<div class="flex items-center gap-2 w-full">
<div class="w-8 h-8 rounded-full grid place-items-center bg-indigo-500/10 text-indigo-500 font-bold text-xs shrink-0">
{{ (slotProps.option.nome_completo || '?').slice(0, 2).toUpperCase() }}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-semibold truncate">{{ slotProps.option.nome_completo }}</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)] flex items-center gap-1.5">
<span v-if="slotProps.option.telefone" class="font-mono">{{ formatPhoneShort(slotProps.option.telefone) }}</span>
<span v-if="slotProps.option.email_principal" class="truncate">· {{ slotProps.option.email_principal }}</span>
</div>
</div>
</div>
</template>
</Select>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" text :disabled="linkingPatient" @click="linkPatientDialog = false" />
<Button
label="Vincular"
icon="pi pi-link"
:loading="linkingPatient"
:disabled="!linkPatientSelected"
@click="confirmLinkPatient"
/>
</template>
</Dialog>
<!-- Mover entre colunas Kanban -->
<div class="flex items-center gap-1.5 flex-wrap">
<span class="text-xs text-[var(--text-color-secondary)]">Mover pra:</span>
<Button
v-for="col in KANBAN_COLUMNS"
:key="col.key"
:label="col.label"
:icon="col.icon"
size="small"
:severity="store.thread.kanban_status === col.key ? 'primary' : 'secondary'"
:outlined="store.thread.kanban_status !== col.key"
:disabled="store.thread.kanban_status === col.key"
@click="onMoveTo(col.key)"
/>
</div>
<!-- Tags da thread -->
<div class="flex items-center gap-1.5 flex-wrap">
<span class="text-xs text-[var(--text-color-secondary)] shrink-0">Tags:</span>
<span
v-for="t in tagsApi.threadTags.value"
:key="t.id"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[0.7rem] font-semibold cursor-pointer hover:opacity-80 transition-opacity"
:style="{
background: t.color + '20',
color: t.color,
border: `1px solid ${t.color}40`
}"
@click="onToggleTag(t.id)"
v-tooltip.top="'Clique para remover'"
>
<i v-if="t.icon" :class="t.icon" class="text-[0.65rem]" />
{{ t.name }}
<i class="pi pi-times text-[0.6rem] opacity-60" />
</span>
<Button
icon="pi pi-plus"
severity="secondary"
outlined
size="small"
class="h-6 w-6 rounded-full"
v-tooltip.top="'Adicionar tag'"
:loading="tagsApi.saving.value"
@click="openTagsPopover"
/>
</div>
<!-- Popover de seleção de tags -->
<Popover ref="tagsPopover" class="!w-[260px]">
<div class="flex flex-col gap-0.5">
<div class="px-2 pb-1 text-[0.65rem] font-bold uppercase text-[var(--text-color-secondary)] opacity-65">
Selecione tags
</div>
<div v-if="tagsApi.loading.value" class="text-xs text-center py-3 text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner mr-1" /> Carregando
</div>
<div v-else-if="!tagsApi.allTags.value.length" class="text-xs text-center py-3 italic text-[var(--text-color-secondary)]">
Nenhuma tag disponível.
</div>
<button
v-else
v-for="t in tagsApi.allTags.value"
:key="t.id"
class="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-left bg-transparent border-0 cursor-pointer hover:bg-[var(--surface-hover)] transition-colors"
@click="onToggleTag(t.id)"
>
<span
class="inline-block w-3 h-3 rounded-full shrink-0"
:style="{ background: t.color }"
/>
<i v-if="t.icon" :class="t.icon" class="text-xs opacity-70 shrink-0" />
<span class="flex-1 truncate">{{ t.name }}</span>
<i v-if="tagsApi.threadTagIds.value.has(t.id)" class="pi pi-check text-xs text-[var(--primary-color)] shrink-0" />
</button>
</div>
</Popover>
<!-- Atribuição da conversa -->
<div class="flex items-center gap-1.5 flex-wrap">
<span class="text-xs text-[var(--text-color-secondary)] shrink-0">Atribuída a:</span>
<Select
:modelValue="assignApi.assignment.value?.assigned_to || null"
:options="assignApi.members.value"
optionLabel="label"
optionValue="user_id"
placeholder="Ninguém"
filter
showClear
size="small"
class="!min-w-[220px]"
:loading="assignApi.loading.value || assignApi.saving.value"
@update:modelValue="onAssignChange"
/>
<Button
v-if="currentUserId && assignApi.assignment.value?.assigned_to !== currentUserId"
label="Assumir"
icon="pi pi-user-plus"
size="small"
severity="secondary"
outlined
class="rounded-full"
:loading="assignApi.saving.value"
v-tooltip.top="'Atribuir essa conversa a mim'"
@click="assignToMe"
/>
<span
v-if="assignApi.assignment.value?.assigned_to"
class="text-[0.68rem] text-[var(--text-color-secondary)] italic"
>
desde {{ fmtNoteDate(assignApi.assignment.value.assigned_at) }}
</span>
</div>
<!-- Notas internas -->
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-ground)]">
<button
class="w-full flex items-center gap-2 px-3 py-2 text-left bg-transparent border-0 cursor-pointer hover:bg-[var(--surface-hover)] rounded-md transition-colors"
@click="notesOpen = !notesOpen"
>
<i class="pi pi-file-edit text-[var(--primary-color)]" />
<span class="text-sm font-semibold">Notas internas</span>
<Badge v-if="notesApi.count.value" :value="notesApi.count.value" severity="secondary" />
<span v-else class="text-xs text-[var(--text-color-secondary)] italic">(nenhuma)</span>
<i class="pi ml-auto text-xs" :class="notesOpen ? 'pi-chevron-up' : 'pi-chevron-down'" />
</button>
<div v-if="notesOpen" class="px-3 pb-3 flex flex-col gap-2">
<!-- Adicionar nota -->
<div class="flex gap-2 items-start">
<Textarea
v-model="newNoteText"
rows="2"
autoResize
placeholder="Escreva uma nota interna (só a equipe vê)…"
class="flex-1 text-sm"
:maxlength="4000"
:disabled="notesApi.saving.value"
@keydown.ctrl.enter="addNote"
@keydown.meta.enter="addNote"
/>
<Button
icon="pi pi-plus"
severity="primary"
v-tooltip.left="'Adicionar (Ctrl+Enter)'"
:loading="notesApi.saving.value"
:disabled="!newNoteText.trim()"
@click="addNote"
/>
</div>
<!-- Lista -->
<div v-if="notesApi.loading.value" class="text-xs text-[var(--text-color-secondary)] text-center py-2">
<i class="pi pi-spin pi-spinner mr-1" /> Carregando notas
</div>
<div v-else-if="!notesApi.notes.value.length" class="text-xs text-[var(--text-color-secondary)] text-center py-2 italic">
Nenhuma nota ainda. Use o campo acima pra registrar observações da equipe.
</div>
<div v-else class="flex flex-col gap-1.5 max-h-[40vh] overflow-y-auto pr-1">
<div
v-for="n in notesApi.notes.value"
:key="n.id"
class="rounded-md border border-[var(--surface-border)] p-2 bg-[var(--surface-card)]"
>
<!-- Modo edição -->
<template v-if="editingNoteId === n.id">
<Textarea
v-model="editingText"
rows="3"
autoResize
class="w-full text-sm mb-1.5"
:maxlength="4000"
:disabled="notesApi.saving.value"
/>
<div class="flex gap-1 justify-end">
<Button label="Cancelar" size="small" severity="secondary" text @click="cancelEditNote" />
<Button label="Salvar" size="small" :loading="notesApi.saving.value" :disabled="!editingText.trim()" @click="saveEditNote(n.id)" />
</div>
</template>
<!-- Modo leitura -->
<template v-else>
<div class="text-sm whitespace-pre-wrap break-words text-[var(--text-color)]">{{ n.body }}</div>
<div class="flex items-center justify-between mt-1.5 text-[0.7rem] text-[var(--text-color-secondary)]">
<span class="truncate">
<i class="pi pi-user text-[0.6rem] mr-0.5 opacity-60" />
{{ n._author_name || 'Usuário' }}
<span class="mx-1 opacity-50">·</span>
{{ fmtNoteDate(n.created_at) }}
<span v-if="n.updated_at && n.updated_at !== n.created_at" class="ml-1 italic opacity-70">(editada)</span>
</span>
<div v-if="n.created_by === currentUserId" class="flex gap-0.5 shrink-0">
<button
class="w-6 h-6 rounded-full border-none bg-transparent text-[var(--text-color-secondary)] grid place-items-center cursor-pointer hover:bg-[var(--surface-hover)]"
v-tooltip.top="'Editar'"
@click="startEditNote(n)"
>
<i class="pi pi-pencil text-[0.7rem]" />
</button>
<button
class="w-6 h-6 rounded-full border-none bg-transparent text-[var(--text-color-secondary)] grid place-items-center cursor-pointer hover:bg-red-500/10 hover:text-red-500"
v-tooltip.top="'Remover'"
@click="confirmDeleteNote(n.id)"
>
<i class="pi pi-trash text-[0.7rem]" />
</button>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- Mensagens -->
<div ref="messagesContainerRef" class="flex-1 overflow-y-auto flex flex-col gap-2 p-1">
<div v-if="store.loading" class="text-xs text-[var(--text-color-secondary)] text-center py-6">
<i class="pi pi-spin pi-spinner mr-2" />Carregando mensagens...
</div>
<div v-else-if="!store.messages.length" class="text-xs text-[var(--text-color-secondary)] text-center py-6 italic">
Nenhuma mensagem ainda. Envie a primeira abaixo.
</div>
<div
v-for="m in store.messages"
:key="m.id"
class="flex flex-col gap-0.5"
:class="m.direction === 'inbound' ? 'items-start' : 'items-end'"
>
<div
class="max-w-[85%] px-3 py-2 rounded-lg text-sm whitespace-pre-wrap break-words"
:class="m.direction === 'inbound' ? 'bg-[var(--surface-ground)] rounded-tl-none' : 'bg-emerald-500/10 text-emerald-900 dark:text-emerald-100 rounded-tr-none'"
>
<template v-if="m.media_url">
<!-- Loading enquanto resolve signed URL -->
<div v-if="!mediaUrls[m.id]" class="flex items-center gap-2 text-xs text-[var(--text-color-secondary)] py-2">
<i class="pi pi-spin pi-spinner" /> carregando mídia
</div>
<template v-else>
<Image
v-if="isImage(m.media_mime)"
:src="mediaUrls[m.id]"
:preview="true"
alt="imagem"
imageClass="max-w-full rounded-md cursor-zoom-in"
class="mb-1 block"
/>
<audio
v-else-if="isAudio(m.media_mime)"
:src="mediaUrls[m.id]"
controls
preload="metadata"
class="block w-full min-w-[260px] max-w-full mb-1"
/>
<video
v-else-if="isVideo(m.media_mime)"
:src="mediaUrls[m.id]"
controls
class="max-w-full rounded-md mb-1"
/>
<a
v-else
:href="mediaUrls[m.id]"
target="_blank"
rel="noopener"
class="inline-flex items-center gap-2 text-xs underline text-[var(--primary-color)] mb-1"
>
<i class="pi pi-paperclip" />
Baixar anexo ({{ m.media_mime || 'arquivo' }})
</a>
</template>
</template>
<div v-if="m.body">{{ m.body }}</div>
</div>
<div class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-75 px-1 flex items-center gap-1">
<span>{{ fmtDateTime(m.created_at) }}</span>
<span v-if="m.direction === 'outbound'" class="flex items-center">
<i v-if="m.delivery_status === 'read'" class="pi pi-check-double text-sky-500" v-tooltip.top="'Lida'" />
<i v-else-if="m.delivery_status === 'delivered'" class="pi pi-check-double" v-tooltip.top="'Entregue'" />
<i v-else-if="m.delivery_status === 'sent'" class="pi pi-check" v-tooltip.top="'Enviada'" />
<i v-else-if="m.delivery_status === 'failed'" class="pi pi-exclamation-circle text-red-500" v-tooltip.top="'Falhou'" />
</span>
<span v-if="m.direction === 'inbound' && !m.patient_id" class="italic">· número não vinculado</span>
</div>
</div>
</div>
<!-- Compose -->
<div v-if="store.thread.channel === 'whatsapp' && store.thread.contact_number" class="border-t border-[var(--surface-border)] pt-2 flex flex-col gap-2">
<div class="flex items-end gap-2">
<Textarea
ref="composeTextareaRef"
v-model="composeText"
autoResize
rows="1"
placeholder="Digite sua mensagem... (Enter envia, Shift+Enter quebra linha)"
class="flex-1 !text-sm !resize-none"
:disabled="store.sending"
:maxlength="4000"
@keydown="onComposeKeydown"
/>
<Button
icon="pi pi-send"
severity="success"
class="!w-10 !h-10 shrink-0"
:loading="store.sending"
:disabled="!composeText.trim() || store.sending"
v-tooltip.top="'Enviar (Enter)'"
@click="sendMessage"
/>
</div>
<div class="flex items-center gap-1 text-[var(--text-color-secondary)]">
<Button icon="pi pi-bookmark" text rounded size="small" class="!w-8 !h-8" v-tooltip.top="'Templates'" @click="openTemplatesPopover" />
<Button icon="pi pi-face-smile" text rounded size="small" class="!w-8 !h-8" v-tooltip.top="'Emoji'" @click="toggleEmojiPopover" />
<span class="ml-auto text-[0.65rem] opacity-60">{{ composeText.length }}/4000</span>
</div>
</div>
<div v-else class="text-[0.7rem] text-[var(--text-color-secondary)] italic text-center py-2 border-t border-[var(--surface-border)]">
Envio direto indisponível (canal: {{ store.thread.channel }}).
</div>
</div>
<!-- Popover: Templates -->
<Popover ref="templatesPopover" :pt="{ content: { class: '!p-0' } }">
<div class="w-[320px] max-h-[400px] overflow-y-auto">
<div class="px-3 py-2 border-b border-[var(--surface-border)] text-xs font-semibold text-[var(--text-color-secondary)] uppercase tracking-wide">
Templates WhatsApp
</div>
<div v-if="store.templatesLoading" class="p-4 text-xs text-center text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner mr-1" /> Carregando...
</div>
<div v-else-if="!store.templates.length" class="p-4 text-xs text-center text-[var(--text-color-secondary)] italic">
Nenhum template disponível.
</div>
<button
v-for="tpl in store.templates"
:key="tpl.id"
class="w-full text-left px-3 py-2 border-none bg-transparent cursor-pointer hover:bg-[var(--surface-hover)] border-b border-[var(--surface-border)] last:border-b-0"
@click="applyTemplate(tpl)"
>
<div class="text-xs font-semibold text-[var(--text-color)] truncate">{{ templateFriendlyLabel(tpl.key) }}</div>
<div class="text-[0.7rem] text-[var(--text-color-secondary)] line-clamp-2 mt-0.5">{{ tpl.body_text }}</div>
</button>
</div>
</Popover>
<!-- Popover: Emoji picker -->
<Popover ref="emojiPopover" :pt="{ content: { class: '!p-2' } }">
<div class="w-[280px] max-h-[280px] overflow-y-auto grid grid-cols-8 gap-1">
<button
v-for="e in emojiList"
:key="e"
class="w-8 h-8 rounded hover:bg-[var(--surface-hover)] border-none bg-transparent cursor-pointer text-lg"
@click="insertEmoji(e)"
>{{ e }}</button>
</div>
</Popover>
</Drawer>
</template>