Files
scoreodonto.com/frontend/views/ContratoModal.tsx
T
VPS 4 Builder d064aa9058 feat(contratos): sistema completo de contratos odontológicos
- Tabelas contratos e contrato_itens com migrações automáticas
- Endpoints CRUD com itens em transação
- ContratosView: listagem, filtro por status, cards, stats
- ContratoModal: wizard 3 abas (Dados/Itens/Cláusulas), catálogo de
  procedimentos, DnD para reordenar, financeiro completo, print A4
- Sidebar: novo item CONTRATOS
- App.tsx: nova rota /contratos com permissões dentista/funcionario
- PatientsView: botão CONTRATOS no menu financeiro

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:04:57 +02:00

977 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { Contrato, ContratoItem, 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-blue-50 border border-blue-100 rounded-lg px-3 py-2">
<span className="text-xs font-bold text-blue-700 uppercase truncate">{value.nome}</span>
<button type="button" onClick={onClear} className="ml-2 text-blue-300 hover:text-blue-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-blue-100 focus:border-blue-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-blue-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 -----------
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: Contrato['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;
contratoId?: string;
onBack: () => void;
}> = ({ form, items, valorTotal, contratoId, 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>
{contratoId && <p className="text-[10px] text-gray-400 mt-0.5">Contrato {contratoId}</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 */}
{contratoId && (
<div className="mt-6 pt-3 border-t border-gray-100 flex justify-between items-center">
<p className="text-[9px] text-gray-300">ID: {contratoId}</p>
<p className="text-[9px] text-gray-300">Gerado em {hoje}</p>
</div>
)}
</div>
</div>
</div>
);
};
// ----------- Main Modal -----------
interface ContratoModalProps {
contrato?: Contrato | null;
paciente?: Paciente | null;
onClose: () => void;
onSaved: () => void;
}
export const ContratoModal: React.FC<ContratoModalProps> = ({ contrato, paciente, 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'>('editor');
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,
})),
};
if (contrato?.id) {
await HybridBackend.updateContrato({ ...contrato, ...payload as any, items: contrato.items });
} else {
await HybridBackend.saveContrato(payload as any);
}
toast.success('CONTRATO SALVO COM SUCESSO!');
onSaved();
onClose();
} catch {
toast.error('ERRO AO SALVAR CONTRATO.');
} 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-indigo-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}
contratoId={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-indigo-100 rounded-lg flex items-center justify-center">
<FileText size={16} className="text-indigo-600" />
</div>
<div>
<p className="text-xs font-black text-gray-800 uppercase">{contrato ? 'Editar Contrato' : 'Novo Contrato'}</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-indigo-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-blue-50 border border-blue-100 rounded-lg px-3 py-2">
<span className="text-xs font-bold text-blue-700 uppercase">{paciente.nome}</span>
<span className="ml-2 text-[9px] text-blue-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-indigo-100 focus:border-indigo-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-indigo-100 focus:border-indigo-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 Contrato</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-indigo-100 focus:border-indigo-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 Contrato['status'] }))}
className="w-full border border-gray-200 rounded-lg p-2.5 text-xs focus:ring-2 focus:ring-indigo-100 focus:border-indigo-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-indigo-100 focus:border-indigo-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-indigo-100 focus:border-indigo-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-indigo-100 focus:border-indigo-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-indigo-100 focus:border-indigo-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-indigo-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-indigo-100 group-hover:bg-indigo-600 text-indigo-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 Contrato ({items.length})</p>
<button
type="button"
onClick={addManualItem}
className="flex items-center gap-1 px-2.5 py-1.5 bg-indigo-50 hover:bg-indigo-100 text-indigo-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-indigo-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-indigo-100 focus:border-indigo-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-indigo-100 focus:border-indigo-300 outline-none"
placeholder="Valor unit."
/>
</div>
{/* Total */}
<div className="text-sm font-black text-indigo-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-indigo-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-indigo-50/50">
<span className="text-xs font-black text-gray-500 uppercase">Total</span>
<span className="text-lg font-black text-indigo-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-indigo-50/60 border border-indigo-100 rounded-2xl p-4 space-y-3">
<p className="text-[10px] font-black text-indigo-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-indigo-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-indigo-100 focus:border-indigo-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-indigo-100 focus:border-indigo-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-indigo-100 focus:border-indigo-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-indigo-100">
<div className="text-center">
<p className="text-[10px] text-indigo-400 font-bold uppercase">Restante</p>
<p className="text-sm font-black text-indigo-700">{fmt(restante)}</p>
</div>
<div className="text-center">
<p className="text-[10px] text-indigo-400 font-bold uppercase">Valor por Parcela</p>
<p className="text-sm font-black text-indigo-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-indigo-500 hover:text-indigo-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-indigo-100 focus:border-indigo-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-indigo-600 hover:bg-indigo-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 Contrato'}
</button>
)}
</div>
</div>
</div>
</div>
);
};