feat(newwhats): ownership de sessões, auto-provisionamento, assinatura e drawer na agenda

- Drawer de WhatsApp focado (WhatsChatDrawer) aberto do slot do agendamento, com
  dropdown de números do paciente + grupo familiar e fluxo de iniciar conversa
- Auto-provisionamento de conta no motor: eager no cadastro/convite + self-heal no
  proxy (404 "conta não encontrada" → provisiona e refaz o request)
- Ownership por sessão (wa_session_authz): dono do workspace (owner_id ou vínculo
  donoclinica/donoconsultorio) tem poder total; delega re-scan, excluir sessão,
  apagar conversa/mensagem, acesso ao Inbox e à Secretária — por conta
- Enforcement no proxy: guardSessionAction (scan/rescan/delete sessão),
  guardAreaAccess (inbox/secretaria), guardInboxDestructive (apagar conversa/msg)
- Endpoints /api/nw/authz (dono gerencia) e /api/nw/access (acesso agregado)
- Assinatura do operador nas mensagens (*Nome*, desambiguação de homônimos → *Rui C.*)
- UI: painel "Gerenciar acesso" com 5 toggles; menu e botões de apagar condicionados
- Proxy de mídia /api/nw/media + re-download sob demanda

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-02 23:26:31 +02:00
parent 372431c0a1
commit 21bebd3256
17 changed files with 1265 additions and 57 deletions
+9 -1
View File
@@ -6,7 +6,7 @@ import { AvaliacaoEditor } from './AvaliacaoEditor.tsx';
import {
User, Stethoscope, Clock, CalendarRange, AlertCircle, CheckCircle2,
History, RotateCcw, CalendarClock, Ban, Users, Pencil, ClipboardCheck, ShieldAlert,
Search, UserPlus, Link2, Trash2, ClipboardList
Search, UserPlus, Link2, Trash2, ClipboardList, MessageCircle
} from 'lucide-react';
interface Props {
@@ -17,6 +17,7 @@ interface Props {
onChanged: () => void; // recarrega a agenda
onEditar: () => void; // abre o modal de edição/reagendar (mover de horário)
onAtender?: () => void;
onVerWhats?: (info: { pacienteId: string; pacienteNome: string }) => void; // abre o drawer de WhatsApp do paciente
podeAtender?: boolean;
dentistaRestrito?: boolean; // dentista linkado por clínica: modal simples, sem dados sensíveis/agendamento
}
@@ -492,6 +493,13 @@ const DetailPage: React.FC<Props> = (p) => {
{/* Navegação / ações principais */}
<div className="space-y-2">
{p.onVerWhats && pacienteId && (
<button onClick={() => p.onVerWhats?.({ pacienteId, pacienteNome: nome })}
className="w-full flex items-center justify-between px-4 py-2.5 rounded-xl bg-emerald-50 hover:bg-emerald-100 transition-colors text-xs font-black uppercase text-emerald-700">
<span className="flex items-center gap-2"><MessageCircle size={15} /> Ver WhatsApp</span>
<span className="text-emerald-300"></span>
</button>
)}
<button onClick={() => push({ title: 'HISTÓRICO', render: () => <HistoryPage appointmentId={ag.id} /> })}
className="w-full flex items-center justify-between px-4 py-2.5 rounded-xl bg-gray-50 hover:bg-gray-100 transition-colors text-xs font-black uppercase text-gray-600">
<span className="flex items-center gap-2"><History size={15} /> Histórico</span>
+18 -4
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { useConfirm } from '../contexts/ConfirmContext.tsx';
import {
Users, Calendar as CalendarIcon, LayoutDashboard,
@@ -25,6 +25,19 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}').is_tutor === true; } catch { return false; }
})();
// Acesso do usuário às áreas do WhatsApp (Inbox/Secretária). null = ainda carregando
// (fail-open: mostra até saber). Dono e autorizados veem; os demais não.
const [waAccess, setWaAccess] = useState<{ isOwner: boolean; inbox: boolean; secretaria: boolean; delete_msg: boolean } | null>(null);
useEffect(() => {
let alive = true;
if (isPluginActive('newwhats')) {
HybridBackend.getWaAccess().then(a => { if (alive) setWaAccess(a); }).catch(() => {});
}
return () => { alive = false; };
}, [activeWorkspace?.id]);
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')) {
// Radiografias são ODONTOLÓGICAS: só papéis odonto (dentista + donos/funcionário).
@@ -40,9 +53,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
// NewWhats: Inbox + Secretária IA — para TODOS os usuários quando o plugin está
// ativo (a CONFIG do plugin fica no catálogo PLUGINS, exclusivo do superadmin).
if (isPluginActive('newwhats')) {
pluginMenuItems.push({ id: 'wa-inbox', label: 'WHATSAPP', icon: MessageCircle, color: '#16a34a' });
pluginMenuItems.push({ id: 'wa-sessions', label: 'INSTÂNCIAS', icon: Smartphone, color: '#0ea5e9' });
pluginMenuItems.push({ id: 'wa-secretaria', label: 'SECRETÁRIA IA', icon: Bot, color: '#2563eb' });
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' });
if (canSeeSecretaria) pluginMenuItems.push({ id: 'wa-secretaria', label: 'SECRETÁRIA IA', icon: Bot, color: '#2563eb' });
}
// Locação de Salas: menus de LOCADOR (administra as próprias salas) e LOCATÁRIO (aluga de
// terceiros). São decisões de NEGÓCIO do titular — não cabem a funcionário (subordinado) nem
+55
View File
@@ -45,6 +45,15 @@ const apiFetch = async (url: string, options: RequestInit = {}) => {
return res;
};
// Permissões de WhatsApp por conta (sessão): reconectar, excluir, acesso Inbox/Secretária.
export interface WaPerms {
can_rescan: boolean;
can_delete: boolean;
can_inbox: boolean;
can_secretaria: boolean;
can_delete_msg: boolean; // apagar conversa/mensagem no inbox
}
export const HybridBackend = {
dbConfig: {
database: 'scoreodonto'
@@ -1472,6 +1481,52 @@ export const HybridBackend = {
return { grupo: j.grupo || null, membros: j.membros || [] };
},
// ── NewWhats: ownership & autorizações de sessão de WhatsApp ──────────────
// Dono do workspace tem poder total; delega re-scan/exclusão por sessão a outras contas.
getWaSessionAuthz: async (instanceId: string): Promise<{
available: boolean; // false = endpoint indisponível (backend antigo/erro) → UI não deve bloquear
isOwner: boolean; ownerId: string | null; ownerNome: string | null;
me: WaPerms;
members: Array<{ usuarioId: string; nome: string; email: string; role: string } & WaPerms>;
}> => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const params = new URLSearchParams({ clinicaId, instanceId });
const openPerms: WaPerms = { can_rescan: true, can_delete: true, can_inbox: true, can_secretaria: true, can_delete_msg: true };
try {
const res = await apiFetch(`${API_URL}/nw/authz?${params}`, {});
// Endpoint ausente (backend sem a feature ainda) → indisponível, não bloqueia a UI.
// A autoridade real é o proxy do backend (403); aqui é só dica visual.
if (!res.ok) return { available: false, isOwner: false, ownerId: null, ownerNome: null, me: openPerms, members: [] };
const j = await res.json();
return { available: true, ...j };
} catch {
return { available: false, isOwner: false, ownerId: null, ownerNome: null, me: openPerms, members: [] };
}
},
// Acesso agregado do usuário logado às áreas do WhatsApp (para esconder menu/botões).
// Fail-open: se o endpoint estiver indisponível, não esconde nada (backend é a autoridade).
getWaAccess: async (): Promise<{ isOwner: boolean; inbox: boolean; secretaria: boolean; delete_msg: boolean }> => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const open = { isOwner: false, inbox: true, secretaria: true, delete_msg: true };
if (!clinicaId) return open;
try {
const res = await apiFetch(`${API_URL}/nw/access?clinicaId=${encodeURIComponent(clinicaId)}`, {});
if (!res.ok) return open;
return await res.json();
} catch { return open; }
},
setWaSessionAuthz: async (instanceId: string, usuarioId: string, perms: WaPerms): Promise<{ ok: boolean; error?: string }> => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const res = await apiFetch(`${API_URL}/nw/authz`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clinicaId, instanceId, usuarioId, ...perms }),
});
const j = await res.json().catch(() => ({}));
return res.ok ? { ok: true } : { ok: false, error: j.error || 'Erro ao salvar autorização.' };
},
// Importa agendamentos do Google p/ dentro do sistema (admin/dono). Idempotente.
importarGoogleAgenda: async (clinicaId?: string, dias?: number): Promise<{ success: boolean; criados?: number; pulados?: number; total?: number; message?: string }> => {
const cid = clinicaId || HybridBackend.getActiveWorkspace()?.id || '';
+12
View File
@@ -6,6 +6,7 @@ import interactionPlugin from '@fullcalendar/interaction';
import { Calendar as CalendarIcon, Plus, X, Loader2, GripVertical, Settings as GearIcon, Edit, Trash2, CalendarRange, Clock, User, Stethoscope, AlertCircle, CheckCircle2, Bell, CalendarClock, History, Download } from 'lucide-react';
import { AgendaSettingsModal } from '../components/AgendaSettingsModal.tsx';
import { AgendaDetailModal, ScoreBadge, FamiliaChips } from '../components/AgendaDetailModal.tsx';
import { WhatsChatDrawer } from './newwhats/WhatsChatDrawer.tsx';
import { AgendaPresence } from '../components/AgendaPresence.tsx';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
import { HybridBackend } from '../services/backend.ts';
@@ -353,6 +354,7 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
const [selectedDateInfo, setSelectedDateInfo] = useState<any>(null);
const [selectedAppointment, setSelectedAppointment] = useState<Agendamento | null>(null);
const [showDetails, setShowDetails] = useState(false);
const [whatsTarget, setWhatsTarget] = useState<{ pacienteId: string; pacienteNome: string } | null>(null);
const [interesses, setInteresses] = useState<any[]>([]);
const [showInteresses, setShowInteresses] = useState(false);
const [pendencias, setPendencias] = useState<any[]>([]);
@@ -720,10 +722,20 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
onChanged={() => { refresh(); refreshGoogle(); loadPendencias(); }}
onEditar={() => { setShowDetails(false); setIsModalOpen(true); }}
onAtender={handleAtender}
onVerWhats={(info) => setWhatsTarget(info)}
podeAtender={!!onNavigate && currentRole !== 'paciente'}
dentistaRestrito={souDentista}
/>
{/* Drawer de WhatsApp do paciente — clone focado da área de mensagens */}
<WhatsChatDrawer
isOpen={!!whatsTarget}
onClose={() => setWhatsTarget(null)}
pacienteId={whatsTarget?.pacienteId}
pacienteNome={whatsTarget?.pacienteNome}
onNavigate={onNavigate}
/>
{/* Lista "A REAGENDAR" — pendências de falta/cancelamento/remarcação */}
{showPendencias && (
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center backdrop-blur-sm p-4" onClick={() => setShowPendencias(false)}>
+11
View File
@@ -32,6 +32,7 @@ import InputBar from './components/InputBar';
import NewConversationModal from './components/NewConversationModal';
import ContactProfile from './components/ContactProfile';
import ProtocolPanel from './components/ProtocolPanel';
import { HybridBackend } from '../../services/backend';
type Chat = NewWhatsChat;
@@ -125,6 +126,14 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
const [isMobile, setIsMobile] = useState(false)
const [isNewConvoModalOpen, setIsNewConvoModalOpen] = useState(false)
// Permissão de apagar conversa/mensagem (dono ou liberado). Fail-open enquanto carrega.
const [canDeleteMsg, setCanDeleteMsg] = useState(true)
useEffect(() => {
let alive = true
HybridBackend.getWaAccess().then(a => { if (alive) setCanDeleteMsg(a.isOwner || a.delete_msg) }).catch(() => {})
return () => { alive = false }
}, [])
// Preloader: aparece APENAS em hard reload (F5 / URL direta) — nunca em
// navegação client-side (router.push). Detectado por:
// 1. performance.getEntriesByType('navigation')[0].type === 'reload'
@@ -356,6 +365,7 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
presenceByJid={presenceByJid}
isFavoriteChat={isFavoriteChat}
drafts={drafts}
canDelete={canDeleteMsg}
/>
</div>
)}
@@ -401,6 +411,7 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
onReact={handleReactToMessage}
onForward={() => {}}
onDelete={() => {}}
canDelete={canDeleteMsg}
onEdit={() => {}}
quickReactions={['👍', '❤️', '😂', '😮', '😢', '🙏']}
getGroupSenderLabel={(msg: any) => {
+277 -28
View File
@@ -7,12 +7,22 @@ import {
Wifi, Plus, RefreshCw, Loader2, QrCode, X,
CheckCircle2, AlertCircle, Trash2, LogOut, Phone,
ArrowLeft, Zap, WifiOff, Clock, Terminal, Download,
ChevronDown, ChevronRight,
ChevronDown, ChevronRight, Crown, Lock, ShieldCheck, UserCog,
} from 'lucide-react';
import { Button } from './components/ui';
import { useSessionsLogic } from './hooks/useSessionsLogic';
import { socketService } from './services/socketService';
import type { Instance } from './store/instanceStore';
import { HybridBackend, type WaPerms } from '../../services/backend';
// Permissões da conta logada sobre uma sessão específica.
interface SessionPerms {
isOwner: boolean;
canRescan: boolean;
canDelete: boolean;
ownerNome: string | null;
}
const OWNER_PERMS: SessionPerms = { isOwner: true, canRescan: true, canDelete: true, ownerNome: null };
// ─── Status config ────────────────────────────────────────────────────────────
@@ -316,20 +326,27 @@ function QrModal({
const SessionCard = React.forwardRef<HTMLDivElement, {
inst: Instance;
perms: SessionPerms;
onConnect: () => void;
onDisconnect: () => void;
onDelete: () => void;
onShowQr: () => void;
onManagePerms: () => void;
}>(function SessionCard({
inst,
perms,
onConnect,
onDisconnect,
onDelete,
onShowQr,
onManagePerms,
}, ref) {
const cfg = STATUS_CONFIG[inst.status] ?? STATUS_CONFIG.DISCONNECTED;
const isConnected = inst.status === 'CONNECTED';
const isQrPending = inst.status === 'QR_PENDING';
const { isOwner, canRescan, canDelete, ownerNome } = perms;
const rescanLocked = !isOwner && !canRescan;
const deleteLocked = !isOwner && !canDelete;
return (
<motion.div
@@ -359,24 +376,50 @@ const SessionCard = React.forwardRef<HTMLDivElement, {
<span className={`w-1.5 h-1.5 rounded-full ${cfg.dot}`} />
{cfg.label}
</span>
{isOwner ? (
<span className="inline-flex items-center gap-1 text-[10px] font-black uppercase tracking-wider px-2 py-0.5 rounded-full border bg-amber-500/10 text-amber-500 border-amber-500/20" title="Você é o dono desta conta">
<Crown className="w-3 h-3" /> Dono
</span>
) : ownerNome ? (
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full border bg-gray-500/10 text-gray-500 border-gray-200" title={`Dono: ${ownerNome}`}>
<Crown className="w-3 h-3" /> {ownerNome}
</span>
) : null}
</div>
</div>
</div>
<button onClick={onDelete} title="Deletar instância"
className="p-2 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-colors shrink-0"
>
<Trash2 className="w-4 h-4" />
</button>
{deleteLocked ? (
<button disabled title="Somente o dono pode excluir esta sessão — peça autorização de exclusão."
className="p-2 text-gray-300 rounded-xl shrink-0 cursor-not-allowed"
>
<Lock className="w-4 h-4" />
</button>
) : (
<button onClick={onDelete} title="Deletar instância"
className="p-2 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-colors shrink-0"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
{isQrPending && (
<button onClick={onShowQr}
className="flex items-center gap-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-2xl text-amber-300 text-sm font-semibold hover:bg-amber-500/15 transition-colors w-full"
>
<QrCode className="w-5 h-5 shrink-0" />
<span className="text-left">QR aguardando escaneamento clique para ver</span>
</button>
rescanLocked ? (
<div className="flex items-center gap-3 p-3 bg-gray-500/5 border border-gray-200 rounded-2xl text-gray-400 text-sm font-semibold w-full"
title="Somente o dono pode parear esta sessão — peça autorização de re-scan."
>
<Lock className="w-5 h-5 shrink-0" />
<span className="text-left">QR disponível somente o dono pode escanear</span>
</div>
) : (
<button onClick={onShowQr}
className="flex items-center gap-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-2xl text-amber-300 text-sm font-semibold hover:bg-amber-500/15 transition-colors w-full"
>
<QrCode className="w-5 h-5 shrink-0" />
<span className="text-left">QR aguardando escaneamento clique para ver</span>
</button>
)
)}
<div className="flex items-center gap-2 mt-auto relative z-10 pt-2 border-t border-gray-100">
@@ -385,18 +428,40 @@ const SessionCard = React.forwardRef<HTMLDivElement, {
<div className="flex items-center gap-2 text-emerald-400 text-xs font-bold uppercase tracking-widest flex-1">
<RefreshCw className="w-3.5 h-3.5 animate-spin" /> Sessão ativa
</div>
{isOwner && (
<button onClick={onManagePerms} title="Gerenciar quem pode reconectar/excluir"
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-brand-600 hover:text-brand-700 bg-brand-500/10 hover:bg-brand-500/15 rounded-xl transition-colors border border-brand-500/20"
>
<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>
</>
) : (
<button onClick={onConnect}
className="flex-1 flex items-center justify-center gap-2 bg-brand-500 text-white font-black py-3 rounded-xl hover:bg-brand-600 transition-all text-[11px] uppercase tracking-widest"
) : 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"
title="Somente o dono pode reconectar esta sessão — peça autorização de re-scan."
>
<Zap className="w-3.5 h-3.5" fill="currentColor" /> Conectar
</button>
<Lock className="w-3.5 h-3.5" /> Conectar
</div>
) : (
<>
<button onClick={onConnect}
className="flex-1 flex items-center justify-center gap-2 bg-brand-500 text-white font-black py-3 rounded-xl hover:bg-brand-600 transition-all text-[11px] uppercase tracking-widest"
>
<Zap className="w-3.5 h-3.5" fill="currentColor" /> Conectar
</button>
{isOwner && (
<button onClick={onManagePerms} title="Gerenciar quem pode reconectar/excluir"
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-brand-600 hover:text-brand-700 bg-brand-500/10 hover:bg-brand-500/15 rounded-xl transition-colors border border-brand-500/20"
>
<UserCog className="w-3.5 h-3.5" /> Acesso
</button>
)}
</>
)}
</div>
</motion.div>
@@ -452,6 +517,112 @@ function ConfirmDeleteModal({
// ─── Main View ────────────────────────────────────────────────────────────────
// ─── Modal: gestão de acesso (autorizações por sessão) — só dono ───────────────
type PermMember = { usuarioId: string; nome: string; email: string; role: string } & WaPerms;
type PermField = keyof WaPerms;
function ManagePermsModal({ inst, onClose }: { inst: Instance; onClose: () => void }) {
const [members, setMembers] = useState<PermMember[]>([]);
const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<string | null>(null);
useEffect(() => {
let alive = true;
HybridBackend.getWaSessionAuthz(inst.id).then((r) => {
if (alive) { setMembers((r.members as PermMember[]) || []); setLoading(false); }
}).catch(() => alive && setLoading(false));
return () => { alive = false; };
}, [inst.id]);
const toggle = useCallback(async (usuarioId: string, field: PermField, value: boolean) => {
const target = members.find((m) => m.usuarioId === usuarioId);
if (!target) return;
const next: PermMember = { ...target, [field]: value };
setSavingId(usuarioId);
// Otimista
setMembers((prev) => prev.map((m) => m.usuarioId === usuarioId ? next : m));
const res = await HybridBackend.setWaSessionAuthz(inst.id, usuarioId, {
can_rescan: next.can_rescan, can_delete: next.can_delete, can_inbox: next.can_inbox, can_secretaria: next.can_secretaria, can_delete_msg: next.can_delete_msg,
});
if (!res.ok) {
// Reverte
setMembers((prev) => prev.map((m) => m.usuarioId === usuarioId ? target : m));
}
setSavingId(null);
}, [members, inst.id]);
return (
<div className="fixed inset-0 z-[130] flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
onClick={onClose} className="absolute inset-0 bg-black/80 backdrop-blur-sm cursor-pointer" />
<motion.div initial={{ opacity: 0, scale: 0.92, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9 }}
className="relative bg-white border border-brand-500/20 border-t-[3px] border-t-brand-500 rounded-[28px] p-7 w-full max-w-lg shadow-2xl max-h-[85vh] flex flex-col"
>
<button onClick={onClose} className="absolute top-5 right-5 p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-lg"><X className="w-4 h-4" /></button>
<div className="flex items-center gap-3 mb-1 mt-1">
<div className="w-11 h-11 rounded-xl bg-brand-500/15 border border-brand-500/30 flex items-center justify-center">
<ShieldCheck className="w-5 h-5 text-brand-600" />
</div>
<div>
<h3 className="font-black text-gray-800">Gerenciar acesso</h3>
<p className="text-xs text-gray-500">{inst.name} · o que cada conta pode fazer</p>
</div>
</div>
<p className="text-[11px] text-gray-500 leading-snug bg-gray-50 rounded-xl p-3 mt-3 mb-4">
Você é o <b>dono</b> desta conta você faz o pareamento inicial. Autorize contas da equipe a acessar o
<b> Inbox</b> e a <b>Secretária</b>, <b>reconectar</b>, <b>apagar conversas/mensagens</b> e/ou <b>excluir</b> a sessão.
O scan inicial permanece exclusivo seu.
</p>
<div className="flex-1 overflow-y-auto custom-scrollbar -mx-1 px-1 space-y-2">
{loading ? (
<div className="py-10 flex items-center justify-center text-gray-400"><Loader2 className="w-6 h-6 animate-spin" /></div>
) : members.length === 0 ? (
<p className="text-center text-xs text-gray-400 font-semibold py-10 uppercase">Nenhuma outra conta neste workspace.</p>
) : members.map((m) => (
<div key={m.usuarioId} className="flex items-center gap-3 p-3 rounded-2xl border border-gray-100 bg-gray-50/60">
<div className="w-9 h-9 rounded-full bg-brand-500/10 flex items-center justify-center text-brand-600 shrink-0 text-xs font-black uppercase">
{(m.nome || m.email || '?').slice(0, 2)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-gray-800 truncate">{m.nome || m.email}</p>
<p className="text-[10px] text-gray-400 font-semibold truncate">{m.email}{m.role ? ` · ${m.role}` : ''}</p>
</div>
<div className="flex items-center gap-1.5 shrink-0 flex-wrap justify-end max-w-[190px]">
<PermToggle label="Inbox" active={m.can_inbox} busy={savingId === m.usuarioId}
onClick={() => toggle(m.usuarioId, 'can_inbox', !m.can_inbox)} />
<PermToggle label="Secretária" active={m.can_secretaria} busy={savingId === m.usuarioId}
onClick={() => toggle(m.usuarioId, 'can_secretaria', !m.can_secretaria)} />
<PermToggle label="Re-scan" active={m.can_rescan} busy={savingId === m.usuarioId}
onClick={() => toggle(m.usuarioId, 'can_rescan', !m.can_rescan)} />
<PermToggle label="Apagar msg" danger active={m.can_delete_msg} busy={savingId === m.usuarioId}
onClick={() => toggle(m.usuarioId, 'can_delete_msg', !m.can_delete_msg)} />
<PermToggle label="Excluir sessão" danger active={m.can_delete} busy={savingId === m.usuarioId}
onClick={() => toggle(m.usuarioId, 'can_delete', !m.can_delete)} />
</div>
</div>
))}
</div>
</motion.div>
</div>
);
}
function PermToggle({ label, active, danger, busy, onClick }: { label: string; active: boolean; danger?: boolean; busy?: boolean; onClick: () => void }) {
const on = danger
? 'bg-red-500/15 text-red-600 border-red-500/30'
: 'bg-emerald-500/15 text-emerald-600 border-emerald-500/30';
const off = 'bg-white text-gray-400 border-gray-200 hover:border-gray-300';
return (
<button onClick={onClick} disabled={busy}
className={`px-2.5 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider border transition-all disabled:opacity-60 ${active ? on : off}`}
>
{active ? '✓ ' : ''}{label}
</button>
);
}
export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => void }) {
const {
instances,
@@ -478,6 +649,62 @@ export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => vo
refreshInstances,
} = useSessionsLogic();
// ── 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);
const [permsModalFor, setPermsModalFor] = useState<Instance | null>(null);
const instanceIdsKey = instances.map((i) => i.id).join(',');
useEffect(() => {
let alive = true;
(async () => {
if (instances.length === 0) {
const r = await HybridBackend.getWaSessionAuthz('').catch(() => null);
if (!alive) return;
setAuthzAvailable(!!r?.available);
if (r) setWorkspaceOwner({ isOwner: r.isOwner, ownerNome: r.ownerNome });
return;
}
const results = await Promise.all(instances.map(async (inst) => {
const r = await HybridBackend.getWaSessionAuthz(inst.id).catch(() => null);
return [inst.id, r] as const;
}));
if (!alive) return;
const map: Record<string, SessionPerms> = {};
let owner = { isOwner: false, ownerNome: null as string | null };
let available = false;
for (const [id, r] of results) {
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 };
if (r?.available && r.isOwner) owner = { isOwner: true, ownerNome: r.ownerNome };
else if (r?.available && !owner.ownerNome && r.ownerNome) owner.ownerNome = r.ownerNome;
}
setAuthzAvailable(available);
setAuthzByInstance(map);
setWorkspaceOwner(owner);
})();
return () => { alive = false; };
}, [instanceIdsKey]);
// Permissões efetivas de uma instância.
// Sem authz disponível → permissivo (não bloqueia; backend é a autoridade).
const permsFor = (inst: Instance): SessionPerms => {
if (!authzAvailable) return { isOwner: false, canRescan: true, canDelete: true, 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;
// Já existe uma sessão ativa no motor para esta conta → mostra mensagem de
// sucesso em vez de sugerir novo pareamento.
const hasActiveSession = instances.some((i) => i.status === 'CONNECTED');
@@ -520,11 +747,19 @@ export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => vo
>
<RefreshCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
</button>
<button onClick={openAddModal}
className="flex items-center gap-2 px-5 py-3 bg-brand-500 hover:bg-brand-600 text-white font-bold rounded-xl transition-all shadow-glow-brand text-sm"
>
<Plus className="w-5 h-5" /> Nova Instância
</button>
{canCreateInstance ? (
<button onClick={openAddModal}
className="flex items-center gap-2 px-5 py-3 bg-brand-500 hover:bg-brand-600 text-white font-bold rounded-xl transition-all shadow-glow-brand text-sm"
>
<Plus className="w-5 h-5" /> Nova Instância
</button>
) : (
<button disabled title="Somente o dono do workspace pode conectar um novo WhatsApp."
className="flex items-center gap-2 px-5 py-3 bg-gray-100 text-gray-400 font-bold rounded-xl text-sm cursor-not-allowed border border-gray-200"
>
<Lock className="w-5 h-5" /> Nova Instância
</button>
)}
</div>
</header>
@@ -565,23 +800,31 @@ export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => vo
</div>
<div>
<h2 className="text-xl font-bold text-gray-800">Nenhuma instância</h2>
<p className="text-gray-500 mt-1 text-sm">Crie uma para começar a usar o WhatsApp.</p>
<p className="text-gray-500 mt-1 text-sm">
{canCreateInstance
? 'Crie uma para começar a usar o WhatsApp.'
: 'O dono do workspace ainda não conectou um WhatsApp.'}
</p>
</div>
<button onClick={openAddModal}
className="flex items-center gap-2 px-6 py-3 bg-gray-50 hover:bg-gray-100 text-gray-800 font-bold rounded-2xl border border-gray-200 transition-all"
>
<Plus className="w-5 h-5" /> Criar instância
</button>
{canCreateInstance && (
<button onClick={openAddModal}
className="flex items-center gap-2 px-6 py-3 bg-gray-50 hover:bg-gray-100 text-gray-800 font-bold rounded-2xl border border-gray-200 transition-all"
>
<Plus className="w-5 h-5" /> Criar instância
</button>
)}
</motion.div>
) : (
instances.map((inst) => (
<SessionCard
key={inst.id}
inst={inst}
perms={permsFor(inst)}
onConnect={() => handleConnect(inst)}
onDisconnect={() => handleDisconnect(inst)}
onDelete={() => setDeleteTarget(inst)}
onShowQr={() => handleShowQr(inst)}
onManagePerms={() => setPermsModalFor(inst)}
/>
))
)}
@@ -650,6 +893,12 @@ export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => vo
/>
)}
</AnimatePresence>
<AnimatePresence>
{permsModalFor && (
<ManagePermsModal inst={permsModalFor} onClose={() => setPermsModalFor(null)} />
)}
</AnimatePresence>
</div>
</div>
);
+449
View File
@@ -0,0 +1,449 @@
'use client';
/**
* WhatsChatDrawer painel slide-over (direita) com um clone FOCADO da área de
* mensagens do NewWhats, aberto a partir de um agendamento da agenda.
*
* Diferente do InboxView (rota /wa-inbox), NÃO mostra a lista lateral de chats:
* abre direto na conversa do paciente, resolvida pelo telefone do cadastro.
* Um dropdown permite trocar entre o número do paciente e os do grupo familiar.
* Se o paciente ainda não tem conversa, oferece iniciar uma (POST /conversations,
* mesmo fluxo do NewConversationModal, que funciona no satélite via proxy nw).
*
* Reaproveita a MESMA fiação de hooks do InboxView (stores zustand globais +
* socket com guard de conexão), então montar este drawer não duplica a conexão.
*/
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 { HybridBackend } from '../../services/backend';
import { useChatStore } from './store/chatStore';
import { useInstanceStore } from './store/instanceStore';
import { formatPhone } from './utils/inboxUtils';
import { nw } from './services/nwClient';
import { useNewInboxBridge } from './hooks/inbox/useNewInboxBridge';
import { useInstanceSelector } from './hooks/inbox/useInstanceSelector';
import { useChatWindow } from './hooks/inbox/useChatWindow';
import { useMessageInput } from './hooks/inbox/useMessageInput';
import InboxHeader from './components/InboxHeader';
import MessageArea from './components/MessageArea';
import InputBar from './components/InputBar';
import ContactProfile from './components/ContactProfile';
// ─── Tipos ──────────────────────────────────────────────────────────────────
export interface WhatsChatDrawerProps {
isOpen: boolean;
onClose: () => void;
pacienteId?: string | null;
pacienteNome?: string | null;
onNavigate?: (view: string) => void;
}
interface NumeroOpcao {
phone: string; // dígitos crus do cadastro
nome: string;
relacao: string; // 'Paciente' | grupofamiliarrelacao
}
// Normaliza para o formato de envio brasileiro (55 + DDD + número).
function normalizeSendPhone(input: string): string {
const digits = String(input || '').replace(/\D/g, '');
if (digits.startsWith('55') && (digits.length === 12 || digits.length === 13)) return digits;
if (digits.length === 10 || digits.length === 11) return '55' + digits;
return digits;
}
// Compara dois telefones de forma tolerante (Brasil: 9º dígito, DDI opcional):
// casa pelos últimos 8 dígitos, que são estáveis.
function samePhone(a: string, b: string): boolean {
const da = String(a || '').replace(/\D/g, '');
const db = String(b || '').replace(/\D/g, '');
if (!da || !db) return false;
return da.slice(-8) === db.slice(-8);
}
// ─── Conteúdo interno (só monta quando aberto → hooks/socket sob demanda) ──────
const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose, pacienteId, pacienteNome, onNavigate }) => {
const { activeInstance, instances, connectedInstances } = useInstanceSelector();
const loadingInstances = useInstanceStore((s) => s.loading);
// Há WhatsApp utilizável? (pelo menos uma instância conectada)
const hasConnected = connectedInstances.length > 0;
const {
selectedChat,
messages,
loadingMessages,
handleSendText,
handleLoadMoreHistory,
activeInstanceId,
} = useNewInboxBridge();
const {
scrollRef, isTyping, replyingTo, reactionPickerFor, hasMoreHistory, loadingMore,
isProfileOpen, isSyncingAvatar, isChatMenuOpen,
setReplyingTo, setIsProfileOpen, setIsChatMenuOpen,
handleTyping, handleScroll, handleSelectChat: onChatWindowSelect,
handleReactToMessage, handleSendMedia, handleSendAudio, handleSyncAvatar, toggleReactionPicker,
} = useChatWindow({
selectedChat,
activeInstanceId: activeInstance?.instance ?? null,
onLoadMoreHistory: handleLoadMoreHistory,
});
const { newMessage, setNewMessage, mentions, setMentions, handleSendMessage } = useMessageInput({
selectedChat,
replyingTo,
onClearReply: () => setReplyingTo(null),
onSendText: handleSendText,
});
// ── Números disponíveis (paciente + grupo familiar) ──────────────────────
const [numeros, setNumeros] = useState<NumeroOpcao[]>([]);
const [selectedPhone, setSelectedPhone] = useState<string>('');
const [numDropdownOpen, setNumDropdownOpen] = useState(false);
// ── Estado de resolução da conversa ──────────────────────────────────────
const [resolving, setResolving] = useState(false);
const [notFound, setNotFound] = useState(false); // número válido, mas sem conversa
const [initText, setInitText] = useState('');
const [initSending, setInitSending] = useState(false);
const [initError, setInitError] = useState('');
// Carrega telefone do paciente + membros da família ao abrir.
useEffect(() => {
let alive = true;
(async () => {
const opts: NumeroOpcao[] = [];
const seen = new Set<string>();
const push = (phone: string, nome: string, relacao: string) => {
const d = String(phone || '').replace(/\D/g, '');
if (!d) return;
const key = d.slice(-8);
if (seen.has(key)) return;
seen.add(key);
opts.push({ phone: d, nome, relacao });
};
// 1) Telefone do próprio paciente — via cadastro (desambigua por id).
if (pacienteNome) {
try {
const r = await HybridBackend.getPacientes(pacienteNome);
const self = (r?.data || []).find((p: any) => p.id === pacienteId)
?? (r?.data || []).find((p: any) => (p.nome || '').toUpperCase() === pacienteNome.toUpperCase());
if (self?.telefone) push(self.telefone, self.nome || pacienteNome, 'Paciente');
} catch { /* ignore */ }
}
// 2) Grupo familiar / dependentes.
if (pacienteId) {
try {
const { membros } = await HybridBackend.getFamiliaPaciente(pacienteId);
for (const m of membros || []) {
if (m.telefone) push(m.telefone, m.nome, m.grupofamiliarrelacao || (m.id === pacienteId ? 'Paciente' : 'Familiar'));
}
} catch { /* ignore */ }
}
if (!alive) return;
setNumeros(opts);
setSelectedPhone(opts[0]?.phone || '');
})();
return () => { alive = false; };
}, [pacienteId, pacienteNome]);
// Resolve a conversa (chatId) a partir do telefone selecionado, sem enviar nada.
const resolveChat = useCallback(async (phoneDigits: string) => {
const store = useChatStore.getState();
if (!activeInstanceId || !phoneDigits) { setNotFound(true); return; }
setResolving(true);
setNotFound(false);
store.setActiveChat(null);
try {
const send = normalizeSendPhone(phoneDigits);
// O GET /inbox?search=<dígitos> casa por `jid contains` no backend.
await store.loadChats(activeInstanceId, { search: send });
const chats = useChatStore.getState().chats;
const hit = chats.find((c) =>
samePhone(c.contactPhone || c.jid.split('@')[0], send)) ?? null;
if (hit) {
await store.selectChat(hit.id);
onChatWindowSelect();
setNotFound(false);
} else {
setNotFound(true);
}
} catch {
setNotFound(true);
} finally {
setResolving(false);
}
}, [activeInstanceId, onChatWindowSelect]);
// (Re)resolve quando muda o número selecionado ou a instância fica pronta.
useEffect(() => {
if (selectedPhone && activeInstanceId) resolveChat(selectedPhone);
}, [selectedPhone, activeInstanceId, resolveChat]);
// Inicia conversa (paciente sem chat): mesmo fluxo do NewConversationModal.
const handleIniciarConversa = useCallback(async () => {
if (!activeInstanceId || !selectedPhone) return;
if (!initText.trim()) { setInitError('Escreva uma mensagem para iniciar a conversa.'); return; }
setInitSending(true);
setInitError('');
try {
const phone = normalizeSendPhone(selectedPhone);
await nw.post('/conversations', { sessionId: activeInstanceId, phone, text: initText.trim() });
setInitText('');
// Dá um respiro para o motor registrar o chat e então re-resolve.
await new Promise((r) => setTimeout(r, 900));
await resolveChat(selectedPhone);
} catch (e: any) {
setInitError(e?.message || 'Erro ao enviar. Verifique o número e tente novamente.');
} finally {
setInitSending(false);
}
}, [activeInstanceId, selectedPhone, initText, resolveChat]);
const selectedNumero = useMemo(
() => numeros.find((n) => n.phone === selectedPhone) ?? null,
[numeros, selectedPhone]
);
const tituloContato = selectedNumero?.nome || pacienteNome || 'Paciente';
return (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
className="fixed inset-0 z-[190] bg-black/40 backdrop-blur-[2px]"
/>
{/* Painel */}
<motion.aside
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
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">
<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>
<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>
)}
</AnimatePresence>
</div>
<button onClick={onClose} className="p-1.5 hover:bg-white/20 rounded-full shrink-0" title="Fechar">
<X className="w-5 h-5" />
</button>
</div>
{/* Corpo */}
<div
className="flex-1 flex flex-col overflow-hidden relative"
style={{ backgroundImage: "url('/chat-bg.png')", backgroundRepeat: 'repeat', backgroundColor: '#b5bdb5' }}
>
<div className="absolute inset-0 bg-white/75 pointer-events-none z-0" />
{(loadingInstances && instances.length === 0) ? (
// ── Carregando estado das sessões ──────────────────────────────
<div className="flex-1 flex flex-col items-center justify-center gap-3 relative z-[1] text-[#667781]">
<Loader2 className="w-8 h-8 animate-spin text-[#008069]" />
<span className="text-sm">Verificando conexão do WhatsApp</span>
</div>
) : !hasConnected ? (
// ── Nenhuma sessão de WhatsApp conectada ────────────────────────
<div className="flex-1 flex flex-col items-center justify-center gap-5 relative z-[1] p-8 text-center">
<div className="relative">
<div className="w-20 h-20 rounded-full bg-[#008069]/10 flex items-center justify-center">
<WifiOff className="w-9 h-9 text-[#008069]" />
</div>
<span className="absolute -bottom-1 -right-1 w-7 h-7 rounded-full bg-white shadow-md flex items-center justify-center">
<MessageCircle className="w-4 h-4 text-[#008069]" />
</span>
</div>
<div className="max-w-xs">
<h3 className="text-lg font-bold text-[#111b21]">
{instances.length === 0 ? 'WhatsApp não conectado' : 'Sessão desconectada'}
</h3>
<p className="text-[13px] text-[#667781] leading-relaxed mt-1.5">
{instances.length === 0
? 'Nenhuma conta de WhatsApp está vinculada a esta clínica. Conecte um número para conversar com os pacientes direto da agenda.'
: 'A sessão do WhatsApp está fora do ar no momento. Reconecte o número lendo o QR Code para retomar as conversas.'}
</p>
</div>
{onNavigate && (
<button
onClick={() => { onClose(); onNavigate('wa-sessions'); }}
className="flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold text-white bg-[#008069] hover:bg-[#017259] transition-all active:scale-[0.98] shadow-sm"
>
<QrCode className="w-4 h-4" />
{instances.length === 0 ? 'Conectar WhatsApp' : 'Reconectar sessão'}
</button>
)}
</div>
) : resolving ? (
<div className="flex-1 flex flex-col items-center justify-center gap-3 relative z-[1] text-[#667781]">
<Loader2 className="w-8 h-8 animate-spin text-[#008069]" />
<span className="text-sm">Abrindo conversa</span>
</div>
) : !selectedPhone ? (
<div className="flex-1 flex flex-col items-center justify-center gap-3 relative z-[1] p-8 text-center">
<Phone className="w-12 h-12 opacity-10" />
<p className="text-sm text-[#667781]">Este paciente não tem telefone no cadastro.</p>
</div>
) : notFound ? (
// ── Sem conversa: iniciar uma nova ──────────────────────────────
<div className="flex-1 flex flex-col items-center justify-center gap-4 relative z-[1] p-6">
<div className="w-14 h-14 rounded-full bg-[#008069]/10 flex items-center justify-center">
<MessageCircle className="w-7 h-7 text-[#008069]" />
</div>
<div className="text-center">
<p className="text-sm font-bold text-[#111b21]">Sem conversa com {tituloContato}</p>
<p className="text-[12px] text-[#667781] mt-0.5">{formatPhone(normalizeSendPhone(selectedPhone))}</p>
</div>
<div className="w-full max-w-sm space-y-2">
<textarea
rows={3}
value={initText}
onChange={(e) => { setInitText(e.target.value); setInitError(''); }}
placeholder="Olá! Tudo bem? Passando para confirmar seu horário…"
disabled={initSending}
className="w-full bg-white rounded-xl px-4 py-3 text-sm text-[#111b21] placeholder:text-[#8696a0] focus:outline-none focus:ring-2 focus:ring-[#008069]/40 resize-none disabled:opacity-60 border border-gray-100"
/>
{initError && <p className="text-[12px] text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{initError}</p>}
<button
onClick={handleIniciarConversa}
disabled={initSending || !initText.trim()}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-sm font-semibold text-white bg-[#008069] hover:bg-[#017259] transition-all active:scale-[0.98] disabled:opacity-50"
>
{initSending
? <><Loader2 className="w-4 h-4 animate-spin" /> Enviando</>
: <><Send className="w-4 h-4" /> Iniciar conversa</>}
</button>
</div>
</div>
) : selectedChat ? (
// ── Conversa ativa ──────────────────────────────────────────────
<div className="flex-1 flex h-full relative z-[1] overflow-hidden">
<div className="flex-1 flex flex-col h-full overflow-hidden">
<InboxHeader
selectedChat={selectedChat}
isMobile={true}
isTyping={isTyping}
onAvatarClick={() => setIsProfileOpen(!isProfileOpen)}
onBack={onClose}
onSyncAvatar={handleSyncAvatar}
isChatMenuOpen={isChatMenuOpen}
isSyncingAvatar={isSyncingAvatar}
onSearchClick={() => {}}
onToggleMenu={() => setIsChatMenuOpen(!isChatMenuOpen)}
/>
<MessageArea
messages={messages as any}
selectedChat={selectedChat}
scrollRef={scrollRef}
msgLoading={loadingMessages}
onScroll={handleScroll}
historyLoadingMore={loadingMore}
hasMoreHistory={hasMoreHistory}
reactionPickerFor={reactionPickerFor}
onReply={setReplyingTo}
onToggleReactionPicker={toggleReactionPicker}
onReact={handleReactToMessage}
onForward={() => {}}
onDelete={() => {}}
onEdit={() => {}}
quickReactions={['👍', '❤️', '😂', '😮', '😢', '🙏']}
getGroupSenderLabel={(msg: any) => {
if (msg.direction === 'out') return 'Você';
if (msg.participant) return msg.participant;
if (msg.senderJid) {
if (msg.senderJid.endsWith('@lid')) return msg.participant || '';
const phone = msg.senderJid.split('@')[0].split(':')[0];
return formatPhone(phone, msg.senderJid);
}
return '';
}}
/>
<InputBar
newMessage={newMessage}
onNewMessageChange={setNewMessage}
onSend={handleSendMessage}
onPresence={handleTyping}
onCancelReply={() => setReplyingTo(null)}
replyingTo={replyingTo}
selectedChat={selectedChat}
isMobile={true}
onSendMedia={handleSendMedia}
onSendAudio={handleSendAudio}
instanceId={activeInstance?.instance ?? null}
mentions={mentions}
onMentionsChange={setMentions}
/>
</div>
<ContactProfile
isOpen={isProfileOpen}
chat={selectedChat}
messages={messages as any}
onClose={() => setIsProfileOpen(false)}
/>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center gap-3 relative z-[1] text-[#667781]">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
)}
</div>
</motion.aside>
</>
);
};
// ─── Wrapper: só monta o conteúdo (e os hooks) quando aberto ──────────────────
export const WhatsChatDrawer: React.FC<WhatsChatDrawerProps> = ({ isOpen, ...rest }) => (
<AnimatePresence>
{isOpen && <DrawerInner {...rest} />}
</AnimatePresence>
);
export default WhatsChatDrawer;
@@ -14,6 +14,7 @@ interface ChatListProps {
presenceByJid: Record<string, PresenceState>;
isFavoriteChat: (chat: NewWhatsChat) => boolean;
drafts?: Record<string, string>;
canDelete?: boolean;
}
export default function ChatList({
@@ -25,6 +26,7 @@ export default function ChatList({
presenceByJid,
isFavoriteChat,
drafts = {},
canDelete = true,
}: ChatListProps) {
if (loading) {
return (
@@ -57,6 +59,7 @@ export default function ChatList({
presenceByJid={presenceByJid}
isFavorite={isFavoriteChat(chat)}
draft={drafts[chat.remote_jid]}
canDelete={canDelete}
/>
))}
</div>
@@ -23,6 +23,7 @@ interface ChatListItemProps {
presenceByJid: Record<string, PresenceState>;
isFavorite: boolean;
draft?: string;
canDelete?: boolean; // false esconde "Apagar conversa" (sem permissão)
}
const renderLastStatus = (chat: NewWhatsChat) => {
@@ -97,7 +98,8 @@ export default function ChatListItem({
onMenuAction,
presenceByJid,
isFavorite,
draft
draft,
canDelete = true
}: ChatListItemProps) {
const [menuOpen, setMenuOpen] = useState(false);
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
@@ -140,9 +142,9 @@ export default function ChatListItem({
{ key: 'mute', label: isMuted ? 'Desativar silêncio' : 'Silenciar', icon: isMuted ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" /> },
{ key: 'pin', label: isPinned ? 'Desafixar' : 'Fixar conversa', icon: isPinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" /> },
{ key: 'mark_read', label: 'Marcar como lida', icon: <MailOpen className="w-4 h-4" /> },
{ key: 'delete', label: 'Apagar conversa', icon: <Trash2 className="w-4 h-4" />, danger: true },
...(canDelete ? [{ key: 'delete', label: 'Apagar conversa', icon: <Trash2 className="w-4 h-4" />, danger: true }] : []),
];
}, [chat, isFavorite, isMuted]);
}, [chat, isFavorite, isMuted, canDelete]);
const MENU_H = 252; // 6 itens × ~40px + 12px padding container
const MENU_W = 224; // w-56
@@ -18,6 +18,7 @@ interface ChatSidebarProps {
onSelectChat: (chat: NewWhatsChat) => void;
onMenuAction: (action: any, chat: NewWhatsChat) => void;
drafts?: Record<string, string>;
canDelete?: boolean;
}
export default function ChatSidebar({
@@ -34,6 +35,7 @@ export default function ChatSidebar({
onSelectChat,
onMenuAction,
drafts = {},
canDelete = true,
}: ChatSidebarProps) {
// UI Pills (Simplified, for now we keep layout consistent)
const [pillFilter, setPillFilter] = useState<'all' | 'unread' | 'favorites' | 'groups'>('all');
@@ -101,6 +103,7 @@ export default function ChatSidebar({
presenceByJid={presenceByJid}
isFavoriteChat={isFavoriteChat}
drafts={drafts}
canDelete={canDelete}
/>
</div>
);
@@ -10,6 +10,7 @@ import { ptBR } from 'date-fns/locale';
import { normalizeJid, parseSafeDate } from '../utils/inboxUtils';
import { Message } from '../types/inboxTypes';
import { mediaApi } from '../services/chatApiService';
import { useChatStore } from '../store/chatStore';
// Message interface removed and moved to types.ts
@@ -31,6 +32,7 @@ interface MessageAreaProps {
onEdit?: (msg: Message) => void;
quickReactions: string[];
getGroupSenderLabel: (msg: Message) => string;
canDelete?: boolean;
}
const MessageArea: React.FC<MessageAreaProps> = ({
@@ -51,6 +53,7 @@ const MessageArea: React.FC<MessageAreaProps> = ({
onEdit,
quickReactions,
getGroupSenderLabel,
canDelete = true,
}) => {
const [showScrollBottom, setShowScrollBottom] = useState(false);
const lastScrollTop = useRef(0);
@@ -60,12 +63,17 @@ const MessageArea: React.FC<MessageAreaProps> = ({
const [viewerMsgId, setViewerMsgId] = useState<string | null>(null)
// ── Re-download de mídia não baixada ─────────────────────────────────────
// ~95% das mídias vêm com mediaUrl NULL (lazy). O motor re-baixa do WhatsApp
// e devolve { mediaUrl }; atualizamos o store direto pelo retorno (o WS
// message:media_ready não trafega pela ponte ext do satélite).
const updateMediaReady = useChatStore((s) => s.updateMediaReady)
const handleRedownloadMedia = useCallback(async (msg: Message) => {
// selectedChat no formato legado usa instance_name (não instanceId)
const instanceId = selectedChat?.instance_name ?? selectedChat?.instanceId
if (!instanceId || !msg.id) return
await mediaApi.redownload(instanceId, String(msg.id))
}, [selectedChat?.instance_name, selectedChat?.instanceId])
const { mediaUrl } = await mediaApi.redownload(instanceId, String(msg.id))
if (mediaUrl) updateMediaReady(String(msg.id), mediaUrl)
}, [selectedChat?.instance_name, selectedChat?.instanceId, updateMediaReady])
const mediaMessages = messages.filter(m => m.type === 'image' || m.type === 'video')
const handleInternalScroll = useCallback(() => {
@@ -204,6 +212,7 @@ const MessageArea: React.FC<MessageAreaProps> = ({
onReact={onReact}
onForward={onForward}
onDelete={onDelete}
canDelete={canDelete}
onStar={onStar}
onEdit={onEdit}
quickReactions={quickReactions}
@@ -310,6 +310,7 @@ interface MessageBubbleProps {
onRedownloadMedia?: (msg: Message) => Promise<void>;
quickReactions: string[];
selectedChat?: any;
canDelete?: boolean; // false esconde a ação "Apagar" (sem permissão)
}
const MessageBubble: React.FC<MessageBubbleProps> = ({
@@ -330,6 +331,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
onRedownloadMedia,
quickReactions,
selectedChat,
canDelete = true,
showTail = false,
}) => {
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
@@ -911,7 +913,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
...(isOut && msg.type === 'text' && onEdit ? [{ label: 'Editar', icon: <span className="w-4 h-4 font-bold text-xs"></span>, onClick: () => { onEdit(msg); setIsMenuOpen(false); } }] : []),
{ label: 'Encaminhar', icon: <Forward className="w-4 h-4" />, onClick: () => { onForward?.(msg); setIsMenuOpen(false); } },
{ label: 'Favoritar', icon: <Star className="w-4 h-4" />, onClick: () => { onStar?.(msg); setIsMenuOpen(false); } },
{ label: 'Apagar', icon: <Trash2 className="w-4 h-4" />, onClick: () => { onDelete?.(msg); setIsMenuOpen(false); }, danger: true },
...(canDelete ? [{ label: 'Apagar', icon: <Trash2 className="w-4 h-4" />, onClick: () => { onDelete?.(msg); setIsMenuOpen(false); }, danger: true }] : []),
].map((item, i) => (
<button
key={i}
@@ -103,9 +103,10 @@ export const mediaApi = {
fd.append('file', new File([blob], 'sticker.webp', { type: 'image/webp' }));
return nw.post(`/inbox/${chatId}/send-media`, fd);
},
// Re-download não existe na ext (mídia já vem resolvida). No-op gracioso.
redownload: (_instanceId: string, _messageDbId: string): Promise<{ mediaUrl: string }> =>
Promise.resolve({ mediaUrl: '' }),
// Re-download sob demanda: ~95% das mídias vêm com mediaUrl NULL (lazy). O motor
// re-baixa do WhatsApp a partir do WAMessage salvo e devolve { mediaUrl }.
redownload: (_instanceId: string, messageDbId: string): Promise<{ mediaUrl: string }> =>
nw.post(`/media/${encodeURIComponent(messageDbId)}/download`),
};
// ─── Grupos (ext não expõe participantes — stub gracioso) ───────────────────
+6 -5
View File
@@ -67,13 +67,14 @@ export const getLabelColor = (index: number) => {
export const getMediaUrl = (url: string | null | undefined): string | null => {
if (!url) return null;
// URL absoluta (ex.: Wasabi/CDN público) → usa direto, como o motor exibe.
if (url.startsWith('http')) return url;
const apiBase = ''; // satélite: same-origin (URLs http/Wasabi já retornam acima)
// Path local (/media/...) — servido pelo Express static do backend
if (url.startsWith('/media/')) return `${apiBase}${url}`;
// Path Wasabi — proxiado pela rota /api/storage/view/ do backend
// Mídia do WhatsApp vive no MOTOR (path /media/<instância>/<arquivo>, servido
// do Wasabi/local). O satélite proxia em /api/nw/media/* (same-origin, sem auth
// no browser) — assim exibe igual ao motor, direto do Wasabi.
if (url.startsWith('/media/')) return `/api/nw/media/${url.slice('/media/'.length)}`;
const clean = url.startsWith('/') ? url.slice(1) : url;
return `${apiBase}/api/storage/view/${clean}`;
return `/api/nw/media/${clean}`;
};
export const getAvatarProxyUrl = (chat: any, _type: 'instance' | 'contact' = 'contact'): string | null => {