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:
@@ -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 || '';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user