diff --git a/frontend/views/LancarProcedimentoModal.tsx b/frontend/views/LancarProcedimentoModal.tsx new file mode 100644 index 0000000..e1ac48f --- /dev/null +++ b/frontend/views/LancarProcedimentoModal.tsx @@ -0,0 +1,447 @@ +import React, { useState, useMemo } from 'react'; +import { + X, Search, Plus, Minus, Trash2, DollarSign, Check, + ChevronDown, Loader2, Tag, Percent, Banknote +} from 'lucide-react'; +import { HybridBackend } from '../services/backend.ts'; +import { Paciente, Procedimento, Especialidade, Plano } from '../types.ts'; +import { useHybridBackend } from '../hooks/useHybridBackend.ts'; +import { useToast } from '../contexts/ToastContext.tsx'; + +// ─── Tipos internos ──────────────────────────────────────────────────────────── +interface LancItem { + uid: string; + proc: Procedimento; + quantidade: number; + dente: string; + face: string; + arco: string; + observacao: string; + valorUnit: number; +} + +const FACES = ['V', 'L', 'M', 'D', 'O']; +const ARCOS = ['Superior', 'Inferior', 'Ambas']; +const FORMAS_PAG = ['Pix', 'Cartão', 'Dinheiro', 'Boleto'] as const; +const STATUS_LIST = ['Pendente', 'Pago', 'Atrasado'] as const; + +const fmt = (v: number) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); + +function addMonths(date: string, n: number): string { + const d = new Date(date + 'T12:00:00'); + d.setMonth(d.getMonth() + n); + return d.toISOString().slice(0, 10); +} + +// ─── Chip de procedimento selecionado ───────────────────────────────────────── +const ItemCard: React.FC<{ + item: LancItem; + onChange: (patch: Partial) => void; + onRemove: () => void; +}> = ({ item, onChange, onRemove }) => { + const [open, setOpen] = useState(item.proc.tipo_regiao !== 'GERAL'); + const inp = "border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:ring-2 focus:ring-blue-100 outline-none w-full"; + + return ( +
+
+
+

{item.proc.nome}

