feat(areas): Fase 2 — catálogo por área + filtro de área no marketplace

- catálogo (Especialidades/Procedimentos): seletor de área no form + badge colorido na lista
- marketplace de profissionais: filtro por área (via usuario_areas)
- backend: GET/PUT /api/me/areas e /api/clinicas/:id/areas (multi-área de perfil/clínica)
- HybridBackend: getAreas/getMyAreas/setMyAreas
- (Fase 2c pendente: UI multi-área no perfil/configurações da clínica)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-14 07:03:52 +02:00
parent 123234895d
commit cf86be6f6d
5 changed files with 101 additions and 5 deletions
+50 -1
View File
@@ -4264,6 +4264,54 @@ app.get('/api/areas', authGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Áreas do usuário logado (multi-área do perfil).
app.get('/api/me/areas', authGuard, async (req, res) => {
try {
const { rows } = await pool.query('SELECT area FROM usuario_areas WHERE usuario_id = $1', [req.authUser.userId]);
res.json({ areas: rows.map(r => r.area) });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/me/areas', authGuard, async (req, res) => {
try {
const areas = Array.isArray(req.body?.areas) ? req.body.areas.filter(a => typeof a === 'string') : [];
const { rows: valid } = await pool.query('SELECT slug FROM areas');
const slugs = new Set(valid.map(v => v.slug));
const ok = [...new Set(areas)].filter(a => slugs.has(a));
await withTransaction(async (client) => {
await client.query('DELETE FROM usuario_areas WHERE usuario_id = $1', [req.authUser.userId]);
for (const a of ok) await client.query('INSERT INTO usuario_areas (usuario_id, area) VALUES ($1,$2) ON CONFLICT DO NOTHING', [req.authUser.userId, a]);
});
res.json({ success: true, areas: ok });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Áreas de uma clínica (multi-área da unidade).
app.get('/api/clinicas/:clinicaId/areas', authGuard, async (req, res) => {
try {
const { rows } = await pool.query('SELECT area FROM clinica_areas WHERE clinica_id = $1', [req.params.clinicaId]);
res.json({ areas: rows.map(r => r.area) });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/clinicas/:clinicaId/areas', authGuard, async (req, res) => {
try {
const cid = req.params.clinicaId;
const { rows: own } = await pool.query(
`SELECT 1 FROM clinicas WHERE id=$1 AND owner_id=$2
UNION SELECT 1 FROM vinculos WHERE clinica_id=$1 AND usuario_id=$2 AND role = ANY(ARRAY['donoclinica','donoconsultorio','admin']) LIMIT 1`,
[cid, req.authUser.userId]);
if (!own.length) return res.status(403).json({ error: 'Apenas o dono/admin gerencia as áreas da unidade.' });
const areas = Array.isArray(req.body?.areas) ? req.body.areas.filter(a => typeof a === 'string') : [];
const { rows: valid } = await pool.query('SELECT slug FROM areas');
const slugs = new Set(valid.map(v => v.slug));
const ok = [...new Set(areas)].filter(a => slugs.has(a));
await withTransaction(async (client) => {
await client.query('DELETE FROM clinica_areas WHERE clinica_id = $1', [cid]);
for (const a of ok) await client.query('INSERT INTO clinica_areas (clinica_id, area) VALUES ($1,$2) ON CONFLICT DO NOTHING', [cid, a]);
});
res.json({ success: true, areas: ok });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Semeia o catálogo de biomedicina estética na clínica/consultório do usuário (idempotente).
// Usado no 1º acesso do biomédico ao catálogo (cobre contas criadas antes do auto-seed).
app.post('/api/me/seed-biomedicina', authGuard, async (req, res) => {
@@ -4651,7 +4699,7 @@ app.put('/api/profissionais/perfil', authGuard, async (req, res) => {
// Busca de profissionais (inclui biomédicos; supera o diretório antigo)
app.get('/api/profissionais/marketplace', authGuard, async (req, res) => {
const { tipo, estado, cidade, especialidade, cep, raio, clinicaId } = req.query;
const { tipo, estado, cidade, especialidade, cep, raio, clinicaId, area } = req.query;
const raioKm = Number(raio);
// Origem = CEP digitado → clínica ativa → próprio usuário (mostra os mais próximos primeiro).
const origin = await resolveOrigem(req, cep, clinicaId);
@@ -4661,6 +4709,7 @@ app.get('/api/profissionais/marketplace', authGuard, async (req, res) => {
if (estado) { params.push(String(estado).toUpperCase()); where += ` AND estado = $${params.length}`; }
if (cidade) { params.push(`%${String(cidade).toUpperCase()}%`); where += ` AND cidade ILIKE $${params.length}`; }
if (especialidade) { params.push(`%${String(especialidade).toUpperCase()}%`); where += ` AND especialidade_dir ILIKE $${params.length}`; }
if (area) { params.push(String(area)); where += ` AND EXISTS (SELECT 1 FROM usuario_areas ua WHERE ua.usuario_id = usuarios.id AND ua.area = $${params.length})`; }
let distSelect = 'NULL::float AS dist_km';
let order = 'ORDER BY estado, cidade, nome';
+14
View File
@@ -725,6 +725,20 @@ export const HybridBackend = {
return await res.json();
},
// --- ÁREAS DE ATUAÇÃO (odontologia/biomedicina/prótese) ---
getAreas: async (): Promise<{ slug: string; nome: string; cor: string; icone: string }[]> => {
try { const r = await apiFetch(`${API_URL}/areas`); return r.ok ? await r.json() : []; } catch { return []; }
},
getMyAreas: async (): Promise<string[]> => {
try { const r = await apiFetch(`${API_URL}/me/areas`); const d = r.ok ? await r.json() : {}; return Array.isArray(d?.areas) ? d.areas : []; } catch { return []; }
},
setMyAreas: async (areas: string[]): Promise<boolean> => {
try {
const r = await apiFetch(`${API_URL}/me/areas`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ areas }) });
return r.ok;
} catch { return false; }
},
// Semeia o catálogo de biomedicina estética no workspace ativo (idempotente).
seedBiomedicina: async (): Promise<boolean> => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
+2
View File
@@ -227,6 +227,7 @@ export interface Especialidade {
descricao: string;
ordem: number;
ativo?: boolean;
area?: string;
}
export interface ProcedimentoValorPlano {
@@ -254,6 +255,7 @@ export interface Procedimento {
exige_face: boolean;
valoresPlanos?: ProcedimentoValorPlano[];
ativo?: boolean;
area?: string;
}
export interface Lead {
+22 -2
View File
@@ -339,6 +339,10 @@ export const EspecialidadesView: React.FC = () => {
const [editingEsp, setEditingEsp] = useState<Partial<Especialidade> | null>(null);
const toast = useToast();
const seedTried = useRef(false);
const [areas, setAreas] = useState<{ slug: string; nome: string; cor: string }[]>([]);
useEffect(() => { HybridBackend.getAreas().then(setAreas); }, []);
const areaNome = (slug?: string) => areas.find(a => a.slug === slug)?.nome || slug;
const areaCor = (slug?: string) => areas.find(a => a.slug === slug)?.cor || '#6b7280';
// Biomédico com catálogo vazio: semeia biomedicina estética no 1º acesso (idempotente).
useEffect(() => {
@@ -448,7 +452,9 @@ export const EspecialidadesView: React.FC = () => {
<td className="px-6 py-4 text-gray-400 cursor-grab active:cursor-grabbing" {...provided.dragHandleProps}>
<GripVertical size={18} />
</td>
<td className="px-6 py-4 font-bold text-gray-800">{esp.nome}{esp.ativo === false && <span className="ml-2 text-[9px] font-black uppercase bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">Inativa</span>}</td>
<td className="px-6 py-4 font-bold text-gray-800">{esp.nome}
{esp.area && <span className="ml-2 text-[9px] font-black uppercase px-2 py-0.5 rounded-full text-white" style={{ backgroundColor: areaCor(esp.area) }}>{areaNome(esp.area)}</span>}
{esp.ativo === false && <span className="ml-2 text-[9px] font-black uppercase bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">Inativa</span>}</td>
<td className="px-6 py-4 text-gray-600">{esp.descricao}</td>
<td className="px-6 py-4 text-right">
<div className="flex justify-end gap-2">
@@ -479,6 +485,10 @@ export const EspecialidadesView: React.FC = () => {
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
<form onSubmit={handleSave} className="space-y-4 max-w-2xl mx-auto">
<input id="esp-nome" name="nome" placeholder="NOME DA ESPECIALIDADE" defaultValue={editingEsp?.nome} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" required />
<select name="area" defaultValue={editingEsp?.area || ''} className="w-full border border-gray-300 rounded-lg p-2">
<option value=""> Área (padrão pelo seu perfil) </option>
{areas.map(a => <option key={a.slug} value={a.slug}>{a.nome}</option>)}
</select>
<textarea id="esp-desc" name="descricao" placeholder="BREVE DESCRIÇÃO" defaultValue={editingEsp?.descricao} className="w-full border border-gray-300 rounded-lg p-2 h-24 uppercase-textarea"></textarea>
<button type="submit" className="w-full bg-green-600 text-white py-2 rounded-lg font-bold hover:bg-green-700 uppercase">SALVAR</button>
</form>
@@ -500,6 +510,10 @@ export const ProcedimentosView: React.FC = () => {
const [editingProcedimento, setEditingProcedimento] = useState<Partial<Procedimento> | null>(null);
const [planValues, setPlanValues] = useState<Partial<ProcedimentoValorPlano>[]>([]);
const toast = useToast();
const [areas, setAreas] = useState<{ slug: string; nome: string; cor: string }[]>([]);
useEffect(() => { HybridBackend.getAreas().then(setAreas); }, []);
const areaNome = (slug?: string) => areas.find(a => a.slug === slug)?.nome || slug;
const areaCor = (slug?: string) => areas.find(a => a.slug === slug)?.cor || '#6b7280';
useEffect(() => {
if (!isModalOpen) return;
@@ -658,7 +672,9 @@ export const ProcedimentosView: React.FC = () => {
<td className="px-6 py-4 text-gray-400 cursor-grab active:cursor-grabbing" {...provided.dragHandleProps}>
<GripVertical size={18} />
</td>
<td className="px-6 py-4 font-bold text-gray-800">{proc.nome}{proc.ativo === false && <span className="ml-2 text-[9px] font-black uppercase bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">Inativo</span>}</td>
<td className="px-6 py-4 font-bold text-gray-800">{proc.nome}
{proc.area && <span className="ml-2 text-[9px] font-black uppercase px-2 py-0.5 rounded-full text-white" style={{ backgroundColor: areaCor(proc.area) }}>{areaNome(proc.area)}</span>}
{proc.ativo === false && <span className="ml-2 text-[9px] font-black uppercase bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">Inativo</span>}</td>
<td className="px-6 py-4">
<div className="flex flex-col gap-1">
<span className="text-[10px] font-bold text-gray-400 uppercase">TUSS: {proc.codigo || '---'}</span>
@@ -697,6 +713,10 @@ export const ProcedimentosView: React.FC = () => {
<div className="flex-1 overflow-y-auto p-5 lg:p-8">
<form onSubmit={handleSave} className="space-y-4 max-w-2xl mx-auto pb-4">
<input name="nome" placeholder="NOME DO PROCEDIMENTO" defaultValue={editingProcedimento?.nome} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" required />
<select name="area" defaultValue={editingProcedimento?.area || ''} className="w-full border border-gray-300 rounded-lg p-2">
<option value=""> Área (padrão pelo seu perfil) </option>
{areas.map(a => <option key={a.slug} value={a.slug}>{a.nome}</option>)}
</select>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-xs font-bold text-gray-500 uppercase">CATEGORIA</label>
+13 -2
View File
@@ -156,6 +156,8 @@ export const ProfissionaisPlugin: React.FC = () => {
// buscar
const [lista, setLista] = useState<Profissional[]>([]);
const [areas, setAreas] = useState<{ slug: string; nome: string }[]>([]);
const [fArea, setFArea] = useState('');
const [fTipo, setFTipo] = useState('');
const [fEstado, setFEstado] = useState('');
const [fCidade, setFCidade] = useState('');
@@ -188,6 +190,7 @@ export const ProfissionaisPlugin: React.FC = () => {
}, [isProfissional, ws?.id]);
useEffect(() => { checkEndereco(); }, [checkEndereco]);
useEffect(() => { HybridBackend.getAreas().then(setAreas); }, []);
const fetchLista = useCallback(async () => {
setLoading(true);
@@ -197,6 +200,7 @@ export const ProfissionaisPlugin: React.FC = () => {
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);
@@ -206,7 +210,7 @@ export const ProfissionaisPlugin: React.FC = () => {
if (res.ok) setLista(await res.json());
} catch { /* silently fail */ }
finally { setLoading(false); }
}, [fTipo, fEstado, fCidade, fEspecialidade, fCep, fRaio]);
}, [fTipo, fEstado, fCidade, fEspecialidade, fCep, fRaio, fArea]);
const fetchPerfil = useCallback(async () => {
setLoading(true);
@@ -314,7 +318,14 @@ export const ProfissionaisPlugin: React.FC = () => {
{tab === 'buscar' && enderecoOk !== false && (
<>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-4">
<div className="grid grid-cols-2 md:grid-cols-6 gap-3 mb-4">
<div>
<label className={labelCls}>Área</label>
<select className={inputCls} 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={labelCls}>Tipo</label>
<select className={inputCls} value={fTipo} onChange={e => setFTipo(e.target.value)}>