3e64514b08
Financeiro V1 (Épicos 1–4): - FKs de origem no lançamento (paciente/profissional/procedimento/agendamento/contrato/realizado), soft delete e cron "Atrasado" - prontuário de procedimentos realizados (fotos antes/depois) + regra sem-comissão (garantia/reabertura do mesmo profissional) - normalizeFinanceiro: corrige CHECK capitalizado vs enforceUppercase (lançamentos não salvavam pela UI) - baixa de pagamento (pago_em), despesas (fornecedor/centro de custo/recorrente), competência por data de conclusão - relatório financeiro (período, paciente, profissional, forma, convênio, glosa) - orçamento Assinado -> contas a receber; contrato vigente -> cobrança recorrente; renovação automática de contrato Financeiro V2 (Comissão/Repasse): - regras de % (padrão/procedimento/override por profissional) + custo de laboratório - base selecionável (recebido/produção), fechamento persistido, pagar (gera despesa) + comprovante + estorno Glosas de convênio: por item de GTO, recurso/protocolo/prazo, recuperar/estornar, impacto no relatório GTO -> Financeiro: finalizar gera RECEITA (convênio a receber); LancarGTO passa a persistir; convênios reais (planos) Contratos: modelo "Atendimento a Convênios / Repasse (Dentista Credenciado)" + variáveis de convênio Salas (redesenho): perfil "Dono de Sala"; locação sempre ativa; menus MINHAS/ALUGAR SALAS; sala como workspace isolado (seletor agrupado Clínicas x Salas, tenantGuard por dono); financeiro de locação (reserva confirmada -> recebível); Painel da Sala (KPIs, agenda visual, recebíveis, relatório por sala) Docs: BACKLOG-FINANCEIRO-V1, AUDITORIA-FUNCIONAL-SCOREODONTO, CHECKLIST-DEPLOY-PRODUCAO Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
185 lines
12 KiB
TypeScript
185 lines
12 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import {
|
|
X, Plus, Stethoscope, ShieldCheck, RotateCcw, Trash2, RefreshCw, Camera, ImagePlus, Save,
|
|
} from 'lucide-react';
|
|
import { HybridBackend } from '../services/backend.ts';
|
|
import { useToast } from '../contexts/ToastContext.tsx';
|
|
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
|
|
|
const COLOR = '#0d9488';
|
|
const inputCls = 'w-full px-3 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-sm font-medium text-gray-700 outline-none focus:border-teal-400 focus:bg-white transition-colors';
|
|
const labelCls = 'text-[11px] font-bold text-gray-500 mb-1.5 block';
|
|
|
|
const TIPO_META: Record<string, { label: string; cls: string }> = {
|
|
novo: { label: 'Novo', cls: 'bg-teal-100 text-teal-700' },
|
|
reabertura: { label: 'Reabertura', cls: 'bg-amber-100 text-amber-700' },
|
|
garantia: { label: 'Garantia', cls: 'bg-violet-100 text-violet-700' },
|
|
};
|
|
const fmtBRL = (v: number) => Number(v || 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
|
|
|
// ─── Slot de foto (antes/depois) ──────────────────────────────────────────────
|
|
const FotoSlot: React.FC<{ url?: string; label: string; onPick: (f: File) => void; busy?: boolean }> = ({ url, label, onPick, busy }) => {
|
|
const ref = useRef<HTMLInputElement>(null);
|
|
return (
|
|
<div className="flex-1">
|
|
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1">{label}</p>
|
|
<button type="button" disabled={busy} onClick={() => ref.current?.click()}
|
|
className="w-full aspect-square rounded-xl border-2 border-dashed border-gray-200 hover:border-teal-300 bg-gray-50 overflow-hidden flex items-center justify-center transition-colors disabled:opacity-60">
|
|
{busy ? <RefreshCw size={18} className="animate-spin text-teal-500" />
|
|
: url ? <img src={url} alt={label} className="w-full h-full object-cover" />
|
|
: <span className="flex flex-col items-center text-gray-300"><ImagePlus size={22} /><span className="text-[9px] font-bold mt-1">Enviar</span></span>}
|
|
</button>
|
|
<input ref={ref} type="file" accept="image/*" className="hidden" onChange={e => { const f = e.target.files?.[0]; if (f) onPick(f); e.currentTarget.value = ''; }} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── Card de procedimento realizado (busca o detalhe p/ fotos) ────────────────
|
|
const CardProc: React.FC<{ id: string; onRefazer: (r: any) => void; onExcluir: (id: string) => void; reloadKey: number }> = ({ id, onRefazer, onExcluir, reloadKey }) => {
|
|
const toast = useToast();
|
|
const [r, setR] = useState<any | null>(null);
|
|
const [busy, setBusy] = useState<'antes' | 'depois' | null>(null);
|
|
const carregar = useCallback(() => HybridBackend.getProcedimentoRealizado(id).then(setR), [id]);
|
|
useEffect(() => { carregar(); }, [carregar, reloadKey]);
|
|
|
|
const fotoDe = (m: string) => (r?.fotos || []).find((f: any) => f.momento === m)?.url;
|
|
const enviar = async (momento: 'antes' | 'depois', file: File) => {
|
|
setBusy(momento);
|
|
try { await HybridBackend.uploadProcedimentoFoto(id, momento, file); toast.success('FOTO ENVIADA.'); await carregar(); }
|
|
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
|
finally { setBusy(null); }
|
|
};
|
|
|
|
if (!r) return <div className="bg-white rounded-2xl border border-gray-100 p-4 flex justify-center"><RefreshCw size={18} className="animate-spin text-teal-400" /></div>;
|
|
const meta = TIPO_META[r.tipo] || TIPO_META.novo;
|
|
return (
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
|
|
<div className="flex items-start justify-between gap-2 mb-2">
|
|
<div className="min-w-0">
|
|
<p className="font-black text-gray-800 text-sm uppercase truncate">{r.procedimento_nome || 'Procedimento'}</p>
|
|
<p className="text-[10px] text-gray-400 font-bold uppercase">{r.profissional_nome || 'Profissional'} · {new Date(r.created_at).toLocaleDateString('pt-BR')}</p>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-1 shrink-0">
|
|
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${meta.cls}`}>{meta.label}</span>
|
|
{r.sem_comissao
|
|
? <span className="text-[9px] font-black px-2 py-0.5 rounded-full uppercase bg-gray-100 text-gray-500">Sem comissão</span>
|
|
: Number(r.valor) > 0 && <span className="text-[9px] font-black px-2 py-0.5 rounded-full uppercase bg-emerald-100 text-emerald-700">{fmtBRL(r.valor)}</span>}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-3 mt-3">
|
|
<FotoSlot label="Antes" url={fotoDe('antes')} onPick={f => enviar('antes', f)} busy={busy === 'antes'} />
|
|
<FotoSlot label="Depois" url={fotoDe('depois')} onPick={f => enviar('depois', f)} busy={busy === 'depois'} />
|
|
</div>
|
|
<div className="flex gap-2 mt-3">
|
|
{r.tipo === 'novo' && (
|
|
<button onClick={() => onRefazer(r)} className="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border border-violet-200 text-violet-600 hover:bg-violet-50 text-[10px] font-black uppercase transition-colors">
|
|
<RotateCcw size={12} /> Refazer na garantia
|
|
</button>
|
|
)}
|
|
<button onClick={() => onExcluir(r.id)} className="px-3 py-2 rounded-lg border border-red-200 text-red-500 hover:bg-red-50 text-[10px] font-black uppercase transition-colors"><Trash2 size={12} /></button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── Modal principal ──────────────────────────────────────────────────────────
|
|
export const ProcedimentosProntuario: React.FC<{ patient: { id: string; nome: string }; onClose: () => void }> = ({ patient, onClose }) => {
|
|
const toast = useToast();
|
|
const confirm = useConfirm();
|
|
const [lista, setLista] = useState<any[]>([]);
|
|
const [dentistas, setDentistas] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [reloadKey, setReloadKey] = useState(0);
|
|
const [form, setForm] = useState({ procedimento_nome: '', profissional_id: '', valor: '', data_conclusao: new Date().toISOString().split('T')[0] });
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const carregar = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [ls, ds] = await Promise.all([HybridBackend.getProcedimentosRealizados(patient.id), HybridBackend.getDentistas()]);
|
|
setLista(ls); setDentistas(Array.isArray(ds) ? ds : []);
|
|
} catch { /* silent */ }
|
|
finally { setLoading(false); }
|
|
}, [patient.id]);
|
|
useEffect(() => { carregar(); }, [carregar]);
|
|
|
|
const refresh = () => { carregar(); setReloadKey(k => k + 1); };
|
|
|
|
const registrar = async () => {
|
|
if (!form.procedimento_nome.trim()) { toast.error('INFORME O PROCEDIMENTO.'); return; }
|
|
setSaving(true);
|
|
try {
|
|
const dent = dentistas.find(d => d.id === form.profissional_id);
|
|
await HybridBackend.saveProcedimentoRealizado({
|
|
paciente_id: patient.id, paciente_nome: patient.nome,
|
|
profissional_id: form.profissional_id || null, profissional_nome: dent?.nome || null,
|
|
procedimento_nome: form.procedimento_nome, valor: form.valor === '' ? 0 : Number(form.valor), tipo: 'novo',
|
|
data_conclusao: form.data_conclusao || null,
|
|
});
|
|
toast.success('PROCEDIMENTO REGISTRADO.');
|
|
setForm(f => ({ procedimento_nome: '', profissional_id: '', valor: '', data_conclusao: f.data_conclusao }));
|
|
refresh();
|
|
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const refazerGarantia = async (r: any) => {
|
|
if (!await confirm(`REFAZER "${r.procedimento_nome}" na garantia? (sem comissão — registre as fotos antes/depois)`)) return;
|
|
try {
|
|
await HybridBackend.saveProcedimentoRealizado({
|
|
paciente_id: patient.id, paciente_nome: patient.nome,
|
|
profissional_id: r.profissional_id, profissional_nome: r.profissional_nome,
|
|
procedimento_nome: `${r.procedimento_nome} (garantia)`, tipo: 'garantia', origem_id: r.id, valor: 0,
|
|
});
|
|
toast.success('GARANTIA REGISTRADA — ANEXE AS FOTOS ANTES/DEPOIS.');
|
|
refresh();
|
|
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
|
};
|
|
|
|
const excluir = async (id: string) => {
|
|
if (!await confirm('EXCLUIR este procedimento?')) return;
|
|
try { await HybridBackend.deleteProcedimentoRealizado(id); toast.success('EXCLUÍDO.'); refresh(); }
|
|
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
|
<div className="bg-white rounded-2xl w-full max-w-3xl shadow-2xl overflow-hidden max-h-[92vh] flex flex-col">
|
|
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-sm font-black text-gray-900 uppercase flex items-center gap-2"><Stethoscope size={16} className="text-teal-600" /> Procedimentos · Prontuário</h3>
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-teal-600">{patient.nome}</p>
|
|
</div>
|
|
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl transition-colors"><X size={18} /></button>
|
|
</div>
|
|
|
|
{/* Registrar */}
|
|
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50">
|
|
<div className="grid grid-cols-1 sm:grid-cols-12 gap-3 items-end">
|
|
<div className="sm:col-span-4"><label className={labelCls}>Procedimento</label><input className={inputCls} value={form.procedimento_nome} onChange={e => setForm(f => ({ ...f, procedimento_nome: e.target.value }))} placeholder="Ex.: Restauração 26" /></div>
|
|
<div className="sm:col-span-3"><label className={labelCls}>Profissional</label>
|
|
<select className={inputCls} value={form.profissional_id} onChange={e => setForm(f => ({ ...f, profissional_id: e.target.value }))}>
|
|
<option value="">Selecione…</option>
|
|
{dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="sm:col-span-2"><label className={labelCls}>Valor</label><input type="number" min="0" className={inputCls} value={form.valor} onChange={e => setForm(f => ({ ...f, valor: e.target.value }))} placeholder="0,00" /></div>
|
|
<div className="sm:col-span-2"><label className={labelCls}>Concluído em</label><input type="date" className={inputCls} value={form.data_conclusao} onChange={e => setForm(f => ({ ...f, data_conclusao: e.target.value }))} /></div>
|
|
<div className="sm:col-span-1"><button onClick={registrar} disabled={saving} title="Registrar" className="w-full h-[42px] rounded-xl text-white flex items-center justify-center disabled:opacity-60" style={{ backgroundColor: COLOR }}>{saving ? <RefreshCw size={16} className="animate-spin" /> : <Plus size={18} />}</button></div>
|
|
</div>
|
|
<p className="text-[10px] text-gray-400 mt-2 flex items-center gap-1"><ShieldCheck size={11} className="text-violet-500" /> Reabertura/garantia pelo mesmo profissional não gera comissão.</p>
|
|
</div>
|
|
|
|
{/* Lista */}
|
|
<div className="overflow-y-auto p-6">
|
|
{loading ? <div className="flex justify-center py-12"><RefreshCw size={22} className="animate-spin" style={{ color: COLOR }} /></div>
|
|
: lista.length === 0 ? <div className="text-center py-12 text-gray-400"><Camera size={36} className="mx-auto mb-3 opacity-40" /><p className="text-sm font-bold uppercase">Nenhum procedimento registrado</p></div>
|
|
: <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{lista.map(r => <CardProc key={r.id} id={r.id} reloadKey={reloadKey} onRefazer={refazerGarantia} onExcluir={excluir} />)}
|
|
</div>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|