+
+ {item.proc.especialidadeNome && ( + {item.proc.especialidadeNome} + )} + {fmt(item.valorUnit * item.quantidade)} + {item.quantidade > 1 && {item.quantidade}× {fmt(item.valorUnit)}} +
+
+
+ + {item.quantidade} + +
+ + +
+ + {open && ( +
+ {item.proc.tipo_regiao === 'DENTE' && ( +
+ + onChange({ dente: e.target.value })} className={inp} placeholder="ex: 16, 21-22" /> +
+ )} + {item.proc.tipo_regiao === 'ARCO' && ( +
+ + +
+ )} + {item.proc.exige_face && ( +
+ +
+ {FACES.map(f => ( + + ))} +
+
+ )} +
+ + onChange({ observacao: e.target.value })} className={inp} placeholder="observação opcional" /> +
+
+ )} +
+ ); +}; + +// ─── Modal principal ────────────────────────────────────────────────────────── +export const LancarProcedimentoModal: React.FC<{ + patient: Paciente; + onClose: () => void; + onSaved: () => void; +}> = ({ patient, onClose, onSaved }) => { + const toast = useToast(); + const { data: procsRaw } = useHybridBackend(HybridBackend.getProcedimentos); + const { data: espsRaw } = useHybridBackend(HybridBackend.getEspecialidades); + const { data: planosRaw } = useHybridBackend(HybridBackend.getPlanos); + const procs = procsRaw ?? []; + const esps = espsRaw ?? []; + const planos = planosRaw ?? []; + + const [busca, setBusca] = useState(''); + const [espFiltro, setEspFiltro] = useState(''); + const [items, setItems] = useState([]); + const [saving, setSaving] = useState(false); + + // Pagamento + const [descTipo, setDescTipo] = useState<'R$' | '%'>('R$'); + const [descValor, setDescValor] = useState(''); + const [entrada, setEntrada] = useState(''); + const [parcelas, setParcelas] = useState(1); + const [primVenc, setPrimVenc] = useState(new Date().toISOString().slice(0, 10)); + const [formaPag, setFormaPag] = useState('Pix'); + const [statusPag, setStatusPag] = useState('Pendente'); + + // Plano vigente do paciente + const planoVigente = useMemo(() => { + if (!patient.convenio) return null; + return planos.find(p => p.nome === patient.convenio) ?? null; + }, [patient.convenio, planos]); + + const getValorProc = (proc: Procedimento): number => { + if (planoVigente) { + const vp = proc.valoresPlanos?.find(v => v.planoId === planoVigente.id); + if (vp) return vp.valor; + } + return proc.valorParticular ?? 0; + }; + + const addProc = (proc: Procedimento) => { + if (items.some(i => i.proc.id === proc.id)) { + setItems(prev => prev.map(i => i.proc.id === proc.id ? { ...i, quantidade: i.quantidade + 1 } : i)); + return; + } + setItems(prev => [...prev, { + uid: `${proc.id}_${Date.now()}`, + proc, quantidade: 1, + dente: '', face: '', arco: '', observacao: '', + valorUnit: getValorProc(proc), + }]); + }; + + const updateItem = (uid: string, patch: Partial) => + setItems(prev => prev.map(i => i.uid === uid ? { ...i, ...patch } : i)); + + const removeItem = (uid: string) => setItems(prev => prev.filter(i => i.uid !== uid)); + + const subtotal = items.reduce((s, i) => s + i.valorUnit * i.quantidade, 0); + const descNum = parseFloat(descValor) || 0; + const descReais = descTipo === '%' ? subtotal * descNum / 100 : descNum; + const entradaNum = parseFloat(entrada) || 0; + const restante = Math.max(0, subtotal - descReais - entradaNum); + const valorParcela = parcelas > 0 ? restante / parcelas : 0; + + const procsFiltrados = useMemo(() => { + return procs.filter(p => { + if (espFiltro && p.especialidadeId !== espFiltro) return false; + if (busca && !p.nome.toLowerCase().includes(busca.toLowerCase())) return false; + return true; + }); + }, [procs, espFiltro, busca]); + + const procsPorEsp = useMemo(() => { + const grupos: Record = {}; + procsFiltrados.forEach(p => { + const key = p.especialidadeId || '__geral'; + if (!grupos[key]) { + grupos[key] = { esp: esps.find(e => e.id === p.especialidadeId) ?? null, procs: [] }; + } + grupos[key].procs.push(p); + }); + return Object.values(grupos).sort((a, b) => (a.esp?.ordem ?? 99) - (b.esp?.ordem ?? 99)); + }, [procsFiltrados, esps]); + + const handleSave = async () => { + if (items.length === 0) { toast.error('SELECIONE AO MENOS UM PROCEDIMENTO'); return; } + setSaving(true); + try { + const entriesToCreate: any[] = []; + const baseDesc = items.map(i => { + const region = [i.dente, i.arco, i.face].filter(Boolean).join('/'); + return i.proc.nome + (region ? ` (${region})` : '') + (i.quantidade > 1 ? ` x${i.quantidade}` : ''); + }).join(', '); + + if (entradaNum > 0) { + entriesToCreate.push({ + id: crypto.randomUUID(), + pacienteNome: patient.nome, + descricao: `ENTRADA — ${baseDesc}`, + valor: entradaNum, + dataVencimento: primVenc, + status: 'Pago', + formaPagamento: formaPag, + }); + } + + for (let i = 0; i < parcelas; i++) { + const label = parcelas > 1 ? ` — ${i + 1}/${parcelas}` : ''; + entriesToCreate.push({ + id: crypto.randomUUID(), + pacienteNome: patient.nome, + descricao: baseDesc + label, + valor: parseFloat(valorParcela.toFixed(2)), + dataVencimento: entradaNum > 0 ? addMonths(primVenc, i + 1) : addMonths(primVenc, i), + status: i === 0 && parcelas === 1 ? statusPag : (i === 0 ? statusPag : 'Pendente'), + formaPagamento: formaPag, + }); + } + + for (const entry of entriesToCreate) { + await HybridBackend.saveFinanceiro(entry); + } + + toast.success(`${entriesToCreate.length} LANÇAMENTO(S) GERADO(S) COM SUCESSO!`); + onSaved(); + onClose(); + } catch { toast.error('ERRO AO SALVAR LANÇAMENTOS'); } finally { setSaving(false); } + }; + + const inp = "border border-gray-200 rounded-lg px-2.5 py-2 text-sm focus:ring-2 focus:ring-blue-100 outline-none w-full bg-white"; + const jaAdicionado = (id: string) => items.some(i => i.proc.id === id); + + return ( +
+
+ + {/* Header */} +
+
+
+ +
+
+

