// Mensagens internas — chat 1:1, grupos e comunicados da equipe (migration 052). // // POR QUE ESTE MODULO EXISTE // A orientacao de trabalho da Promoup vivia no WhatsApp e se perdia. Aqui ela // fica com historico, com contador de nao lidas e — no caso do comunicado — // com registro de quem leu e de quem clicou em "Li e estou ciente". // // DECISOES QUE NAO SAO OBVIAS OLHANDO O CODIGO // * A lista da esquerda le a VIEW v_minhas_conversas, nao a tabela. A view ja // devolve o contador de nao lidas, o titulo e a foto do outro participante // numa consulta so; ler a tabela obrigaria uma consulta por conversa. // * Entrar em conversa, criar grupo e publicar comunicado passam por RPC. // A tabela de participantes nao tem policy de insert de proposito: com ela, // qualquer um se adicionaria a qualquer conversa. // * O bucket de anexos e privado e a policy amarra o acesso a participacao na // conversa. Por isso toda exibicao passa por createSignedUrl, nunca por URL // publica. // * "Lida" no chat sai de message_thread_members.last_read_at, comparado com // a hora da mensagem. Nao existe tabela de "lido por" no chat normal: ela // cresceria em usuarios x mensagens sem ninguem consultar depois. const MSG_PRIORIDADES = { normal: { label: 'Normal', cor: '#10B981', bg: '#F0FDF4', bola: '🟢' }, importante: { label: 'Importante', cor: '#F59E0B', bg: '#FFFBEB', bola: '🟡' }, urgente: { label: 'Urgente', cor: '#EF4444', bg: '#FEF2F2', bola: '🔴' }, }; const MSG_FILTROS = [ ['todas', 'Todas'], ['nao_lidas', 'Não lidas'], ['conversas', 'Conversas'], ['comunicados', 'Comunicados'], ['arquivadas', 'Arquivadas'], ]; // Hora curta da lista: hoje mostra a hora, ontem mostra "Ontem", antes disso // a data. Mesma regra do modulo de WhatsApp, para nao ter duas linguagens. function fmtMsgHoraLista(iso) { if (!iso) return ''; const d = new Date(iso); const hoje = new Date(); if (d.toDateString() === hoje.toDateString()) { return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); } const ontem = new Date(hoje); ontem.setDate(hoje.getDate() - 1); if (d.toDateString() === ontem.toDateString()) return 'Ontem'; return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' }); } function fmtMsgHora(iso) { if (!iso) return ''; return new Date(iso).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); } function fmtMsgDataHora(iso) { if (!iso) return '—'; return new Date(iso).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', }); } // Separador de dia dentro da conversa. function fmtMsgDiaSeparador(iso) { const d = new Date(iso); const hoje = new Date(); if (d.toDateString() === hoje.toDateString()) return 'Hoje'; const ontem = new Date(hoje); ontem.setDate(hoje.getDate() - 1); if (d.toDateString() === ontem.toDateString()) return 'Ontem'; return d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' }); } function msgIdAleatorio() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = Math.random() * 16 | 0; return (c === 'x' ? r : ((r & 0x3) | 0x8)).toString(16); }); } function msgExtensao(nome, mime) { const porNome = (nome || '').split('.').pop(); if (porNome && porNome.length <= 5 && porNome !== nome) return porNome.toLowerCase(); const porMime = (mime || '').split('/')[1]; return (porMime || 'bin').toLowerCase(); } function msgEhImagem(mime) { return (mime || '').startsWith('image/'); } // ============================================================ // Bolha de mensagem comum // ============================================================ function MsgBolha({ msg, ehMinha, autor, citada, urls, lida, onResponder }) { const prio = MSG_PRIORIDADES[msg.priority] || MSG_PRIORIDADES.normal; const destaque = msg.priority !== 'normal'; const anexos = Array.isArray(msg.attachments) ? msg.attachments : []; return (
{/* Em grupo, saber quem falou importa; em 1:1 seria ruido. */} {!ehMinha && autor && (
{autor}
)} {destaque && (
{prio.bola} {prio.label}
)} {citada && (
{citada.texto}
)} {anexos.map(a => { const url = urls[a.path]; if (msgEhImagem(a.mime)) { return url ? {a.name :
Carregando imagem…
; } return ( { if (!url) e.preventDefault(); }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px', background: '#00000008', borderRadius: 6, marginBottom: 6, color: DS.colors.text, textDecoration: 'none', fontSize: 12, fontWeight: 600, }}> {a.name || 'Arquivo'} ); })} {msg.body &&
{msg.body}
}
onResponder && onResponder(msg)} title="Responder" style={{ cursor: 'pointer', fontWeight: 600 }} >responder {fmtMsgHora(msg.created_at)} {ehMinha && ( {lida ? '✓✓' : '✓'} )}
); } // ============================================================ // Card de comunicado — com o botao de ciencia // ============================================================ function MsgComunicado({ msg, titulo, autor, urls, recibo, souGestor, souAutor, confirmando, onConfirmar, onVerStatus }) { const prio = MSG_PRIORIDADES[msg.priority] || MSG_PRIORIDADES.normal; const anexos = Array.isArray(msg.attachments) ? msg.attachments : []; const jaConfirmou = !!(recibo && recibo.acknowledged_at); return (
📢 Comunicado · {prio.label} {autor} · {fmtMsgDataHora(msg.created_at)}
{titulo}
{msg.body}
{anexos.map(a => { const url = urls[a.path]; if (msgEhImagem(a.mime)) { return url ? {a.name :
Carregando imagem…
; } return ( { if (!url) e.preventDefault(); }} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: '#fff', border: `1px solid ${DS.colors.border}`, borderRadius: 6, marginBottom: 10, color: DS.colors.text, textDecoration: 'none', fontSize: 12, fontWeight: 600, }}> {a.name || 'Arquivo'} ); })}
{souAutor ? ( // Quem publicou nao precisa dar ciencia do proprio comunicado.
Você publicou este comunicado
) : jaConfirmou ? (
Você confirmou em {fmtMsgDataHora(recibo.acknowledged_at)}
) : ( {confirmando ? 'Confirmando…' : 'LI E ESTOU CIENTE'} )} {souGestor && ( Quem já leu )}
); } // ============================================================ // Seletor de colaboradores reutilizado nos tres modais // ============================================================ function MsgSeletorPessoas({ equipe, selecionados, onToggle, altura = 260 }) { const [busca, setBusca] = React.useState(''); const filtrados = React.useMemo(() => { const q = busca.trim().toLowerCase(); if (!q) return equipe; return equipe.filter(u => (u.name || u.email || '').toLowerCase().includes(q) || (u.department || '').toLowerCase().includes(q)); }, [equipe, busca]); return (
setBusca(e.target.value)} placeholder="Buscar colaborador ou setor…" style={{ width: '100%', boxSizing: 'border-box', border: `1px solid ${DS.colors.border}`, borderRadius: DS.radius.sm, padding: '8px 12px 8px 34px', fontSize: 13, outline: 'none', fontFamily: 'inherit', }} />
{filtrados.length === 0 && (
Ninguém encontrado.
)} {filtrados.map(u => { const marcado = selecionados.includes(u.id); return (
onToggle(u.id)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px', borderBottom: `1px solid ${DS.colors.border}`, cursor: 'pointer', background: marcado ? DS.colors.primaryLight : '#fff', }}>
{u.name || u.email}
{deptLabel(u.department)}
); })}
); } // ============================================================ // Painel "quem leu / quem nao confirmou" // ============================================================ function MsgStatusComunicado({ messageId, onClose, showToast }) { const [linhas, setLinhas] = React.useState(null); const [cobrando, setCobrando] = React.useState(false); React.useEffect(() => { let vivo = true; sb.rpc('msg_status_comunicado', { p_message_id: messageId }).then(({ data, error }) => { if (!vivo) return; if (error) { showToast('Não foi possível carregar as confirmações: ' + error.message, 'danger'); setLinhas([]); return; } setLinhas(data || []); }); return () => { vivo = false; }; }, [messageId]); const pendentes = (linhas || []).filter(l => !l.acknowledged_at); const confirmaram = (linhas || []).filter(l => l.acknowledged_at); const cobrar = async () => { if (!pendentes.length) return; setCobrando(true); for (const p of pendentes) { await createNotification({ type: 'comunicado', title: 'Você ainda não confirmou um comunicado', message: 'Abra Mensagens e clique em "Li e estou ciente".', link: 'mensagens', icon: 'alert', target_user_id: p.user_id, }); } setCobrando(false); showToast(`Lembrete enviado para ${pendentes.length} pessoa(s).`); }; return ( {linhas === null ? : ( <>
{confirmaram.length} de {linhas.length} confirmaram
{pendentes.length > 0 && ( {cobrando ? 'Enviando…' : `Cobrar ${pendentes.length}`} )}
{linhas.map(l => (
{l.name}
{deptLabel(l.department)}
{l.acknowledged_at ? ( <> Confirmou
{fmtMsgDataHora(l.acknowledged_at)}
) : l.read_at ? ( <> Leu, não confirmou
{fmtMsgDataHora(l.read_at)}
) : ( Não abriu )}
))}
)}
); } // ============================================================ // Modal: nova conversa 1:1 // ============================================================ function ModalNovaConversa({ equipe, onClose, onEscolher }) { const [busca, setBusca] = React.useState(''); const filtrados = React.useMemo(() => { const q = busca.trim().toLowerCase(); if (!q) return equipe; return equipe.filter(u => (u.name || u.email || '').toLowerCase().includes(q) || (u.department || '').toLowerCase().includes(q)); }, [equipe, busca]); return (
setBusca(e.target.value)} autoFocus placeholder="Buscar colaborador…" style={{ width: '100%', boxSizing: 'border-box', border: `1px solid ${DS.colors.border}`, borderRadius: DS.radius.sm, padding: '9px 12px 9px 34px', fontSize: 13, outline: 'none', fontFamily: 'inherit', }} />
{filtrados.length === 0 && (
Nenhum colaborador encontrado.
)} {filtrados.map(u => (
onEscolher(u.id)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderBottom: `1px solid ${DS.colors.border}`, cursor: 'pointer', }} onMouseEnter={e => e.currentTarget.style.background = '#F8FAFC'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{u.name || u.email}
{deptLabel(u.department)}
))}
); } // ============================================================ // Modal: novo grupo // ============================================================ function ModalNovoGrupo({ equipe, onClose, onCriado, showToast }) { const [nome, setNome] = React.useState(''); const [sel, setSel] = React.useState([]); const [salvando, setSalvando] = React.useState(false); const toggle = (id) => setSel(prev => (prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])); const criar = async () => { if (!nome.trim()) { showToast('Dê um nome ao grupo.', 'danger'); return; } if (!sel.length) { showToast('Escolha pelo menos um participante.', 'danger'); return; } setSalvando(true); const { data, error } = await sb.rpc('msg_criar_grupo', { p_title: nome.trim(), p_member_ids: sel }); setSalvando(false); if (error) { showToast('Erro ao criar grupo: ' + error.message, 'danger'); return; } showToast('Grupo criado.'); onCriado(data); }; return ( 0} extra={{salvando ? 'Criando…' : 'Criar grupo'}}>
Participantes ({sel.length} selecionado{sel.length !== 1 ? 's' : ''})
); } // ============================================================ // Modal: novo comunicado // ============================================================ function ModalComunicado({ equipe, onClose, onPublicado, showToast }) { const [titulo, setTitulo] = React.useState(''); const [texto, setTexto] = React.useState(''); const [prio, setPrio] = React.useState('importante'); const [alvo, setAlvo] = React.useState('todos'); // todos | setor | pessoas const [setor, setSetor] = React.useState(''); const [sel, setSel] = React.useState([]); const [salvando, setSalvando] = React.useState(false); const toggle = (id) => setSel(prev => (prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])); const quantos = alvo === 'todos' ? equipe.length : alvo === 'setor' ? equipe.filter(u => u.department === setor).length : sel.length; const publicar = async () => { if (!titulo.trim() || !texto.trim()) { showToast('Preencha título e texto.', 'danger'); return; } if (alvo === 'setor' && !setor) { showToast('Escolha o setor.', 'danger'); return; } if (alvo === 'pessoas' && !sel.length) { showToast('Escolha ao menos um destinatário.', 'danger'); return; } if (!quantos) { showToast('Esse destino não tem ninguém.', 'danger'); return; } setSalvando(true); const { data, error } = await sb.rpc('msg_publicar_comunicado', { p_title: titulo.trim(), p_body: texto.trim(), p_priority: prio, p_member_ids: alvo === 'pessoas' ? sel : [], p_department: alvo === 'setor' ? setor : null, p_todos: alvo === 'todos', p_attachments: [], }); setSalvando(false); if (error) { showToast('Erro ao publicar: ' + error.message, 'danger'); return; } showToast('Comunicado publicado.'); onPublicado(data); }; return ( {salvando ? 'Publicando…' : 'Publicar'}}>