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>
983 lines
49 KiB
TypeScript
983 lines
49 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
||
import { X, Search, Plus, Trash2, GripVertical, Loader2, ChevronLeft, ChevronRight, Save, Printer, FileText, Users, Minus } from 'lucide-react';
|
||
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
|
||
import { HybridBackend } from '../services/backend.ts';
|
||
import { Orcamento, OrcamentoItem, Dentista, Especialidade, Paciente, Procedimento } from '../types.ts';
|
||
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
||
import { useToast } from '../contexts/ToastContext.tsx';
|
||
|
||
// ----------- Local AutocompleteField -----------
|
||
function useDebounce(value: string, delay: number) {
|
||
const [dv, setDv] = useState(value);
|
||
useEffect(() => {
|
||
const t = setTimeout(() => setDv(value), delay);
|
||
return () => clearTimeout(t);
|
||
}, [value, delay]);
|
||
return dv;
|
||
}
|
||
|
||
function AutocompleteField<T extends { id: string; nome: string }>({
|
||
label, placeholder, value, onSelect, onClear, fetchFn
|
||
}: {
|
||
label: string; placeholder: string;
|
||
value?: { id: string; nome: string } | null;
|
||
onSelect: (item: T) => void; onClear: () => void;
|
||
fetchFn: (q: string) => Promise<T[]>;
|
||
}) {
|
||
const [query, setQuery] = useState('');
|
||
const [results, setResults] = useState<T[]>([]);
|
||
const [open, setOpen] = useState(false);
|
||
const [loading, setLoading] = useState(false);
|
||
const debounced = useDebounce(query, 300);
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (!debounced.trim() || value) { setResults([]); setOpen(false); return; }
|
||
setLoading(true);
|
||
fetchFn(debounced).then(r => {
|
||
const arr = Array.isArray(r) ? r : [];
|
||
setResults(arr); setOpen(arr.length > 0);
|
||
}).finally(() => setLoading(false));
|
||
}, [debounced]);
|
||
|
||
useEffect(() => {
|
||
const close = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||
document.addEventListener('mousedown', close);
|
||
return () => document.removeEventListener('mousedown', close);
|
||
}, []);
|
||
|
||
if (value) return (
|
||
<div>
|
||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">{label}</label>
|
||
<div className="flex items-center justify-between bg-teal-50 border border-teal-100 rounded-lg px-3 py-2">
|
||
<span className="text-xs font-bold text-teal-700 uppercase truncate">{value.nome}</span>
|
||
<button type="button" onClick={onClear} className="ml-2 text-teal-300 hover:text-teal-600 flex-shrink-0"><X size={13} /></button>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div ref={ref} className="relative">
|
||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">{label}</label>
|
||
<div className="relative">
|
||
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
|
||
<input
|
||
value={query} onChange={e => setQuery(e.target.value)}
|
||
placeholder={placeholder}
|
||
className="w-full pl-7 pr-3 py-2 border border-gray-200 rounded-lg text-xs focus:ring-2 focus:ring-teal-100 focus:border-teal-300 outline-none"
|
||
/>
|
||
{loading && <Loader2 size={12} className="absolute right-2.5 top-1/2 -translate-y-1/2 animate-spin text-gray-400" />}
|
||
</div>
|
||
{open && results.length > 0 && (
|
||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden">
|
||
{results.map(item => (
|
||
<button key={item.id} type="button" onClick={() => { onSelect(item); setQuery(''); setOpen(false); }}
|
||
className="w-full text-left px-3 py-2 hover:bg-teal-50 text-xs font-medium text-gray-700 flex items-center gap-2 border-b border-gray-50 last:border-0">
|
||
<Users size={12} className="text-gray-400 flex-shrink-0" />
|
||
{item.nome}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ----------- Default Clauses -----------
|
||
export const DEFAULT_CLAUSULAS = `CLÁUSULA 1ª – DO OBJETO
|
||
O presente contrato tem por objeto a prestação de serviços odontológicos conforme os procedimentos descritos neste instrumento, obrigando-se o profissional a realizá-los com toda a técnica e diligência necessárias.
|
||
|
||
CLÁUSULA 2ª – DAS OBRIGAÇÕES DO CONTRATANTE
|
||
O(A) paciente obriga-se a: comparecer às consultas agendadas; comunicar cancelamentos com antecedência mínima de 24 horas; seguir as orientações clínicas do profissional; e efetuar os pagamentos nos prazos acordados.
|
||
|
||
CLÁUSULA 3ª – DO PAGAMENTO
|
||
O valor total do tratamento, a forma de pagamento e o parcelamento estão descritos neste contrato. Em caso de atraso no pagamento, incidirão multa de 2% (dois por cento) e juros de 1% (um por cento) ao mês sobre o valor devido.
|
||
|
||
CLÁUSULA 4ª – DA VIGÊNCIA
|
||
O presente contrato vigorará pelo período necessário à conclusão do tratamento acordado, podendo ser prorrogado mediante aditivo escrito firmado por ambas as partes.
|
||
|
||
CLÁUSULA 5ª – DO CANCELAMENTO E DESISTÊNCIA
|
||
Em caso de desistência do tratamento pelo contratante, serão devidos os valores correspondentes aos procedimentos já realizados. Os valores pagos a maior serão restituídos no prazo de 30 dias.
|
||
|
||
CLÁUSULA 6ª – DA PROTEÇÃO DE DADOS (LGPD)
|
||
Os dados pessoais e de saúde do contratante serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD), utilizados exclusivamente para fins relacionados ao tratamento odontológico.
|
||
|
||
CLÁUSULA 7ª – DO FORO
|
||
Fica eleito o foro da comarca do domicílio do contratado para dirimir quaisquer controvérsias oriundas do presente contrato, com renúncia expressa de qualquer outro.`;
|
||
|
||
// ----------- Types -----------
|
||
interface LancItem {
|
||
id: string;
|
||
procedimentoNome: string;
|
||
quantidade: number;
|
||
valorUnitario: number;
|
||
valorTotal: number;
|
||
descricao?: string;
|
||
ordem: number;
|
||
}
|
||
|
||
interface FormState {
|
||
titulo: string;
|
||
status: Orcamento['status'];
|
||
pacienteId: string;
|
||
pacienteNome: string;
|
||
dentistaId: string;
|
||
dentistaNome: string;
|
||
especialidadeId: string;
|
||
especialidadeNome: string;
|
||
dataInicio: string;
|
||
dataFim: string;
|
||
observacoes: string;
|
||
entrada: number;
|
||
parcelas: number;
|
||
formaPagamento: string;
|
||
clausulas: string;
|
||
}
|
||
|
||
const STEPS = ['DADOS', 'ITENS', 'CLÁUSULAS'];
|
||
|
||
const fmt = (v: number) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||
|
||
// ----------- Print View -----------
|
||
const PrintView: React.FC<{
|
||
form: FormState;
|
||
items: LancItem[];
|
||
valorTotal: number;
|
||
orcamentoId?: string;
|
||
onBack: () => void;
|
||
}> = ({ form, items, valorTotal, orcamentoId, onBack }) => {
|
||
const hoje = new Date().toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' });
|
||
const restante = valorTotal - form.entrada;
|
||
const valorParcela = form.parcelas > 0 && restante > 0 ? restante / form.parcelas : 0;
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
{/* Print toolbar - hidden when printing */}
|
||
<div className="p-4 border-b border-gray-100 flex items-center gap-3 print:hidden flex-shrink-0">
|
||
<button type="button" onClick={onBack} className="flex items-center gap-1.5 px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg text-xs font-bold">
|
||
<ChevronLeft size={14} /> Voltar
|
||
</button>
|
||
<button type="button" onClick={() => window.print()} className="flex items-center gap-1.5 px-4 py-2 bg-gray-900 hover:bg-black text-white rounded-lg text-xs font-bold">
|
||
<Printer size={14} /> Imprimir / Salvar PDF
|
||
</button>
|
||
</div>
|
||
|
||
{/* Printable content */}
|
||
<div className="flex-1 overflow-y-auto bg-gray-50 p-6 print:p-0 print:bg-white">
|
||
<div className="max-w-3xl mx-auto bg-white rounded-2xl shadow-sm border border-gray-200 p-10 print:shadow-none print:rounded-none print:border-0 print:max-w-full">
|
||
|
||
{/* Header */}
|
||
<div className="text-center border-b-2 border-gray-800 pb-6 mb-6">
|
||
<h1 className="text-xl font-black text-gray-900 uppercase tracking-wide">
|
||
CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS
|
||
</h1>
|
||
<p className="text-sm text-gray-500 mt-1 font-bold uppercase">Nome da Clínica</p>
|
||
{orcamentoId && <p className="text-[10px] text-gray-400 mt-0.5">Orcamento Nº {orcamentoId}</p>}
|
||
</div>
|
||
|
||
{/* Parties */}
|
||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||
<div className="border border-gray-200 rounded-xl p-4">
|
||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2">CONTRATANTE (PACIENTE)</p>
|
||
<p className="font-bold text-gray-800 uppercase">{form.pacienteNome || '—'}</p>
|
||
<div className="mt-2 space-y-1">
|
||
<p className="text-xs text-gray-500">CPF: ___________________________________</p>
|
||
<p className="text-xs text-gray-500">RG: ____________________________________</p>
|
||
<p className="text-xs text-gray-500">Endereço: ______________________________</p>
|
||
</div>
|
||
</div>
|
||
<div className="border border-gray-200 rounded-xl p-4">
|
||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2">CONTRATADO (DENTISTA)</p>
|
||
<p className="font-bold text-gray-800 uppercase">{form.dentistaNome || '—'}</p>
|
||
<div className="mt-2 space-y-1">
|
||
<p className="text-xs text-gray-500">CRO: ___________________________________</p>
|
||
<p className="text-xs text-gray-500">Especialidade: {form.especialidadeNome || '—'}</p>
|
||
<p className="text-xs text-gray-500">Data de início: {form.dataInicio ? new Date(form.dataInicio + 'T12:00').toLocaleDateString('pt-BR') : '—'}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Object */}
|
||
<div className="mb-6">
|
||
<h2 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3 border-b border-gray-100 pb-1">OBJETO DO CONTRATO — {form.titulo}</h2>
|
||
{items.length > 0 ? (
|
||
<table className="w-full text-sm border-collapse">
|
||
<thead>
|
||
<tr className="bg-gray-50">
|
||
<th className="text-left px-3 py-2 text-xs font-black text-gray-500 uppercase border border-gray-200">Procedimento</th>
|
||
<th className="text-center px-3 py-2 text-xs font-black text-gray-500 uppercase border border-gray-200 w-16">Qtd</th>
|
||
<th className="text-right px-3 py-2 text-xs font-black text-gray-500 uppercase border border-gray-200 w-28">Unit.</th>
|
||
<th className="text-right px-3 py-2 text-xs font-black text-gray-500 uppercase border border-gray-200 w-28">Total</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{items.map((item, idx) => (
|
||
<tr key={item.id} className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50/50'}>
|
||
<td className="px-3 py-2 text-xs text-gray-700 border border-gray-200 font-medium uppercase">{item.procedimentoNome}</td>
|
||
<td className="px-3 py-2 text-xs text-gray-700 border border-gray-200 text-center">{item.quantidade}</td>
|
||
<td className="px-3 py-2 text-xs text-gray-700 border border-gray-200 text-right">{fmt(item.valorUnitario)}</td>
|
||
<td className="px-3 py-2 text-xs text-gray-700 border border-gray-200 text-right font-bold">{fmt(item.valorTotal)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr className="bg-gray-100">
|
||
<td colSpan={3} className="px-3 py-2 text-xs font-black text-gray-700 uppercase border border-gray-200 text-right">VALOR TOTAL</td>
|
||
<td className="px-3 py-2 text-sm font-black text-gray-900 border border-gray-200 text-right">{fmt(valorTotal)}</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
) : (
|
||
<p className="text-xs text-gray-500 italic">Nenhum procedimento listado.</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Financial */}
|
||
<div className="mb-6 border border-gray-200 rounded-xl p-4">
|
||
<h2 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3">RESUMO FINANCEIRO</h2>
|
||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||
<div><span className="text-gray-400 text-xs font-bold uppercase block">Valor Total</span><p className="font-bold text-gray-800">{fmt(valorTotal)}</p></div>
|
||
<div><span className="text-gray-400 text-xs font-bold uppercase block">Forma de Pagamento</span><p className="font-bold text-gray-800">{form.formaPagamento || '—'}</p></div>
|
||
<div><span className="text-gray-400 text-xs font-bold uppercase block">Entrada</span><p className="font-bold text-gray-800">{fmt(form.entrada)}</p></div>
|
||
<div><span className="text-gray-400 text-xs font-bold uppercase block">Restante</span><p className="font-bold text-gray-800">{fmt(restante)}</p></div>
|
||
<div><span className="text-gray-400 text-xs font-bold uppercase block">Parcelas</span><p className="font-bold text-gray-800">{form.parcelas}x</p></div>
|
||
<div><span className="text-gray-400 text-xs font-bold uppercase block">Valor por Parcela</span><p className="font-bold text-gray-800">{fmt(valorParcela)}</p></div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Clauses */}
|
||
{form.clausulas && (
|
||
<div className="mb-6">
|
||
<h2 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3 border-b border-gray-100 pb-1">CLÁUSULAS CONTRATUAIS</h2>
|
||
<div className="text-xs text-gray-700 whitespace-pre-wrap leading-relaxed">{form.clausulas}</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Signature section */}
|
||
<div className="mt-10 pt-6 border-t-2 border-gray-800">
|
||
<p className="text-xs text-gray-500 text-center mb-8">
|
||
Estando de pleno acordo com as cláusulas e condições acima, as partes assinam o presente contrato.
|
||
</p>
|
||
<p className="text-xs text-gray-500 text-center mb-10">___________________, {hoje}.</p>
|
||
<div className="grid grid-cols-3 gap-8">
|
||
{[
|
||
{ title: 'CONTRATANTE (PACIENTE)', name: form.pacienteNome },
|
||
{ title: 'CONTRATADO (DENTISTA)', name: form.dentistaNome },
|
||
{ title: 'TESTEMUNHA', name: '' },
|
||
].map((sig, idx) => (
|
||
<div key={idx} className="text-center">
|
||
<div className="h-16 border-b border-gray-400 mb-2" />
|
||
<p className="text-[10px] font-black text-gray-500 uppercase">{sig.title}</p>
|
||
{sig.name && <p className="text-[10px] text-gray-400 uppercase mt-0.5">{sig.name}</p>}
|
||
<p className="text-[10px] text-gray-400 mt-1">CPF: __________________________</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
{orcamentoId && (
|
||
<div className="mt-6 pt-3 border-t border-gray-100 flex justify-between items-center">
|
||
<p className="text-[9px] text-gray-300">ID: {orcamentoId}</p>
|
||
<p className="text-[9px] text-gray-300">Gerado em {hoje}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ----------- Main Modal -----------
|
||
interface OrcamentoModalProps {
|
||
contrato?: Orcamento | null;
|
||
paciente?: Paciente | null;
|
||
initialView?: 'editor' | 'print';
|
||
onClose: () => void;
|
||
onSaved: () => void;
|
||
}
|
||
|
||
export const OrcamentoModal: React.FC<OrcamentoModalProps> = ({ contrato, paciente, initialView = 'editor', onClose, onSaved }) => {
|
||
const toast = useToast();
|
||
const { data: dentistas } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
|
||
const { data: especialidades } = useHybridBackend<Especialidade[]>(HybridBackend.getEspecialidades);
|
||
const { data: procedimentos } = useHybridBackend<Procedimento[]>(HybridBackend.getProcedimentos);
|
||
|
||
const [page, setPage] = useState(0);
|
||
const [view, setView] = useState<'editor' | 'print'>(initialView);
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
// Form state
|
||
const [form, setForm] = useState<FormState>({
|
||
titulo: contrato?.titulo || 'CONTRATO DE SERVIÇOS ODONTOLÓGICOS',
|
||
status: contrato?.status || 'Rascunho',
|
||
pacienteId: contrato?.pacienteId || paciente?.id || '',
|
||
pacienteNome: contrato?.pacienteNome || paciente?.nome || '',
|
||
dentistaId: contrato?.dentistaId || '',
|
||
dentistaNome: contrato?.dentistaNome || '',
|
||
especialidadeId: contrato?.especialidadeId || '',
|
||
especialidadeNome: contrato?.especialidadeNome || '',
|
||
dataInicio: contrato?.dataInicio || new Date().toISOString().split('T')[0],
|
||
dataFim: contrato?.dataFim || '',
|
||
observacoes: contrato?.observacoes || '',
|
||
entrada: contrato?.entrada || 0,
|
||
parcelas: contrato?.parcelas || 1,
|
||
formaPagamento: contrato?.formaPagamento || 'Pix',
|
||
clausulas: contrato?.clausulas || DEFAULT_CLAUSULAS,
|
||
});
|
||
|
||
// Items state
|
||
const [items, setItems] = useState<LancItem[]>(
|
||
contrato?.items?.map((i, idx) => ({
|
||
id: i.id,
|
||
procedimentoNome: i.procedimentoNome,
|
||
quantidade: i.quantidade,
|
||
valorUnitario: i.valorUnitario,
|
||
valorTotal: i.valorTotal,
|
||
descricao: i.descricao,
|
||
ordem: i.ordem ?? idx,
|
||
})) ?? []
|
||
);
|
||
|
||
// Patient selection (when no pre-filled patient)
|
||
const [selectedPaciente, setSelectedPaciente] = useState<{ id: string; nome: string } | null>(
|
||
paciente ? { id: paciente.id, nome: paciente.nome } : (contrato?.pacienteId ? { id: contrato.pacienteId, nome: contrato.pacienteNome } : null)
|
||
);
|
||
|
||
// Procedure catalog search
|
||
const [procSearch, setProcSearch] = useState('');
|
||
|
||
const valorTotal = items.length > 0
|
||
? items.reduce((s, i) => s + i.valorTotal, 0)
|
||
: 0;
|
||
|
||
const restante = valorTotal - form.entrada;
|
||
const valorParcela = form.parcelas > 0 && restante > 0 ? restante / form.parcelas : 0;
|
||
|
||
useEffect(() => {
|
||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||
document.addEventListener('keydown', h);
|
||
return () => document.removeEventListener('keydown', h);
|
||
}, [onClose]);
|
||
|
||
// Auto-fill title when specialty changes
|
||
const handleEspecialidadeChange = (id: string) => {
|
||
const esp = especialidades?.find(e => e.id === id);
|
||
setForm(f => ({
|
||
...f,
|
||
especialidadeId: id,
|
||
especialidadeNome: esp?.nome || '',
|
||
titulo: esp ? `CONTRATO DE SERVIÇOS ODONTOLÓGICOS — ${esp.nome.toUpperCase()}` : f.titulo,
|
||
}));
|
||
};
|
||
|
||
// Add procedure from catalog
|
||
const addProcedimento = (proc: Procedimento) => {
|
||
const existing = items.find(i => i.procedimentoNome === proc.nome);
|
||
if (existing) {
|
||
setItems(prev => prev.map(i => i.id === existing.id
|
||
? { ...i, quantidade: i.quantidade + 1, valorTotal: (i.quantidade + 1) * i.valorUnitario }
|
||
: i
|
||
));
|
||
return;
|
||
}
|
||
const newItem: LancItem = {
|
||
id: Math.random().toString(36).substring(2, 10),
|
||
procedimentoNome: proc.nome,
|
||
quantidade: 1,
|
||
valorUnitario: proc.valorParticular,
|
||
valorTotal: proc.valorParticular,
|
||
ordem: items.length,
|
||
};
|
||
setItems(prev => [...prev, newItem]);
|
||
};
|
||
|
||
// Add manual item
|
||
const addManualItem = () => {
|
||
const newItem: LancItem = {
|
||
id: Math.random().toString(36).substring(2, 10),
|
||
procedimentoNome: '',
|
||
quantidade: 1,
|
||
valorUnitario: 0,
|
||
valorTotal: 0,
|
||
ordem: items.length,
|
||
};
|
||
setItems(prev => [...prev, newItem]);
|
||
};
|
||
|
||
// Update item field
|
||
const updateItem = (id: string, field: keyof LancItem, value: any) => {
|
||
setItems(prev => prev.map(i => {
|
||
if (i.id !== id) return i;
|
||
const updated = { ...i, [field]: value };
|
||
if (field === 'quantidade' || field === 'valorUnitario') {
|
||
updated.valorTotal = updated.quantidade * updated.valorUnitario;
|
||
}
|
||
return updated;
|
||
}));
|
||
};
|
||
|
||
const removeItem = (id: string) => setItems(prev => prev.filter(i => i.id !== id));
|
||
|
||
const onDragEnd = (result: DropResult) => {
|
||
if (!result.destination) return;
|
||
const reordered = [...items];
|
||
const [removed] = reordered.splice(result.source.index, 1);
|
||
reordered.splice(result.destination.index, 0, removed);
|
||
setItems(reordered.map((i, idx) => ({ ...i, ordem: idx })));
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
if (!form.pacienteNome && !selectedPaciente) {
|
||
toast.error('SELECIONE UM PACIENTE.');
|
||
return;
|
||
}
|
||
setSaving(true);
|
||
try {
|
||
const payload = {
|
||
id: contrato?.id,
|
||
titulo: form.titulo,
|
||
status: form.status,
|
||
paciente_id: selectedPaciente?.id || form.pacienteId || undefined,
|
||
paciente_nome: selectedPaciente?.nome || form.pacienteNome,
|
||
dentista_id: form.dentistaId || undefined,
|
||
dentista_nome: form.dentistaNome || undefined,
|
||
especialidade_id: form.especialidadeId || undefined,
|
||
especialidade_nome: form.especialidadeNome || undefined,
|
||
data_inicio: form.dataInicio || undefined,
|
||
data_fim: form.dataFim || undefined,
|
||
valor_total: valorTotal,
|
||
entrada: form.entrada,
|
||
parcelas: form.parcelas,
|
||
forma_pagamento: form.formaPagamento,
|
||
clausulas: form.clausulas,
|
||
observacoes: form.observacoes || undefined,
|
||
items: items.map((i, idx) => ({
|
||
id: i.id,
|
||
procedimento_nome: i.procedimentoNome,
|
||
quantidade: i.quantidade,
|
||
valor_unitario: i.valorUnitario,
|
||
valor_total: i.valorTotal,
|
||
descricao: i.descricao || undefined,
|
||
ordem: idx,
|
||
})),
|
||
};
|
||
|
||
let res: any = null;
|
||
if (contrato?.id) {
|
||
res = await HybridBackend.updateOrcamento({ ...contrato, ...payload as any, items: contrato.items });
|
||
} else {
|
||
await HybridBackend.saveOrcamento(payload as any);
|
||
}
|
||
if (res?.lancamentos_gerados > 0) {
|
||
toast.success(`ORÇAMENTO ASSINADO — ${res.lancamentos_gerados} PARCELA(S) LANÇADA(S) NO FINANCEIRO!`);
|
||
} else {
|
||
toast.success('ORÇAMENTO SALVO COM SUCESSO!');
|
||
}
|
||
onSaved();
|
||
onClose();
|
||
} catch {
|
||
toast.error('ERRO AO SALVAR ORÇAMENTO.');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
// Grouped procedures for catalog
|
||
const groupedProcs = React.useMemo(() => {
|
||
const all = procedimentos ?? [];
|
||
const filtered = procSearch.trim()
|
||
? all.filter(p => p.nome.toLowerCase().includes(procSearch.toLowerCase()) || p.especialidadeNome.toLowerCase().includes(procSearch.toLowerCase()))
|
||
: all;
|
||
const groups: Record<string, Procedimento[]> = {};
|
||
filtered.forEach(p => {
|
||
const key = p.especialidadeNome || 'Outros';
|
||
if (!groups[key]) groups[key] = [];
|
||
groups[key].push(p);
|
||
});
|
||
return groups;
|
||
}, [procedimentos, procSearch]);
|
||
|
||
if (view === 'print') {
|
||
return (
|
||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center backdrop-blur-sm p-0">
|
||
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-2xl flex flex-col animate-in fade-in zoom-in-50 duration-200 overflow-hidden">
|
||
<div className="p-4 border-b border-gray-100 flex items-center justify-between flex-shrink-0 print:hidden">
|
||
<div className="flex items-center gap-2">
|
||
<FileText size={16} className="text-emerald-600" />
|
||
<span className="text-sm font-black text-gray-800 uppercase">Visualização de Impressão</span>
|
||
</div>
|
||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={18} /></button>
|
||
</div>
|
||
<PrintView
|
||
form={form}
|
||
items={items}
|
||
valorTotal={valorTotal}
|
||
orcamentoId={contrato?.id}
|
||
onBack={() => setView('editor')}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center backdrop-blur-sm p-0">
|
||
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-2xl flex flex-col animate-in fade-in zoom-in-50 duration-200 overflow-hidden">
|
||
|
||
{/* Header */}
|
||
<div className="p-5 border-b border-gray-100 flex items-center justify-between flex-shrink-0">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-8 h-8 bg-emerald-100 rounded-lg flex items-center justify-center">
|
||
<FileText size={16} className="text-emerald-600" />
|
||
</div>
|
||
<div>
|
||
<p className="text-xs font-black text-gray-800 uppercase">{contrato ? 'Editar Orcamento' : 'Novo Orcamento'}</p>
|
||
<p className="text-[10px] text-gray-400 font-medium uppercase">{form.pacienteNome || 'Paciente não selecionado'}</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setView('print')}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg text-xs font-bold transition-colors"
|
||
>
|
||
<Printer size={13} /> Imprimir/Assinar
|
||
</button>
|
||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X size={18} /></button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Step indicator */}
|
||
<div className="px-6 pt-4 pb-2 flex-shrink-0">
|
||
<div className="flex items-center">
|
||
{STEPS.map((s, i) => (
|
||
<React.Fragment key={i}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPage(i)}
|
||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] font-black uppercase transition-all whitespace-nowrap ${
|
||
page === i ? 'bg-emerald-600 text-white' : i < page ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-400'
|
||
}`}
|
||
>
|
||
<span className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-black">
|
||
{i < page ? '✓' : i + 1}
|
||
</span>
|
||
{s}
|
||
</button>
|
||
{i < STEPS.length - 1 && (
|
||
<div className={`flex-1 h-0.5 mx-1 ${i < page ? 'bg-green-300' : 'bg-gray-200'}`} />
|
||
)}
|
||
</React.Fragment>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
|
||
|
||
{/* ===== TAB 0: DADOS ===== */}
|
||
{page === 0 && (
|
||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||
{/* Patient */}
|
||
{paciente ? (
|
||
<div>
|
||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Paciente</label>
|
||
<div className="flex items-center bg-teal-50 border border-teal-100 rounded-lg px-3 py-2">
|
||
<span className="text-xs font-bold text-teal-700 uppercase">{paciente.nome}</span>
|
||
<span className="ml-2 text-[9px] text-teal-400 font-bold">(PRÉ-SELECIONADO)</span>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<AutocompleteField<Paciente>
|
||
label="Paciente"
|
||
placeholder="Buscar paciente..."
|
||
value={selectedPaciente}
|
||
onSelect={p => {
|
||
setSelectedPaciente({ id: p.id, nome: p.nome });
|
||
setForm(f => ({ ...f, pacienteId: p.id, pacienteNome: p.nome }));
|
||
}}
|
||
onClear={() => {
|
||
setSelectedPaciente(null);
|
||
setForm(f => ({ ...f, pacienteId: '', pacienteNome: '' }));
|
||
}}
|
||
fetchFn={q => HybridBackend.getPacientes(q).then(r => r.data)}
|
||
/>
|
||
)}
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{/* Dentista */}
|
||
<div>
|
||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Dentista</label>
|
||
<select
|
||
value={form.dentistaId}
|
||
onChange={e => {
|
||
const d = dentistas?.find(d => d.id === e.target.value);
|
||
setForm(f => ({ ...f, dentistaId: e.target.value, dentistaNome: d?.nome || '' }));
|
||
}}
|
||
className="w-full border border-gray-200 rounded-lg p-2.5 text-xs focus:ring-2 focus:ring-emerald-100 focus:border-emerald-300 outline-none"
|
||
>
|
||
<option value="">— Selecionar —</option>
|
||
{dentistas?.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Especialidade */}
|
||
<div>
|
||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Especialidade</label>
|
||
<select
|
||
value={form.especialidadeId}
|
||
onChange={e => handleEspecialidadeChange(e.target.value)}
|
||
className="w-full border border-gray-200 rounded-lg p-2.5 text-xs focus:ring-2 focus:ring-emerald-100 focus:border-emerald-300 outline-none"
|
||
>
|
||
<option value="">— Selecionar —</option>
|
||
{especialidades?.map(e => <option key={e.id} value={e.id}>{e.nome}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Título */}
|
||
<div>
|
||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Título do Orcamento</label>
|
||
<input
|
||
value={form.titulo}
|
||
onChange={e => setForm(f => ({ ...f, titulo: e.target.value.toUpperCase() }))}
|
||
className="w-full border border-gray-200 rounded-lg p-2.5 text-xs focus:ring-2 focus:ring-emerald-100 focus:border-emerald-300 outline-none"
|
||
placeholder="CONTRATO DE SERVIÇOS ODONTOLÓGICOS"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-3">
|
||
{/* Status */}
|
||
<div>
|
||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Status</label>
|
||
<select
|
||
value={form.status}
|
||
onChange={e => setForm(f => ({ ...f, status: e.target.value as Orcamento['status'] }))}
|
||
className="w-full border border-gray-200 rounded-lg p-2.5 text-xs focus:ring-2 focus:ring-emerald-100 focus:border-emerald-300 outline-none"
|
||
>
|
||
<option value="Rascunho">Rascunho</option>
|
||
<option value="Ativo">Ativo</option>
|
||
<option value="Assinado">Assinado</option>
|
||
<option value="Encerrado">Encerrado</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Data Início */}
|
||
<div>
|
||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Data Início</label>
|
||
<input
|
||
type="date"
|
||
value={form.dataInicio}
|
||
onChange={e => setForm(f => ({ ...f, dataInicio: e.target.value }))}
|
||
className="w-full border border-gray-200 rounded-lg p-2.5 text-xs focus:ring-2 focus:ring-emerald-100 focus:border-emerald-300 outline-none"
|
||
/>
|
||
</div>
|
||
|
||
{/* Data Fim */}
|
||
<div>
|
||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Data Fim (opcional)</label>
|
||
<input
|
||
type="date"
|
||
value={form.dataFim}
|
||
onChange={e => setForm(f => ({ ...f, dataFim: e.target.value }))}
|
||
className="w-full border border-gray-200 rounded-lg p-2.5 text-xs focus:ring-2 focus:ring-emerald-100 focus:border-emerald-300 outline-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Observações */}
|
||
<div>
|
||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Observações</label>
|
||
<textarea
|
||
value={form.observacoes}
|
||
onChange={e => setForm(f => ({ ...f, observacoes: e.target.value }))}
|
||
rows={3}
|
||
className="w-full border border-gray-200 rounded-lg p-2.5 text-xs focus:ring-2 focus:ring-emerald-100 focus:border-emerald-300 outline-none resize-none"
|
||
placeholder="Observações gerais do contrato..."
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ===== TAB 1: ITENS ===== */}
|
||
{page === 1 && (
|
||
<div className="flex-1 overflow-hidden flex gap-0 min-h-0">
|
||
{/* Left: Catalog */}
|
||
<div className="w-72 flex-shrink-0 border-r border-gray-100 flex flex-col min-h-0">
|
||
<div className="p-3 border-b border-gray-100">
|
||
<p className="text-[10px] font-black text-gray-500 uppercase tracking-wider mb-2">Catálogo de Procedimentos</p>
|
||
<div className="relative">
|
||
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
|
||
<input
|
||
value={procSearch}
|
||
onChange={e => setProcSearch(e.target.value)}
|
||
placeholder="Buscar procedimento..."
|
||
className="w-full pl-7 pr-3 py-1.5 border border-gray-200 rounded-lg text-[11px] focus:ring-2 focus:ring-emerald-100 focus:border-emerald-300 outline-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
||
{Object.entries(groupedProcs).map(([esp, procs]) => (
|
||
<div key={esp}>
|
||
<p className="text-[9px] font-black text-gray-400 uppercase tracking-widest px-2 py-1">{esp}</p>
|
||
{procs.map(proc => (
|
||
<button
|
||
key={proc.id}
|
||
type="button"
|
||
onClick={() => addProcedimento(proc)}
|
||
className="w-full flex items-center justify-between px-2 py-1.5 hover:bg-emerald-50 rounded-lg text-left transition-colors group"
|
||
>
|
||
<span className="text-[11px] text-gray-700 font-medium flex-1 truncate">{proc.nome}</span>
|
||
<div className="flex items-center gap-1.5 flex-shrink-0 ml-1">
|
||
<span className="text-[10px] text-green-600 font-bold">{fmt(proc.valorParticular)}</span>
|
||
<div className="w-5 h-5 rounded-full bg-emerald-100 group-hover:bg-emerald-600 text-emerald-600 group-hover:text-white flex items-center justify-center transition-colors">
|
||
<Plus size={11} />
|
||
</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
))}
|
||
{Object.keys(groupedProcs).length === 0 && (
|
||
<p className="text-center text-xs text-gray-400 py-6">Nenhum procedimento encontrado.</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right: Selected items */}
|
||
<div className="flex-1 flex flex-col min-h-0">
|
||
<div className="p-3 border-b border-gray-100 flex items-center justify-between">
|
||
<p className="text-[10px] font-black text-gray-500 uppercase tracking-wider">Itens do Orcamento ({items.length})</p>
|
||
<button
|
||
type="button"
|
||
onClick={addManualItem}
|
||
className="flex items-center gap-1 px-2.5 py-1.5 bg-emerald-50 hover:bg-emerald-100 text-emerald-700 rounded-lg text-[11px] font-bold transition-colors"
|
||
>
|
||
<Plus size={12} /> Adicionar Item Manual
|
||
</button>
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto p-3">
|
||
{items.length === 0 ? (
|
||
<div className="h-full flex flex-col items-center justify-center text-gray-400 py-10">
|
||
<FileText size={36} className="mb-3 opacity-30" />
|
||
<p className="text-xs font-bold uppercase">Nenhum item adicionado</p>
|
||
<p className="text-[10px] mt-1">Clique em procedimentos no catálogo ou adicione manualmente</p>
|
||
</div>
|
||
) : (
|
||
<DragDropContext onDragEnd={onDragEnd}>
|
||
<Droppable droppableId="items">
|
||
{provided => (
|
||
<div ref={provided.innerRef} {...provided.droppableProps} className="space-y-2">
|
||
{items.map((item, idx) => (
|
||
<Draggable key={item.id} draggableId={item.id} index={idx}>
|
||
{(provided, snapshot) => (
|
||
<div
|
||
ref={provided.innerRef}
|
||
{...provided.draggableProps}
|
||
className={`bg-white border border-gray-200 rounded-xl p-3 ${snapshot.isDragging ? 'shadow-lg border-emerald-300' : ''}`}
|
||
>
|
||
<div className="flex items-start gap-2">
|
||
<div {...provided.dragHandleProps} className="mt-1 text-gray-300 hover:text-gray-500 cursor-grab flex-shrink-0">
|
||
<GripVertical size={16} />
|
||
</div>
|
||
<div className="flex-1 space-y-2">
|
||
{/* Procedure name */}
|
||
<input
|
||
value={item.procedimentoNome}
|
||
onChange={e => updateItem(item.id, 'procedimentoNome', e.target.value.toUpperCase())}
|
||
className="w-full border border-gray-200 rounded-lg px-2.5 py-1.5 text-xs font-bold text-gray-800 focus:ring-2 focus:ring-emerald-100 focus:border-emerald-300 outline-none"
|
||
placeholder="Nome do procedimento"
|
||
/>
|
||
<div className="flex items-center gap-2">
|
||
{/* Qty */}
|
||
<div className="flex items-center gap-1 border border-gray-200 rounded-lg overflow-hidden">
|
||
<button type="button" onClick={() => updateItem(item.id, 'quantidade', Math.max(1, item.quantidade - 1))}
|
||
className="px-2 py-1.5 hover:bg-gray-100 text-gray-600 transition-colors">
|
||
<Minus size={11} />
|
||
</button>
|
||
<span className="text-xs font-bold text-gray-800 w-6 text-center">{item.quantidade}</span>
|
||
<button type="button" onClick={() => updateItem(item.id, 'quantidade', item.quantidade + 1)}
|
||
className="px-2 py-1.5 hover:bg-gray-100 text-gray-600 transition-colors">
|
||
<Plus size={11} />
|
||
</button>
|
||
</div>
|
||
{/* Unit price */}
|
||
<div className="flex-1">
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
min="0"
|
||
value={item.valorUnitario}
|
||
onChange={e => updateItem(item.id, 'valorUnitario', parseFloat(e.target.value) || 0)}
|
||
className="w-full border border-gray-200 rounded-lg px-2.5 py-1.5 text-xs focus:ring-2 focus:ring-emerald-100 focus:border-emerald-300 outline-none"
|
||
placeholder="Valor unit."
|
||
/>
|
||
</div>
|
||
{/* Total */}
|
||
<div className="text-sm font-black text-emerald-600 w-24 text-right flex-shrink-0">
|
||
{fmt(item.valorTotal)}
|
||
</div>
|
||
{/* Remove */}
|
||
<button type="button" onClick={() => removeItem(item.id)}
|
||
className="text-red-300 hover:text-red-600 flex-shrink-0 transition-colors">
|
||
<Trash2 size={14} />
|
||
</button>
|
||
</div>
|
||
{/* Description */}
|
||
<input
|
||
value={item.descricao || ''}
|
||
onChange={e => updateItem(item.id, 'descricao', e.target.value)}
|
||
className="w-full border border-gray-100 rounded-lg px-2.5 py-1 text-[11px] text-gray-500 focus:ring-1 focus:ring-emerald-100 outline-none bg-gray-50"
|
||
placeholder="Descrição opcional..."
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Draggable>
|
||
))}
|
||
{provided.placeholder}
|
||
</div>
|
||
)}
|
||
</Droppable>
|
||
</DragDropContext>
|
||
)}
|
||
</div>
|
||
{/* Total */}
|
||
{items.length > 0 && (
|
||
<div className="p-3 border-t border-gray-100 flex justify-between items-center bg-emerald-50/50">
|
||
<span className="text-xs font-black text-gray-500 uppercase">Total</span>
|
||
<span className="text-lg font-black text-emerald-700">{fmt(valorTotal)}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ===== TAB 2: CLÁUSULAS E FINANCEIRO ===== */}
|
||
{page === 2 && (
|
||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-5">
|
||
{/* Financial */}
|
||
<div className="bg-emerald-50/60 border border-emerald-100 rounded-2xl p-4 space-y-3">
|
||
<p className="text-[10px] font-black text-emerald-600 uppercase tracking-widest">Financeiro</p>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||
{/* Valor Total */}
|
||
<div>
|
||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Valor Total</label>
|
||
<div className="w-full border border-gray-200 rounded-lg p-2.5 text-sm font-black text-emerald-700 bg-white">
|
||
{fmt(valorTotal)}
|
||
</div>
|
||
</div>
|
||
{/* Entrada */}
|
||
<div>
|
||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Entrada (R$)</label>
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
min="0"
|
||
value={form.entrada || ''}
|
||
onChange={e => setForm(f => ({ ...f, entrada: parseFloat(e.target.value) || 0 }))}
|
||
className="w-full border border-gray-200 rounded-lg p-2.5 text-xs focus:ring-2 focus:ring-emerald-100 focus:border-emerald-300 outline-none"
|
||
placeholder="0,00"
|
||
/>
|
||
</div>
|
||
{/* Parcelas */}
|
||
<div>
|
||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Parcelas</label>
|
||
<select
|
||
value={form.parcelas}
|
||
onChange={e => setForm(f => ({ ...f, parcelas: parseInt(e.target.value) }))}
|
||
className="w-full border border-gray-200 rounded-lg p-2.5 text-xs focus:ring-2 focus:ring-emerald-100 focus:border-emerald-300 outline-none"
|
||
>
|
||
{Array.from({ length: 48 }, (_, i) => i + 1).map(n => (
|
||
<option key={n} value={n}>{n}x</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
{/* Forma pagamento */}
|
||
<div>
|
||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider mb-1">Forma de Pagamento</label>
|
||
<select
|
||
value={form.formaPagamento}
|
||
onChange={e => setForm(f => ({ ...f, formaPagamento: e.target.value }))}
|
||
className="w-full border border-gray-200 rounded-lg p-2.5 text-xs focus:ring-2 focus:ring-emerald-100 focus:border-emerald-300 outline-none"
|
||
>
|
||
{['Pix', 'Dinheiro', 'Cartão de Crédito', 'Cartão de Débito', 'Boleto', 'Transferência'].map(f => (
|
||
<option key={f} value={f}>{f}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
{/* Summary row */}
|
||
<div className="flex flex-wrap gap-4 pt-2 border-t border-emerald-100">
|
||
<div className="text-center">
|
||
<p className="text-[10px] text-emerald-400 font-bold uppercase">Restante</p>
|
||
<p className="text-sm font-black text-emerald-700">{fmt(restante)}</p>
|
||
</div>
|
||
<div className="text-center">
|
||
<p className="text-[10px] text-emerald-400 font-bold uppercase">Valor por Parcela</p>
|
||
<p className="text-sm font-black text-emerald-700">{fmt(valorParcela)}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Clauses */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-wider">Cláusulas Contratuais</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => setForm(f => ({ ...f, clausulas: DEFAULT_CLAUSULAS }))}
|
||
className="text-[10px] text-emerald-500 hover:text-emerald-700 font-bold uppercase transition-colors"
|
||
>
|
||
Restaurar padrão
|
||
</button>
|
||
</div>
|
||
<textarea
|
||
value={form.clausulas}
|
||
onChange={e => setForm(f => ({ ...f, clausulas: e.target.value }))}
|
||
rows={18}
|
||
className="w-full border border-gray-200 rounded-xl p-3 text-xs font-mono focus:ring-2 focus:ring-emerald-100 focus:border-emerald-300 outline-none resize-none leading-relaxed"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="p-4 border-t border-gray-100 flex justify-between items-center flex-shrink-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => page > 0 ? setPage(p => p - 1) : onClose()}
|
||
className="px-5 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold rounded-lg text-xs uppercase flex items-center gap-1.5"
|
||
>
|
||
<ChevronLeft size={14} /> {page === 0 ? 'Cancelar' : 'Voltar'}
|
||
</button>
|
||
<div className="flex items-center gap-2">
|
||
{page < STEPS.length - 1 ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setPage(p => p + 1)}
|
||
className="px-5 py-2 bg-emerald-600 hover:bg-emerald-700 text-white font-bold rounded-lg text-xs uppercase flex items-center gap-1.5"
|
||
>
|
||
Próximo <ChevronRight size={14} />
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
className="px-5 py-2 bg-green-600 hover:bg-green-700 disabled:opacity-60 text-white font-bold rounded-lg text-xs uppercase flex items-center gap-1.5"
|
||
>
|
||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||
{saving ? 'Salvando...' : 'Salvar Orcamento'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|