2644e60bb6
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>
1178 lines
54 KiB
Vue
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 já 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>
|