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
+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)}>