diff --git a/backend/server.js b/backend/server.js index 8da32b4..6fec4b0 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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'; diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index b2cf929..9b04f2f 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -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 => { + 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 => { + 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 => { const clinicaId = HybridBackend.getActiveWorkspace()?.id || ''; diff --git a/frontend/types.ts b/frontend/types.ts index 3062418..9a1498f 100644 --- a/frontend/types.ts +++ b/frontend/types.ts @@ -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 { diff --git a/frontend/views/AdminViews.tsx b/frontend/views/AdminViews.tsx index 1e1fe09..bd36804 100644 --- a/frontend/views/AdminViews.tsx +++ b/frontend/views/AdminViews.tsx @@ -339,6 +339,10 @@ export const EspecialidadesView: React.FC = () => { const [editingEsp, setEditingEsp] = useState | 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 = () => { - {esp.nome}{esp.ativo === false && Inativa} + {esp.nome} + {esp.area && {areaNome(esp.area)}} + {esp.ativo === false && Inativa} {esp.descricao}
@@ -479,6 +485,10 @@ export const EspecialidadesView: React.FC = () => {
+
@@ -500,6 +510,10 @@ export const ProcedimentosView: React.FC = () => { const [editingProcedimento, setEditingProcedimento] = useState | null>(null); const [planValues, setPlanValues] = useState[]>([]); 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 = () => { - {proc.nome}{proc.ativo === false && Inativo} + {proc.nome} + {proc.area && {areaNome(proc.area)}} + {proc.ativo === false && Inativo}
TUSS: {proc.codigo || '---'} @@ -697,6 +713,10 @@ export const ProcedimentosView: React.FC = () => {
+
diff --git a/frontend/views/plugins/ProfissionaisPlugin.tsx b/frontend/views/plugins/ProfissionaisPlugin.tsx index 3611b14..31e95ef 100644 --- a/frontend/views/plugins/ProfissionaisPlugin.tsx +++ b/frontend/views/plugins/ProfissionaisPlugin.tsx @@ -156,6 +156,8 @@ export const ProfissionaisPlugin: React.FC = () => { // buscar const [lista, setLista] = useState([]); + 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 && ( <>
-
+
+
+ + +