Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a88995146 | |||
| f5a6dc7ea0 | |||
| b5d093d2ce | |||
| 489d2753ee |
@@ -0,0 +1,48 @@
|
||||
# Marketplace transacional de Prótese
|
||||
|
||||
> Desenho (24/jun/2026). O último grande item do backlog do Provedor de Prótese.
|
||||
> Princípio: **transformar o diretório (descoberta) em jornada de compra** — descobrir → ver
|
||||
> serviços/preços → solicitar → cotar → contratar → vira OS. Reaproveita ao máximo o que já existe.
|
||||
|
||||
## O que já existe (não recriar)
|
||||
- **Descoberta:** `/api/profissionais/marketplace` (busca por proximidade/especialidade) **+ Nota
|
||||
ScoreOdonto** no card (feito).
|
||||
- **Catálogo:** `protese_produtos` (nome, preço, garantia) + `GET /api/protese/laboratorios/:id/produtos`
|
||||
— já acessível a quem está no **diretório** (`proteticoAcessivelPelaClinica` aceita `disponivel_diretorio`).
|
||||
- **Propostas:** `propostas_profissional` (clínica→profissional: mensagem, `pendente→aceita/recusada`).
|
||||
- **Contratação:** a clínica **já cria OS para qualquer protético do diretório** (Nova OS).
|
||||
- **Pós-venda:** OS completa (etapas, custódia, ocorrências), financeiro, score — tudo pronto.
|
||||
|
||||
**Conclusão:** o "transacional" não exige nova fundação — falta **costurar a jornada** dentro do
|
||||
marketplace (hoje a clínica vê nome/nota/distância, mas não os serviços, e contrata por outra tela).
|
||||
|
||||
## Fatias
|
||||
|
||||
### ✅ Fatia 1 — Vitrine (catálogo no marketplace) — IMPLEMENTADA
|
||||
No card do protético no marketplace, **"Ver catálogo"** expande os produtos (nome, preço, garantia)
|
||||
via `getLaboratorioProdutos(id)` (reuso — zero endpoint novo). A clínica passa a **decidir vendo
|
||||
serviços + preço + garantia + ★nota** antes de contratar. Botão de contato vira **"Solicitar
|
||||
prótese"** (proposta com contexto de prótese).
|
||||
|
||||
### ✅ Fatia 2 — Solicitação → OS direto — IMPLEMENTADA
|
||||
"Solicitar prótese" (card do protético) abre o `SolicitarProteseModal` — fluxo enxuto com o lab
|
||||
**pré-selecionado**: produto do catálogo (ou descrição livre) + paciente (busca) + dentista + dentes
|
||||
+ prazo + observações → **cria a OS** via `createProteseOS` (que já aceita lab do diretório, sem
|
||||
vínculo prévio). Tela de confirmação com CTA para a Bancada. Optei por um modal próprio e auto-contido
|
||||
(reusa getLaboratorioProdutos/getDentistas/getPacientes/createProteseOS) em vez de extrair a Nova OS —
|
||||
menor risco. Validado em DEV: OS criada para lab do diretório (Coroa de Zircônia, status solicitado).
|
||||
|
||||
### Fatia 3 — Cotação (RFQ) para trabalho sob medida
|
||||
Quando não há produto de catálogo: a clínica descreve o trabalho e **N labs cotam** (preço + prazo);
|
||||
a clínica escolhe e vira OS. Estende `propostas_profissional` com itens de prótese e valores, ou nova
|
||||
`protese_rfq` + `protese_rfq_cotacao`.
|
||||
|
||||
### Fatia 4 — Reputação pública e conversão
|
||||
Avaliação da clínica ao final da OS (alimenta a Nota, hoje só operacional), selo "lab verificado",
|
||||
ordenar marketplace por nota dentro do raio.
|
||||
|
||||
## Decisões de modelagem
|
||||
- **Catálogo público = catálogo atual** (`protese_produtos`); a vitrine só o exibe — sem duplicar.
|
||||
- **Solicitação reusa a OS** (não cria entidade paralela): toda contratação termina em `protese_os`,
|
||||
preservando rastreabilidade/financeiro/score.
|
||||
- **Cotação (Fatia 3)** é o único caso que precisa de entidade nova (pré-OS multi-lab).
|
||||
+3
-3
@@ -9,7 +9,7 @@
|
||||
### 1. Onde está o código?
|
||||
- **Fonte da verdade:** Gitea na **VPS3**, repo `ruicesar/scoreodonto.com`.
|
||||
- **Dev (trabalho):** VPS4 em `/home/deploy/stack/scoreodonto.com`.
|
||||
- **Produção:** VPS1 roda **imagens versionadas do registry** (não código-fonte). No ar: **v1.0.25 · commit `8040e70`**.
|
||||
- **Produção:** VPS1 roda **imagens versionadas do registry** (não código-fonte). No ar: **v1.0.27 · commit `b5d093d`**.
|
||||
- **Documentação:** centralizada em `scoreodonto.com/doc/` (pasta única).
|
||||
|
||||
### 2. Onde está o banco?
|
||||
@@ -28,7 +28,7 @@
|
||||
- ✅ **Build Once → Promote (Passo 5, validado em produção):** push builda 1× e publica por **commit** (`:<sha>`); a **tag re-tagueia** esse artefato como `:vX.Y.Z` (sem rebuild — digest idêntico) e deploya. Provado em v1.0.16 (`:v1.0.16` == `:471c2c2`, mesmo digest).
|
||||
|
||||
### 5. Como versiona?
|
||||
- A versão exibida vem do **backend em runtime**: `GET /api/version` (de `process.env`), mostrada em **Configurações → "Sobre o sistema"** e nas telas públicas (o frontend consome via `useAppVersion()`, não embute mais a versão). No ar: **v1.0.25 · `8040e70` · PROD**.
|
||||
- A versão exibida vem do **backend em runtime**: `GET /api/version` (de `process.env`), mostrada em **Configurações → "Sobre o sistema"** e nas telas públicas (o frontend consome via `useAppVersion()`, não embute mais a versão). No ar: **v1.0.27 · `b5d093d` · PROD**.
|
||||
- A versão **nasce da tag git** e é **injetada no deploy em runtime** (`APP_VERSION`=tag, `APP_ENV=PROD`); `commit`/`build` vêm carimbados na imagem.
|
||||
- ✅ **`constants.ts`/`update-version.js` foram removidos** (Passo 5) — não há mais versão manual.
|
||||
|
||||
@@ -39,4 +39,4 @@
|
||||
---
|
||||
|
||||
## Veredito de uma linha
|
||||
> O código vive no Gitea (VPS3); a **produção (VPS1) roda imagens versionadas do registry** (hoje **v1.0.25**), sem buildar; o **DEV está isolado no `pgdev`** (não toca o banco de produção). **CI/CD por tag ativo** no modelo **Build Once → Promote → Deploy** (push builda por commit; tag re-tagueia sem rebuild e deploya — validado em v1.0.16), com **versão visível em runtime** (`/api/version` → Configurações) nascendo da tag git.
|
||||
> O código vive no Gitea (VPS3); a **produção (VPS1) roda imagens versionadas do registry** (hoje **v1.0.27**), sem buildar; o **DEV está isolado no `pgdev`** (não toca o banco de produção). **CI/CD por tag ativo** no modelo **Build Once → Promote → Deploy** (push builda por commit; tag re-tagueia sem rebuild e deploya — validado em v1.0.16), com **versão visível em runtime** (`/api/version` → Configurações) nascendo da tag git.
|
||||
|
||||
@@ -2,8 +2,150 @@ import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
UserSearch, Stethoscope, Wrench, Sparkles, MapPin, Search, Mail, Phone, RefreshCw,
|
||||
Save, BadgeCheck, Inbox, Send, CheckCircle2, XCircle, CircleDollarSign, UserCircle2, X,
|
||||
ChevronUp, Eraser, FileText, Plus, Pencil, Trash2
|
||||
ChevronUp, Eraser, FileText, Plus, Pencil, Trash2, Tags, ChevronDown
|
||||
} from 'lucide-react';
|
||||
|
||||
const fmtBRLp = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
||||
|
||||
// Fatia 2: solicitar prótese direto do marketplace → cria a OS (lab pré-selecionado).
|
||||
const SolicitarProteseModal: React.FC<{ prof: any; onClose: () => void }> = ({ prof, onClose }) => {
|
||||
const ws = HybridBackend.getActiveWorkspace();
|
||||
const [produtos, setProdutos] = useState<any[]>([]);
|
||||
const [dentistas, setDentistas] = useState<any[]>([]);
|
||||
const [busca, setBusca] = useState('');
|
||||
const [pacientes, setPacientes] = useState<any[]>([]);
|
||||
const [paciente, setPaciente] = useState<any>(null);
|
||||
const [form, setForm] = useState<any>({ produto_id: '', tipo: '', dentista_id: '', dentes: '', prazo_entrega: '', observacoes: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [okMsg, setOkMsg] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
HybridBackend.getLaboratorioProdutos(prof.id).then(r => setProdutos(Array.isArray(r) ? r : [])).catch(() => setProdutos([]));
|
||||
HybridBackend.getDentistas(ws?.id).then(r => setDentistas(Array.isArray(r) ? r : [])).catch(() => setDentistas([]));
|
||||
}, [prof.id, ws?.id]);
|
||||
useEffect(() => {
|
||||
if (paciente) return;
|
||||
const t = setTimeout(() => { HybridBackend.getPacientes(busca).then(r => setPacientes(r.data || [])).catch(() => setPacientes([])); }, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [busca, paciente]);
|
||||
const set = (k: string, v: any) => setForm((f: any) => ({ ...f, [k]: v }));
|
||||
|
||||
const salvar = async () => {
|
||||
if (!form.produto_id && !form.tipo.trim()) { alert('Escolha um produto do catálogo ou descreva o trabalho.'); return; }
|
||||
const dent = dentistas.find(d => d.id === form.dentista_id);
|
||||
setSaving(true);
|
||||
try {
|
||||
await HybridBackend.createProteseOS({
|
||||
...form, protetico_id: prof.id, valor: 0,
|
||||
paciente_id: paciente?.id || null, paciente_nome: paciente?.nome || null, dentista_nome: dent?.nome || null,
|
||||
});
|
||||
setOkMsg(true);
|
||||
} catch (e: any) { alert((e.message || 'Erro').toUpperCase()); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-100 sticky top-0 bg-white">
|
||||
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><Wrench size={16} className="text-violet-600" /> Solicitar prótese</h3>
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
{okMsg ? (
|
||||
<div className="p-8 text-center space-y-2">
|
||||
<CheckCircle2 size={40} className="text-green-500 mx-auto" />
|
||||
<p className="font-black text-gray-800 uppercase text-sm">OS enviada ao laboratório</p>
|
||||
<p className="text-xs text-gray-500">{prof.nome} recebeu a solicitação. Acompanhe em Prótese / Bancada.</p>
|
||||
<button onClick={onClose} className="mt-2 px-4 py-2 rounded-xl bg-gray-800 text-white text-xs font-black uppercase">Fechar</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="bg-violet-50 rounded-xl px-3 py-2 text-xs font-black text-violet-700 uppercase">Laboratório: {prof.nome}</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Produto do catálogo</label>
|
||||
{produtos.length > 0 ? (
|
||||
<select value={form.produto_id} onChange={e => { const pr = produtos.find(x => x.id === e.target.value); set('produto_id', e.target.value); set('tipo', pr?.nome || ''); }} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
|
||||
<option value="">Escolha o trabalho…</option>
|
||||
{produtos.map(pr => <option key={pr.id} value={pr.id}>{pr.nome} — {fmtBRLp(pr.preco)}{pr.garantia_meses ? ` · ${pr.garantia_meses}m` : ''}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input value={form.tipo} onChange={e => set('tipo', e.target.value)} placeholder="Descreva o trabalho (sem catálogo)" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Paciente (opcional)</label>
|
||||
{paciente ? (
|
||||
<div className="flex items-center justify-between bg-gray-50 rounded-xl px-3 py-2 mt-1"><span className="text-sm font-bold text-gray-700">{paciente.nome}</span><button onClick={() => { setPaciente(null); setBusca(''); }}><X size={16} className="text-gray-400" /></button></div>
|
||||
) : (
|
||||
<div className="relative mt-1">
|
||||
<Search size={15} className="absolute left-3 top-2.5 text-gray-300" />
|
||||
<input value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar paciente..." className="w-full pl-9 pr-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
||||
{busca && pacientes.length > 0 && (
|
||||
<div className="absolute z-10 left-0 right-0 mt-1 bg-white border border-gray-100 rounded-xl shadow-lg max-h-40 overflow-y-auto">
|
||||
{pacientes.slice(0, 8).map(p => <button key={p.id} onClick={() => setPaciente(p)} className="w-full text-left px-3 py-2 hover:bg-gray-50 text-sm font-medium text-gray-700">{p.nome}</button>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Dentista</label>
|
||||
<select value={form.dentista_id} onChange={e => set('dentista_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
|
||||
<option value="">—</option>
|
||||
{dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Prazo</label>
|
||||
<input type="date" value={form.prazo_entrega} onChange={e => set('prazo_entrega', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Dentes / observações</label>
|
||||
<input value={form.dentes} onChange={e => set('dentes', e.target.value)} placeholder="Ex.: 11, 21" className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm" />
|
||||
<textarea value={form.observacoes} onChange={e => set('observacoes', e.target.value)} rows={2} placeholder="Cor, material, instruções…" className="w-full mt-2 px-3 py-2 rounded-xl border border-gray-200 text-sm resize-none" />
|
||||
</div>
|
||||
<button disabled={saving} onClick={salvar} className="w-full py-3 rounded-xl bg-[#2d6a4f] text-white font-black text-sm uppercase disabled:opacity-50 flex items-center justify-center gap-2"><Send size={15} /> Enviar solicitação</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Fatia 1 do marketplace transacional: vitrine do catálogo do laboratório (reusa getLaboratorioProdutos).
|
||||
const VitrineProtetico: React.FC<{ proteticoId: string }> = ({ proteticoId }) => {
|
||||
const [aberto, setAberto] = useState(false);
|
||||
const [produtos, setProdutos] = useState<any[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const toggle = async () => {
|
||||
if (!aberto && produtos === null) {
|
||||
setLoading(true);
|
||||
try { const p = await HybridBackend.getLaboratorioProdutos(proteticoId); setProdutos(Array.isArray(p) ? p : []); }
|
||||
catch { setProdutos([]); } finally { setLoading(false); }
|
||||
}
|
||||
setAberto(a => !a);
|
||||
};
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<button onClick={toggle} className="w-full py-2 rounded-xl border border-gray-200 text-gray-600 text-[10px] font-black uppercase flex items-center justify-center gap-1.5 hover:bg-gray-50">
|
||||
<Tags size={12} /> {aberto ? 'Ocultar catálogo' : 'Ver catálogo de próteses'} {aberto ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
{aberto && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{loading ? <p className="text-[10px] text-gray-300 uppercase font-bold text-center py-2">Carregando…</p>
|
||||
: produtos && produtos.length ? produtos.map(pr => (
|
||||
<div key={pr.id} className="flex items-center gap-2 text-xs bg-gray-50 rounded-lg px-3 py-1.5">
|
||||
<span className="font-bold text-gray-700 flex-1 truncate">{pr.nome}</span>
|
||||
{pr.garantia_meses ? <span className="text-[9px] text-emerald-600 font-black uppercase">{pr.garantia_meses}m gar.</span> : null}
|
||||
<span className="font-black text-gray-800">{fmtBRLp(pr.preco)}</span>
|
||||
</div>
|
||||
)) : <p className="text-[10px] text-gray-300 uppercase font-bold text-center py-2">Sem catálogo publicado.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { PageHeader } from '../../components/PageHeader.tsx';
|
||||
import { useToast } from '../../contexts/ToastContext.tsx';
|
||||
import { useConfirm } from '../../contexts/ConfirmContext.tsx';
|
||||
@@ -407,6 +549,7 @@ export const ProfissionaisPlugin: React.FC = () => {
|
||||
// propostas
|
||||
const [propostas, setPropostas] = useState<{ enviadas: Proposta[]; recebidas: Proposta[] }>({ enviadas: [], recebidas: [] });
|
||||
const [proposingTo, setProposingTo] = useState<Profissional | null>(null);
|
||||
const [solicitarTo, setSolicitarTo] = useState<Profissional | null>(null);
|
||||
// null = checando; true/false = endereço da origem (clínica ou perfil) preenchido?
|
||||
const [enderecoOk, setEnderecoOk] = useState<boolean | null>(null);
|
||||
|
||||
@@ -723,10 +866,11 @@ export const ProfissionaisPlugin: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{p.role === 'protetico' && <VitrineProtetico proteticoId={p.id} />}
|
||||
{ws?.id && (
|
||||
<button onClick={() => setProposingTo(p)}
|
||||
<button onClick={() => p.role === 'protetico' ? setSolicitarTo(p) : setProposingTo(p)}
|
||||
className="w-full py-2.5 rounded-xl text-white text-[10px] font-black uppercase flex items-center justify-center gap-1.5 transition-colors hover:opacity-90" style={{ backgroundColor: COLOR }}>
|
||||
<Send size={13} /> ENVIAR PROPOSTA
|
||||
<Send size={13} /> {p.role === 'protetico' ? 'SOLICITAR PRÓTESE' : 'ENVIAR PROPOSTA'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -895,6 +1039,7 @@ export const ProfissionaisPlugin: React.FC = () => {
|
||||
{tab === 'modelos' && <ModelosManager />}
|
||||
|
||||
{proposingTo && <PropostaModal prof={proposingTo} onClose={() => setProposingTo(null)} onSent={fetchPropostas} />}
|
||||
{solicitarTo && <SolicitarProteseModal prof={solicitarTo} onClose={() => setSolicitarTo(null)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user