6cd8c1f9e1
- A clínica avalia o laboratório (1-5★ + comentário) após a entrega (protese_avaliacao, 1 por OS, reavaliável). POST /protese/os/:id/avaliar; avaliação volta no GET detalhe. - Nota ScoreOdonto passa a combinar 0.6·operacional + 0.4·satisfação (estrelas) quando há avaliações. Selo "Verificado" (≥5 entregas). Exposto no marketplace (★nota · nº avaliações · Verificado) e nos indicadores do lab (KPI Satisfação). Helper notasScorePorProtetico unificado (dropdown + marketplace). - UI: seção "Avaliar o laboratório" na OS entregue (lado clínica); selo/avaliações no card do marketplace. - Correção de passagem: FileText/Star importados em ProteseView (FileText faltava desde a fatia 3). Validado em DEV: 6 entregas + 5★/3★ → nota 9.2, satisfação 4★, verificado (marketplace e indicadores). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1049 lines
60 KiB
TypeScript
1049 lines
60 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
UserSearch, Stethoscope, Wrench, Sparkles, MapPin, Search, Mail, Phone, RefreshCw,
|
|
Save, BadgeCheck, Inbox, Send, CheckCircle2, XCircle, CircleDollarSign, UserCircle2, X,
|
|
ChevronUp, Eraser, FileText, Plus, Pencil, Trash2, Tags, ChevronDown
|
|
} from 'lucide-react';
|
|
|
|
const fmtBRLp = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
|
|
|
// Fatia 2: solicitar prótese direto do marketplace → cria a OS (lab pré-selecionado).
|
|
const SolicitarProteseModal: React.FC<{ prof: any; onClose: () => void }> = ({ prof, onClose }) => {
|
|
const ws = HybridBackend.getActiveWorkspace();
|
|
const [produtos, setProdutos] = useState<any[]>([]);
|
|
const [dentistas, setDentistas] = useState<any[]>([]);
|
|
const [busca, setBusca] = useState('');
|
|
const [pacientes, setPacientes] = useState<any[]>([]);
|
|
const [paciente, setPaciente] = useState<any>(null);
|
|
const [form, setForm] = useState<any>({ produto_id: '', tipo: '', dentista_id: '', dentes: '', prazo_entrega: '', observacoes: '' });
|
|
const [saving, setSaving] = useState(false);
|
|
const [okMsg, setOkMsg] = useState(false);
|
|
|
|
useEffect(() => {
|
|
HybridBackend.getLaboratorioProdutos(prof.id).then(r => setProdutos(Array.isArray(r) ? r : [])).catch(() => setProdutos([]));
|
|
HybridBackend.getDentistas(ws?.id).then(r => setDentistas(Array.isArray(r) ? r : [])).catch(() => setDentistas([]));
|
|
}, [prof.id, ws?.id]);
|
|
useEffect(() => {
|
|
if (paciente) return;
|
|
const t = setTimeout(() => { HybridBackend.getPacientes(busca).then(r => setPacientes(r.data || [])).catch(() => setPacientes([])); }, 250);
|
|
return () => clearTimeout(t);
|
|
}, [busca, paciente]);
|
|
const set = (k: string, v: any) => setForm((f: any) => ({ ...f, [k]: v }));
|
|
|
|
const salvar = async () => {
|
|
if (!form.produto_id && !form.tipo.trim()) { alert('Escolha um produto do catálogo ou descreva o trabalho.'); return; }
|
|
const dent = dentistas.find(d => d.id === form.dentista_id);
|
|
setSaving(true);
|
|
try {
|
|
await HybridBackend.createProteseOS({
|
|
...form, protetico_id: prof.id, valor: 0,
|
|
paciente_id: paciente?.id || null, paciente_nome: paciente?.nome || null, dentista_nome: dent?.nome || null,
|
|
});
|
|
setOkMsg(true);
|
|
} catch (e: any) { alert((e.message || 'Erro').toUpperCase()); } finally { setSaving(false); }
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
|
<div className="bg-white rounded-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between p-5 border-b border-gray-100 sticky top-0 bg-white">
|
|
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><Wrench size={16} className="text-violet-600" /> Solicitar prótese</h3>
|
|
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
|
</div>
|
|
{okMsg ? (
|
|
<div className="p-8 text-center space-y-2">
|
|
<CheckCircle2 size={40} className="text-green-500 mx-auto" />
|
|
<p className="font-black text-gray-800 uppercase text-sm">OS enviada ao laboratório</p>
|
|
<p className="text-xs text-gray-500">{prof.nome} recebeu a solicitação. Acompanhe em Prótese / Bancada.</p>
|
|
<button onClick={onClose} className="mt-2 px-4 py-2 rounded-xl bg-gray-800 text-white text-xs font-black uppercase">Fechar</button>
|
|
</div>
|
|
) : (
|
|
<div className="p-5 space-y-4">
|
|
<div className="bg-violet-50 rounded-xl px-3 py-2 text-xs font-black text-violet-700 uppercase">Laboratório: {prof.nome}</div>
|
|
<div>
|
|
<label className="text-[10px] font-black text-gray-500 uppercase">Produto do catálogo</label>
|
|
{produtos.length > 0 ? (
|
|
<select value={form.produto_id} onChange={e => { const pr = produtos.find(x => x.id === e.target.value); set('produto_id', e.target.value); set('tipo', pr?.nome || ''); }} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
|
|
<option value="">Escolha o trabalho…</option>
|
|
{produtos.map(pr => <option key={pr.id} value={pr.id}>{pr.nome} — {fmtBRLp(pr.preco)}{pr.garantia_meses ? ` · ${pr.garantia_meses}m` : ''}</option>)}
|
|
</select>
|
|
) : (
|
|
<input value={form.tipo} onChange={e => set('tipo', e.target.value)} placeholder="Descreva o trabalho (sem catálogo)" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
|
)}
|
|
</div>
|
|
<div>
|
|
<label className="text-[10px] font-black text-gray-500 uppercase">Paciente (opcional)</label>
|
|
{paciente ? (
|
|
<div className="flex items-center justify-between bg-gray-50 rounded-xl px-3 py-2 mt-1"><span className="text-sm font-bold text-gray-700">{paciente.nome}</span><button onClick={() => { setPaciente(null); setBusca(''); }}><X size={16} className="text-gray-400" /></button></div>
|
|
) : (
|
|
<div className="relative mt-1">
|
|
<Search size={15} className="absolute left-3 top-2.5 text-gray-300" />
|
|
<input value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar paciente..." className="w-full pl-9 pr-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
|
{busca && pacientes.length > 0 && (
|
|
<div className="absolute z-10 left-0 right-0 mt-1 bg-white border border-gray-100 rounded-xl shadow-lg max-h-40 overflow-y-auto">
|
|
{pacientes.slice(0, 8).map(p => <button key={p.id} onClick={() => setPaciente(p)} className="w-full text-left px-3 py-2 hover:bg-gray-50 text-sm font-medium text-gray-700">{p.nome}</button>)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-[10px] font-black text-gray-500 uppercase">Dentista</label>
|
|
<select value={form.dentista_id} onChange={e => set('dentista_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
|
|
<option value="">—</option>
|
|
{dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-[10px] font-black text-gray-500 uppercase">Prazo</label>
|
|
<input type="date" value={form.prazo_entrega} onChange={e => set('prazo_entrega', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-[10px] font-black text-gray-500 uppercase">Dentes / observações</label>
|
|
<input value={form.dentes} onChange={e => set('dentes', e.target.value)} placeholder="Ex.: 11, 21" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
|
<textarea value={form.observacoes} onChange={e => set('observacoes', e.target.value)} rows={2} placeholder="Cor, material, instruções…" className="w-full mt-2 px-3 py-2 rounded-xl border border-gray-200 text-sm resize-none" />
|
|
</div>
|
|
<button disabled={saving} onClick={salvar} className="w-full py-3 rounded-xl bg-[#2d6a4f] text-white font-black text-sm uppercase disabled:opacity-50 flex items-center justify-center gap-2"><Send size={15} /> Enviar solicitação</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Fatia 1 do marketplace transacional: vitrine do catálogo do laboratório (reusa getLaboratorioProdutos).
|
|
const VitrineProtetico: React.FC<{ proteticoId: string }> = ({ proteticoId }) => {
|
|
const [aberto, setAberto] = useState(false);
|
|
const [produtos, setProdutos] = useState<any[] | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const toggle = async () => {
|
|
if (!aberto && produtos === null) {
|
|
setLoading(true);
|
|
try { const p = await HybridBackend.getLaboratorioProdutos(proteticoId); setProdutos(Array.isArray(p) ? p : []); }
|
|
catch { setProdutos([]); } finally { setLoading(false); }
|
|
}
|
|
setAberto(a => !a);
|
|
};
|
|
return (
|
|
<div className="mb-2">
|
|
<button onClick={toggle} className="w-full py-2 rounded-xl border border-gray-200 text-gray-600 text-[10px] font-black uppercase flex items-center justify-center gap-1.5 hover:bg-gray-50">
|
|
<Tags size={12} /> {aberto ? 'Ocultar catálogo' : 'Ver catálogo de próteses'} {aberto ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
|
</button>
|
|
{aberto && (
|
|
<div className="mt-2 space-y-1">
|
|
{loading ? <p className="text-[10px] text-gray-300 uppercase font-bold text-center py-2">Carregando…</p>
|
|
: produtos && produtos.length ? produtos.map(pr => (
|
|
<div key={pr.id} className="flex items-center gap-2 text-xs bg-gray-50 rounded-lg px-3 py-1.5">
|
|
<span className="font-bold text-gray-700 flex-1 truncate">{pr.nome}</span>
|
|
{pr.garantia_meses ? <span className="text-[9px] text-emerald-600 font-black uppercase">{pr.garantia_meses}m gar.</span> : null}
|
|
<span className="font-black text-gray-800">{fmtBRLp(pr.preco)}</span>
|
|
</div>
|
|
)) : <p className="text-[10px] text-gray-300 uppercase font-bold text-center py-2">Sem catálogo publicado.</p>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
import { PageHeader } from '../../components/PageHeader.tsx';
|
|
import { useToast } from '../../contexts/ToastContext.tsx';
|
|
import { useConfirm } from '../../contexts/ConfirmContext.tsx';
|
|
import { HybridBackend } from '../../services/backend.ts';
|
|
import { buscarCep } from '../../services/viacep.ts';
|
|
|
|
const API_URL = (window as any).__API_URL__ || '';
|
|
const UF_LIST = ['', '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 COLOR = '#0d9488'; // teal — identidade do plugin (paleta do login)
|
|
// Tipo (role) → área (slug) para popular o dropdown de especialidade pela seleção anterior.
|
|
const ROLE_TO_AREA: Record<string, string> = { dentista: 'odontologia', biomedico: 'biomedicina', protetico: 'protese' };
|
|
|
|
const apiFetch = (path: string, opts: RequestInit = {}) =>
|
|
fetch(`${API_URL}${path}`, {
|
|
...opts,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${localStorage.getItem('SCOREODONTO_AUTH_TOKEN')}`,
|
|
...(opts.headers || {}),
|
|
},
|
|
});
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
interface Profissional {
|
|
id: string;
|
|
nome: string;
|
|
email: string;
|
|
celular?: string;
|
|
role: 'dentista' | 'biomedico' | 'protetico';
|
|
estado: string;
|
|
cidade: string;
|
|
bairro: string;
|
|
logradouro?: string;
|
|
numero?: string;
|
|
cep?: string;
|
|
especialidade: string;
|
|
raio_atuacao_km?: number | null;
|
|
bio_profissional?: string | null;
|
|
dist_km?: number | string | null;
|
|
nota?: number | null; // Nota ScoreOdonto (só protéticos com volume mínimo)
|
|
verificado?: boolean; // selo (≥ 5 entregas)
|
|
avaliacoes?: number; // nº de avaliações da clínica
|
|
}
|
|
|
|
interface Proposta {
|
|
id: string;
|
|
clinica_id: string;
|
|
clinica_nome?: string;
|
|
profissional_id: string;
|
|
profissional_nome?: string;
|
|
profissional_email?: string;
|
|
profissional_role?: string;
|
|
enviada_por_nome?: string;
|
|
mensagem?: string | null;
|
|
status: 'pendente' | 'aceita' | 'recusada' | 'cancelada';
|
|
created_at: string;
|
|
}
|
|
|
|
const ROLE_META: Record<string, { label: string; icon: any; cls: string }> = {
|
|
dentista: { label: 'Dentista', icon: Stethoscope, cls: 'bg-teal-100 text-teal-700' },
|
|
biomedico: { label: 'Biomédico(a)', icon: Sparkles, cls: 'bg-pink-100 text-pink-700' },
|
|
protetico: { label: 'Protético', icon: Wrench, cls: 'bg-violet-100 text-violet-700' },
|
|
};
|
|
|
|
const STATUS_STYLE: Record<string, string> = {
|
|
pendente: 'bg-amber-100 text-amber-700',
|
|
aceita: 'bg-emerald-100 text-emerald-700',
|
|
recusada: 'bg-red-100 text-red-600',
|
|
cancelada: 'bg-gray-100 text-gray-500',
|
|
};
|
|
|
|
// Modelos de proposta editáveis/persistidos por clínica (backend).
|
|
// Placeholders no texto: {prof} = nome do profissional, {clinica} = unidade ativa.
|
|
type CatModelo = 'dentista' | 'biomedico';
|
|
interface ModeloProposta { id: string; categoria: CatModelo; titulo: string; texto: string; ordem?: number }
|
|
const CAT_META: Record<CatModelo, { label: string; icon: any }> = {
|
|
dentista: { label: 'Dentista', icon: Stethoscope },
|
|
biomedico: { label: 'Biomédico', icon: Sparkles },
|
|
};
|
|
|
|
const inputCls = 'w-full px-3 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-xs font-bold text-gray-700 outline-none focus:border-teal-400';
|
|
const labelCls = 'text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1';
|
|
// Estilo da busca (versão nova) — rótulos title-case e campos maiores/arredondados.
|
|
const inputNew = 'w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm font-semibold text-gray-700 outline-none focus:border-teal-400 focus:bg-white transition-colors';
|
|
const labelNew = 'text-[11px] font-bold text-gray-500 mb-1.5 block';
|
|
|
|
// ─── Modal: enviar proposta ──────────────────────────────────────────────────
|
|
const PropostaModal: React.FC<{ prof: Profissional; onClose: () => void; onSent: () => void }> = ({ prof, onClose, onSent }) => {
|
|
const toast = useToast();
|
|
const ws = HybridBackend.getActiveWorkspace();
|
|
const [mensagem, setMensagem] = useState('');
|
|
const [saving, setSaving] = useState(false);
|
|
const [showModelos, setShowModelos] = useState(false);
|
|
const [catModelo, setCatModelo] = useState<CatModelo>(prof.role === 'biomedico' ? 'biomedico' : 'dentista');
|
|
const [modelos, setModelos] = useState<ModeloProposta[]>([]);
|
|
const [modelosLoading, setModelosLoading] = useState(false);
|
|
|
|
const preencherModelo = (texto: string) =>
|
|
texto.replace(/\{prof\}/g, (prof.nome || '').split(' ')[0] || prof.nome)
|
|
.replace(/\{clinica\}/g, ws?.nome || 'nossa clínica');
|
|
const aplicarModelo = (texto: string) => { setMensagem(preencherModelo(texto)); setShowModelos(false); };
|
|
|
|
const fetchModelos = useCallback(async () => {
|
|
if (!ws?.id) return;
|
|
setModelosLoading(true);
|
|
try {
|
|
const res = await apiFetch(`/api/propostas/modelos?clinicaId=${encodeURIComponent(ws.id)}`);
|
|
setModelos(res.ok ? await res.json() : []);
|
|
} catch { setModelos([]); }
|
|
finally { setModelosLoading(false); }
|
|
}, [ws?.id]);
|
|
useEffect(() => { if (showModelos) fetchModelos(); }, [showModelos, fetchModelos]);
|
|
|
|
const handleSend = async () => {
|
|
if (!ws?.id) { toast.error('SELECIONE UMA CLÍNICA/CONSULTÓRIO ATIVO PARA ENVIAR PROPOSTAS.'); return; }
|
|
setSaving(true);
|
|
try {
|
|
const res = await apiFetch('/api/propostas', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ clinicaId: ws.id, profissionalId: prof.id, mensagem: mensagem || null }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Erro ao enviar proposta.');
|
|
toast.success('PROPOSTA ENVIADA! O PROFISSIONAL SERÁ NOTIFICADO.');
|
|
onSent();
|
|
onClose();
|
|
} catch (e: any) { toast.error(e.message.toUpperCase()); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center backdrop-blur-sm p-4">
|
|
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden">
|
|
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-sm font-black text-gray-900 uppercase">PROPOSTA PARA {prof.nome}</h3>
|
|
<p className="text-[10px] font-bold uppercase tracking-widest" style={{ color: COLOR }}>EM NOME DE {ws?.nome || '—'}</p>
|
|
</div>
|
|
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl transition-colors"><X size={18} /></button>
|
|
</div>
|
|
<div className="p-6">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className={`${labelCls} mb-0`}>Mensagem (opcional)</label>
|
|
<button type="button" onClick={() => setShowModelos(s => !s)}
|
|
className="flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest px-2.5 py-1.5 rounded-lg border transition-colors"
|
|
style={showModelos
|
|
? { backgroundColor: COLOR, color: '#fff', borderColor: COLOR }
|
|
: { color: COLOR, borderColor: '#99f6e4' }}>
|
|
<FileText size={12} /> Modelos
|
|
</button>
|
|
</div>
|
|
|
|
{showModelos && (
|
|
<div className="mb-3 border border-gray-100 rounded-xl overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200">
|
|
<div className="flex items-center gap-1 p-1 bg-gray-50">
|
|
{(Object.keys(CAT_META) as CatModelo[]).map(c => {
|
|
const M = CAT_META[c]; const Icon = M.icon; const on = catModelo === c;
|
|
return (
|
|
<button key={c} type="button" onClick={() => setCatModelo(c)}
|
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all ${on ? 'text-white shadow-sm' : 'text-gray-500 hover:bg-white'}`}
|
|
style={on ? { backgroundColor: COLOR } : {}}>
|
|
<Icon size={12} /> {M.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="max-h-52 overflow-y-auto p-2 space-y-1.5">
|
|
{modelosLoading ? (
|
|
<div className="flex justify-center py-5"><RefreshCw size={16} className="animate-spin" style={{ color: COLOR }} /></div>
|
|
) : modelos.filter(m => m.categoria === catModelo).length === 0 ? (
|
|
<p className="text-[11px] text-gray-400 text-center py-4 font-bold uppercase">Nenhum modelo · cadastre na aba Modelos</p>
|
|
) : modelos.filter(m => m.categoria === catModelo).map(m => (
|
|
<button key={m.id} type="button" onClick={() => aplicarModelo(m.texto)}
|
|
className="w-full text-left p-3 rounded-lg border border-gray-100 hover:border-teal-300 hover:bg-teal-50/50 transition-colors">
|
|
<p className="text-[11px] font-black text-gray-800 uppercase tracking-wide flex items-center gap-1.5">
|
|
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: COLOR }} />{m.titulo}
|
|
</p>
|
|
<p className="text-[11px] text-gray-500 font-medium mt-1 line-clamp-2 leading-snug">{preencherModelo(m.texto)}</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<textarea className={inputCls} rows={5} value={mensagem} onChange={e => setMensagem(e.target.value)}
|
|
placeholder="Escreva sua proposta ou escolha um modelo acima…" />
|
|
</div>
|
|
<div className="px-6 pb-6 flex gap-3">
|
|
<button onClick={onClose} className="flex-1 py-3 border border-gray-200 rounded-xl text-xs font-black uppercase text-gray-500 hover:bg-gray-50 transition-colors">CANCELAR</button>
|
|
<button onClick={handleSend} disabled={saving} className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase transition-colors flex items-center justify-center gap-2 disabled:opacity-60" style={{ backgroundColor: COLOR }}>
|
|
{saving ? <RefreshCw size={14} className="animate-spin" /> : <Send size={14} />} ENVIAR PROPOSTA
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── Aba: Gerenciar Modelos de Proposta ──────────────────────────────────────
|
|
const ModelosManager: React.FC = () => {
|
|
const toast = useToast();
|
|
const confirm = useConfirm();
|
|
const ws = HybridBackend.getActiveWorkspace();
|
|
const [modelos, setModelos] = useState<ModeloProposta[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [form, setForm] = useState<{ id?: string; categoria: CatModelo; titulo: string; texto: string } | null>(null);
|
|
|
|
const fetchModelos = useCallback(async () => {
|
|
if (!ws?.id) return;
|
|
setLoading(true);
|
|
try {
|
|
const res = await apiFetch(`/api/propostas/modelos?clinicaId=${encodeURIComponent(ws.id)}`);
|
|
setModelos(res.ok ? await res.json() : []);
|
|
} catch { setModelos([]); }
|
|
finally { setLoading(false); }
|
|
}, [ws?.id]);
|
|
useEffect(() => { fetchModelos(); }, [fetchModelos]);
|
|
|
|
const salvar = async () => {
|
|
if (!form || !ws?.id) return;
|
|
if (!form.titulo.trim() || !form.texto.trim()) { toast.error('PREENCHA TÍTULO E TEXTO.'); return; }
|
|
setSaving(true);
|
|
try {
|
|
const res = await apiFetch(form.id ? `/api/propostas/modelos/${form.id}` : '/api/propostas/modelos', {
|
|
method: form.id ? 'PUT' : 'POST',
|
|
body: JSON.stringify({ clinicaId: ws.id, categoria: form.categoria, titulo: form.titulo, texto: form.texto }),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(data.error || 'Erro ao salvar.');
|
|
toast.success('MODELO SALVO!');
|
|
setForm(null);
|
|
fetchModelos();
|
|
} catch (e: any) { toast.error(e.message.toUpperCase()); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const excluir = async (id: string) => {
|
|
if (!ws?.id || !await confirm('EXCLUIR ESTE MODELO?')) return;
|
|
try {
|
|
const res = await apiFetch(`/api/propostas/modelos/${id}?clinicaId=${encodeURIComponent(ws.id)}`, { method: 'DELETE' });
|
|
if (!res.ok) throw new Error('Erro ao excluir.');
|
|
toast.success('MODELO EXCLUÍDO.');
|
|
fetchModelos();
|
|
} catch (e: any) { toast.error(e.message.toUpperCase()); }
|
|
};
|
|
|
|
if (!ws?.id) {
|
|
return (
|
|
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm p-8 text-center">
|
|
<div className="w-14 h-14 rounded-2xl bg-amber-50 flex items-center justify-center mx-auto mb-4"><FileText size={26} className="text-amber-500" /></div>
|
|
<h3 className="text-base font-black uppercase tracking-tight text-gray-900">Selecione uma unidade</h3>
|
|
<p className="text-sm text-gray-500 mt-2 max-w-md mx-auto">Os modelos de proposta são por clínica/consultório. Ative uma unidade para gerenciá-los.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
{/* Cabeçalho */}
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 flex flex-col sm:flex-row sm:items-center gap-3">
|
|
<div className="flex-1">
|
|
<h3 className="text-sm font-black text-gray-900 uppercase">Modelos de Proposta</h3>
|
|
<p className="text-xs text-gray-500 font-medium mt-1">
|
|
Mensagens prontas para enviar aos profissionais. As variáveis{' '}
|
|
<code className="px-1 rounded bg-gray-100 text-gray-700 font-bold">{'{prof}'}</code> e{' '}
|
|
<code className="px-1 rounded bg-gray-100 text-gray-700 font-bold">{'{clinica}'}</code> são preenchidas automaticamente no envio.
|
|
</p>
|
|
</div>
|
|
<button onClick={() => setForm({ categoria: 'dentista', titulo: '', texto: '' })}
|
|
className="flex items-center gap-2 px-5 py-3 text-white rounded-xl text-xs font-black uppercase tracking-widest hover:opacity-90 transition-opacity shrink-0" style={{ backgroundColor: COLOR }}>
|
|
<Plus size={14} /> Novo modelo
|
|
</button>
|
|
</div>
|
|
|
|
{/* Editor */}
|
|
{form && (
|
|
<div className="bg-white rounded-2xl border-2 shadow-sm p-5 space-y-3" style={{ borderColor: COLOR }}>
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-xs font-black text-gray-900 uppercase tracking-wide">{form.id ? 'Editar modelo' : 'Novo modelo'}</h4>
|
|
<button onClick={() => setForm(null)} className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"><X size={16} /></button>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
<div>
|
|
<label className={labelNew}>Categoria</label>
|
|
<select className={inputNew} value={form.categoria} onChange={e => setForm({ ...form, categoria: e.target.value as CatModelo })}>
|
|
<option value="dentista">Dentista</option>
|
|
<option value="biomedico">Biomédico</option>
|
|
</select>
|
|
</div>
|
|
<div className="md:col-span-2">
|
|
<label className={labelNew}>Título</label>
|
|
<input className={inputNew} placeholder="Ex.: Parceria fixa semanal" value={form.titulo} onChange={e => setForm({ ...form, titulo: e.target.value })} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className={labelNew}>Texto da proposta</label>
|
|
<textarea className={inputNew} rows={4} placeholder="Escreva a mensagem. Use {prof} e {clinica} como variáveis." value={form.texto} onChange={e => setForm({ ...form, texto: e.target.value })} />
|
|
</div>
|
|
<div className="flex gap-3 justify-end">
|
|
<button onClick={() => setForm(null)} className="px-5 py-2.5 border border-gray-200 rounded-xl text-xs font-black uppercase text-gray-500 hover:bg-gray-50 transition-colors">Cancelar</button>
|
|
<button onClick={salvar} disabled={saving}
|
|
className="flex items-center gap-2 px-5 py-2.5 text-white rounded-xl text-xs font-black uppercase tracking-widest hover:opacity-90 transition-opacity disabled:opacity-60" style={{ backgroundColor: COLOR }}>
|
|
{saving ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />} Salvar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Listagem por categoria */}
|
|
{loading ? (
|
|
<div className="flex justify-center py-12"><RefreshCw size={24} className="animate-spin" style={{ color: COLOR }} /></div>
|
|
) : (Object.keys(CAT_META) as CatModelo[]).map(cat => {
|
|
const M = CAT_META[cat]; const Icon = M.icon;
|
|
const itens = modelos.filter(m => m.categoria === cat);
|
|
return (
|
|
<div key={cat}>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Icon size={14} style={{ color: COLOR }} />
|
|
<h4 className="text-[11px] font-black text-gray-500 uppercase tracking-widest">{M.label}</h4>
|
|
<span className="text-[10px] font-black px-2 py-0.5 rounded-full bg-gray-100 text-gray-500">{itens.length}</span>
|
|
</div>
|
|
{itens.length === 0 ? (
|
|
<p className="text-xs text-gray-300 font-bold uppercase py-2">Nenhum modelo nesta categoria</p>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
{itens.map(m => (
|
|
<div key={m.id} className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 hover:shadow-md transition-all">
|
|
<div className="flex items-start justify-between gap-2 mb-1.5">
|
|
<p className="text-xs font-black text-gray-900 uppercase tracking-wide flex items-center gap-1.5 min-w-0">
|
|
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: COLOR }} /><span className="truncate">{m.titulo}</span>
|
|
</p>
|
|
<div className="flex gap-1 shrink-0">
|
|
<button onClick={() => setForm({ id: m.id, categoria: m.categoria, titulo: m.titulo, texto: m.texto })} className="p-1.5 rounded-md hover:bg-teal-50 text-teal-600 transition-colors"><Pencil size={14} /></button>
|
|
<button onClick={() => excluir(m.id)} className="p-1.5 rounded-md hover:bg-red-50 text-red-500 transition-colors"><Trash2 size={14} /></button>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-gray-500 font-medium leading-relaxed whitespace-pre-line">{m.texto}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── Main view ───────────────────────────────────────────────────────────────
|
|
type Tab = 'buscar' | 'perfil' | 'propostas' | 'modelos';
|
|
|
|
// Bloqueio quando falta endereço/CEP da origem (clínica ou perfil) — a busca é por proximidade.
|
|
const GateEndereco: React.FC<{ isClinic: boolean; onCompletarPerfil: () => void }> = ({ isClinic, onCompletarPerfil }) => (
|
|
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm p-8 text-center">
|
|
<div className="w-14 h-14 rounded-2xl bg-amber-50 flex items-center justify-center mx-auto mb-4">
|
|
<MapPin size={26} className="text-amber-500" />
|
|
</div>
|
|
<h3 className="text-base font-black uppercase tracking-tight text-gray-900">Complete seu endereço</h3>
|
|
<p className="text-sm text-gray-500 mt-2 max-w-md mx-auto leading-relaxed">
|
|
A busca de profissionais é por proximidade. Preencha {isClinic
|
|
? 'a localização e o CEP da sua clínica/consultório (em Configurações)'
|
|
: 'a localização e o CEP do seu perfil'} para continuar.
|
|
</p>
|
|
<button onClick={() => isClinic ? window.location.assign('/configuracoes') : onCompletarPerfil()}
|
|
className="mt-5 inline-flex items-center gap-2 px-5 py-3 rounded-xl text-white text-xs font-black uppercase tracking-widest hover:opacity-90 transition-opacity"
|
|
style={{ backgroundColor: COLOR }}>
|
|
{isClinic ? 'Ir para Configurações' : 'Completar meu perfil'}
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
export const ProfissionaisPlugin: React.FC = () => {
|
|
const toast = useToast();
|
|
const confirm = useConfirm();
|
|
const role = HybridBackend.getCurrentRole();
|
|
const ws = HybridBackend.getActiveWorkspace();
|
|
const isProfissional = ['dentista', 'biomedico', 'protetico'].includes(role);
|
|
|
|
const [tab, setTab] = useState<Tab>('buscar');
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// buscar
|
|
const [lista, setLista] = useState<Profissional[]>([]);
|
|
const [areas, setAreas] = useState<{ slug: string; nome: string; cor: string }[]>([]);
|
|
const [fNome, setFNome] = useState('');
|
|
const [fArea, setFArea] = useState('');
|
|
const [fTipo, setFTipo] = useState('');
|
|
const [fEstado, setFEstado] = useState('');
|
|
const [fCidade, setFCidade] = useState('');
|
|
const [fEspecialidade, setFEspecialidade] = useState('');
|
|
const [espOpts, setEspOpts] = useState<string[]>([]);
|
|
const [fCep, setFCep] = useState('');
|
|
const [fRaio, setFRaio] = useState('');
|
|
const [showFiltros, setShowFiltros] = useState(true);
|
|
// Área efetiva p/ o dropdown de especialidade: Área selecionada, ou derivada do Tipo.
|
|
const espArea = fArea || ROLE_TO_AREA[fTipo] || '';
|
|
// perfil
|
|
const [perfil, setPerfil] = useState<any>(null);
|
|
const [myAreas, setMyAreas] = useState<string[]>([]);
|
|
const [savingPerfil, setSavingPerfil] = useState(false);
|
|
// propostas
|
|
const [propostas, setPropostas] = useState<{ enviadas: Proposta[]; recebidas: Proposta[] }>({ enviadas: [], recebidas: [] });
|
|
const [proposingTo, setProposingTo] = useState<Profissional | null>(null);
|
|
const [solicitarTo, setSolicitarTo] = useState<Profissional | null>(null);
|
|
// null = checando; true/false = endereço da origem (clínica ou perfil) preenchido?
|
|
const [enderecoOk, setEnderecoOk] = useState<boolean | null>(null);
|
|
|
|
const checkEndereco = useCallback(async () => {
|
|
try {
|
|
if (isProfissional) {
|
|
const res = await apiFetch('/api/profissionais/perfil');
|
|
const p = res.ok ? await res.json() : {};
|
|
setEnderecoOk(!!(p?.cep && p?.cidade && p?.estado));
|
|
} else if (ws?.id) {
|
|
const r = await HybridBackend.getClinicaProfile(ws.id);
|
|
const c: any = r?.clinica || {};
|
|
setEnderecoOk(!!(c.cep && c.cidade && c.estado));
|
|
} else {
|
|
setEnderecoOk(true); // sem contexto claro → não bloqueia
|
|
}
|
|
} catch { setEnderecoOk(true); } // em erro, não bloqueia a tela
|
|
}, [isProfissional, ws?.id]);
|
|
|
|
useEffect(() => { checkEndereco(); }, [checkEndereco]);
|
|
useEffect(() => { HybridBackend.getAreas().then(setAreas); }, []);
|
|
|
|
// Carrega especialidades da área/tipo selecionado; limpa a seleção se sair da lista.
|
|
useEffect(() => {
|
|
if (!espArea) { setEspOpts([]); return; }
|
|
let cancel = false;
|
|
(async () => {
|
|
try {
|
|
const res = await apiFetch(`/api/profissionais/especialidades?area=${encodeURIComponent(espArea)}`);
|
|
const data: string[] = res.ok ? await res.json() : [];
|
|
if (!cancel) {
|
|
setEspOpts(data);
|
|
setFEspecialidade(prev => (prev && !data.includes(prev)) ? '' : prev);
|
|
}
|
|
} catch { if (!cancel) setEspOpts([]); }
|
|
})();
|
|
return () => { cancel = true; };
|
|
}, [espArea]);
|
|
|
|
const fetchLista = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (fNome.trim()) params.set('q', fNome.trim());
|
|
if (fTipo) params.set('tipo', fTipo);
|
|
if (fEstado) params.set('estado', fEstado);
|
|
if (fCidade) params.set('cidade', fCidade);
|
|
if (fEspecialidade) params.set('especialidade', fEspecialidade);
|
|
if (fArea) params.set('area', fArea);
|
|
// Origem automática: clínica ativa (backend cai no geo do usuário se faltar) → mais próximos primeiro.
|
|
const wsId = HybridBackend.getActiveWorkspace()?.id;
|
|
if (wsId) params.set('clinicaId', wsId);
|
|
if (fCep) params.set('cep', fCep); // CEP digitado sobrepõe a origem automática
|
|
if (fCep && Number(fRaio) > 0) params.set('raio', fRaio);
|
|
const res = await apiFetch(`/api/profissionais/marketplace?${params}`);
|
|
if (res.ok) setLista(await res.json());
|
|
} catch { /* silently fail */ }
|
|
finally { setLoading(false); }
|
|
}, [fNome, fTipo, fEstado, fCidade, fEspecialidade, fCep, fRaio, fArea]);
|
|
|
|
const limparFiltros = () => {
|
|
setFNome(''); setFArea(''); setFTipo(''); setFEstado('');
|
|
setFCidade(''); setFEspecialidade(''); setFCep(''); setFRaio('');
|
|
};
|
|
|
|
const fetchPerfil = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await apiFetch('/api/profissionais/perfil'); if (res.ok) setPerfil(await res.json());
|
|
setMyAreas(await HybridBackend.getMyAreas());
|
|
}
|
|
catch { /* silently fail */ }
|
|
finally { setLoading(false); }
|
|
}, []);
|
|
|
|
const fetchPropostas = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
// Recebidas (como profissional)
|
|
const rRes = await apiFetch('/api/propostas');
|
|
const recebidas = rRes.ok ? (await rRes.json()).recebidas : [];
|
|
// Enviadas (pela unidade ativa, se houver)
|
|
let enviadas: Proposta[] = [];
|
|
if (ws?.id) {
|
|
const eRes = await apiFetch(`/api/propostas?clinicaId=${encodeURIComponent(ws.id)}`);
|
|
if (eRes.ok) enviadas = (await eRes.json()).enviadas;
|
|
}
|
|
setPropostas({ enviadas, recebidas });
|
|
} catch { /* silently fail */ }
|
|
finally { setLoading(false); }
|
|
}, [ws?.id]);
|
|
|
|
useEffect(() => {
|
|
if (tab === 'buscar') fetchLista();
|
|
if (tab === 'perfil') fetchPerfil();
|
|
if (tab === 'propostas') fetchPropostas();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [tab]);
|
|
|
|
const handleSavePerfil = async () => {
|
|
if (perfil?.disponivel_diretorio && (!perfil.estado || !perfil.cidade)) {
|
|
toast.error('ESTADO E CIDADE SÃO OBRIGATÓRIOS PARA APARECER NO MARKETPLACE.');
|
|
return;
|
|
}
|
|
setSavingPerfil(true);
|
|
try {
|
|
const res = await apiFetch('/api/profissionais/perfil', {
|
|
method: 'PUT',
|
|
body: JSON.stringify({
|
|
estado: perfil.estado, cidade: perfil.cidade, bairro: perfil.bairro,
|
|
logradouro: perfil.logradouro, numero: perfil.numero, cep: perfil.cep,
|
|
especialidade: perfil.especialidade_dir, raio_atuacao_km: perfil.raio_atuacao_km,
|
|
bio_profissional: perfil.bio_profissional, disponivel_diretorio: perfil.disponivel_diretorio === true,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Erro ao salvar.');
|
|
await HybridBackend.setMyAreas(myAreas);
|
|
toast.success('PERFIL PROFISSIONAL ATUALIZADO!');
|
|
} catch (e: any) { toast.error(e.message.toUpperCase()); }
|
|
finally { setSavingPerfil(false); }
|
|
};
|
|
|
|
const handlePropostaAction = async (p: Proposta, acao: 'aceitar' | 'recusar' | 'cancelar') => {
|
|
if (acao === 'cancelar' && !await confirm('CANCELAR ESTA PROPOSTA?')) return;
|
|
try {
|
|
const res = await apiFetch(`/api/propostas/${p.id}`, { method: 'PUT', body: JSON.stringify({ acao }) });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Erro.');
|
|
toast.success(`PROPOSTA ${data.status?.toUpperCase()}.`);
|
|
fetchPropostas();
|
|
} catch (e: any) { toast.error(e.message.toUpperCase()); }
|
|
};
|
|
|
|
const setP = (k: string, v: any) => setPerfil((prev: any) => ({ ...prev, [k]: v }));
|
|
|
|
const TABS: Array<{ id: Tab; label: string; icon: any; show: boolean }> = [
|
|
{ id: 'buscar', label: 'BUSCAR PROFISSIONAIS', icon: Search, show: true },
|
|
{ id: 'perfil', label: 'MEU PERFIL', icon: UserCircle2, show: isProfissional },
|
|
{ id: 'propostas', label: 'PROPOSTAS', icon: CircleDollarSign, show: true },
|
|
{ id: 'modelos', label: 'MODELOS', icon: FileText, show: !!ws?.id },
|
|
];
|
|
|
|
const pendentesRecebidas = propostas.recebidas.filter(p => p.status === 'pendente').length;
|
|
|
|
return (
|
|
<div className="space-y-8 animate-in fade-in duration-500">
|
|
<PageHeader
|
|
title="MARKETPLACE DE PROFISSIONAIS"
|
|
description="Encontre e contrate dentistas, biomédicos e protéticos — ou divulgue seu perfil e receba propostas."
|
|
/>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-2 flex-wrap">
|
|
{TABS.filter(t => t.show).map(t => {
|
|
const Icon = t.icon;
|
|
const active = tab === t.id;
|
|
return (
|
|
<button key={t.id} onClick={() => setTab(t.id)}
|
|
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-[11px] font-black uppercase tracking-wider transition-all ${active ? 'text-white shadow-md' : 'bg-white text-gray-500 border border-gray-200 hover:bg-gray-50'}`}
|
|
style={active ? { backgroundColor: COLOR } : {}}>
|
|
<Icon size={14} /> {t.label}
|
|
{t.id === 'propostas' && pendentesRecebidas > 0 && (
|
|
<span className={`text-[9px] font-black px-1.5 py-0.5 rounded-full ${active ? 'bg-white/25' : 'bg-amber-100 text-amber-700'}`}>{pendentesRecebidas}</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* BUSCAR — bloqueia se faltar endereço/CEP da origem */}
|
|
{tab === 'buscar' && enderecoOk === false && (
|
|
<GateEndereco isClinic={!isProfissional} onCompletarPerfil={() => setTab('perfil')} />
|
|
)}
|
|
{tab === 'buscar' && enderecoOk !== false && (
|
|
<>
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
|
|
{/* Busca por nome ou palavra-chave */}
|
|
<div className="relative mb-5">
|
|
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
|
|
<input
|
|
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-200 rounded-2xl text-sm font-medium text-gray-700 outline-none focus:border-teal-400 focus:bg-white transition-colors"
|
|
value={fNome}
|
|
onChange={e => setFNome(e.target.value)}
|
|
onKeyDown={e => { if (e.key === 'Enter') fetchLista(); }}
|
|
placeholder="Buscar por nome ou palavra-chave..."
|
|
/>
|
|
</div>
|
|
|
|
{/* Filtros (recolhíveis) */}
|
|
{showFiltros && (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div>
|
|
<label className={labelNew}>Área</label>
|
|
<select className={inputNew} value={fArea} onChange={e => setFArea(e.target.value)}>
|
|
<option value="">Todas</option>
|
|
{areas.map(a => <option key={a.slug} value={a.slug}>{a.nome}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className={labelNew}>Tipo</label>
|
|
<select className={inputNew} value={fTipo} onChange={e => setFTipo(e.target.value)}>
|
|
<option value="">Todos</option>
|
|
<option value="dentista">Dentistas</option>
|
|
<option value="biomedico">Biomédicos(as)</option>
|
|
<option value="protetico">Protéticos</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className={labelNew}>Especialidade</label>
|
|
<select className={`${inputNew} disabled:opacity-60 disabled:cursor-not-allowed`} value={fEspecialidade}
|
|
onChange={e => setFEspecialidade(e.target.value)} disabled={!espArea}>
|
|
<option value="">{espArea ? 'Todas' : 'Selecione Área ou Tipo'}</option>
|
|
{espOpts.map(s => <option key={s} value={s}>{s}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 md:items-end">
|
|
<div>
|
|
<label className={labelNew}>Cidade</label>
|
|
<input className={`${inputNew} uppercase`} value={fCidade} onChange={e => setFCidade(e.target.value.toUpperCase())} placeholder="CAMPO GRANDE" />
|
|
</div>
|
|
<div>
|
|
<label className={labelNew}>Estado</label>
|
|
<select className={inputNew} value={fEstado} onChange={e => setFEstado(e.target.value)}>
|
|
{UF_LIST.map(uf => <option key={uf} value={uf}>{uf || 'Todos'}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className={labelNew}>Meu CEP</label>
|
|
<input className={inputNew} value={fCep} onChange={e => setFCep(e.target.value)} placeholder="79000-000" />
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<label className={`${labelNew} mb-0`}>Raio de Busca</label>
|
|
<span className="text-[10px] font-black px-2 py-0.5 rounded-full bg-teal-100 text-teal-700">{fRaio || 0} KM</span>
|
|
</div>
|
|
<input
|
|
type="range" min="0" max="200" step="5"
|
|
value={fRaio || 0}
|
|
onChange={e => setFRaio(e.target.value === '0' ? '' : e.target.value)}
|
|
className="w-full accent-teal-600 cursor-pointer"
|
|
/>
|
|
{!fCep && <p className="text-[10px] text-gray-400 mt-1">Informe o CEP para filtrar por raio.</p>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Barra de ações */}
|
|
<div className="flex items-center justify-between mt-5 pt-4 border-t border-gray-50">
|
|
<button onClick={() => setShowFiltros(s => !s)}
|
|
className="flex items-center gap-1.5 text-xs font-bold text-gray-500 hover:text-gray-700 transition-colors">
|
|
<ChevronUp size={14} className={`transition-transform ${showFiltros ? '' : 'rotate-180'}`} />
|
|
{showFiltros ? 'Ocultar Filtros' : 'Mostrar Filtros'}
|
|
</button>
|
|
<div className="flex items-center gap-4">
|
|
<button onClick={limparFiltros}
|
|
className="flex items-center gap-1.5 text-xs font-bold text-gray-500 hover:text-gray-700 transition-colors">
|
|
<Eraser size={13} /> Limpar Filtros
|
|
</button>
|
|
<button onClick={fetchLista} disabled={loading}
|
|
className="flex items-center gap-2 px-6 py-3 text-white rounded-xl text-xs font-black uppercase tracking-widest transition-colors disabled:opacity-60 hover:opacity-90" style={{ backgroundColor: COLOR }}>
|
|
{loading ? <RefreshCw size={14} className="animate-spin" /> : <Search size={14} />} Buscar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex justify-center py-12"><RefreshCw size={24} className="animate-spin" style={{ color: COLOR }} /></div>
|
|
) : lista.length === 0 ? (
|
|
<div className="text-center py-12 text-gray-400">
|
|
<UserSearch size={40} className="mx-auto mb-3 opacity-40" />
|
|
<p className="font-bold text-sm uppercase">Nenhum profissional encontrado</p>
|
|
<p className="text-xs mt-1">Ajuste os filtros ou aguarde novos cadastros</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{lista.map(p => {
|
|
const meta = ROLE_META[p.role] || ROLE_META.dentista;
|
|
const Icon = meta.icon;
|
|
return (
|
|
<div key={p.id} className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 hover:shadow-md transition-all">
|
|
<div className="flex items-start gap-3 mb-3">
|
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${meta.cls.split(' ')[0]}`}>
|
|
<Icon size={18} className={meta.cls.split(' ')[1]} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-black text-gray-900 text-sm uppercase truncate">{p.nome}</span>
|
|
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${meta.cls}`}>{meta.label}</span>
|
|
{p.nota != null && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 uppercase" title="Nota ScoreOdonto">★ {p.nota}{p.avaliacoes ? ` · ${p.avaliacoes}` : ''}</span>}
|
|
{p.verificado && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-teal-100 text-teal-700 uppercase flex items-center gap-0.5" title="Laboratório verificado"><BadgeCheck size={10} /> Verificado</span>}
|
|
</div>
|
|
{p.especialidade && <p className="text-xs text-gray-500 font-medium mt-0.5 uppercase">{p.especialidade}</p>}
|
|
</div>
|
|
</div>
|
|
{p.bio_profissional && <p className="text-xs text-gray-500 font-medium mb-2 line-clamp-2">{p.bio_profissional}</p>}
|
|
<div className="space-y-1.5 mb-4">
|
|
{(p.cidade || p.estado) && (
|
|
<div className="flex items-center gap-1.5 text-xs text-gray-500 font-medium">
|
|
<MapPin size={12} className="shrink-0 text-gray-400" />
|
|
<span>{[p.bairro, p.cidade, p.estado].filter(Boolean).join(' · ')}{p.raio_atuacao_km ? ` · ATENDE EM ATÉ ${p.raio_atuacao_km} KM` : ''}</span>
|
|
{p.dist_km != null && <span className="ml-auto shrink-0 text-[9px] font-black px-2 py-0.5 rounded-full bg-teal-100 text-teal-700">~{p.dist_km} KM</span>}
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-1.5 text-xs text-gray-500 font-medium">
|
|
<Mail size={12} className="shrink-0 text-gray-400" /><span>{p.email}</span>
|
|
</div>
|
|
{p.celular && (
|
|
<div className="flex items-center gap-1.5 text-xs text-gray-500 font-medium">
|
|
<Phone size={12} className="shrink-0 text-gray-400" /><span>{p.celular}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{p.role === 'protetico' && <VitrineProtetico proteticoId={p.id} />}
|
|
{ws?.id && (
|
|
<button onClick={() => p.role === 'protetico' ? setSolicitarTo(p) : setProposingTo(p)}
|
|
className="w-full py-2.5 rounded-xl text-white text-[10px] font-black uppercase flex items-center justify-center gap-1.5 transition-colors hover:opacity-90" style={{ backgroundColor: COLOR }}>
|
|
<Send size={13} /> {p.role === 'protetico' ? 'SOLICITAR PRÓTESE' : 'ENVIAR PROPOSTA'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* MEU PERFIL */}
|
|
{tab === 'perfil' && (
|
|
loading || !perfil ? (
|
|
<div className="flex justify-center py-12"><RefreshCw size={24} className="animate-spin" style={{ color: COLOR }} /></div>
|
|
) : (
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 max-w-2xl space-y-4">
|
|
<div className="flex items-center gap-3 pb-2 border-b border-gray-50">
|
|
<BadgeCheck size={18} style={{ color: COLOR }} />
|
|
<div>
|
|
<p className="text-sm font-black text-gray-900 uppercase">{perfil.nome}</p>
|
|
<p className="text-[10px] text-gray-400 font-bold uppercase">{(ROLE_META[perfil.role]?.label || perfil.role)} · {perfil.email}</p>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<div>
|
|
<label className={labelCls}>Estado *</label>
|
|
<select className={inputCls} value={perfil.estado || ''} onChange={e => setP('estado', e.target.value)}>
|
|
{UF_LIST.map(uf => <option key={uf} value={uf}>{uf || 'SELECIONE'}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Cidade *</label>
|
|
<input className={`${inputCls} uppercase`} value={perfil.cidade || ''} onChange={e => setP('cidade', e.target.value.toUpperCase())} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Bairro</label>
|
|
<input className={`${inputCls} uppercase`} value={perfil.bairro || ''} onChange={e => setP('bairro', e.target.value.toUpperCase())} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>CEP</label>
|
|
<input className={inputCls} value={perfil.cep || ''} onChange={async e => {
|
|
const v = e.target.value;
|
|
setP('cep', v);
|
|
if (v.replace(/\D/g, '').length === 8) {
|
|
const r = await buscarCep(v);
|
|
if (r) { setPerfil((prev: any) => ({ ...prev, logradouro: r.logradouro, bairro: r.bairro, cidade: r.cidade, estado: r.estado })); toast.success('ENDEREÇO PREENCHIDO PELO CEP!'); }
|
|
else toast.error('CEP NÃO ENCONTRADO.');
|
|
}
|
|
}} placeholder="79000-000" />
|
|
</div>
|
|
<div className="md:col-span-2">
|
|
<label className={labelCls}>Rua / Logradouro</label>
|
|
<input className={`${inputCls} uppercase`} value={perfil.logradouro || ''} onChange={e => setP('logradouro', e.target.value.toUpperCase())} placeholder="EX: AVENIDA AFONSO PENA" />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Número</label>
|
|
<input className={inputCls} value={perfil.numero || ''} onChange={e => setP('numero', e.target.value)} placeholder="EX: 1234" />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Especialidade principal</label>
|
|
<input className={`${inputCls} uppercase`} value={perfil.especialidade_dir || ''} onChange={e => setP('especialidade_dir', e.target.value.toUpperCase())} placeholder="EX: IMPLANTODONTIA" />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Raio de atuação (km)</label>
|
|
<input type="number" min="0" max="2000" className={inputCls} value={perfil.raio_atuacao_km ?? ''} onChange={e => setP('raio_atuacao_km', e.target.value === '' ? null : +e.target.value)} placeholder="EX: 100" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Áreas de atuação</label>
|
|
<div className="flex flex-wrap gap-2 mt-1">
|
|
{areas.map(a => {
|
|
const on = myAreas.includes(a.slug);
|
|
return (
|
|
<button key={a.slug} type="button"
|
|
onClick={() => setMyAreas(prev => on ? prev.filter(s => s !== a.slug) : [...prev, a.slug])}
|
|
className={`text-[11px] font-black uppercase px-3 py-1.5 rounded-full border transition-all ${on ? 'text-white border-transparent' : 'text-gray-500 border-gray-200 bg-white hover:bg-gray-50'}`}
|
|
style={on ? { backgroundColor: a.cor } : undefined}>
|
|
{a.nome}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<p className="text-[10px] text-gray-400 mt-1">Você pode atuar em mais de uma área (ex.: dentista que também faz harmonização).</p>
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Apresentação / currículo resumido</label>
|
|
<textarea className={inputCls} rows={3} value={perfil.bio_profissional || ''} onChange={e => setP('bio_profissional', e.target.value)}
|
|
placeholder="Ex: Ortodontista com 10 anos de experiência, atendo clínicas e consultórios na região de Campo Grande." />
|
|
</div>
|
|
<label className="flex items-center gap-3 p-3 rounded-xl border border-gray-100 bg-gray-50/60 cursor-pointer">
|
|
<input type="checkbox" checked={perfil.disponivel_diretorio === true} onChange={e => setP('disponivel_diretorio', e.target.checked)}
|
|
className="w-4 h-4 accent-teal-600" />
|
|
<span className="text-[11px] font-black text-gray-600 uppercase">Aparecer no marketplace (disponível para propostas)</span>
|
|
</label>
|
|
<button onClick={handleSavePerfil} disabled={savingPerfil}
|
|
className="flex items-center gap-2 px-6 py-3 text-white rounded-xl text-xs font-black uppercase tracking-widest transition-colors disabled:opacity-60 hover:opacity-90" style={{ backgroundColor: COLOR }}>
|
|
{savingPerfil ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />} SALVAR PERFIL
|
|
</button>
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
{/* PROPOSTAS */}
|
|
{tab === 'propostas' && (
|
|
loading ? (
|
|
<div className="flex justify-center py-12"><RefreshCw size={24} className="animate-spin" style={{ color: COLOR }} /></div>
|
|
) : (
|
|
<div className="space-y-8">
|
|
{isProfissional && (
|
|
<div>
|
|
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3 flex items-center gap-1.5"><Inbox size={12} /> RECEBIDAS</p>
|
|
{propostas.recebidas.length === 0
|
|
? <p className="text-xs text-gray-300 font-bold uppercase">Nenhuma proposta recebida</p>
|
|
: <div className="space-y-3">
|
|
{propostas.recebidas.map(p => (
|
|
<div key={p.id} className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex flex-col md:flex-row md:items-center gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-black text-gray-900 text-xs uppercase">{p.clinica_nome || 'UNIDADE'}</span>
|
|
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${STATUS_STYLE[p.status]}`}>{p.status}</span>
|
|
</div>
|
|
{p.mensagem && <p className="text-[11px] text-gray-500 font-medium mt-1">{p.mensagem}</p>}
|
|
<p className="text-[10px] text-gray-400 font-medium uppercase mt-0.5">POR {p.enviada_por_nome || '—'} · {new Date(p.created_at).toLocaleDateString('pt-BR')}</p>
|
|
</div>
|
|
{p.status === 'pendente' && (
|
|
<div className="flex gap-2 shrink-0">
|
|
<button onClick={() => handlePropostaAction(p, 'aceitar')} className="px-3 py-2 rounded-xl bg-emerald-500 text-white text-[10px] font-black uppercase flex items-center gap-1 hover:bg-emerald-600 transition-colors"><CheckCircle2 size={12} /> ACEITAR</button>
|
|
<button onClick={() => handlePropostaAction(p, 'recusar')} className="px-3 py-2 rounded-xl border border-red-200 text-red-500 text-[10px] font-black uppercase flex items-center gap-1 hover:bg-red-50 transition-colors"><XCircle size={12} /> RECUSAR</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>}
|
|
</div>
|
|
)}
|
|
{ws?.id && (
|
|
<div>
|
|
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3 flex items-center gap-1.5"><Send size={12} /> ENVIADAS POR {ws.nome?.toUpperCase() || 'MINHA UNIDADE'}</p>
|
|
{propostas.enviadas.length === 0
|
|
? <p className="text-xs text-gray-300 font-bold uppercase">Nenhuma proposta enviada</p>
|
|
: <div className="space-y-3">
|
|
{propostas.enviadas.map(p => (
|
|
<div key={p.id} className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex flex-col md:flex-row md:items-center gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-black text-gray-900 text-xs uppercase">{p.profissional_nome || p.profissional_id}</span>
|
|
{p.profissional_role && <span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${ROLE_META[p.profissional_role]?.cls || 'bg-gray-100 text-gray-500'}`}>{ROLE_META[p.profissional_role]?.label || p.profissional_role}</span>}
|
|
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${STATUS_STYLE[p.status]}`}>{p.status}</span>
|
|
</div>
|
|
{p.mensagem && <p className="text-[11px] text-gray-500 font-medium mt-1">{p.mensagem}</p>}
|
|
<p className="text-[10px] text-gray-400 font-medium uppercase mt-0.5">{p.profissional_email || ''} · {new Date(p.created_at).toLocaleDateString('pt-BR')}</p>
|
|
</div>
|
|
{p.status === 'pendente' && (
|
|
<button onClick={() => handlePropostaAction(p, 'cancelar')} className="px-3 py-2 rounded-xl border border-gray-200 text-gray-400 text-[10px] font-black uppercase hover:bg-gray-50 transition-colors shrink-0">CANCELAR</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
{/* MODELOS DE PROPOSTA */}
|
|
{tab === 'modelos' && <ModelosManager />}
|
|
|
|
{proposingTo && <PropostaModal prof={proposingTo} onClose={() => setProposingTo(null)} onSent={fetchPropostas} />}
|
|
{solicitarTo && <SolicitarProteseModal prof={solicitarTo} onClose={() => setSolicitarTo(null)} />}
|
|
</div>
|
|
);
|
|
};
|