Files
scoreodonto.com/frontend/components/ComunicacaoInternaModal.tsx
T
VPS 4 Builder 8ea8faa4c2 wip: inbox/agenda diversos (trabalho anterior à sessão)
Consolida WIP acumulado que não faz parte das features de hoje: melhorias do
inbox (WhatsChatDrawer, InputBar, MessageBubble, ProtocolPanel, StickerPanel,
LinkPreview, hooks, socketService, chatStore, nwClient), componentes novos
(ConversationSearch, ForwardModal, LabelManagerModal, roleMeta), AgendaPresence,
ComunicacaoInternaModal, Sidebar, GestaoEquipeView, SessionsView, appTime e o
asset chat-bg.png.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 06:08:07 +02:00

371 lines
25 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react';
import { X, Plus, Trash2, MapPin, Phone, Clock, CalendarClock, Save, Loader2, MessageSquare, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { useToast } from '../contexts/ToastContext.tsx';
import { buscarCep } from '../services/viacep.ts';
import { HorariosConfig } from './HorariosConfig.tsx';
import { HybridBackend } from '../services/backend.ts';
// Comunicação interna: perfil de contato de não-pacientes (dentistas/funcionários).
// Números de WhatsApp múltiplos (cada um com descrição, situações e janelas de horário
// permitido p/ a Secretária enviar msg — proteção contra mensagem fora do expediente),
// endereço e disponibilidade de agenda (abrir/fechar + jornada).
const API_URL = (window as any).__API_URL__ || '';
const COLOR = '#0d9488';
const UF = ['', 'AC', 'AL', 'AM', 'AP', 'BA', 'CE', 'DF', 'ES', 'GO', 'MA', 'MG', 'MS', 'MT', 'PA', 'PB', 'PE', 'PI', 'PR', 'RJ', 'RN', 'RO', 'RR', 'RS', 'SC', 'SE', 'SP', 'TO'];
const DIAS = [{ v: 0, l: 'Dom' }, { v: 1, l: 'Seg' }, { v: 2, l: 'Ter' }, { v: 3, l: 'Qua' }, { v: 4, l: 'Qui' }, { v: 5, l: 'Sex' }, { v: 6, l: 'Sáb' }];
const api = (path: string, opts: RequestInit = {}) =>
fetch(`${API_URL}${path}`, {
...opts,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('SCOREODONTO_AUTH_TOKEN')}` },
});
interface Janela { dias: number[]; inicio: string; fim: string }
interface Numero { numero: string; descricao: string; situacoes: string; janelas: Janela[] }
interface Endereco { cep?: string; logradouro?: string; numero?: string; complemento?: string; bairro?: string; cidade?: string; estado?: string }
interface Agenda { aberta?: boolean; horarios?: Janela[] }
const input = 'w-full px-3 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-sm font-medium text-gray-700 outline-none focus:border-teal-400';
const label = 'text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1';
// ── Editor de janelas de horário (dias + faixa) — reutilizado p/ contato e agenda ──
const JanelaEditor: React.FC<{ janelas: Janela[]; onChange: (j: Janela[]) => void; addLabel: string }> = ({ janelas, onChange, addLabel }) => {
const upd = (i: number, patch: Partial<Janela>) => onChange(janelas.map((j, k) => (k === i ? { ...j, ...patch } : j)));
const toggleDia = (i: number, d: number) => {
const j = janelas[i]; const has = j.dias.includes(d);
upd(i, { dias: has ? j.dias.filter(x => x !== d) : [...j.dias, d].sort((a, b) => a - b) });
};
return (
<div className="space-y-2">
{janelas.map((j, i) => (
<div key={i} className="rounded-xl border border-gray-200 p-3 bg-white space-y-2">
<div className="flex items-center justify-between">
<div className="flex flex-wrap gap-1">
{DIAS.map(d => {
const on = j.dias.includes(d.v);
return (
<button key={d.v} type="button" onClick={() => toggleDia(i, d.v)}
className={`w-8 h-8 rounded-lg text-[10px] font-black transition-all ${on ? 'text-white' : 'bg-gray-100 text-gray-400 hover:bg-gray-200'}`}
style={on ? { backgroundColor: COLOR } : {}}>{d.l}</button>
);
})}
</div>
<button type="button" onClick={() => onChange(janelas.filter((_, k) => k !== i))} className="p-1.5 text-gray-300 hover:text-red-500"><Trash2 size={15} /></button>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-black text-gray-400 uppercase">Das</span>
<input type="time" value={j.inicio} onChange={e => upd(i, { inicio: e.target.value })} className="px-2 py-1.5 bg-gray-50 border border-gray-200 rounded-lg text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
<span className="text-[10px] font-black text-gray-400 uppercase">até</span>
<input type="time" value={j.fim} onChange={e => upd(i, { fim: e.target.value })} className="px-2 py-1.5 bg-gray-50 border border-gray-200 rounded-lg text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
</div>
</div>
))}
<button type="button" onClick={() => onChange([...janelas, { dias: [1, 2, 3, 4, 5], inicio: '08:00', fim: '18:00' }])}
className="w-full py-2 rounded-xl border border-dashed border-gray-300 text-[11px] font-black uppercase text-gray-400 hover:border-teal-400 hover:text-teal-600 flex items-center justify-center gap-1.5">
<Plus size={13} /> {addLabel}
</button>
</div>
);
};
export const ComunicacaoInternaModal: React.FC<{
usuarioId?: string; clinicaId?: string; nome?: string; forced?: boolean;
onClose: () => void; onSaved?: (completo: boolean) => void; onSnooze?: () => void;
}> = ({ usuarioId = 'me', clinicaId, nome, forced, onClose, onSaved, onSnooze }) => {
const toast = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [endereco, setEndereco] = useState<Endereco>({});
const [numeros, setNumeros] = useState<Numero[]>([]);
const [enderecoDaClinica, setEnderecoDaClinica] = useState(false);
const [isDentista, setIsDentista] = useState(false); // dentista precisa da jornada
const [isDono, setIsDono] = useState(false); // dono precisa do horário da clínica
const [jornadaOk, setJornadaOk] = useState(false); // jornada real (dentistas_horarios) configurada
const [horarioClinicaOk, setHorarioClinicaOk] = useState(false); // clinicas_horarios configurado
const [horariosTab, setHorariosTab] = useState<'dentistas' | 'clinica' | null>(null); // abre "Horários de Funcionamento"
const [trocarOpen, setTrocarOpen] = useState(false); // dropdown "Trocar de clínica" (escape do gate forçado)
// Escape do gate forçado: o gate é POR clínica; travar ESTA não deve prender o
// usuário no sistema. Lista as OUTRAS clínicas (exceto a atual e salas) para trocar.
const outrasClinicas = (HybridBackend.getWorkspaces() || [])
.filter((w: any) => w.tipo !== 'sala' && w.id !== clinicaId);
const load = useCallback(async () => {
setLoading(true);
try {
const qs = clinicaId ? `?clinicaId=${encodeURIComponent(clinicaId)}` : '';
const r = await api(`/api/comunicacao/${usuarioId}${qs}`);
const d = await r.json();
setEndereco(d.endereco || {});
setNumeros(Array.isArray(d.numeros) ? d.numeros : []);
setEnderecoDaClinica(!!d.endereco_da_clinica);
setIsDentista((d.role || '') === 'dentista');
setIsDono(['donoclinica', 'donoconsultorio'].includes(d.role || ''));
setJornadaOk(!!d.jornada_configurada);
setHorarioClinicaOk(!!d.horario_clinica_configurado);
} catch { /* mantém vazio */ } finally { setLoading(false); }
}, [usuarioId, clinicaId]);
useEffect(() => { load(); }, [load]);
const setEnd = (k: keyof Endereco, v: string) => setEndereco(p => ({ ...p, [k]: v }));
const onCep = async () => {
const cep = (endereco.cep || '').replace(/\D/g, '');
if (cep.length !== 8) return;
const e = await buscarCep(cep);
if (e) setEndereco(p => ({ ...p, logradouro: e.logradouro || p.logradouro, bairro: e.bairro || p.bairro, cidade: e.cidade || p.cidade, estado: e.estado || p.estado }));
};
const updNum = (i: number, patch: Partial<Numero>) => setNumeros(ns => ns.map((n, k) => (k === i ? { ...n, ...patch } : n)));
const addNum = () => setNumeros(ns => [...ns, { numero: '', descricao: '', situacoes: '', janelas: [{ dias: [1, 2, 3, 4, 5], inicio: '08:00', fim: '18:00' }] }]);
// Notificação de número compartilhado: ao sair do campo, verifica se o número já é de
// outra conta/papel da clínica (a mesma pessoa pode ser dono E dentista, por ex.).
const [avisos, setAvisos] = useState<Record<number, { nome: string; role: string }[]>>({});
const roleLabel = (r: string) => (({ dentista: 'dentista', donoclinica: 'dono', donoconsultorio: 'dono', funcionario: 'funcionário', admin: 'admin' } as Record<string, string>)[r] || r || 'conta');
const checkNumero = async (i: number, numero: string) => {
const tel = (numero || '').replace(/\D/g, '');
if (tel.length < 10 || !clinicaId) { setAvisos(a => ({ ...a, [i]: [] })); return; }
try {
const r = await api(`/api/comunicacao/numero-lookup?clinicaId=${encodeURIComponent(clinicaId)}&numero=${encodeURIComponent(tel)}`);
const d = await r.json();
setAvisos(a => ({ ...a, [i]: Array.isArray(d.encontrados) ? d.encontrados : [] }));
} catch { /* silencioso */ }
};
// Completude (espelha o backend) — orienta o preenchimento e destrava o gate.
const enderecoOk = !!(endereco.cidade && endereco.estado && (endereco.cep || endereco.logradouro));
const numerosOk = numeros.length >= 1 && numeros.every(n => (n.numero || '').replace(/\D/g, '').length >= 10 && (n.descricao || '').trim() && (n.janelas?.length ?? 0) >= 1);
const jornadaFeita = !isDentista || jornadaOk; // dentista: jornada real configurada
const clinicaFeita = !isDono || horarioClinicaOk; // dono: horário da clínica configurado
const completo = enderecoOk && numerosOk && jornadaFeita && clinicaFeita;
const salvar = async () => {
setSaving(true);
try {
const r = await api(`/api/comunicacao/${usuarioId}`, { method: 'PUT', body: JSON.stringify({ clinicaId, endereco, numeros }) });
const d = await r.json();
if (!r.ok) throw new Error(d.error || 'Erro ao salvar.');
toast.success('DADOS SALVOS!');
onSaved?.(!!d.completo);
if (!forced || d.completo) onClose();
else toast.error('AINDA FALTAM CAMPOS OBRIGATÓRIOS PARA LIBERAR O SISTEMA.');
} catch (e: any) { toast.error(String(e.message).toUpperCase()); }
finally { setSaving(false); }
};
return (
<>
<div className="fixed inset-0 z-[70] bg-white flex flex-col">
{/* Header */}
<div className="px-5 sm:px-8 py-4 border-b border-gray-100 flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ backgroundColor: `${COLOR}18`, color: COLOR }}><MessageSquare size={20} /></div>
<div>
<h2 className="text-sm sm:text-base font-black text-gray-900 uppercase">Comunicação interna{nome ? ` · ${nome}` : ''}</h2>
<p className="text-[10px] font-bold uppercase tracking-widest" style={{ color: completo ? '#16a34a' : COLOR }}>
{completo ? 'Perfil completo' : 'Preencha para liberar o sistema'}
</p>
</div>
</div>
{!forced && <button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-500"><X size={20} /></button>}
</div>
{loading ? (
<div className="flex-1 flex items-center justify-center text-gray-400"><Loader2 className="animate-spin" size={28} /></div>
) : (
<>
{/* Conteúdo */}
<div className="flex-1 overflow-y-auto px-5 sm:px-8 py-6">
<div className="max-w-3xl mx-auto space-y-8">
{forced && (
<div className="rounded-2xl bg-amber-50 border border-amber-200 p-4 flex gap-3">
<AlertTriangle size={18} className="text-amber-500 shrink-0 mt-0.5" />
<p className="text-xs text-amber-700 font-medium leading-relaxed">
Precisamos destes dados para o sistema funcionar corretamente e para a <b>Secretária IA</b> contatar você
nos horários certos. Mensagens fora do expediente podem gerar problemas por isso as <b>janelas de horário</b> são obrigatórias.
</p>
</div>
)}
{/* ── Endereço ── */}
<section>
<h3 className="flex items-center gap-2 text-xs font-black text-gray-700 uppercase tracking-wide mb-1"><MapPin size={15} style={{ color: COLOR }} /> Endereço</h3>
{enderecoDaClinica && <p className="text-[11px] text-teal-600 font-bold mb-3">Puxamos o endereço da clínica é conferir (ou ajustar, se o seu for diferente).</p>}
<div className="grid grid-cols-1 sm:grid-cols-6 gap-3">
<div className="sm:col-span-2">
<label className={label}>CEP</label>
<input className={input} value={endereco.cep || ''} onChange={e => setEnd('cep', e.target.value)} onBlur={onCep} placeholder="00000-000" />
</div>
<div className="sm:col-span-4">
<label className={label}>Logradouro</label>
<input className={input} value={endereco.logradouro || ''} onChange={e => setEnd('logradouro', e.target.value)} placeholder="Rua / Avenida" />
</div>
<div className="sm:col-span-1">
<label className={label}>Número</label>
<input className={input} value={endereco.numero || ''} onChange={e => setEnd('numero', e.target.value)} />
</div>
<div className="sm:col-span-2">
<label className={label}>Complemento</label>
<input className={input} value={endereco.complemento || ''} onChange={e => setEnd('complemento', e.target.value)} />
</div>
<div className="sm:col-span-3">
<label className={label}>Bairro</label>
<input className={input} value={endereco.bairro || ''} onChange={e => setEnd('bairro', e.target.value)} />
</div>
<div className="sm:col-span-4">
<label className={label}>Cidade</label>
<input className={input} value={endereco.cidade || ''} onChange={e => setEnd('cidade', e.target.value)} />
</div>
<div className="sm:col-span-2">
<label className={label}>UF</label>
<select className={input} value={endereco.estado || ''} onChange={e => setEnd('estado', e.target.value)}>
{UF.map(u => <option key={u} value={u}>{u || '—'}</option>)}
</select>
</div>
</div>
</section>
{/* ── Telefones / WhatsApp ── */}
<section>
<h3 className="flex items-center gap-2 text-xs font-black text-gray-700 uppercase tracking-wide mb-1"><Phone size={15} style={{ color: COLOR }} /> Números de WhatsApp</h3>
<p className="text-[11px] text-gray-400 font-medium mb-3">Cadastre todos os seus números. Para cada um, descreva quando ele pode ser usado e em quais horários a Secretária pode enviar mensagem.</p>
<div className="space-y-4">
{numeros.map((n, i) => (
<div key={i} className="rounded-2xl border border-gray-200 p-4 space-y-3">
<div className="flex items-start gap-3">
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className={label}>Número</label>
<input className={input} value={n.numero} onChange={e => updNum(i, { numero: e.target.value })} onBlur={e => checkNumero(i, e.target.value)} placeholder="(67) 99999-9999" />
</div>
<div>
<label className={label}>Descrição</label>
<input className={input} value={n.descricao} onChange={e => updNum(i, { descricao: e.target.value })} placeholder="Ex.: WhatsApp de trabalho" />
</div>
</div>
<button type="button" onClick={() => setNumeros(ns => ns.filter((_, k) => k !== i))} className="p-2 text-gray-300 hover:text-red-500 mt-5"><Trash2 size={16} /></button>
</div>
{(avisos[i]?.length ?? 0) > 0 && (
<div className="rounded-xl bg-amber-50 border border-amber-200 p-2.5 text-[11px] text-amber-700 font-medium flex gap-2">
<AlertTriangle size={14} className="shrink-0 mt-0.5 text-amber-500" />
<span>Este número está cadastrado por <b>{avisos[i].map(x => `${x.nome} (${roleLabel(x.role)})`).join(', ')}</b>. Pode compartilhar o mesmo número descreva abaixo as situações deste papel; a Secretária identifica pelo assunto quem deve responder.</span>
</div>
)}
<div>
<label className={label}>Situações em que a Secretária pode mandar msg neste número</label>
<textarea className={input} rows={2} value={n.situacoes} onChange={e => updNum(i, { situacoes: e.target.value })} placeholder="Ex.: só confirmações de encaixe e avisos urgentes; nunca assuntos pessoais." />
</div>
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest flex items-center gap-1.5 mb-1"><Clock size={12} /> Horários permitidos para envio</label>
<JanelaEditor janelas={n.janelas || []} onChange={j => updNum(i, { janelas: j })} addLabel="Adicionar janela de horário" />
</div>
</div>
))}
<button type="button" onClick={addNum} className="w-full py-3 rounded-2xl border border-dashed border-gray-300 text-xs font-black uppercase text-gray-400 hover:border-teal-400 hover:text-teal-600 flex items-center justify-center gap-2">
<Plus size={15} /> Adicionar número
</button>
</div>
</section>
{/* ── Agenda de atendimento — fonte única: "Horários de Funcionamento" ── */}
{isDentista && (
<section>
<h3 className="flex items-center gap-2 text-xs font-black text-gray-700 uppercase tracking-wide mb-1"><CalendarClock size={15} style={{ color: COLOR }} /> Agenda de atendimento</h3>
<p className="text-[11px] text-gray-400 font-medium mb-3">São os horários em que a Secretária pode marcar consultas para você. Ficam em "Horários de Funcionamento" (a mesma jornada que a agenda usa).</p>
{jornadaOk ? (
<div className="rounded-xl bg-green-50 border border-green-200 p-3.5 flex items-center justify-between">
<span className="flex items-center gap-2 text-xs font-black text-green-700 uppercase"><CheckCircle2 size={15} /> Jornada configurada</span>
<button type="button" onClick={() => setHorariosTab('dentistas')} className="text-[10px] font-black uppercase text-teal-600 hover:text-teal-700">Editar</button>
</div>
) : (
<div className="rounded-xl bg-amber-50 border border-amber-200 p-3.5">
<p className="text-[11px] text-amber-700 font-medium mb-2 flex gap-2"><AlertTriangle size={14} className="shrink-0 mt-0.5 text-amber-500" /> Você ainda não configurou sua agenda de atendimento. Sem ela, a Secretária não consegue marcar consultas com você.</p>
<button type="button" onClick={() => setHorariosTab('dentistas')} className="w-full py-2.5 rounded-xl text-[11px] font-black uppercase text-white hover:opacity-90" style={{ backgroundColor: COLOR }}>Configurar minha agenda</button>
</div>
)}
</section>
)}
{/* ── Horário de funcionamento da clínica — só para o DONO ── */}
{isDono && (
<section>
<h3 className="flex items-center gap-2 text-xs font-black text-gray-700 uppercase tracking-wide mb-1"><CalendarClock size={15} style={{ color: COLOR }} /> Horário da clínica</h3>
<p className="text-[11px] text-gray-400 font-medium mb-3">É o horário de funcionamento que a Secretária usa para atender e marcar consultas. Fica em "Horários de Funcionamento".</p>
{horarioClinicaOk ? (
<div className="rounded-xl bg-green-50 border border-green-200 p-3.5 flex items-center justify-between">
<span className="flex items-center gap-2 text-xs font-black text-green-700 uppercase"><CheckCircle2 size={15} /> Horário da clínica configurado</span>
<button type="button" onClick={() => setHorariosTab('clinica')} className="text-[10px] font-black uppercase text-teal-600 hover:text-teal-700">Editar</button>
</div>
) : (
<div className="rounded-xl bg-amber-50 border border-amber-200 p-3.5">
<p className="text-[11px] text-amber-700 font-medium mb-2 flex gap-2"><AlertTriangle size={14} className="shrink-0 mt-0.5 text-amber-500" /> A clínica ainda não tem horário de funcionamento. Sem ele, a Secretária não consegue atender nem marcar consultas.</p>
<button type="button" onClick={() => setHorariosTab('clinica')} className="w-full py-2.5 rounded-xl text-[11px] font-black uppercase text-white hover:opacity-90" style={{ backgroundColor: COLOR }}>Configurar horário da clínica</button>
</div>
)}
</section>
)}
</div>
</div>
{/* Footer */}
<div className="px-5 sm:px-8 py-4 border-t border-gray-100 shrink-0">
<div className="max-w-3xl mx-auto flex items-center gap-3">
<div className="flex-1 flex flex-wrap gap-x-3 gap-y-1 text-[10px] font-black uppercase tracking-wide">
<span className={enderecoOk ? 'text-green-600' : 'text-gray-300'}>{enderecoOk ? '✓' : '○'} Endereço</span>
<span className={numerosOk ? 'text-green-600' : 'text-gray-300'}>{numerosOk ? '✓' : '○'} Números</span>
{isDentista && <span className={jornadaFeita ? 'text-green-600' : 'text-gray-300'}>{jornadaFeita ? '✓' : '○'} Agenda</span>}
{isDono && <span className={clinicaFeita ? 'text-green-600' : 'text-gray-300'}>{clinicaFeita ? '✓' : '○'} Clínica</span>}
</div>
{!forced && onSnooze && (
<button onClick={onSnooze} className="px-4 py-3 rounded-xl text-xs font-black uppercase text-gray-500 border border-gray-200 hover:bg-gray-50">
Lembrar mais tarde
</button>
)}
{/* Escape do gate forçado: sair para OUTRA clínica sem preencher esta. */}
{forced && outrasClinicas.length > 0 && (
<div className="relative">
<button type="button" onClick={() => setTrocarOpen(o => !o)}
className="px-4 py-3 rounded-xl text-xs font-black uppercase text-gray-600 border border-gray-200 hover:bg-gray-50 flex items-center gap-1.5">
Trocar de clínica
</button>
{trocarOpen && (
<>
<div className="fixed inset-0 z-[71]" onClick={() => setTrocarOpen(false)} />
<div className="absolute right-0 bottom-full mb-2 z-[72] min-w-[220px] bg-white border border-gray-200 rounded-2xl shadow-xl py-2 max-h-[50vh] overflow-y-auto">
<p className="px-4 py-1.5 text-[9px] font-black text-gray-400 uppercase tracking-widest">Ir para outra clínica</p>
{outrasClinicas.map((w: any) => (
<button key={w.id} type="button" onClick={() => HybridBackend.switchWorkspace(w.id)}
className="w-full flex items-center gap-2.5 px-4 py-2 hover:bg-gray-50 text-left">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: w.cor || '#2563eb' }} />
<span className="text-xs font-bold text-gray-700 uppercase truncate">{w.nome}</span>
</button>
))}
</div>
</>
)}
</div>
)}
<button onClick={salvar} disabled={saving}
className="px-6 py-3 text-white rounded-xl text-xs font-black uppercase hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2" style={{ backgroundColor: completo ? '#16a34a' : COLOR }}>
{saving ? <Loader2 size={15} className="animate-spin" /> : completo ? <CheckCircle2 size={15} /> : <Save size={15} />}
{forced ? (completo ? 'Salvar e liberar' : 'Salvar progresso') : 'Salvar'}
</button>
</div>
</div>
</>
)}
</div>
{/* "Horários de Funcionamento" por cima — fonte única (dentistas_horarios / clinicas_horarios). */}
{horariosTab && (
<HorariosConfig isOpen clinicaId={clinicaId} initialTab={horariosTab} soDentista={horariosTab === 'dentistas' && usuarioId === 'me'}
onClose={() => { setHorariosTab(null); load(); }} />
)}
</>
);
};
export default ComunicacaoInternaModal;