wip: inbox/agenda diversos (trabalho anterior à sessão)
Consolida WIP acumulado que não faz parte das features de hoje: melhorias do inbox (WhatsChatDrawer, InputBar, MessageBubble, ProtocolPanel, StickerPanel, LinkPreview, hooks, socketService, chatStore, nwClient), componentes novos (ConversationSearch, ForwardModal, LabelManagerModal, roleMeta), AgendaPresence, ComunicacaoInternaModal, Sidebar, GestaoEquipeView, SessionsView, appTime e o asset chat-bg.png. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -65,42 +65,41 @@ export const AgendaPresence: React.FC = () => {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Grupo oculto que expande PARA BAIXO dentro da div (inline) — largura cheia
|
||||
da coluna/gaveta, sem flutuar; assim não é cortado pelo overflow no desktop. */}
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[55]" onClick={() => setOpen(false)} />
|
||||
<div className="absolute right-0 mt-2 w-64 bg-white rounded-2xl shadow-2xl border border-gray-100 z-[56] overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-50">
|
||||
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest flex items-center gap-1.5">
|
||||
<Circle size={8} className="fill-emerald-500 text-emerald-500" /> Online agora ({online.length})
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto custom-scrollbar">
|
||||
{online.length === 0 && (
|
||||
<p className="px-4 py-3 text-[11px] text-gray-400 font-bold uppercase">Só você por aqui.</p>
|
||||
)}
|
||||
{online.map(o => (
|
||||
<div key={o.usuario_id} className="flex items-center gap-2.5 px-4 py-2.5">
|
||||
<span className="relative w-7 h-7 rounded-full flex items-center justify-center text-[9px] font-black text-white shrink-0" style={{ backgroundColor: colorFor(o.nome) }}>
|
||||
{initials(o.nome)}
|
||||
<Circle size={9} className="absolute -bottom-0.5 -right-0.5 fill-emerald-500 text-white" />
|
||||
</span>
|
||||
<span className="text-xs font-bold text-gray-700 uppercase truncate">{o.nome}</span>
|
||||
</div>
|
||||
))}
|
||||
{ultimasOffline.length > 0 && (
|
||||
<div className="px-4 py-2 border-t border-gray-50">
|
||||
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest mb-1">Últimas visitas</p>
|
||||
{ultimasOffline.slice(0, 6).map(u => (
|
||||
<div key={u.usuario_id} className="flex items-center justify-between py-1">
|
||||
<span className="text-[11px] font-bold text-gray-500 uppercase truncate">{u.usuario_nome}</span>
|
||||
<span className="text-[9px] text-gray-300 font-bold whitespace-nowrap ml-2">{relTime(u.last_view_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 w-full bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-50">
|
||||
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest flex items-center gap-1.5">
|
||||
<Circle size={8} className="fill-emerald-500 text-emerald-500" /> Online agora ({online.length})
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
<div className="max-h-72 overflow-y-auto custom-scrollbar">
|
||||
{online.length === 0 && (
|
||||
<p className="px-4 py-3 text-[11px] text-gray-400 font-bold uppercase">Só você por aqui.</p>
|
||||
)}
|
||||
{online.map(o => (
|
||||
<div key={o.usuario_id} className="flex items-center gap-2.5 px-4 py-2.5">
|
||||
<span className="relative w-7 h-7 rounded-full flex items-center justify-center text-[9px] font-black text-white shrink-0" style={{ backgroundColor: colorFor(o.nome) }}>
|
||||
{initials(o.nome)}
|
||||
<Circle size={9} className="absolute -bottom-0.5 -right-0.5 fill-emerald-500 text-white" />
|
||||
</span>
|
||||
<span className="text-xs font-bold text-gray-700 uppercase truncate">{o.nome}</span>
|
||||
</div>
|
||||
))}
|
||||
{ultimasOffline.length > 0 && (
|
||||
<div className="px-4 py-2 border-t border-gray-50">
|
||||
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest mb-1">Últimas visitas</p>
|
||||
{ultimasOffline.slice(0, 6).map(u => (
|
||||
<div key={u.usuario_id} className="flex items-center justify-between py-1">
|
||||
<span className="text-[11px] font-bold text-gray-500 uppercase truncate">{u.usuario_nome}</span>
|
||||
<span className="text-[9px] text-gray-300 font-bold whitespace-nowrap ml-2">{relTime(u.last_view_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { X, Plus, Trash2, MapPin, Phone, Clock, CalendarClock, Save, Loader2, Me
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { buscarCep } from '../services/viacep.ts';
|
||||
import { HorariosConfig } from './HorariosConfig.tsx';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
|
||||
// Comunicação interna: perfil de contato de não-pacientes (dentistas/funcionários).
|
||||
// Números de WhatsApp múltiplos (cada um com descrição, situações e janelas de horário
|
||||
@@ -83,6 +84,12 @@ export const ComunicacaoInternaModal: React.FC<{
|
||||
const [jornadaOk, setJornadaOk] = useState(false); // jornada real (dentistas_horarios) configurada
|
||||
const [horarioClinicaOk, setHorarioClinicaOk] = useState(false); // clinicas_horarios configurado
|
||||
const [horariosTab, setHorariosTab] = useState<'dentistas' | 'clinica' | null>(null); // abre "Horários de Funcionamento"
|
||||
const [trocarOpen, setTrocarOpen] = useState(false); // dropdown "Trocar de clínica" (escape do gate forçado)
|
||||
|
||||
// Escape do gate forçado: o gate é POR clínica; travar ESTA não deve prender o
|
||||
// usuário no sistema. Lista as OUTRAS clínicas (exceto a atual e salas) para trocar.
|
||||
const outrasClinicas = (HybridBackend.getWorkspaces() || [])
|
||||
.filter((w: any) => w.tipo !== 'sala' && w.id !== clinicaId);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -316,6 +323,30 @@ export const ComunicacaoInternaModal: React.FC<{
|
||||
Lembrar mais tarde
|
||||
</button>
|
||||
)}
|
||||
{/* Escape do gate forçado: sair para OUTRA clínica sem preencher esta. */}
|
||||
{forced && outrasClinicas.length > 0 && (
|
||||
<div className="relative">
|
||||
<button type="button" onClick={() => setTrocarOpen(o => !o)}
|
||||
className="px-4 py-3 rounded-xl text-xs font-black uppercase text-gray-600 border border-gray-200 hover:bg-gray-50 flex items-center gap-1.5">
|
||||
Trocar de clínica
|
||||
</button>
|
||||
{trocarOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[71]" onClick={() => setTrocarOpen(false)} />
|
||||
<div className="absolute right-0 bottom-full mb-2 z-[72] min-w-[220px] bg-white border border-gray-200 rounded-2xl shadow-xl py-2 max-h-[50vh] overflow-y-auto">
|
||||
<p className="px-4 py-1.5 text-[9px] font-black text-gray-400 uppercase tracking-widest">Ir para outra clínica</p>
|
||||
{outrasClinicas.map((w: any) => (
|
||||
<button key={w.id} type="button" onClick={() => HybridBackend.switchWorkspace(w.id)}
|
||||
className="w-full flex items-center gap-2.5 px-4 py-2 hover:bg-gray-50 text-left">
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: w.cor || '#2563eb' }} />
|
||||
<span className="text-xs font-bold text-gray-700 uppercase truncate">{w.nome}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button onClick={salvar} disabled={saving}
|
||||
className="px-6 py-3 text-white rounded-xl text-xs font-black uppercase hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2" style={{ backgroundColor: completo ? '#16a34a' : COLOR }}>
|
||||
{saving ? <Loader2 size={15} className="animate-spin" /> : completo ? <CheckCircle2 size={15} /> : <Save size={15} />}
|
||||
|
||||
@@ -35,8 +35,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
}
|
||||
return () => { alive = false; };
|
||||
}, [activeWorkspace?.id]);
|
||||
const canSeeInbox = !waAccess || waAccess.isOwner || waAccess.inbox;
|
||||
const canSeeSecretaria = !waAccess || waAccess.isOwner || waAccess.secretaria;
|
||||
// Fail-CLOSED: enquanto waAccess não carregou (null) ou negou, NÃO mostra as áreas.
|
||||
const canSeeInbox = !!waAccess && (waAccess.isOwner || waAccess.inbox);
|
||||
const canSeeSecretaria = !!waAccess && (waAccess.isOwner || waAccess.secretaria);
|
||||
|
||||
const pluginMenuItems: Array<{ id: string; label: string; icon: any; color: string }> = [];
|
||||
if (activePluginIds.includes('rx-scoreodonto')) {
|
||||
@@ -54,8 +55,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
// ativo (a CONFIG do plugin fica no catálogo PLUGINS, exclusivo do superadmin).
|
||||
if (isPluginActive('newwhats')) {
|
||||
if (canSeeInbox) pluginMenuItems.push({ id: 'wa-inbox', label: 'WHATSAPP', icon: MessageCircle, color: '#16a34a' });
|
||||
// INSTÂNCIAS (gestão/QR) só para o dono; os demais operam via Inbox.
|
||||
if (!waAccess || waAccess.isOwner) pluginMenuItems.push({ id: 'wa-sessions', label: 'INSTÂNCIAS', icon: Smartphone, color: '#0ea5e9' });
|
||||
// INSTÂNCIAS (gestão/QR) só para o dono; os demais operam via Inbox. Fail-closed.
|
||||
if (waAccess?.isOwner) pluginMenuItems.push({ id: 'wa-sessions', label: 'INSTÂNCIAS', icon: Smartphone, color: '#0ea5e9' });
|
||||
// SECRETÁRIA IA saiu do menu — vive agora como aba DENTRO do WhatsApp (wa-inbox).
|
||||
}
|
||||
// Locação de Salas: menus de LOCADOR (administra as próprias salas) e LOCATÁRIO (aluga de
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
@@ -16,3 +16,14 @@ export const fmtDataCurta = (v?: string | number | Date | null): string => {
|
||||
export const fmtDataHora = (v?: string | number | Date | null): string => {
|
||||
const d = D(v); return d ? d.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: STORAGE_TZ }) : '—';
|
||||
};
|
||||
|
||||
// Converte um instante (ISO/Date) para o WALL-CLOCK naive no fuso canônico, no formato
|
||||
// "YYYY-MM-DDTHH:mm:ss" (SEM offset). Uso: alimentar o FullCalendar, que — sem o plugin
|
||||
// de timezone (luxon) — IGNORA o prop `timeZone` nomeado e cai no fuso do NAVEGADOR.
|
||||
// Passando a string naive, o calendário exibe o horário literal em qualquer navegador
|
||||
// (mesma garantia do fmtHora, mas para os eventos do grid). Retorna undefined se vazio.
|
||||
export const toCalendarNaive = (v?: string | number | Date | null): string | undefined => {
|
||||
const d = D(v); if (!d) return undefined;
|
||||
// 'sv-SE' produz "YYYY-MM-DD HH:mm:ss" (ISO-like); trocamos o espaço por 'T'.
|
||||
return d.toLocaleString('sv-SE', { timeZone: STORAGE_TZ, hour12: false }).replace(' ', 'T');
|
||||
};
|
||||
|
||||
@@ -204,6 +204,7 @@ const UsuariosTab: React.FC<{ clinicaId: string; isOwner: boolean; myNivel: numb
|
||||
role: editing.role,
|
||||
setor_id: editing.setor_id || null,
|
||||
funcao_id: editing.funcao_id || null,
|
||||
nome: editing.nome?.trim() || undefined,
|
||||
});
|
||||
if (!res.success) { toast.error(res.message || 'ERRO AO ATUALIZAR.'); return; }
|
||||
toast.success('MEMBRO ATUALIZADO!');
|
||||
@@ -372,6 +373,12 @@ const UsuariosTab: React.FC<{ clinicaId: string; isOwner: boolean; myNivel: numb
|
||||
{editing && (
|
||||
<Modal title={`EDITAR — ${editing.nome}`} onClose={() => setEditing(null)}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">NOME</label>
|
||||
<input value={editing.nome || ''} onChange={e => setEditing({ ...editing, nome: e.target.value.toUpperCase() })}
|
||||
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm font-bold uppercase focus:outline-none focus:border-teal-400" />
|
||||
<p className="text-[10px] text-gray-400 mt-1 leading-snug">Usado na assinatura das mensagens do WhatsApp. Troque quando a pessoa desta conta mudar (ex.: nova secretária).</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">ACESSO NO SISTEMA</label>
|
||||
<select value={editing.role} onChange={e => setEditing({ ...editing, role: e.target.value })}
|
||||
|
||||
@@ -435,11 +435,20 @@ const SessionCard = React.forwardRef<HTMLDivElement, {
|
||||
<UserCog className="w-3.5 h-3.5" /> Acesso
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onDisconnect}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-gray-600 hover:text-gray-800 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors border border-gray-100"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" /> Desconectar
|
||||
</button>
|
||||
{/* Desconectar = gestão da conexão (o backend exige dono ou can_rescan). */}
|
||||
{rescanLocked ? (
|
||||
<div title="Somente o dono pode desconectar esta sessão — peça autorização de re-scan."
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-gray-400 bg-gray-500/5 rounded-xl border border-gray-200 cursor-not-allowed"
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5" /> Desconectar
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={onDisconnect}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-gray-600 hover:text-gray-800 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors border border-gray-100"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" /> Desconectar
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : rescanLocked ? (
|
||||
<div className="flex-1 flex items-center justify-center gap-2 bg-gray-500/5 text-gray-400 font-black py-3 rounded-xl text-[11px] uppercase tracking-widest border border-gray-200 cursor-not-allowed"
|
||||
@@ -652,9 +661,9 @@ export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => vo
|
||||
// ── Ownership & permissões por sessão ──────────────────────────────────────
|
||||
const [authzByInstance, setAuthzByInstance] = useState<Record<string, SessionPerms>>({});
|
||||
const [workspaceOwner, setWorkspaceOwner] = useState<{ isOwner: boolean; ownerNome: string | null }>({ isOwner: false, ownerNome: null });
|
||||
// Endpoint de authz disponível? Se não (backend antigo/erro), a UI NÃO bloqueia —
|
||||
// o enforcement real é do proxy do backend. Começa true (otimista) até saber.
|
||||
const [authzAvailable, setAuthzAvailable] = useState(true);
|
||||
// Endpoint de authz disponível? Fail-CLOSED: se não (backend antigo/erro), a UI TRAVA as
|
||||
// ações destrutivas (o proxy segue sendo a autoridade). Começa false até confirmar.
|
||||
const [authzAvailable, setAuthzAvailable] = useState(false);
|
||||
const [permsModalFor, setPermsModalFor] = useState<Instance | null>(null);
|
||||
const instanceIdsKey = instances.map((i) => i.id).join(',');
|
||||
|
||||
@@ -680,7 +689,7 @@ export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => vo
|
||||
if (r?.available) available = true;
|
||||
map[id] = r
|
||||
? { isOwner: r.isOwner, canRescan: r.me.can_rescan, canDelete: r.me.can_delete, ownerNome: r.ownerNome }
|
||||
: { isOwner: false, canRescan: true, canDelete: true, ownerNome: null };
|
||||
: { isOwner: false, canRescan: false, canDelete: false, ownerNome: null }; // fail-closed em erro
|
||||
if (r?.available && r.isOwner) owner = { isOwner: true, ownerNome: r.ownerNome };
|
||||
else if (r?.available && !owner.ownerNome && r.ownerNome) owner.ownerNome = r.ownerNome;
|
||||
}
|
||||
@@ -692,18 +701,18 @@ export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => vo
|
||||
}, [instanceIdsKey]);
|
||||
|
||||
// Permissões efetivas de uma instância.
|
||||
// Sem authz disponível → permissivo (não bloqueia; backend é a autoridade).
|
||||
// Fail-CLOSED: sem authz disponível (endpoint em erro), trava as ações na UI — o backend
|
||||
// segue sendo a autoridade, mas a UI não deve expor botões destrutivos por falha transitória.
|
||||
const permsFor = (inst: Instance): SessionPerms => {
|
||||
if (!authzAvailable) return { isOwner: false, canRescan: true, canDelete: true, ownerNome: null };
|
||||
if (!authzAvailable) return { isOwner: false, canRescan: false, canDelete: false, ownerNome: null };
|
||||
return authzByInstance[inst.id]
|
||||
?? (workspaceOwner.isOwner
|
||||
? { ...OWNER_PERMS, ownerNome: workspaceOwner.ownerNome }
|
||||
: { isOwner: false, canRescan: false, canDelete: false, ownerNome: workspaceOwner.ownerNome });
|
||||
};
|
||||
|
||||
// "Nova Instância" (scan inicial): só o dono. Mas se o endpoint não está
|
||||
// disponível ainda, não bloqueia na UI (o backend decide).
|
||||
const canCreateInstance = !authzAvailable || workspaceOwner.isOwner;
|
||||
// "Nova Instância" (scan inicial): só o dono (fail-closed — o backend também exige dono).
|
||||
const canCreateInstance = authzAvailable && workspaceOwner.isOwner;
|
||||
|
||||
// Já existe uma sessão ativa no motor para esta conta → mostra mensagem de
|
||||
// sucesso em vez de sugerir novo pareamento.
|
||||
|
||||
@@ -15,10 +15,13 @@
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ChevronDown, User, Phone, Send, MessageCircle, Loader2, WifiOff, QrCode } from 'lucide-react';
|
||||
import { X, ChevronDown, User, Users, Phone, Send, MessageCircle, Loader2, WifiOff, QrCode } from 'lucide-react';
|
||||
|
||||
import { HybridBackend } from '../../services/backend';
|
||||
import { useChatStore } from './store/chatStore';
|
||||
import { chatApi } from './services/chatApiService';
|
||||
import ForwardModal from './components/ForwardModal';
|
||||
import LabelManagerModal from './components/LabelManagerModal';
|
||||
import { useInstanceStore } from './store/instanceStore';
|
||||
import { formatPhone } from './utils/inboxUtils';
|
||||
import { nw } from './services/nwClient';
|
||||
@@ -29,6 +32,7 @@ import { useChatWindow } from './hooks/inbox/useChatWindow';
|
||||
import { useMessageInput } from './hooks/inbox/useMessageInput';
|
||||
|
||||
import InboxHeader from './components/InboxHeader';
|
||||
import ConversationSearch from './components/ConversationSearch';
|
||||
import MessageArea from './components/MessageArea';
|
||||
import InputBar from './components/InputBar';
|
||||
import ContactProfile from './components/ContactProfile';
|
||||
@@ -39,6 +43,10 @@ export interface WhatsChatDrawerProps {
|
||||
onClose: () => void;
|
||||
pacienteId?: string | null;
|
||||
pacienteNome?: string | null;
|
||||
// Telefone CRU direto (ex.: pedido da fila da Secretária, que pode ser um lead SEM
|
||||
// cadastro). Quando fornecido, entra como 1ª opção de número e resolve o chat sem
|
||||
// depender de lookup por cadastro.
|
||||
phone?: string | null;
|
||||
onNavigate?: (view: string) => void;
|
||||
}
|
||||
|
||||
@@ -65,8 +73,16 @@ function samePhone(a: string, b: string): boolean {
|
||||
return da.slice(-8) === db.slice(-8);
|
||||
}
|
||||
|
||||
// Nome curto p/ o header: 1º nome + um pouco do 2º (até 5 letras + … se cortar).
|
||||
function shortContactName(full: string): string {
|
||||
const parts = String(full || '').trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length <= 1) return parts[0] || full;
|
||||
const second = parts[1];
|
||||
return `${parts[0]} ${second.length > 5 ? second.slice(0, 5) + '…' : second}`;
|
||||
}
|
||||
|
||||
// ─── Conteúdo interno (só monta quando aberto → hooks/socket sob demanda) ──────
|
||||
const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose, pacienteId, pacienteNome, onNavigate }) => {
|
||||
const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose, pacienteId, pacienteNome, phone, onNavigate }) => {
|
||||
const { activeInstance, instances, connectedInstances } = useInstanceSelector();
|
||||
const loadingInstances = useInstanceStore((s) => s.loading);
|
||||
// Há WhatsApp utilizável? (pelo menos uma instância conectada)
|
||||
@@ -77,32 +93,65 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
|
||||
loadingMessages,
|
||||
handleSendText,
|
||||
handleLoadMoreHistory,
|
||||
handleDeleteMessage,
|
||||
presenceByJid,
|
||||
activeInstanceId,
|
||||
} = useNewInboxBridge();
|
||||
|
||||
// Permissão de apagar mensagem (dono ou liberado). Fail-safe: false enquanto carrega.
|
||||
const [canDeleteMsg, setCanDeleteMsg] = useState(false);
|
||||
// "Limpar conversa" é só do DONO do workspace. Fail-closed.
|
||||
const [isWaOwner, setIsWaOwner] = useState(false);
|
||||
const [labelModalOpen, setLabelModalOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
HybridBackend.getWaAccess().then(a => { if (alive) { setCanDeleteMsg(!!(a.isOwner || a.delete_msg)); setIsWaOwner(!!a.isOwner); useChatStore.getState().setOperatorSignature(a.signature ?? null); } }).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
const {
|
||||
scrollRef, isTyping, replyingTo, reactionPickerFor, hasMoreHistory, loadingMore,
|
||||
scrollRef, replyingTo, reactionPickerFor, hasMoreHistory, loadingMore,
|
||||
isProfileOpen, isSyncingAvatar, isChatMenuOpen,
|
||||
setReplyingTo, setIsProfileOpen, setIsChatMenuOpen,
|
||||
handleTyping, handleScroll, handleSelectChat: onChatWindowSelect,
|
||||
handleReactToMessage, handleSendMedia, handleSendAudio, handleSyncAvatar, toggleReactionPicker,
|
||||
handleClearConversation,
|
||||
editingMessage, setEditingMessage, forwardingMessage, setForwardingMessage,
|
||||
} = useChatWindow({
|
||||
selectedChat,
|
||||
activeInstanceId: activeInstance?.instance ?? null,
|
||||
onLoadMoreHistory: handleLoadMoreHistory,
|
||||
});
|
||||
|
||||
// "digitando..." do CONTATO (presença composing/recording do chat aberto).
|
||||
const activePresence = selectedChat?.remote_jid ? presenceByJid[selectedChat.remote_jid] : undefined;
|
||||
const isTyping = !!activePresence && (Date.now() - activePresence.ts <= 8000) && (activePresence.state === 'composing' || activePresence.state === 'recording');
|
||||
|
||||
const { newMessage, setNewMessage, mentions, setMentions, handleSendMessage } = useMessageInput({
|
||||
selectedChat,
|
||||
replyingTo,
|
||||
onClearReply: () => setReplyingTo(null),
|
||||
onSendText: handleSendText,
|
||||
editingMessage,
|
||||
onClearEdit: () => setEditingMessage(null),
|
||||
onEditText: async (messageId, text) => {
|
||||
if (!selectedChat?.id) return;
|
||||
try {
|
||||
await chatApi.editMessage(String(selectedChat.id), messageId, text);
|
||||
useChatStore.getState().patchMessage(messageId, { body: text });
|
||||
} catch (e) { console.error('Erro ao editar:', e); }
|
||||
},
|
||||
});
|
||||
const handleEnterEdit = useCallback((msg: any) => {
|
||||
setEditingMessage(msg); setReplyingTo(null); setNewMessage(msg?.text ?? msg?.body ?? '');
|
||||
}, [setEditingMessage, setReplyingTo, setNewMessage]);
|
||||
|
||||
// ── Números disponíveis (paciente + grupo familiar) ──────────────────────
|
||||
const [numeros, setNumeros] = useState<NumeroOpcao[]>([]);
|
||||
const [selectedPhone, setSelectedPhone] = useState<string>('');
|
||||
const [numDropdownOpen, setNumDropdownOpen] = useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// ── Estado de resolução da conversa ──────────────────────────────────────
|
||||
const [resolving, setResolving] = useState(false);
|
||||
@@ -126,6 +175,8 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
|
||||
opts.push({ phone: d, nome, relacao });
|
||||
};
|
||||
|
||||
// 0) Telefone CRU direto (pedido da fila): 1ª opção, funciona mesmo sem cadastro.
|
||||
if (phone) push(phone, pacienteNome || 'Contato', 'Contato');
|
||||
// 1) Telefone do próprio paciente — via cadastro (desambigua por id).
|
||||
if (pacienteNome) {
|
||||
try {
|
||||
@@ -150,7 +201,7 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
|
||||
setSelectedPhone(opts[0]?.phone || '');
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [pacienteId, pacienteNome]);
|
||||
}, [pacienteId, pacienteNome, phone]);
|
||||
|
||||
// Resolve a conversa (chatId) a partir do telefone selecionado, sem enviar nada.
|
||||
const resolveChat = useCallback(async (phoneDigits: string) => {
|
||||
@@ -230,48 +281,59 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
className="fixed inset-y-0 right-0 z-[191] w-full sm:w-[440px] bg-[#f0f2f5] shadow-2xl flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Barra superior do drawer: contato + dropdown de números + fechar */}
|
||||
<div className="shrink-0 bg-[#008069] text-white px-3 py-2.5 flex items-center gap-2">
|
||||
{/* Barra superior: nome + dropdown do GRUPO FAMILIAR (troca a conversa) + fechar */}
|
||||
<div className="shrink-0 bg-[#008069] text-white px-3 py-2 flex items-center gap-2.5">
|
||||
<MessageCircle className="w-5 h-5 shrink-0" />
|
||||
<div className="min-w-0 flex-1 relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => numeros.length > 1 && setNumDropdownOpen((v) => !v)}
|
||||
className={`flex items-center gap-1.5 max-w-full ${numeros.length > 1 ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
>
|
||||
<span className="font-bold text-sm truncate">{tituloContato}</span>
|
||||
{selectedNumero && (
|
||||
<span className="text-[11px] text-white/70 truncate">
|
||||
{formatPhone(normalizeSendPhone(selectedNumero.phone))}
|
||||
</span>
|
||||
)}
|
||||
{numeros.length > 1 && <ChevronDown className={`w-4 h-4 shrink-0 transition-transform ${numDropdownOpen ? 'rotate-180' : ''}`} />}
|
||||
</button>
|
||||
{/* Nome (truncado) + pill dropdown do grupo familiar — NA MESMA LINHA */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<p className="font-bold text-sm truncate leading-tight" title={tituloContato}>{shortContactName(tituloContato)}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNumDropdownOpen((v) => !v)}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-full bg-white/15 hover:bg-white/25 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wide transition-colors"
|
||||
>
|
||||
<Users className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="truncate max-w-[90px]">{selectedNumero?.relacao || 'Paciente'}</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 shrink-0 transition-transform ${numDropdownOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{numDropdownOpen && numeros.length > 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }}
|
||||
className="absolute left-0 top-full mt-1 w-72 bg-white text-gray-800 rounded-xl shadow-2xl border border-gray-100 py-1.5 z-10"
|
||||
>
|
||||
<p className="px-3 py-1 text-[9px] font-black uppercase tracking-widest text-gray-400">Número / grupo familiar</p>
|
||||
{numeros.map((n) => (
|
||||
<button
|
||||
key={n.phone}
|
||||
onClick={() => { setSelectedPhone(n.phone); setNumDropdownOpen(false); }}
|
||||
className={`w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2.5 ${n.phone === selectedPhone ? 'bg-teal-50' : ''}`}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-teal-50 flex items-center justify-center shrink-0">
|
||||
<User size={15} className="text-teal-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-black uppercase truncate">{n.nome}</p>
|
||||
<p className="text-[10px] text-gray-400 font-bold">
|
||||
<span className="text-teal-500">{n.relacao}</span> · {formatPhone(normalizeSendPhone(n.phone))}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
{numDropdownOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[5]" onClick={() => setNumDropdownOpen(false)} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }}
|
||||
className="absolute left-0 top-full mt-1.5 w-72 bg-white text-gray-800 rounded-xl shadow-2xl border border-gray-100 py-1.5 z-10 overflow-hidden"
|
||||
>
|
||||
<p className="px-3 py-1.5 text-[9px] font-black uppercase tracking-widest text-gray-400 flex items-center gap-1.5">
|
||||
<Users size={11} className="text-teal-500" /> Grupo familiar
|
||||
</p>
|
||||
{numeros.map((n) => (
|
||||
<button
|
||||
key={n.phone}
|
||||
onClick={() => { setSelectedPhone(n.phone); setNumDropdownOpen(false); }}
|
||||
className={`w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2.5 transition-colors ${n.phone === selectedPhone ? 'bg-teal-50' : ''}`}
|
||||
>
|
||||
<div className={`w-9 h-9 rounded-full flex items-center justify-center shrink-0 ${n.phone === selectedPhone ? 'bg-teal-500 text-white' : 'bg-teal-50 text-teal-600'}`}>
|
||||
<User size={16} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-black uppercase truncate">{n.nome}</p>
|
||||
<p className="text-[10px] text-gray-400 font-bold truncate">
|
||||
<span className="text-teal-500">{n.relacao}</span> · {formatPhone(normalizeSendPhone(n.phone))}
|
||||
</p>
|
||||
</div>
|
||||
{n.phone === selectedPhone && <span className="text-[9px] font-black text-teal-600 uppercase shrink-0">ativo</span>}
|
||||
</button>
|
||||
))}
|
||||
{numeros.length <= 1 && (
|
||||
<p className="px-3 pt-2 pb-1 mt-1 border-t border-gray-50 text-[10px] text-gray-400 leading-snug">
|
||||
Cadastre o <b className="text-gray-500">grupo familiar</b> no paciente (mãe, filho, cônjuge…) para trocar de conversa aqui e notificar sobre o paciente.
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
@@ -378,9 +440,21 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
|
||||
onSyncAvatar={handleSyncAvatar}
|
||||
isChatMenuOpen={isChatMenuOpen}
|
||||
isSyncingAvatar={isSyncingAvatar}
|
||||
onSearchClick={() => {}}
|
||||
isOwner={isWaOwner}
|
||||
onClearConversation={handleClearConversation}
|
||||
onManageLabels={() => setLabelModalOpen(true)}
|
||||
onSearchClick={() => setIsSearchOpen(true)}
|
||||
onToggleMenu={() => setIsChatMenuOpen(!isChatMenuOpen)}
|
||||
/>
|
||||
{isSearchOpen && (
|
||||
<ConversationSearch
|
||||
messages={messages as any}
|
||||
onClose={() => setIsSearchOpen(false)}
|
||||
onQueryChange={setSearchQuery}
|
||||
hasOlder={hasMoreHistory}
|
||||
loadOlder={async () => { const r = await handleLoadMoreHistory(); return !!r?.hasMore; }}
|
||||
/>
|
||||
)}
|
||||
<MessageArea
|
||||
messages={messages as any}
|
||||
selectedChat={selectedChat}
|
||||
@@ -388,14 +462,16 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
|
||||
msgLoading={loadingMessages}
|
||||
onScroll={handleScroll}
|
||||
historyLoadingMore={loadingMore}
|
||||
highlightTerm={isSearchOpen ? searchQuery : ''}
|
||||
hasMoreHistory={hasMoreHistory}
|
||||
reactionPickerFor={reactionPickerFor}
|
||||
onReply={setReplyingTo}
|
||||
onToggleReactionPicker={toggleReactionPicker}
|
||||
onReact={handleReactToMessage}
|
||||
onForward={() => {}}
|
||||
onDelete={() => {}}
|
||||
onEdit={() => {}}
|
||||
onForward={(msg: any) => setForwardingMessage(msg)}
|
||||
onDelete={(msg: any) => { if (selectedChat && msg?.id) handleDeleteMessage(String(selectedChat.id), String(msg.id)); }}
|
||||
canDelete={canDeleteMsg}
|
||||
onEdit={handleEnterEdit}
|
||||
quickReactions={['👍', '❤️', '😂', '😮', '😢', '🙏']}
|
||||
getGroupSenderLabel={(msg: any) => {
|
||||
if (msg.direction === 'out') return 'Você';
|
||||
@@ -415,6 +491,8 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
|
||||
onPresence={handleTyping}
|
||||
onCancelReply={() => setReplyingTo(null)}
|
||||
replyingTo={replyingTo}
|
||||
editingMessage={editingMessage}
|
||||
onCancelEdit={() => { setEditingMessage(null); setNewMessage(''); }}
|
||||
selectedChat={selectedChat}
|
||||
isMobile={true}
|
||||
onSendMedia={handleSendMedia}
|
||||
@@ -438,6 +516,24 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
|
||||
)}
|
||||
</div>
|
||||
</motion.aside>
|
||||
<LabelManagerModal
|
||||
isOpen={labelModalOpen}
|
||||
onClose={() => setLabelModalOpen(false)}
|
||||
chat={selectedChat as any}
|
||||
onChanged={() => { if (activeInstance?.instance) useChatStore.getState().loadChats(activeInstance.instance); }}
|
||||
/>
|
||||
|
||||
<ForwardModal
|
||||
isOpen={!!forwardingMessage}
|
||||
onClose={() => setForwardingMessage(null)}
|
||||
chats={useChatStore.getState().chats as any}
|
||||
onForward={async (toChatIds: string[]) => {
|
||||
if (!forwardingMessage || !selectedChat?.id) return;
|
||||
try { await chatApi.forward(String(selectedChat.id), String((forwardingMessage as any).id), toChatIds); }
|
||||
catch (e) { console.error('Erro ao encaminhar:', e); }
|
||||
setForwardingMessage(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Search, ChevronUp, ChevronDown, X, Loader2 } from 'lucide-react';
|
||||
|
||||
interface Msg { message_id?: string; id?: string | number; text?: string; transcription?: string | null }
|
||||
|
||||
// Barra de busca DENTRO da conversa: filtra as mensagens por texto (corpo +
|
||||
// transcrição), rola até o resultado e o realça. Navega com ↑/↓ (ou Enter/Shift+
|
||||
// Enter). Se o termo não estiver nas mensagens já carregadas, CARREGA o histórico
|
||||
// antigo automaticamente (uma página por vez, até achar ou acabar). Usa o id do DOM
|
||||
// já existente na bolha (id="msg-<message_id>").
|
||||
const MAX_PAGES = 40; // teto de páginas antigas a carregar por busca (~evita runaway)
|
||||
|
||||
const ConversationSearch: React.FC<{
|
||||
messages: Msg[];
|
||||
onClose: () => void;
|
||||
loadOlder?: () => Promise<boolean>; // carrega 1 página antiga; retorna se ainda há mais
|
||||
hasOlder?: boolean;
|
||||
onQueryChange?: (q: string) => void; // avisa o pai p/ realçar o termo nas bolhas
|
||||
}> = ({ messages, onClose, loadOlder, hasOlder, onQueryChange }) => {
|
||||
const [q, setQ] = useState('');
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [autoLoading, setAutoLoading] = useState(false);
|
||||
const [pagesLoaded, setPagesLoaded] = useState(0);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const t = q.trim().toLowerCase();
|
||||
if (!t) return [] as string[];
|
||||
return messages
|
||||
.filter((m) => `${m.text ?? ''} ${m.transcription ?? ''}`.toLowerCase().includes(t))
|
||||
.map((m) => String(m.message_id ?? m.id ?? ''))
|
||||
.filter(Boolean);
|
||||
}, [q, messages]);
|
||||
|
||||
// Reinicia navegação e o contador de páginas ao mudar o termo; avisa o pai.
|
||||
useEffect(() => { setIdx(0); setPagesLoaded(0); onQueryChange?.(q); }, [q]);
|
||||
// Ao desmontar (fechar busca), limpa o realce.
|
||||
useEffect(() => () => onQueryChange?.(''), []);
|
||||
|
||||
// Sem resultados nas mensagens carregadas → puxa mais histórico antigo (1 página
|
||||
// por vez; o React recomputa `matches` e o efeito re-roda até achar ou acabar).
|
||||
useEffect(() => {
|
||||
if (!q.trim() || !loadOlder || !hasOlder || loadingRef.current) return;
|
||||
if (matches.length > 0 || pagesLoaded >= MAX_PAGES) return;
|
||||
loadingRef.current = true;
|
||||
setAutoLoading(true);
|
||||
let cancelled = false;
|
||||
loadOlder()
|
||||
.then(() => { if (!cancelled) setPagesLoaded((p) => p + 1); })
|
||||
.finally(() => { if (!cancelled) { loadingRef.current = false; setAutoLoading(false); } });
|
||||
return () => { cancelled = true; loadingRef.current = false; };
|
||||
}, [q, matches.length, hasOlder, pagesLoaded, loadOlder]);
|
||||
|
||||
// Rola até o resultado atual e o realça brevemente.
|
||||
useEffect(() => {
|
||||
if (!matches.length) return;
|
||||
const id = matches[Math.min(idx, matches.length - 1)];
|
||||
const el = document.getElementById(`msg-${id}`);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.add('bg-[#00a884]/15', 'rounded-xl', 'transition-colors');
|
||||
const t = setTimeout(() => el.classList.remove('bg-[#00a884]/15', 'rounded-xl'), 1400);
|
||||
return () => clearTimeout(t);
|
||||
}, [idx, matches]);
|
||||
|
||||
const go = (d: number) => { if (matches.length) setIdx((i) => (i + d + matches.length) % matches.length); };
|
||||
|
||||
// Texto do contador: buscando / n de N / nenhum.
|
||||
const counter = !q.trim()
|
||||
? ''
|
||||
: matches.length
|
||||
? `${idx + 1}/${matches.length}`
|
||||
: (autoLoading ? '' : '0');
|
||||
|
||||
return (
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 bg-[#f0f2f5] border-b border-[#d1d7db] z-20">
|
||||
<Search className="w-4 h-4 text-[#54656f] shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') go(e.shiftKey ? -1 : 1);
|
||||
if (e.key === 'Escape') onClose();
|
||||
}}
|
||||
placeholder="Buscar na conversa..."
|
||||
className="flex-1 min-w-0 bg-transparent outline-none text-sm text-[#111b21] placeholder:text-[#8696a0]"
|
||||
/>
|
||||
{autoLoading && !matches.length && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-[#667781] font-bold shrink-0">
|
||||
<Loader2 className="w-3 h-3 animate-spin" /> antigas…
|
||||
</span>
|
||||
)}
|
||||
{counter && <span className="text-[11px] text-[#667781] font-bold shrink-0 tabular-nums">{counter}</span>}
|
||||
<button onClick={() => go(-1)} disabled={!matches.length} title="Anterior"
|
||||
className="p-1 rounded-full hover:bg-black/10 disabled:opacity-40 text-[#54656f] transition-colors"><ChevronUp className="w-4 h-4" /></button>
|
||||
<button onClick={() => go(1)} disabled={!matches.length} title="Próximo"
|
||||
className="p-1 rounded-full hover:bg-black/10 disabled:opacity-40 text-[#54656f] transition-colors"><ChevronDown className="w-4 h-4" /></button>
|
||||
<button onClick={onClose} title="Fechar busca"
|
||||
className="p-1 rounded-full hover:bg-black/10 text-[#54656f] transition-colors"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversationSearch;
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Search, Send, Check } from 'lucide-react';
|
||||
|
||||
interface ForwardModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
chats: any[];
|
||||
onForward: (toChatIds: string[]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
// Modal para escolher um ou mais chats de destino e encaminhar a mensagem selecionada.
|
||||
const ForwardModal: React.FC<ForwardModalProps> = ({ isOpen, onClose, chats, onForward }) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const label = (c: any) => c.displayName || c.subject || c.name || c.phone || c.remote_jid || String(c.id);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return chats;
|
||||
return chats.filter((c) => label(c).toLowerCase().includes(q));
|
||||
}, [chats, query]);
|
||||
|
||||
if (!isOpen || typeof document === 'undefined') return null;
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.has(id) ? n.delete(id) : n.add(id);
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
const handleForward = async () => {
|
||||
if (selected.size === 0 || sending) return;
|
||||
setSending(true);
|
||||
try { await onForward(Array.from(selected)); }
|
||||
finally { setSending(false); setSelected(new Set()); setQuery(''); }
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" onClick={onClose}>
|
||||
<div className="w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[80vh]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
||||
<h3 className="text-[15px] font-bold text-[#111b21]">Encaminhar para…</h3>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-700 rounded-full transition-colors"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2.5 border-b border-gray-100">
|
||||
<div className="flex items-center gap-2 bg-[#f0f2f5] rounded-lg px-3 py-2">
|
||||
<Search className="w-4 h-4 text-[#667781] shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Pesquisar conversa"
|
||||
className="w-full bg-transparent text-[14px] text-[#111b21] placeholder:text-[#667781] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-4 py-6 text-center text-[13px] text-[#667781]">Nenhuma conversa encontrada.</div>
|
||||
)}
|
||||
{filtered.map((c) => {
|
||||
const id = String(c.id);
|
||||
const isSel = selected.has(id);
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => toggle(id)}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-[#f5f6f6] transition-colors text-left"
|
||||
>
|
||||
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors ${isSel ? 'bg-[#00a884] border-[#00a884]' : 'border-gray-300'}`}>
|
||||
{isSel && <Check className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
<span className="text-[14px] text-[#111b21] truncate">{label(c)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between">
|
||||
<span className="text-[12px] text-[#667781]">{selected.size} selecionada(s)</span>
|
||||
<button
|
||||
onClick={handleForward}
|
||||
disabled={selected.size === 0 || sending}
|
||||
className="flex items-center gap-2 bg-[#00a884] text-white text-[14px] font-bold px-4 py-2 rounded-lg disabled:opacity-40 hover:bg-[#008f6f] transition-colors"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
{sending ? 'Encaminhando…' : 'Encaminhar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export default ForwardModal;
|
||||
@@ -43,6 +43,8 @@ interface InputBarProps {
|
||||
onSendAudio: (blob: Blob) => Promise<void>;
|
||||
replyingTo: Message | null;
|
||||
onCancelReply: () => void;
|
||||
editingMessage?: Message | null;
|
||||
onCancelEdit?: () => void;
|
||||
isMobile: boolean;
|
||||
selectedChat: any;
|
||||
instanceId?: string | null;
|
||||
@@ -59,6 +61,8 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
onSendAudio,
|
||||
replyingTo,
|
||||
onCancelReply,
|
||||
editingMessage,
|
||||
onCancelEdit,
|
||||
isMobile,
|
||||
selectedChat,
|
||||
instanceId,
|
||||
@@ -205,10 +209,11 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
}, []);
|
||||
|
||||
const handleSendSticker = useCallback(async (blob: Blob) => {
|
||||
if (!instanceId || !selectedChat?.remote_jid) return;
|
||||
if (!selectedChat?.id) return;
|
||||
setShowStickerPanel(false);
|
||||
await mediaApi.sendSticker(instanceId, selectedChat.remote_jid, blob);
|
||||
}, [instanceId, selectedChat]);
|
||||
// mediaApi do satélite é por chatId (não instanceId/jid).
|
||||
await mediaApi.sendSticker(String(selectedChat.id), blob);
|
||||
}, [selectedChat]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Mention dropdown navigation
|
||||
@@ -405,6 +410,32 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Edit banner */}
|
||||
<AnimatePresence>
|
||||
{editingMessage && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
className="mx-4 mb-2 bg-white/80 backdrop-blur-sm border-l-[4px] border-[#f59e0b] rounded-lg px-4 py-3 flex items-start justify-between gap-4 shadow-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12px] font-medium text-[#b45309] mb-1">Editando mensagem</div>
|
||||
<div className="text-sm text-gray-500 font-medium truncate italic">
|
||||
{editingMessage.text || 'Mensagem'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-gray-100 hover:bg-gray-200 text-gray-500 p-1 rounded-full transition-colors"
|
||||
onClick={onCancelEdit}
|
||||
>
|
||||
<X className="w-3.5 h-3.5 stroke-[3px]" />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Reply Preview */}
|
||||
<AnimatePresence>
|
||||
{replyingTo && (
|
||||
@@ -513,30 +544,36 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
Mensagem Rica
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Emoji */}
|
||||
<button
|
||||
onClick={() => { setShowEmojiPicker(true); setShowStickerPanel(false); setShowAttachMenu(false); }}
|
||||
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
|
||||
title="Emojis"
|
||||
>
|
||||
<Smile className="w-[22px] h-[22px]" />
|
||||
<span className="absolute left-full ml-2 whitespace-nowrap text-xs bg-[#111b21] text-white px-2 py-1 rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">
|
||||
Emoji
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Figurinhas */}
|
||||
{instanceId && (
|
||||
<button
|
||||
onClick={() => { setShowStickerPanel(true); setShowEmojiPicker(false); setShowAttachMenu(false); }}
|
||||
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
|
||||
title="Figurinhas"
|
||||
>
|
||||
<Sticker className="w-[22px] h-[22px]" />
|
||||
<span className="absolute left-full ml-2 whitespace-nowrap text-xs bg-[#111b21] text-white px-2 py-1 rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">
|
||||
Figurinhas
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Emoji — mantém no lugar */}
|
||||
<button
|
||||
onClick={() => { setShowEmojiPicker(!showEmojiPicker); setShowStickerPanel(false); }}
|
||||
className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showEmojiPicker ? 'text-[#00a884]' : 'text-[#54656f]'}`}
|
||||
title="Emojis"
|
||||
>
|
||||
<Smile className="w-[26px] h-[26px]" />
|
||||
</button>
|
||||
|
||||
{/* Figurinhas */}
|
||||
{instanceId && (
|
||||
<button
|
||||
onClick={() => { setShowStickerPanel(!showStickerPanel); setShowEmojiPicker(false); }}
|
||||
className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showStickerPanel ? 'text-[#00a884]' : 'text-[#54656f]'}`}
|
||||
title="Figurinhas"
|
||||
>
|
||||
<Sticker className="w-[24px] h-[24px]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
@@ -589,7 +626,7 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
placeholder="Digite uma mensagem"
|
||||
className="w-full px-3 py-2.5 bg-white rounded-lg text-[15px] text-[#111b21] border-none focus:ring-0 focus:outline-none placeholder:text-[#667781] resize-none shadow-sm transition-all overflow-hidden leading-[1.4]"
|
||||
className="w-full px-3.5 py-3 bg-white rounded-xl text-[15px] text-[#111b21] border-none focus:ring-0 focus:outline-none placeholder:text-[#667781] resize-none shadow-sm transition-all overflow-hidden leading-[1.4]"
|
||||
value={newMessage}
|
||||
onChange={(e) => {
|
||||
onNewMessageChange(e.target.value);
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Plus, Trash2, Check, Tag } from 'lucide-react';
|
||||
import { labelApi, type Label } from '../services/chatApiService';
|
||||
|
||||
interface LabelManagerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
// Se fornecido, mostra atribuição de etiquetas para ESTA conversa.
|
||||
chat?: { id: any; labels?: Label[] } | null;
|
||||
// Chamado após qualquer mudança (para o inbox recarregar os chips).
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
const PALETTE = ['#00a884', '#ef4444', '#f59e0b', '#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6', '#64748b'];
|
||||
|
||||
const LabelManagerModal: React.FC<LabelManagerModalProps> = ({ isOpen, onClose, chat, onChanged }) => {
|
||||
const [labels, setLabels] = useState<Label[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newColor, setNewColor] = useState(PALETTE[0]);
|
||||
const [assigned, setAssigned] = useState<Set<string>>(new Set());
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await labelApi.list();
|
||||
setLabels(Array.isArray(list) ? list : []);
|
||||
} catch { /* ignore */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
load();
|
||||
setAssigned(new Set((chat?.labels ?? []).map((l) => l.id)));
|
||||
}, [isOpen, chat, load]);
|
||||
|
||||
if (!isOpen || typeof document === 'undefined') return null;
|
||||
|
||||
const create = async () => {
|
||||
if (!newName.trim()) return;
|
||||
try {
|
||||
await labelApi.create(newName.trim(), newColor);
|
||||
setNewName('');
|
||||
await load();
|
||||
onChanged?.();
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
try { await labelApi.delete(id); setAssigned((s) => { const n = new Set(s); n.delete(id); return n; }); await load(); onChanged?.(); }
|
||||
catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const toggleAssign = (id: string) => {
|
||||
setAssigned((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
};
|
||||
|
||||
const saveAssignments = async () => {
|
||||
if (!chat?.id || saving) return;
|
||||
setSaving(true);
|
||||
try { await labelApi.setForChat(String(chat.id), Array.from(assigned)); onChanged?.(); onClose(); }
|
||||
catch { /* ignore */ } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" onClick={onClose}>
|
||||
<div className="w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[85vh]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
||||
<h3 className="text-[15px] font-bold text-[#111b21] flex items-center gap-2"><Tag className="w-4 h-4 text-[#00a884]" /> Etiquetas</h3>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-700 rounded-full transition-colors"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Atribuição à conversa */}
|
||||
{chat && (
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<div className="text-[12px] font-bold text-[#667781] uppercase tracking-wide mb-2">Nesta conversa</div>
|
||||
{labels.length === 0 && <div className="text-[13px] text-[#667781]">Nenhuma etiqueta criada ainda.</div>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{labels.map((l) => {
|
||||
const on = assigned.has(l.id);
|
||||
return (
|
||||
<button key={l.id} onClick={() => toggleAssign(l.id)}
|
||||
className={`flex items-center gap-1.5 pl-2 pr-2.5 py-1 rounded-full text-[13px] font-medium border transition-all ${on ? 'text-white' : 'text-[#111b21] bg-white'}`}
|
||||
style={on ? { backgroundColor: l.color, borderColor: l.color } : { borderColor: l.color }}>
|
||||
{on && <Check className="w-3.5 h-3.5" />}
|
||||
<span style={!on ? { color: l.color } : undefined}>{l.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lista/gestão */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="text-[12px] font-bold text-[#667781] uppercase tracking-wide mb-2">Todas as etiquetas</div>
|
||||
{loading && <div className="text-[13px] text-[#667781]">Carregando…</div>}
|
||||
<div className="space-y-1.5">
|
||||
{labels.map((l) => (
|
||||
<div key={l.id} className="flex items-center gap-2.5 py-1">
|
||||
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ backgroundColor: l.color }} />
|
||||
<span className="flex-1 text-[14px] text-[#111b21] truncate">{l.name}</span>
|
||||
<button onClick={() => remove(l.id)} className="p-1 text-gray-300 hover:text-red-500 transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Criar */}
|
||||
<div className="px-4 py-3 border-t border-gray-100">
|
||||
<div className="text-[12px] font-bold text-[#667781] uppercase tracking-wide mb-2">Nova etiqueta</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input value={newName} onChange={(e) => setNewName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && create()}
|
||||
placeholder="Nome da etiqueta"
|
||||
className="flex-1 bg-[#f0f2f5] rounded-lg px-3 py-2 text-[14px] text-[#111b21] placeholder:text-[#667781] focus:outline-none" />
|
||||
<button onClick={create} disabled={!newName.trim()}
|
||||
className="flex items-center gap-1 bg-[#00a884] text-white text-[13px] font-bold px-3 py-2 rounded-lg disabled:opacity-40 hover:bg-[#008f6f] transition-colors">
|
||||
<Plus className="w-4 h-4" /> Criar
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{PALETTE.map((c) => (
|
||||
<button key={c} onClick={() => setNewColor(c)}
|
||||
className={`w-6 h-6 rounded-full transition-transform ${newColor === c ? 'ring-2 ring-offset-2 ring-gray-400 scale-110' : ''}`}
|
||||
style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chat && (
|
||||
<div className="px-4 py-3 border-t border-gray-100 flex justify-end">
|
||||
<button onClick={saveAssignments} disabled={saving}
|
||||
className="bg-[#00a884] text-white text-[14px] font-bold px-4 py-2 rounded-lg disabled:opacity-40 hover:bg-[#008f6f] transition-colors">
|
||||
{saving ? 'Salvando…' : 'Salvar nesta conversa'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export default LabelManagerModal;
|
||||
@@ -118,7 +118,22 @@ export const extractFirstUrl = (text: string | null | undefined): string | null
|
||||
return m ? m[0] : null;
|
||||
};
|
||||
|
||||
// Renders plain text with URLs converted to clickable <a> tags
|
||||
// Formatação inline estilo WhatsApp: *negrito*, _itálico_, ~tachado~. Guarda contra
|
||||
// falso-positivo exigindo não-espaço colado às marcas (evita "3 * 4" virar negrito).
|
||||
const INLINE_RE = /(\*\S(?:[^*\n]*\S)?\*|_\S(?:[^_\n]*\S)?_|~\S(?:[^~\n]*\S)?~)/g;
|
||||
const renderInline = (text: string, keyBase: string): React.ReactNode[] => {
|
||||
if (!text) return [];
|
||||
return text.split(INLINE_RE).map((part, i) => {
|
||||
if (!part) return null;
|
||||
const key = `${keyBase}-${i}`;
|
||||
if (/^\*\S(?:[^*\n]*\S)?\*$/.test(part)) return <strong key={key} className="font-semibold">{part.slice(1, -1)}</strong>;
|
||||
if (/^_\S(?:[^_\n]*\S)?_$/.test(part)) return <em key={key}>{part.slice(1, -1)}</em>;
|
||||
if (/^~\S(?:[^~\n]*\S)?~$/.test(part)) return <s key={key}>{part.slice(1, -1)}</s>;
|
||||
return <React.Fragment key={key}>{part}</React.Fragment>;
|
||||
});
|
||||
};
|
||||
|
||||
// Renders text with URLs as <a> tags + WhatsApp inline formatting (bold/italic/strike)
|
||||
export const renderTextWithLinks = (text: string): React.ReactNode[] => {
|
||||
const parts = text.split(/(https?:\/\/[^\s<>"{}|\\^`[\]]{4,})/g);
|
||||
return parts.map((part, i) => {
|
||||
@@ -136,7 +151,7 @@ export const renderTextWithLinks = (text: string): React.ReactNode[] => {
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
return <React.Fragment key={i}>{renderInline(part, String(i))}</React.Fragment>;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ const AudioBubble: React.FC<AudioBubbleProps> = ({ msg, fullMediaUrl, isOut, Tim
|
||||
onEnded={() => { setPlaying(false); setCurrentTime(0) }}
|
||||
preload="metadata"
|
||||
/>
|
||||
<div className="flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px]">
|
||||
<div className="flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] max-w-full">
|
||||
{/* Play/pause */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
@@ -341,7 +341,9 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
// o trecho casado vira <mark>. Sem termo/sem match → comportamento normal.
|
||||
const renderTextHL = (text?: string | null): React.ReactNode => {
|
||||
const t = (highlightTerm ?? '').trim();
|
||||
const src = text ?? '';
|
||||
// Assinatura do operador: 1ª linha "*NOME*" (negrito WhatsApp) seguida de \n vira
|
||||
// "*NOME:*" — exibe "NOME:" em negrito antes da quebra. Idempotente (não duplica :).
|
||||
const src = (text ?? '').replace(/^\*([^*\n]+?):?\*(\n)/, '*$1:*$2');
|
||||
if (!t) return renderTextWithLinks(src);
|
||||
const lower = src.toLowerCase();
|
||||
const lt = t.toLowerCase();
|
||||
@@ -750,13 +752,13 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
<div>
|
||||
<div
|
||||
onClick={mediaUnavailable ? undefined : handleRedownload}
|
||||
className={`flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] transition-colors ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:bg-[#e8eaed]'}`}
|
||||
className={`flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] max-w-full transition-colors ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:bg-[#e8eaed]'}`}
|
||||
>
|
||||
{isRedownloading
|
||||
? <div className="w-8 h-8 border-2 border-slate-400 border-t-transparent rounded-full animate-spin shrink-0" />
|
||||
: <div className="w-10 h-10 rounded-full bg-[#8696a0] flex items-center justify-center shrink-0"><Mic className="w-4 h-4 text-white" /></div>
|
||||
}
|
||||
<span className="text-[12px] text-[#667781]">
|
||||
<span className="text-[12px] text-[#667781] min-w-0 truncate">
|
||||
{isRedownloading ? 'Baixando...' : mediaUnavailable ? 'Áudio não disponível' : 'Toque para baixar áudio'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -898,7 +900,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
{/* Authentic WhatsApp Message Menu Button */}
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className={`absolute top-1 right-1 p-1 text-slate-500 opacity-0 group-hover:opacity-100 hover:text-slate-300 transition-opacity z-30`}
|
||||
className={`absolute top-1 right-1 p-1 text-slate-500 hover:text-slate-300 transition-opacity z-30 ${(isMenuOpen || reactionPickerFor === msg.message_id) ? 'opacity-0 pointer-events-none' : 'opacity-0 group-hover:opacity-100'}`}
|
||||
>
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
@@ -121,6 +121,8 @@ function ProtocolCard({ protocol, isActive, updatingId, onStatusChange }: Protoc
|
||||
|
||||
<AnimatePresence>
|
||||
{showActions && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9]" onClick={() => setShowActions(false)} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -141,6 +143,7 @@ function ProtocolCard({ protocol, isActive, updatingId, onStatusChange }: Protoc
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,9 @@ export default function SettingsPanel({ instanceId }: SettingsPanelProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [version, setVersion] = useState<any>(null);
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8008';
|
||||
// Same-origin (NÃO localhost do PC — isso disparava o prompt de "acessar rede local").
|
||||
// A troca de engine é recurso do standalone; no satélite degrada gracioso (404).
|
||||
const API_URL = '';
|
||||
const authHeader = (): Record<string, string> => {
|
||||
const t = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Heart, Clock, Loader2 } from 'lucide-react';
|
||||
import { stickerApi, type StickerRecord } from '../services/chatApiService';
|
||||
import { getStickerFromCache, setStickerInCache } from '../hooks/useStickerCache';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3003';
|
||||
const LIST_VERSION_KEY = 'nw:sticker-list-version';
|
||||
|
||||
interface StickerPanelProps {
|
||||
@@ -15,10 +14,10 @@ interface StickerPanelProps {
|
||||
onSend: (blob: Blob) => void;
|
||||
}
|
||||
|
||||
// Converte URL Wasabi → URL do proxy do backend
|
||||
// Converte path Wasabi → URL same-origin do proxy nw (NÃO localhost do PC).
|
||||
function stickerSrcUrl(wasabiPath: string): string {
|
||||
const clean = wasabiPath.startsWith('/') ? wasabiPath.slice(1) : wasabiPath;
|
||||
return `${API}/api/storage/view/${clean}`;
|
||||
return `/api/nw/media/${clean}`;
|
||||
}
|
||||
|
||||
// Carrega sticker: tenta IndexedDB, depois proxy, depois persiste no cache
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* - abertura de perfil / menu de chat
|
||||
*/
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { mediaApi } from '../../services/chatApiService'
|
||||
import { mediaApi, chatApi, avatarApi } from '../../services/chatApiService'
|
||||
import { useChatStore } from '../../store/chatStore'
|
||||
import type { NewWhatsChat } from '../../types/inboxTypes'
|
||||
import type { Message } from '../../types/inboxTypes'
|
||||
|
||||
@@ -39,20 +40,32 @@ export function useChatWindow({
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false)
|
||||
const [isSyncingAvatar, setIsSyncingAvatar] = useState(false)
|
||||
const [isChatMenuOpen, setIsChatMenuOpen] = useState(false)
|
||||
const [editingMessage, setEditingMessage] = useState<Message | null>(null)
|
||||
const [forwardingMessage, setForwardingMessage] = useState<Message | null>(null)
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Chamado quando O OPERADOR digita no input. NÃO deve acender o "digitando" — esse
|
||||
// indicador é da presença `composing` do CONTATO (outro lado), não da minha. Antes,
|
||||
// digitar no input marcava isTyping=true localmente, mostrando "digitando" para mim
|
||||
// mesmo. Mantido como no-op até o motor repassar a presença do contato pelo stream.
|
||||
// Envia MINHA presença "composing" ao contato ao digitar (throttle ~3s) e "paused"
|
||||
// depois de ~4s de ociosidade. Não altera o indicador local (que é do CONTATO).
|
||||
const lastComposingRef = useRef(0)
|
||||
const pausedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const handleTyping = useCallback(() => {
|
||||
if (!selectedChat) return
|
||||
if (!isTypingRef.current) {
|
||||
isTypingRef.current = true
|
||||
setIsTyping(true)
|
||||
const chatId = selectedChat?.id ? String(selectedChat.id) : null
|
||||
if (!chatId) return
|
||||
const now = Date.now()
|
||||
if (now - lastComposingRef.current > 3000) {
|
||||
lastComposingRef.current = now
|
||||
chatApi.typing(chatId, 'composing').catch(() => {})
|
||||
}
|
||||
if (typingTimeout.current) clearTimeout(typingTimeout.current)
|
||||
typingTimeout.current = setTimeout(() => {
|
||||
isTypingRef.current = false
|
||||
setIsTyping(false)
|
||||
}, 3000)
|
||||
if (pausedTimerRef.current) clearTimeout(pausedTimerRef.current)
|
||||
pausedTimerRef.current = setTimeout(() => {
|
||||
lastComposingRef.current = 0
|
||||
chatApi.typing(chatId, 'paused').catch(() => {})
|
||||
}, 4000)
|
||||
}, [selectedChat])
|
||||
|
||||
const handleScroll = useCallback(async () => {
|
||||
@@ -70,39 +83,54 @@ export function useChatWindow({
|
||||
setHasMoreHistory(true)
|
||||
}, [])
|
||||
|
||||
const handleReactToMessage = useCallback((_msg: Message, _emoji: string) => {
|
||||
// Reage a uma mensagem: envia ao WhatsApp e atualiza o store na hora (otimista).
|
||||
// Toggle: clicar no mesmo emoji já reagido remove a reação. Reverte em caso de erro.
|
||||
const handleReactToMessage = useCallback(async (msg: Message, emoji: string) => {
|
||||
setReactionPickerFor(null)
|
||||
// Será implementado via endpoint /api/instances/:id/react
|
||||
}, [])
|
||||
|
||||
const handleSendMedia = useCallback(async (file: File, caption?: string) => {
|
||||
if (!selectedChat || !activeInstanceId) return
|
||||
const chatId = selectedChat?.id ? String(selectedChat.id) : null
|
||||
if (!chatId || !msg?.id) return
|
||||
const current = (msg as any).reactions?.me as string | undefined
|
||||
const next = current === emoji ? '' : emoji
|
||||
useChatStore.getState().reactMessage(chatId, String(msg.id), next)
|
||||
try {
|
||||
await mediaApi.sendMedia(activeInstanceId, selectedChat.remote_jid, file, caption || undefined, undefined)
|
||||
await chatApi.react(chatId, String(msg.id), next)
|
||||
} catch (err) {
|
||||
console.error('Erro ao reagir:', err)
|
||||
useChatStore.getState().reactMessage(chatId, String(msg.id), current || '') // reverte
|
||||
}
|
||||
}, [selectedChat])
|
||||
|
||||
// O mediaApi do satélite é por chatId (a ext resolve jid/instância): sendMedia
|
||||
// (chatId, file, caption?) e sendAudio(chatId, blob). Antes passávamos a assinatura
|
||||
// antiga do motor (instanceId, jid, ...) e o blob caía no lugar errado → o FormData
|
||||
// recebia o jid (string) em vez do Blob e quebrava o envio.
|
||||
const handleSendMedia = useCallback(async (file: File, caption?: string) => {
|
||||
if (!selectedChat?.id) return
|
||||
try {
|
||||
await mediaApi.sendMedia(String(selectedChat.id), file, caption || undefined)
|
||||
} catch (err) {
|
||||
console.error('Erro ao enviar mídia:', err)
|
||||
}
|
||||
}, [selectedChat, activeInstanceId])
|
||||
}, [selectedChat])
|
||||
|
||||
const handleSendAudio = useCallback(async (blob: Blob) => {
|
||||
if (!selectedChat || !activeInstanceId) return
|
||||
if (!selectedChat?.id) return
|
||||
try {
|
||||
await mediaApi.sendAudio(activeInstanceId, selectedChat.remote_jid, blob)
|
||||
await mediaApi.sendAudio(String(selectedChat.id), blob)
|
||||
} catch (err) {
|
||||
console.error('Erro ao enviar áudio:', err)
|
||||
}
|
||||
}, [selectedChat, activeInstanceId])
|
||||
}, [selectedChat])
|
||||
|
||||
const handleSyncAvatar = useCallback(async () => {
|
||||
if (!selectedChat || !activeInstanceId) return
|
||||
setIsSyncingAvatar(true)
|
||||
try {
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3003'
|
||||
const token = localStorage.getItem('token')
|
||||
await fetch(
|
||||
`${API}/api/inbox/avatar/${encodeURIComponent(selectedChat.remote_jid)}?instance=${activeInstanceId}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
// Via proxy nw (NÃO localhost do PC — isso disparava o prompt de "acessar rede local").
|
||||
const avatar = await avatarApi.refresh(activeInstanceId, selectedChat.remote_jid)
|
||||
if (avatar && selectedChat.id) useChatStore.getState().patchChat(String(selectedChat.id), { contactAvatarUrl: avatar } as any)
|
||||
} catch (err) {
|
||||
console.error('Erro ao sincronizar foto:', err)
|
||||
} finally {
|
||||
setIsSyncingAvatar(false)
|
||||
}
|
||||
@@ -112,6 +140,23 @@ export function useChatWindow({
|
||||
setReactionPickerFor(prev => prev === id ? null : id)
|
||||
}, [])
|
||||
|
||||
// "Limpar conversa" (só o dono — a visibilidade do botão já é gateada por isOwner).
|
||||
// Remove todas as mensagens do chat no motor e esvazia a lista local. Não revoga no
|
||||
// WhatsApp; é limpeza local do inbox (análogo ao "Limpar conversa" do WhatsApp).
|
||||
const handleClearConversation = useCallback(async () => {
|
||||
if (!selectedChat?.id) return
|
||||
const id = String(selectedChat.id)
|
||||
if (typeof window !== 'undefined' &&
|
||||
!window.confirm('Limpar esta conversa? Todas as mensagens serão removidas do inbox. Esta ação não pode ser desfeita.')) return
|
||||
try {
|
||||
await chatApi.clearConversation(id)
|
||||
useChatStore.getState().setMessages(id, [])
|
||||
} catch (err) {
|
||||
console.error('Erro ao limpar conversa:', err)
|
||||
if (typeof window !== 'undefined') window.alert('Não foi possível limpar a conversa.')
|
||||
}
|
||||
}, [selectedChat])
|
||||
|
||||
return {
|
||||
// Refs
|
||||
scrollRef,
|
||||
@@ -130,6 +175,10 @@ export function useChatWindow({
|
||||
setReplyingTo,
|
||||
setIsProfileOpen,
|
||||
setIsChatMenuOpen,
|
||||
editingMessage,
|
||||
setEditingMessage,
|
||||
forwardingMessage,
|
||||
setForwardingMessage,
|
||||
|
||||
// Handlers
|
||||
handleTyping,
|
||||
@@ -140,5 +189,6 @@ export function useChatWindow({
|
||||
handleSendAudio,
|
||||
handleSyncAvatar,
|
||||
toggleReactionPicker,
|
||||
handleClearConversation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,21 @@ export interface LegacyInstance {
|
||||
phone: string | null
|
||||
avatar: string | null
|
||||
profilePictureUrl: string | null
|
||||
// Seletor multi-conta:
|
||||
role: string | null
|
||||
label: string | null
|
||||
area: string | null
|
||||
notes: string | null
|
||||
clinicaId: string | null
|
||||
ownerNome: string | null
|
||||
own: boolean
|
||||
}
|
||||
|
||||
// Conta ativa da inbox — lida pelo nwClient p/ decidir o x-nw-clinica das rotas de
|
||||
// inbox (clinicaId null = conta própria → o proxy fala como o próprio usuário).
|
||||
const INBOX_ACCOUNT_KEY = 'nw:inbox-account'
|
||||
function persistInboxAccount(clinicaId: string | null, instanceId: string) {
|
||||
try { localStorage.setItem(INBOX_ACCOUNT_KEY, JSON.stringify({ clinicaId, instanceId })) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export interface MappedInstance {
|
||||
@@ -46,11 +61,18 @@ export function useInstanceSelector() {
|
||||
const instancesLegacy = useMemo<LegacyInstance[]>(() =>
|
||||
instances.map((i) => ({
|
||||
instance: i.id,
|
||||
nome: i.name,
|
||||
nome: i.label || i.name,
|
||||
status: i.status === 'CONNECTED' ? 'connected' : i.status.toLowerCase(),
|
||||
phone: i.phone,
|
||||
avatar: i.avatar ?? null,
|
||||
profilePictureUrl: i.avatar ?? null,
|
||||
role: i.role ?? null,
|
||||
label: i.label ?? null,
|
||||
area: i.area ?? null,
|
||||
notes: i.notes ?? null,
|
||||
clinicaId: i.clinicaId ?? null,
|
||||
ownerNome: i.ownerNome ?? null,
|
||||
own: i.own ?? true,
|
||||
})),
|
||||
[instances]
|
||||
)
|
||||
@@ -83,20 +105,38 @@ export function useInstanceSelector() {
|
||||
useEffect(() => {
|
||||
if (!activeId && instances.length > 0) {
|
||||
const first = instances[0]
|
||||
persistInboxAccount(first.clinicaId ?? null, first.id)
|
||||
setActiveId(first.id)
|
||||
socketService.joinInstance(first.id)
|
||||
}
|
||||
}, [instances, activeId, setActiveId])
|
||||
|
||||
// Mantém nw:inbox-account SEMPRE em sincronia com a instância ativa — inclusive
|
||||
// quando activeId é restaurado do localStorage (aí o auto-select acima não roda).
|
||||
// Sem isto, o nw:inbox-account fica com a clínica de uma seleção anterior e o
|
||||
// inbox da conta própria vai com x-nw-clinica errado → proxy reescreve → 404.
|
||||
useEffect(() => {
|
||||
if (!activeId || instances.length === 0) return
|
||||
const inst = instances.find((i) => i.id === activeId)
|
||||
if (inst) {
|
||||
persistInboxAccount(inst.clinicaId ?? null, activeId)
|
||||
socketService.syncAccount() // realtime no tenant certo (própria vs. clínica)
|
||||
}
|
||||
}, [activeId, instances])
|
||||
|
||||
// ── Troca de instância ────────────────────────────────────────────────────
|
||||
|
||||
const selectInstance = useCallback((legacyInstance: LegacyInstance | { instance: string }) => {
|
||||
const id = legacyInstance.instance
|
||||
if (id === activeId) return
|
||||
// Fixa a conta ativa ANTES de carregar os chats: o nwClient usa isso p/ decidir
|
||||
// o x-nw-clinica (conta própria vs. caixa da clínica) nas rotas de inbox.
|
||||
const clinicaId = instances.find((i) => i.id === id)?.clinicaId ?? null
|
||||
persistInboxAccount(clinicaId, id)
|
||||
resetChats([])
|
||||
setActiveId(id)
|
||||
socketService.joinInstance(id)
|
||||
}, [activeId, setActiveId, resetChats])
|
||||
}, [activeId, setActiveId, resetChats, instances])
|
||||
|
||||
// ── Refresh ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -11,11 +11,14 @@ import type { NewWhatsChat } from '../../types/inboxTypes'
|
||||
interface UseMessageInputOptions {
|
||||
selectedChat: NewWhatsChat | null
|
||||
replyingTo?: { message_id: string } | null
|
||||
editingMessage?: { id: string | number } | null
|
||||
onEditText?: (messageId: string, text: string) => Promise<void>
|
||||
onClearEdit?: () => void
|
||||
onClearReply?: () => void
|
||||
onSendText: (text: string, mentions?: string[], replyToMessageId?: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useMessageInput({ selectedChat, replyingTo, onClearReply, onSendText }: UseMessageInputOptions) {
|
||||
export function useMessageInput({ selectedChat, replyingTo, editingMessage, onEditText, onClearEdit, onClearReply, onSendText }: UseMessageInputOptions) {
|
||||
const [newMessage, setNewMessage] = useState('')
|
||||
const [mentions, setMentions] = useState<string[]>([])
|
||||
|
||||
@@ -23,13 +26,20 @@ export function useMessageInput({ selectedChat, replyingTo, onClearReply, onSend
|
||||
e?.preventDefault()
|
||||
if (!selectedChat || !newMessage.trim()) return
|
||||
const text = newMessage
|
||||
// Modo edição: salva a edição da mensagem em vez de enviar uma nova.
|
||||
if (editingMessage) {
|
||||
setNewMessage('')
|
||||
onClearEdit?.()
|
||||
await onEditText?.(String(editingMessage.id), text)
|
||||
return
|
||||
}
|
||||
const mentionJids = mentions.length > 0 ? [...mentions] : undefined
|
||||
const replyToMessageId = replyingTo?.message_id
|
||||
setNewMessage('')
|
||||
setMentions([])
|
||||
onClearReply?.()
|
||||
await onSendText(text, mentionJids, replyToMessageId)
|
||||
}, [selectedChat, newMessage, mentions, replyingTo, onSendText, onClearReply])
|
||||
}, [selectedChat, newMessage, mentions, replyingTo, editingMessage, onEditText, onClearEdit, onSendText, onClearReply])
|
||||
|
||||
return {
|
||||
newMessage,
|
||||
|
||||
@@ -9,14 +9,39 @@ export function nwToken(): string | null {
|
||||
try { return localStorage.getItem(TOKEN_KEY); } catch { return null; }
|
||||
}
|
||||
|
||||
// Clínica do workspace ativo — escopo por-requisição (modelo de canal).
|
||||
function activeClinicaId(): string | null {
|
||||
// Clínica do workspace ativo (agenda/secretária) — escopo por-requisição (modelo de canal).
|
||||
function activeWorkspaceId(): string | null {
|
||||
try { return JSON.parse(localStorage.getItem('SCOREODONTO_ACTIVE_WORKSPACE') || '{}')?.id || null; } catch { return null; }
|
||||
}
|
||||
|
||||
function authHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
// Conta ativa da INBOX (seletor de número multi-conta). Quando o usuário escolhe um
|
||||
// número no seletor, guardamos { clinicaId, instanceId }. clinicaId null = conta
|
||||
// PRÓPRIA (sem x-nw-clinica → o proxy fala com o motor como o próprio usuário).
|
||||
export const INBOX_ACCOUNT_KEY = 'nw:inbox-account';
|
||||
function inboxAccountClinica(): { set: boolean; clinicaId: string | null } {
|
||||
try {
|
||||
const raw = localStorage.getItem(INBOX_ACCOUNT_KEY);
|
||||
if (!raw) return { set: false, clinicaId: null };
|
||||
const a = JSON.parse(raw);
|
||||
return a && a.instanceId ? { set: true, clinicaId: a.clinicaId ?? null } : { set: false, clinicaId: null };
|
||||
} catch { return { set: false, clinicaId: null }; }
|
||||
}
|
||||
|
||||
// Resolve o x-nw-clinica da requisição. Rotas de inbox usam SEMPRE a CONTA ATIVA do
|
||||
// seletor (nunca o workspace global da agenda) — assim a caixa própria não herda a
|
||||
// clínica selecionada na agenda, o que faria o proxy reescrever para o dono errado
|
||||
// e o motor devolver 404. clinicaId null (conta própria/não-setado) → sem header.
|
||||
// As demais rotas (agenda/secretária) seguem o workspace global.
|
||||
function clinicaForPath(path: string): string | null {
|
||||
if (/^\/(inbox|conversations)(\/|$|\?)/.test(path)) {
|
||||
return inboxAccountClinica().clinicaId;
|
||||
}
|
||||
return activeWorkspaceId();
|
||||
}
|
||||
|
||||
function authHeaders(path: string, extra?: Record<string, string>): Record<string, string> {
|
||||
const t = nwToken();
|
||||
const c = activeClinicaId();
|
||||
const c = clinicaForPath(path);
|
||||
return {
|
||||
...(t ? { Authorization: `Bearer ${t}` } : {}),
|
||||
...(c ? { 'x-nw-clinica': c } : {}),
|
||||
@@ -28,7 +53,7 @@ async function req(method: string, path: string, body?: any): Promise<any> {
|
||||
const isForm = typeof FormData !== 'undefined' && body instanceof FormData;
|
||||
const res = await fetch(`${API}/nw/v1${path}`, {
|
||||
method,
|
||||
headers: authHeaders(isForm ? undefined : (body != null ? { 'Content-Type': 'application/json' } : undefined)),
|
||||
headers: authHeaders(path, isForm ? undefined : (body != null ? { 'Content-Type': 'application/json' } : undefined)),
|
||||
body: body == null ? undefined : (isForm ? body : JSON.stringify(body)),
|
||||
});
|
||||
const text = await res.text();
|
||||
|
||||
@@ -21,6 +21,16 @@ class SocketShim {
|
||||
private listeners = new Map<string, Set<Listener>>();
|
||||
private retry: ReturnType<typeof setTimeout> | null = null;
|
||||
private closedByUser = false;
|
||||
private currentClinica: string | null = null;
|
||||
|
||||
// Clínica da conta ativa da inbox (nw:inbox-account). null = conta própria.
|
||||
private readClinica(): string | null {
|
||||
try {
|
||||
const raw = localStorage.getItem('nw:inbox-account');
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw)?.clinicaId ?? null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
connect(_token?: string) {
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return;
|
||||
@@ -33,6 +43,11 @@ class SocketShim {
|
||||
try { u = new URL(`${abs}/nw/v1/stream`); } catch { return; }
|
||||
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
u.searchParams.set('token', token);
|
||||
// Stream por-tenant: p/ a caixa da clínica, o backend usa ?clinica= para reescrever
|
||||
// a conta-alvo (dono) quando o usuário tem can_inbox. null = conta própria.
|
||||
const clinica = this.readClinica();
|
||||
this.currentClinica = clinica;
|
||||
if (clinica) u.searchParams.set('clinica', clinica);
|
||||
|
||||
let ws: WebSocket;
|
||||
try { ws = new WebSocket(u.toString()); } catch { return; }
|
||||
@@ -73,6 +88,20 @@ class SocketShim {
|
||||
this._emit('message:update', m);
|
||||
break;
|
||||
}
|
||||
case 'message.reaction':
|
||||
// Reação recebida numa mensagem: { messageId, emoji, sender }.
|
||||
this._emit('message:reaction', { messageId: data?.messageId, emoji: data?.emoji, sender: data?.sender });
|
||||
break;
|
||||
case 'message.media_ready':
|
||||
this._emit('message:media_ready', { messageId: data?.messageId, mediaUrl: data?.mediaUrl });
|
||||
break;
|
||||
case 'message.transcription':
|
||||
this._emit('message:transcription', { messageId: data?.messageId, transcription: data?.transcription });
|
||||
break;
|
||||
case 'presence':
|
||||
// Presença do CONTATO (composing/recording/paused/available) por jid.
|
||||
this._emit('presence:update', { jid: data?.jid, state: data?.state });
|
||||
break;
|
||||
case 'conversation.handoff': this._emit('handoff', data); break;
|
||||
case 'conversation.escalated': this._emit('escalated', data); break;
|
||||
default: break;
|
||||
@@ -88,6 +117,16 @@ class SocketShim {
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
// Reabre o stream se a conta ativa (clínica) mudou. O realtime é por-tenant, então
|
||||
// alternar entre a caixa própria e a da clínica exige reconectar no tenant certo.
|
||||
syncAccount() {
|
||||
if (this.readClinica() === this.currentClinica && this.ws) return;
|
||||
if (this.retry) clearTimeout(this.retry);
|
||||
try { this.ws?.close(); } catch { /* ignore */ }
|
||||
this.ws = null;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
// Satélite não envia comandos pelo stream (é read-only); mantidos p/ compat.
|
||||
emit(_event: string, ..._args: any[]) { /* no-op */ }
|
||||
joinInstance(_instanceId: string) { /* no-op — stream é tenant-wide */ }
|
||||
|
||||
@@ -8,6 +8,11 @@ import { immer } from 'zustand/middleware/immer'
|
||||
import { socketService } from '../services/socketService'
|
||||
import { chatApi, messageApi } from '../services/chatApiService'
|
||||
|
||||
// Registra os listeners do socket UMA vez por sessão. Sem isto, cada mount do bridge
|
||||
// (inbox do plugin ou drawer da agenda) reempilha handlers no socketService (que só
|
||||
// acumula), fazendo message:new rodar N vezes → unreadCount inflado e reprocessamento.
|
||||
let _chatListenersReady = false
|
||||
|
||||
// ─── Mapper: resposta aninhada do backend → Chat plano do store ────────────────
|
||||
|
||||
function mapBackendChat(raw: any): Chat {
|
||||
@@ -42,6 +47,7 @@ function mapBackendChat(raw: any): Chat {
|
||||
lastMessageSender: raw.lastMessage?.pushName ?? raw.lastMessageSender ?? null,
|
||||
protocolNumber: raw.protocol?.number ?? raw.protocolNumber ?? null,
|
||||
botPaused: raw.botPaused ?? false,
|
||||
labels: Array.isArray(raw.labels) ? raw.labels : [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +77,7 @@ export interface Chat {
|
||||
lastMessageSender: string | null // pushName do remetente (grupos: quem enviou a última msg)
|
||||
protocolNumber: string | null
|
||||
botPaused: boolean
|
||||
labels: Array<{ id: string; name: string; color: string }>
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
@@ -97,6 +104,7 @@ export interface Message {
|
||||
timestamp: string
|
||||
pushName?: string | null
|
||||
senderJid?: string | null
|
||||
reactions?: Record<string, string> | null // { remetente: emoji } — 'me' = minha reação
|
||||
}
|
||||
|
||||
// ─── Estado ───────────────────────────────────────────────────────────────────
|
||||
@@ -107,8 +115,14 @@ interface ChatState {
|
||||
activeChatId: string | null
|
||||
messages: Record<string, Message[]>
|
||||
messagesLoading: boolean
|
||||
// Presença do contato por jid (composing/recording/paused/available) + timestamp.
|
||||
presenceByJid: Record<string, { state: string; ts: number }>
|
||||
// Assinatura do operador ("Nome") — usada para exibir "*Nome:*" na msg otimista.
|
||||
operatorSignature: string | null
|
||||
|
||||
// Sync
|
||||
setPresence: (jid: string, state: string) => void
|
||||
setOperatorSignature: (sig: string | null) => void
|
||||
setChats: (chats: Chat[]) => void
|
||||
setChatsLoading: (v: boolean) => void
|
||||
upsertChat: (chat: Chat) => void
|
||||
@@ -120,12 +134,14 @@ interface ChatState {
|
||||
appendMessage: (msg: Message) => void
|
||||
patchMessage: (msgId: string, patch: Partial<Message>) => void
|
||||
updateMediaReady: (msgId: string, mediaUrl: string) => void
|
||||
reactMessage: (chatId: string, msgId: string, emoji: string, sender?: string) => void
|
||||
|
||||
// Async
|
||||
loadChats: (instanceId: string, opts?: { search?: string; archived?: boolean }) => Promise<void>
|
||||
loadMessages: (chatId: string, opts?: { before?: string }) => Promise<{ hasMore: boolean }>
|
||||
selectChat: (chatId: string) => Promise<void>
|
||||
sendMessage: (instanceId: string, jid: string, text: string, replyToMessageId?: string, mentions?: string[]) => Promise<void>
|
||||
deleteMessage: (chatId: string, messageId: string) => Promise<void>
|
||||
|
||||
// Socket
|
||||
initSocketListeners: () => void
|
||||
@@ -195,6 +211,15 @@ export const useChatStore = create<ChatState>()(
|
||||
activeChatId: null,
|
||||
messages: {},
|
||||
messagesLoading: false,
|
||||
presenceByJid: {},
|
||||
operatorSignature: null,
|
||||
|
||||
// Presença do contato (composing/recording/…): guarda estado + timestamp por jid.
|
||||
setPresence: (jid, state) =>
|
||||
set((s) => { if (jid) s.presenceByJid[jid] = { state, ts: Date.now() } }),
|
||||
|
||||
setOperatorSignature: (sig) =>
|
||||
set((s) => { s.operatorSignature = sig }),
|
||||
|
||||
// ── Chats ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -262,6 +287,17 @@ export const useChatStore = create<ChatState>()(
|
||||
}
|
||||
}),
|
||||
|
||||
// Reação (otimista + eco do WS): emoji vazio remove a reação do remetente.
|
||||
reactMessage: (chatId, msgId, emoji, sender = 'me') =>
|
||||
set((s) => {
|
||||
const list = s.messages[chatId]
|
||||
const msg = list?.find((m) => m.id === msgId || m.messageId === msgId)
|
||||
if (!msg) return
|
||||
const r = { ...(msg.reactions || {}) }
|
||||
if (emoji) r[sender] = emoji; else delete r[sender]
|
||||
msg.reactions = r
|
||||
}),
|
||||
|
||||
// ── Async ─────────────────────────────────────────────────────────────────
|
||||
|
||||
loadChats: async (instanceId, opts = {}) => {
|
||||
@@ -350,13 +386,19 @@ export const useChatStore = create<ChatState>()(
|
||||
? get().messages[chatId]?.find((m) => m.messageId === replyToMessageId) ?? null
|
||||
: null
|
||||
|
||||
// Otimista com a assinatura "*Nome:*\n" JÁ aplicada (mesma que o proxy adiciona no
|
||||
// servidor) — evita o flicker de a msg aparecer sem "Nome:" e "puxar" depois do eco.
|
||||
// IMPORTANTE: envia o texto CRU (o proxy adiciona a assinatura; não duplicar aqui).
|
||||
const sig = get().operatorSignature
|
||||
const optimisticBody = sig ? `*${sig}:*\n${text}` : text
|
||||
|
||||
const optimistic: Message = {
|
||||
id: `opt-${Date.now()}`,
|
||||
chatId,
|
||||
messageId: `opt-${Date.now()}`,
|
||||
fromMe: true,
|
||||
type: 'TEXT',
|
||||
body: text,
|
||||
body: optimisticBody,
|
||||
mediaUrl: null,
|
||||
mimeType: null,
|
||||
fileName: null,
|
||||
@@ -387,9 +429,21 @@ export const useChatStore = create<ChatState>()(
|
||||
}
|
||||
},
|
||||
|
||||
// Apaga a mensagem para todos (revoke no WhatsApp). Só remove do estado se o
|
||||
// backend confirmar — o erro sobe para a UI (ex.: não é sua / instância off).
|
||||
deleteMessage: async (chatId, messageId) => {
|
||||
await chatApi.deleteMessage(chatId, messageId)
|
||||
set((s) => {
|
||||
if (s.messages[chatId]) s.messages[chatId] = s.messages[chatId].filter((m) => m.id !== messageId)
|
||||
})
|
||||
},
|
||||
|
||||
// ── Socket ────────────────────────────────────────────────────────────────
|
||||
|
||||
initSocketListeners: () => {
|
||||
// Idempotente: só registra uma vez (evita listeners duplicados entre mounts).
|
||||
if (_chatListenersReady) return
|
||||
_chatListenersReady = true
|
||||
// IMPORTANTE: sempre usar get() dentro dos callbacks para ler estado atual.
|
||||
// Capturar `const store = get()` fora criaria uma closure stale.
|
||||
|
||||
@@ -423,6 +477,26 @@ export const useChatStore = create<ChatState>()(
|
||||
get().patchMessage(messageId, { status })
|
||||
})
|
||||
|
||||
socketService.on('presence:update', ({ jid, state }: { jid?: string; state?: string }) => {
|
||||
if (jid && state) get().setPresence(jid, state)
|
||||
})
|
||||
|
||||
// Reação recebida: acha a mensagem pelo messageId (WA) em qualquer chat carregado.
|
||||
socketService.on('message:reaction', ({ messageId, emoji, sender }: { messageId?: string; emoji?: string; sender?: string }) => {
|
||||
if (!messageId) return
|
||||
set((s) => {
|
||||
for (const list of Object.values(s.messages)) {
|
||||
const msg = list.find((m) => m.messageId === messageId)
|
||||
if (msg) {
|
||||
const r = { ...(msg.reactions || {}) }
|
||||
if (emoji) r[sender || 'contato'] = emoji; else delete r[sender || 'contato']
|
||||
msg.reactions = r
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
socketService.on('chat:upsert', (raw: any) => {
|
||||
get().upsertChat(mapBackendChat(raw))
|
||||
})
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Papéis de número (sec_numbers) — rótulo em PT + cor. Usado pelo seletor de número
|
||||
// da inbox e pelo ícone (i) de legenda. Espelha o ROLE_META da Secretária, porém
|
||||
// leve (sem ícones lucide), para badges e pontos coloridos.
|
||||
export const ROLE_LABELS: Record<string, { label: string; color: string }> = {
|
||||
secretary_virtual: { label: 'Secretária Virtual', color: '#8b5cf6' },
|
||||
clinic: { label: 'Clínica / Recepção', color: '#3b82f6' },
|
||||
doctor: { label: 'Dentista / Dr.', color: '#10b981' },
|
||||
specialist: { label: 'Especialista', color: '#a855f7' },
|
||||
manager: { label: 'Gerente', color: '#f97316' },
|
||||
reserve: { label: 'Reserva / Sala', color: '#eab308' },
|
||||
human_secretary: { label: 'Secretária', color: '#f43f5e' },
|
||||
}
|
||||
|
||||
export function roleMeta(role?: string | null): { label: string; color: string } {
|
||||
return (role && ROLE_LABELS[role]) || { label: 'Sem papel definido', color: '#9ca3af' }
|
||||
}
|
||||
Reference in New Issue
Block a user