Lançar Procedimento

+

+ {patient.nome} + {planoVigente && {planoVigente.nome}} +

+
+
+ +
+ + {/* Body */} +
+ + {/* ── Catálogo (esquerda) ── */} +
+ {/* Busca + filtro especialidade */} +
+
+ + setBusca(e.target.value)} placeholder="Buscar procedimento..." + className="w-full pl-8 pr-3 py-2 border border-gray-200 rounded-lg text-xs focus:ring-2 focus:ring-blue-100 outline-none bg-white" /> +
+
+ + {esps.map(e => ( + + ))} +
+
+ + {/* Lista procedimentos */} +
+ {!procsRaw &&
} + {procsPorEsp.map((grupo, gi) => ( +
+ {grupo.esp && ( +

{grupo.esp.nome}

+ )} +
+ {grupo.procs.map(p => { + const val = getValorProc(p); + const adicionado = jaAdicionado(p.id); + return ( + + ); + })} +
+
+ ))} + {procsRaw && procsFiltrados.length === 0 && ( +

Nenhum procedimento encontrado

+ )} +
+
+ + {/* ── Selecionados + pagamento (direita) ── */} +
+ {/* Items selecionados */} +
+ {items.length === 0 ? ( +
+
+ +
+

Nenhum procedimento selecionado

+

Clique nos procedimentos ao lado para adicionar

+
+ ) : ( + items.map(item => ( + updateItem(item.uid, patch)} + onRemove={() => removeItem(item.uid)} /> + )) + )} +
+ + {/* ── Painel financeiro ── */} +
+ {/* Subtotal */} +
+ Subtotal + {fmt(subtotal)} +
+ + {/* Desconto + entrada */} +
+
+ +
+ + setDescValor(e.target.value)} + placeholder="0" className={inp + " text-sm"} /> +
+ {descReais > 0 &&

− {fmt(descReais)}

} +
+
+ + setEntrada(e.target.value)} + placeholder="0" className={inp} /> + {entradaNum > 0 &&

− {fmt(entradaNum)} (marca como Pago)

} +
+
+ + {/* Restante + parcelas */} +
+
+ Restante a parcelar + {fmt(restante)} +
+
+
+ + +
+
+ + setPrimVenc(e.target.value)} className={inp + " text-xs"} /> +
+
+ + +
+
+ {parcelas > 1 && ( +
+ {parcelas}x de + {fmt(valorParcela)} +
+ )} +
+
+ + +
+
+
+

Total geral

+

{fmt(entradaNum + restante)}

+
+
+
+
+ + {/* Ações */} +
+ + +
+
+
+
+
+
+ ); +}; diff --git a/frontend/views/PatientsView.tsx b/frontend/views/PatientsView.tsx index ad4dd31..75506e3 100644 --- a/frontend/views/PatientsView.tsx +++ b/frontend/views/PatientsView.tsx @@ -751,6 +751,7 @@ import { PageHeader } from '../components/PageHeader.tsx'; import { ReceitaModal } from './ReceitaModal.tsx'; import { PedidoExameModal } from './PedidoExameModal.tsx'; import { useGTOStore } from './LancarGTO.tsx'; +import { LancarProcedimentoModal } from './LancarProcedimentoModal.tsx'; // ----------- TRATAMENTOS MODAL ----------- const TratamentosModal: React.FC<{ patient: Paciente; onClose: () => void }> = ({ patient, onClose }) => { @@ -1207,6 +1208,11 @@ export const PatientsView: React.FC = () => { )} + {/* LANÇAR PROCEDIMENTO MODAL */} + {modal.type === 'lancar' && modal.patient && ( + + )} + {/* MODAL MENU FINANCEIRO */} {modal.type === 'financial' && modal.patient && (
@@ -1218,7 +1224,7 @@ export const PatientsView: React.FC = () => {

{modal.patient.nome}

- +