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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 — só 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>
|
||||
);
|
||||
|
||||
@@ -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 já 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) ───────────────────
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user