feat: ViaCEP em todos os forms de endereço + menus por papel + fix preview de plugins

ViaCEP (auto-preenche endereço ao completar 8 dígitos):
- novo util services/viacep.ts (buscarCep), reutilizado
- ligado em Pacientes, Salas, Profissionais(perfil); Configurações migrada p/ o util
- filtro 'CEP (região)' do marketplace mantido como busca (sem auto-fill)

Menus por papel:
- biomédico: sem Ortodontia, RX e 'Ser Tutor Orto' (não são escopo)
- protético: passa a ver Dashboard/Agenda/Tratamentos/Lançar GTO (antes não via nada)
- RX/Radiografias: só papéis odonto (dentista + donos/funcionário)
- bottom nav mobile ajustado (biomédico/protético)

Fix 'Visualizar como':
- getActivePlugins é preview-aware (todos os plugins ativos enquanto houver PREVIEW_ROLE),
  imune ao timing de hydrate/cache; enterPreview/exitPreview simplificados

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-13 20:14:31 +02:00
parent ca7eba4531
commit cb00d9f383
9 changed files with 81 additions and 40 deletions
+4 -2
View File
@@ -199,7 +199,8 @@ const BOTTOM_TABS: Record<string, Array<{ id: ViewKey; icon: React.ElementType;
donoconsultorio: [{ id: 'dashboard', icon: LayoutDashboard, label: 'Início' }, { id: 'pacientes', icon: Users, label: 'Pacientes' }, { id: 'agenda', icon: CalendarIcon, label: 'Agenda' }, { id: 'financeiro', icon: DollarSign, label: 'Financeiro' }, { more: true }],
funcionario: [{ id: 'dashboard', icon: LayoutDashboard, label: 'Início' }, { id: 'pacientes', icon: Users, label: 'Pacientes' }, { id: 'agenda', icon: CalendarIcon, label: 'Agenda' }, { id: 'financeiro', icon: DollarSign, label: 'Financeiro' }, { more: true }],
dentista: [{ id: 'dashboard', icon: LayoutDashboard, label: 'Início' }, { id: 'agenda', icon: CalendarIcon, label: 'Agenda' }, { id: 'ortodontia', icon: Activity, label: 'Orto' }, { more: true }],
biomedico: [{ id: 'dashboard', icon: LayoutDashboard, label: 'Início' }, { id: 'agenda', icon: CalendarIcon, label: 'Agenda' }, { id: 'ortodontia', icon: Activity, label: 'Orto' }, { more: true }],
biomedico: [{ id: 'dashboard', icon: LayoutDashboard, label: 'Início' }, { id: 'agenda', icon: CalendarIcon, label: 'Agenda' }, { id: 'tratamentos', icon: ClipboardList, label: 'Tratam.' }, { more: true }],
protetico: [{ id: 'dashboard', icon: LayoutDashboard, label: 'Início' }, { id: 'agenda', icon: CalendarIcon, label: 'Agenda' }, { id: 'lancar-gto', icon: ClipboardList, label: 'GTO' }, { more: true }],
paciente: [{ id: 'tratamentos', icon: ClipboardList, label: 'Tratamentos' }, { id: 'notificacoes', icon: Bell, label: 'Notif.' }, { id: 'configuracoes', icon: Settings, label: 'Config.' }],
};
@@ -271,7 +272,8 @@ const App: React.FC = () => {
const permissions: Record<string, ViewKey[]> = {
paciente: ['tratamentos', 'notificacoes', 'configuracoes'],
dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'],
biomedico: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
biomedico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'],
};
+9 -3
View File
@@ -26,8 +26,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
const pluginMenuItems: Array<{ id: string; label: string; icon: any; color: string }> = [];
if (activePluginIds.includes('rx-scoreodonto')) {
// Visualização operacional de radiografias: papéis de clínica (não o superadmin).
if (currentRole !== 'superadmin') {
// Radiografias são ODONTOLÓGICAS: só papéis odonto (dentista + donos/funcionário).
// Biomédico e protético NÃO veem RX.
if (['dentista', 'funcionario', 'admin', 'donoclinica', 'donoconsultorio'].includes(currentRole)) {
pluginMenuItems.push({ id: 'rx-radiografias', label: 'RX / RADIOGRAFIAS', icon: ScanLine, color: '#7c3aed' });
}
// DISPOSITIVOS RX (config + PCs): admin/donos da clínica e superadmin (gestão global).
@@ -106,7 +107,12 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
if (['admin', 'donoclinica', 'donoconsultorio'].includes(currentRole)) return true;
if (currentRole === 'paciente') return ['tratamentos', 'notificacoes', 'configuracoes'].includes(item.id);
if (currentRole === 'dentista' || currentRole === 'biomedico') return ['dashboard', 'tratamentos', 'agenda', 'ortodontia', 'notificacoes', 'clinicas', 'configuracoes', 'candidatura-tutor', 'diretorio-profissionais'].includes(item.id);
// Dentista: clínico odonto completo (ortodontia, tutoria orto).
if (currentRole === 'dentista') return ['dashboard', 'tratamentos', 'agenda', 'ortodontia', 'notificacoes', 'clinicas', 'configuracoes', 'candidatura-tutor', 'diretorio-profissionais'].includes(item.id);
// Biomédico(a): SEM itens odontológicos (ortodontia, RX, tutoria orto).
if (currentRole === 'biomedico') return ['dashboard', 'agenda', 'tratamentos', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
// Protético: laboratório — agenda própria + GTO; sem ortodontia/RX/pacientes clínicos.
if (currentRole === 'protetico') return ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
if (currentRole === 'funcionario') return ['dashboard', 'leads', 'pacientes', 'tratamentos', 'lancar-gto', 'agenda', 'financeiro', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'contratos', 'diretorio-profissionais'].includes(item.id);
return false;
});
+2 -13
View File
@@ -259,12 +259,6 @@ export const HybridBackend = {
getPreviewRole: (): string | null => localStorage.getItem('SCOREODONTO_PREVIEW_ROLE'),
enterPreview: (role: string) => {
// Na primeira entrada do preview, guarda os plugins reais do superadmin p/ restaurar no exit.
// (não sobrescreve o backup se já estiver previewando e só trocando de papel)
if (!localStorage.getItem('SCOREODONTO_PREVIEW_ROLE')) {
const prevPlugins = localStorage.getItem('SCOREODONTO_ACTIVE_PLUGINS');
localStorage.setItem('SCOREODONTO_PREVIEW_PREV_PLUGINS', prevPlugins === null ? '__none__' : prevPlugins);
}
const synthetic = {
id: '00000000-0000-0000-0000-000000000000', // UUID inexistente → consultas retornam vazio
nome: `VISUALIZAÇÃO: ${role.toUpperCase()}`,
@@ -275,8 +269,8 @@ export const HybridBackend = {
localStorage.setItem('SCOREODONTO_PREVIEW_ROLE', role);
localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify(synthetic));
localStorage.setItem('SCOREODONTO_WORKSPACES', JSON.stringify([synthetic]));
// Preview ativa os plugins para o superadmin ver/testar as telas (RX, Salas, Profissionais).
localStorage.setItem('SCOREODONTO_ACTIVE_PLUGINS', JSON.stringify(['rx-scoreodonto', 'locacao-salas', 'profissionais-marketplace']));
// Os plugins ativos no preview vêm de getActivePlugins() (preview-aware): todos
// ficam ativos enquanto SCOREODONTO_PREVIEW_ROLE existir. Nada a mutar aqui.
const path = role === 'paciente' ? '/tratamentos' : '/dashboard';
window.location.assign(path);
},
@@ -285,11 +279,6 @@ export const HybridBackend = {
localStorage.removeItem('SCOREODONTO_PREVIEW_ROLE');
localStorage.removeItem('SCOREODONTO_ACTIVE_WORKSPACE');
localStorage.removeItem('SCOREODONTO_WORKSPACES');
// Restaura os plugins que o superadmin tinha antes do preview.
const prev = localStorage.getItem('SCOREODONTO_PREVIEW_PREV_PLUGINS');
if (prev === null || prev === '__none__') localStorage.removeItem('SCOREODONTO_ACTIVE_PLUGINS');
else localStorage.setItem('SCOREODONTO_ACTIVE_PLUGINS', prev);
localStorage.removeItem('SCOREODONTO_PREVIEW_PREV_PLUGINS');
window.location.assign('/superadmin/overview');
},
+27
View File
@@ -0,0 +1,27 @@
// Busca de endereço por CEP via ViaCEP. Retorna os campos em MAIÚSCULAS
// (padrão de endereço do sistema). Retorna null se o CEP for inválido,
// não existir ou a API falhar — o chamador trata o feedback ao usuário.
export interface EnderecoCep {
logradouro: string;
bairro: string;
cidade: string;
estado: string;
}
export async function buscarCep(cep: string): Promise<EnderecoCep | null> {
const digits = (cep || '').replace(/\D/g, '');
if (digits.length !== 8) return null;
try {
const r = await fetch(`https://viacep.com.br/ws/${digits}/json/`);
const d = await r.json();
if (d.erro) return null;
return {
logradouro: (d.logradouro || '').toUpperCase(),
bairro: (d.bairro || '').toUpperCase(),
cidade: (d.localidade || '').toUpperCase(),
estado: (d.uf || '').toUpperCase(),
};
} catch {
return null;
}
}
+1 -17
View File
@@ -5,28 +5,12 @@ import { GoogleConnectButton } from '../components/GoogleConnectButton.tsx';
import { useToast } from '../contexts/ToastContext.tsx';
import { ChangePasswordModal } from '../components/ChangePasswordModal.tsx';
import { APP_VERSION } from '../constants.ts';
import { buscarCep } from '../services/viacep.ts';
const FIELD_CLS = 'w-full border border-gray-200 rounded-xl px-3 py-2.5 text-sm font-medium text-gray-800 outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-100 transition-all';
const LABEL_CLS = 'text-[10px] font-black text-gray-400 uppercase tracking-wider block mb-1';
const TIPO_LABEL: Record<string, string> = { pessoal: 'Pessoal', consultorio: 'Consultório', clinica: 'Clínica' };
// Busca de endereço por CEP (ViaCEP) — retorna tudo em MAIÚSCULAS.
async function buscarCep(cep: string): Promise<{ logradouro: string; bairro: string; cidade: string; estado: string } | null> {
const digits = (cep || '').replace(/\D/g, '');
if (digits.length !== 8) return null;
try {
const r = await fetch(`https://viacep.com.br/ws/${digits}/json/`);
const d = await r.json();
if (d.erro) return null;
return {
logradouro: (d.logradouro || '').toUpperCase(),
bairro: (d.bairro || '').toUpperCase(),
cidade: (d.localidade || '').toUpperCase(),
estado: (d.uf || '').toUpperCase(),
};
} catch { return null; }
}
// --- Card: Meu Perfil (Sistema) ---
const MeuPerfilCard: React.FC<{ clinColor: string; role: string }> = ({ clinColor, role }) => {
const toast = useToast();
+10 -1
View File
@@ -4,6 +4,7 @@ import { HybridBackend } from '../services/backend.ts';
import { Paciente, Dentista, Agendamento, Plano } from '../types.ts';
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { buscarCep } from '../services/viacep.ts';
const RELACOES_COMUNS = ['MÃE', 'PAI', 'FILHO', 'FILHA', 'IRMÃO', 'IRMÃ', 'CÔNJUGE', 'AVÔ', 'AVÓ', 'NETO', 'NETA', 'TIO', 'TIA', 'SOBRINHO', 'SOBRINHA', 'PRIMO', 'PRIMA', 'PADRASTO', 'MADRASTA', 'ENTEADO', 'ENTEADA'];
@@ -637,7 +638,15 @@ const EditarPacienteModal: React.FC<{ patient: Paciente; onClose: () => void; on
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">CEP</label>
<input value={form.cep || ''} onChange={e => setForm(f => ({ ...f, cep: e.target.value }))} className={inp} placeholder="00000-000" maxLength={9} />
<input value={form.cep || ''} onChange={async e => {
const v = e.target.value;
setForm(f => ({ ...f, cep: v }));
if (v.replace(/\D/g, '').length === 8) {
const r = await buscarCep(v);
if (r) { setForm(f => ({ ...f, 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.');
}
}} className={inp} placeholder="00000-000" maxLength={9} />
</div>
<div className="col-span-2">
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Logradouro</label>
+10 -1
View File
@@ -6,6 +6,7 @@ import {
import { PageHeader } from '../../components/PageHeader.tsx';
import { useToast } from '../../contexts/ToastContext.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'];
@@ -385,7 +386,15 @@ export const ProfissionaisPlugin: React.FC = () => {
</div>
<div>
<label className={labelCls}>CEP</label>
<input className={inputCls} value={perfil.cep || ''} onChange={e => setP('cep', e.target.value)} placeholder="79000-000" />
<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, bairro: r.bairro, cidade: r.cidade, estado: r.estado })); toast.success('LOCALIZAÇÃO PREENCHIDA PELO CEP!'); }
else toast.error('CEP NÃO ENCONTRADO.');
}
}} placeholder="79000-000" />
</div>
<div>
<label className={labelCls}>Especialidade principal</label>
+10 -1
View File
@@ -6,6 +6,7 @@ import {
import { PageHeader } from '../../components/PageHeader.tsx';
import { useToast } from '../../contexts/ToastContext.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'];
@@ -171,7 +172,15 @@ const SalaFormModal: React.FC<{ sala: Sala | null; onClose: () => void; onSaved:
</div>
<div>
<label className={labelCls}>CEP</label>
<input className={inputCls} value={f.cep} onChange={e => set('cep', e.target.value)} placeholder="79000-000" />
<input className={inputCls} value={f.cep} onChange={async e => {
const v = e.target.value;
set('cep', v);
if (v.replace(/\D/g, '').length === 8) {
const r = await buscarCep(v);
if (r) { setF(p => ({ ...p, endereco: 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>
<label className={labelCls}>Cidade *</label>
+8 -2
View File
@@ -1,9 +1,15 @@
export const ACTIVE_PLUGINS_KEY = 'SCOREODONTO_ACTIVE_PLUGINS';
export const PLUGIN_CONFIG_KEY = 'SCOREODONTO_PLUGIN_CONFIG';
// "Visualizar como" (superadmin) ativa todos os plugins para teste, sem depender
// do cache em localStorage (que é reconciliado/limpo em outros fluxos).
const PREVIEW_PLUGINS = ['rx-scoreodonto', 'locacao-salas', 'profissionais-marketplace'];
export const getActivePlugins = (): string[] => {
try { return JSON.parse(localStorage.getItem(ACTIVE_PLUGINS_KEY) || '[]'); }
catch { return []; }
try {
if (localStorage.getItem('SCOREODONTO_PREVIEW_ROLE')) return [...PREVIEW_PLUGINS];
return JSON.parse(localStorage.getItem(ACTIVE_PLUGINS_KEY) || '[]');
} catch { return []; }
};
export const isPluginActive = (id: string): boolean => getActivePlugins().includes(id);