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>
This commit is contained in:
VPS 4 Builder
2026-05-14 20:04:57 +02:00
parent d6d2bc1e78
commit d064aa9058
12 changed files with 2178 additions and 9 deletions
+976
View File
@@ -0,0 +1,976 @@
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>
);
};
+234
View File
@@ -0,0 +1,234 @@
import React, { useState } from 'react';
import { Plus, Search, FileText, Pencil, Printer, Trash2, Loader2 } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { Contrato } from '../types.ts';
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
import { ContratoModal } from './ContratoModal.tsx';
type StatusFilter = 'TODOS' | 'Ativo' | 'Rascunho' | 'Assinado' | 'Encerrado';
const STATUS_TABS: StatusFilter[] = ['TODOS', 'Ativo', 'Rascunho', 'Assinado', 'Encerrado'];
const STATUS_COLORS: Record<string, string> = {
Ativo: 'bg-green-100 text-green-700',
Rascunho: 'bg-amber-100 text-amber-700',
Assinado: 'bg-blue-100 text-blue-700',
Encerrado: 'bg-gray-100 text-gray-600',
};
const fmt = (v: number) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
export const ContratosView: React.FC = () => {
const toast = useToast();
const { data: contratos, isLoading, refresh } = useHybridBackend<Contrato[]>(HybridBackend.getContratos);
const list = contratos ?? [];
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('TODOS');
const [modalOpen, setModalOpen] = useState(false);
const [editContrato, setEditContrato] = useState<Contrato | null>(null);
const [printContrato, setPrintContrato] = useState<Contrato | null>(null);
const filtered = list.filter(c => {
const matchSearch = !search.trim() ||
c.pacienteNome.toLowerCase().includes(search.toLowerCase()) ||
c.titulo.toLowerCase().includes(search.toLowerCase());
const matchStatus = statusFilter === 'TODOS' || c.status === statusFilter;
return matchSearch && matchStatus;
});
const stats = {
total: list.length,
ativos: list.filter(c => c.status === 'Ativo').length,
rascunhos: list.filter(c => c.status === 'Rascunho').length,
encerrados: list.filter(c => c.status === 'Encerrado' || c.status === 'Assinado').length,
};
const handleDelete = async (id: string, nome: string) => {
if (!confirm(`EXCLUIR o contrato de ${nome}?`)) return;
try {
await HybridBackend.deleteContrato(id);
toast.success('CONTRATO EXCLUÍDO.');
refresh();
} catch {
toast.error('ERRO AO EXCLUIR CONTRATO.');
}
};
const openNew = () => { setEditContrato(null); setModalOpen(true); };
const openEdit = (c: Contrato) => { setEditContrato(c); setModalOpen(true); };
const openPrint = (c: Contrato) => { setPrintContrato(c); setModalOpen(true); };
return (
<div className="h-full flex flex-col">
<PageHeader title="CONTRATOS" description="Gestão de contratos de serviços odontológicos">
<button
onClick={openNew}
className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors uppercase font-bold text-sm shadow-sm"
>
<Plus size={18} /> NOVO CONTRATO
</button>
</PageHeader>
{/* Stats bar */}
<div className="grid grid-cols-4 gap-3 mb-4">
{[
{ label: 'Total', value: stats.total, color: 'bg-indigo-50 text-indigo-700' },
{ label: 'Ativos', value: stats.ativos, color: 'bg-green-50 text-green-700' },
{ label: 'Rascunhos', value: stats.rascunhos, color: 'bg-amber-50 text-amber-700' },
{ label: 'Encerrados/Assinados', value: stats.encerrados, color: 'bg-gray-50 text-gray-600' },
].map(s => (
<div key={s.label} className={`${s.color} rounded-xl p-3 text-center`}>
<p className="text-2xl font-black">{s.value}</p>
<p className="text-[10px] font-bold uppercase opacity-70">{s.label}</p>
</div>
))}
</div>
{/* Search + status filters */}
<div className="flex flex-col sm:flex-row gap-3 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" size={16} />
<input
type="text"
placeholder="Buscar por paciente ou título..."
className="w-full pl-9 pr-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-100 focus:border-indigo-300 outline-none bg-white"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1 overflow-x-auto">
{STATUS_TABS.map(s => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className={`px-2.5 py-1 rounded-md text-[11px] font-bold transition-all whitespace-nowrap ${statusFilter === s ? 'bg-white text-gray-800 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
>
{s}
</button>
))}
</div>
</div>
{/* Loading */}
{isLoading && (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="animate-spin text-indigo-400" size={32} />
</div>
)}
{/* Empty state */}
{!isLoading && filtered.length === 0 && (
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 py-20">
<FileText size={48} className="mb-4 opacity-30" />
<p className="text-sm font-bold uppercase">Nenhum contrato encontrado</p>
<p className="text-xs mt-1">{search || statusFilter !== 'TODOS' ? 'Tente limpar os filtros.' : 'Clique em "NOVO CONTRATO" para começar.'}</p>
{!search && statusFilter === 'TODOS' && (
<button
onClick={openNew}
className="mt-4 flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-bold transition-colors"
>
<Plus size={16} /> Criar Primeiro Contrato
</button>
)}
</div>
)}
{/* Grid */}
{!isLoading && filtered.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 overflow-y-auto pb-6">
{filtered.map(c => (
<div key={c.id} className="bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition-all duration-200 flex flex-col">
<div className="p-4 flex-1">
{/* Header row */}
<div className="flex items-start justify-between gap-2 mb-2">
<h3 className="font-black text-gray-800 text-sm uppercase leading-tight flex-1">{c.pacienteNome}</h3>
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase flex-shrink-0 ${STATUS_COLORS[c.status] || 'bg-gray-100 text-gray-600'}`}>
{c.status}
</span>
</div>
<p className="text-[11px] text-gray-500 font-medium uppercase truncate mb-2">{c.titulo}</p>
<div className="flex flex-wrap gap-1 mb-3">
{c.especialidadeNome && (
<span className="bg-indigo-50 text-indigo-600 text-[9px] font-bold px-2 py-0.5 rounded-full uppercase">
{c.especialidadeNome}
</span>
)}
{c.dentistaNome && (
<span className="bg-gray-50 text-gray-500 text-[9px] font-bold px-2 py-0.5 rounded-full uppercase">
{c.dentistaNome}
</span>
)}
</div>
<div className="grid grid-cols-2 gap-2 text-[10px]">
<div>
<span className="text-gray-400 font-bold uppercase block">Valor Total</span>
<span className="font-black text-indigo-700 text-sm">{fmt(c.valorTotal)}</span>
</div>
<div>
<span className="text-gray-400 font-bold uppercase block">Itens</span>
<span className="font-bold text-gray-700">{c.items?.length ?? 0} proc.</span>
</div>
{c.dataInicio && (
<div>
<span className="text-gray-400 font-bold uppercase block">Início</span>
<span className="font-bold text-gray-700">
{new Date(c.dataInicio + 'T12:00').toLocaleDateString('pt-BR')}
</span>
</div>
)}
{c.dataFim && (
<div>
<span className="text-gray-400 font-bold uppercase block">Fim</span>
<span className="font-bold text-gray-700">
{new Date(c.dataFim + 'T12:00').toLocaleDateString('pt-BR')}
</span>
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex border-t border-gray-100">
<button
onClick={() => openEdit(c)}
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-indigo-600 hover:bg-indigo-50 rounded-bl-xl text-[10px] font-bold transition-colors uppercase"
>
<Pencil size={12} /> Editar
</button>
<div className="border-l border-gray-100" />
<button
onClick={() => openPrint(c)}
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-gray-600 hover:bg-gray-50 text-[10px] font-bold transition-colors uppercase"
>
<Printer size={12} /> Imprimir
</button>
<div className="border-l border-gray-100" />
<button
onClick={() => handleDelete(c.id, c.pacienteNome)}
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-red-500 hover:bg-red-50 rounded-br-xl text-[10px] font-bold transition-colors uppercase"
>
<Trash2 size={12} /> Excluir
</button>
</div>
</div>
))}
</div>
)}
{/* Modal */}
{modalOpen && (
<ContratoModal
contrato={editContrato}
onClose={() => { setModalOpen(false); setEditContrato(null); setPrintContrato(null); }}
onSaved={refresh}
/>
)}
</div>
);
};
+8 -1
View File
@@ -752,6 +752,7 @@ import { ReceitaModal } from './ReceitaModal.tsx';
import { PedidoExameModal } from './PedidoExameModal.tsx';
import { useGTOStore } from './LancarGTO.tsx';
import { LancarProcedimentoModal } from './LancarProcedimentoModal.tsx';
import { ContratoModal } from './ContratoModal.tsx';
// ----------- TRATAMENTOS MODAL -----------
const TratamentosModal: React.FC<{ patient: Paciente; onClose: () => void }> = ({ patient, onClose }) => {
@@ -1213,6 +1214,11 @@ export const PatientsView: React.FC = () => {
<LancarProcedimentoModal patient={modal.patient} onClose={closeModal} onSaved={refresh} />
)}
{/* CONTRATO MODAL */}
{modal.type === 'contrato' && modal.patient && (
<ContratoModal paciente={modal.patient} onClose={closeModal} onSaved={refresh} />
)}
{/* MODAL MENU FINANCEIRO */}
{modal.type === 'financial' && modal.patient && (
<div className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center backdrop-blur-sm p-0">
@@ -1223,10 +1229,11 @@ export const PatientsView: React.FC = () => {
</div>
<p className="text-gray-500 text-sm mb-8 font-bold uppercase">{modal.patient.nome}</p>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<button onClick={() => setModal({ type: 'lancar', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 bg-white border border-gray-200 rounded-xl hover:border-green-400 hover:bg-green-50/50 transition-all group shadow-sm"><PlusCircle size={32} className="text-green-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm">LANÇAR</span></button>
<button onClick={() => setModal({ type: 'asaas', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 bg-white border border-gray-200 rounded-xl hover:border-blue-400 hover:bg-blue-50/50 transition-all group shadow-sm"><Banknote size={32} className="text-blue-600 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm">ASAAS</span></button>
<button onClick={() => setModal({ type: 'historico', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 bg-white border border-gray-200 rounded-xl hover:border-gray-400 hover:bg-gray-50/50 transition-all group shadow-sm"><History size={32} className="text-gray-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm">HISTÓRICO</span></button>
<button onClick={() => setModal({ type: 'contrato', patient: modal.patient })} className="flex flex-col items-center justify-center p-6 bg-white border border-gray-200 rounded-xl hover:border-indigo-400 hover:bg-indigo-50/50 transition-all group shadow-sm"><FileText size={32} className="text-indigo-500 mb-2 group-hover:scale-110 transition-transform" /><span className="font-bold text-gray-700 text-sm">CONTRATOS</span></button>
</div>
</div>
</div>