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
+8 -3
View File
@@ -24,6 +24,7 @@ import { DentistasView, EspecialidadesView, PlanosView, SyncView, ProcedimentosV
import { NotificationsView } from './views/NotificationsView.tsx';
import { ReportsView } from './views/ReportsView.tsx';
import { ConfiguracoesView } from './views/ConfiguracoesView.tsx';
import { ContratosView } from './views/ContratosView.tsx';
export type ViewKey =
| 'landing'
@@ -47,7 +48,8 @@ export type ViewKey =
| 'sync'
| 'relatorios'
| 'cadastro-dentista'
| 'configuracoes';
| 'configuracoes'
| 'contratos';
// ------- URL <-> VIEW mapping -------
const ROUTE_MAP: Record<string, ViewKey> = {
@@ -74,6 +76,7 @@ const ROUTE_MAP: Record<string, ViewKey> = {
'/relatorios': 'relatorios',
'/cadastro-dentista': 'cadastro-dentista',
'/configuracoes': 'configuracoes',
'/contratos': 'contratos',
};
const VIEW_TO_ROUTE: Record<ViewKey, string> = {
@@ -99,6 +102,7 @@ const VIEW_TO_ROUTE: Record<ViewKey, string> = {
sync: '/admin/sync',
'cadastro-dentista': '/cadastro-dentista',
configuracoes: '/configuracoes',
contratos: '/contratos',
};
/** Resolve current URL to a ViewKey */
@@ -126,8 +130,8 @@ const App: React.FC = () => {
const permissions: Record<string, ViewKey[]> = {
paciente: ['tratamentos', 'notificacoes', 'configuracoes'],
dentista: ['dashboard', 'pacientes', 'agenda', 'ortodontia', 'tratamentos', 'notificacoes', 'clinicas', 'configuracoes'],
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes'],
dentista: ['dashboard', 'pacientes', 'agenda', 'ortodontia', 'tratamentos', 'notificacoes', 'clinicas', 'configuracoes', 'contratos'],
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'contratos'],
};
return permissions[role]?.includes(view) || false;
@@ -230,6 +234,7 @@ const App: React.FC = () => {
case 'relatorios': return <ReportsView />;
case 'cadastro-dentista': return <DentistRegisterView />;
case 'configuracoes': return <ConfiguracoesView />;
case 'contratos': return <ContratosView />;
case 'login':
default:
return <LoginView onLoginSuccess={() => {
+4 -3
View File
@@ -1,7 +1,7 @@
import React from 'react';
import {
Users, Calendar as CalendarIcon, LayoutDashboard, Stethoscope,
DollarSign, RefreshCw, Database, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3, Settings
DollarSign, RefreshCw, Database, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3, Settings, FileText
} from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
@@ -27,6 +27,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
{ id: 'agenda', label: 'AGENDA', icon: CalendarIcon },
{ id: 'ortodontia', label: 'ORTODONTIA', icon: Activity },
{ id: 'financeiro', label: 'FINANCEIRO', icon: DollarSign },
{ id: 'contratos', label: 'CONTRATOS', icon: FileText },
{ id: 'relatorios', label: 'RELATÓRIOS', icon: BarChart3 },
{ id: 'dentistas', label: 'DENTISTAS', icon: Stethoscope },
{ id: 'especialidades', label: 'ESPECIALIDADES', icon: Award },
@@ -38,8 +39,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
].filter(item => {
if (['admin', 'donoclinica'].includes(currentRole)) return true;
if (currentRole === 'paciente') return ['tratamentos', 'notificacoes', 'configuracoes'].includes(item.id);
if (currentRole === 'dentista') return ['dashboard', 'pacientes', 'tratamentos', 'agenda', 'ortodontia', 'notificacoes', 'clinicas', 'configuracoes'].includes(item.id);
if (currentRole === 'funcionario') return ['dashboard', 'leads', 'pacientes', 'tratamentos', 'lancar-gto', 'agenda', 'financeiro', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes'].includes(item.id);
if (currentRole === 'dentista') return ['dashboard', 'pacientes', 'tratamentos', 'agenda', 'ortodontia', 'notificacoes', 'clinicas', 'configuracoes', 'contratos'].includes(item.id);
if (currentRole === 'funcionario') return ['dashboard', 'leads', 'pacientes', 'tratamentos', 'lancar-gto', 'agenda', 'financeiro', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'contratos'].includes(item.id);
return false;
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ScoreOdonto CRM</title>
<script>
(function () {
var path = window.location.pathname;
if (path.indexOf('/odonto') !== -1 && !path.endsWith('/') && path.split('/').pop().indexOf('.') === -1) {
var newUrl = path + '/' + window.location.search + window.location.hash;
window.location.replace(newUrl);
}
})();
</script>
<base href="/">
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-CS4bnJiX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-VV5qCksm.css">
</head>
<body>
<div id="root"></div>
<div id="toast-container"></div>
<div id="global-error-display"
style="display: none; position: fixed; top: 10px; left: 10px; right: 10px; background-color: #ef4444; color: white; padding: 16px; border-radius: 8px; z-index: 9999; font-family: monospace; font-size: 14px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
</div>
</body>
</html>
+24 -2
View File
@@ -1,4 +1,4 @@
import { Agendamento, Dentista, DentistaHorario, Especialidade, EvolucaoOrto, FinanceiroItem, Lead, Notificacao, Paciente, Plano, Procedimento, Settings, TratamentoOrto } from '../types.ts';
import { Agendamento, Contrato, Dentista, DentistaHorario, Especialidade, EvolucaoOrto, FinanceiroItem, Lead, Notificacao, Paciente, Plano, Procedimento, Settings, TratamentoOrto } from '../types.ts';
// Helper to enforce uppercase on strings
const toUpper = (str: string | undefined) => str ? str.toUpperCase() : '';
@@ -821,5 +821,27 @@ export const HybridBackend = {
} catch {
return [];
}
}
},
getContratos: async (pacienteId?: string): Promise<Contrato[]> => {
const q = pacienteId ? `?paciente_id=${pacienteId}` : '';
const res = await apiFetch(`${API_URL}/contratos${q}`);
return res.json();
},
saveContrato: async (c: Partial<Contrato> & { items: any[] }): Promise<{ id: string }> => {
const res = await apiFetch(`${API_URL}/contratos`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(c),
});
return res.json();
},
updateContrato: async (c: Contrato): Promise<void> => {
await apiFetch(`${API_URL}/contratos/${c.id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(c),
});
},
deleteContrato: async (id: string): Promise<void> => {
await apiFetch(`${API_URL}/contratos/${id}`, { method: 'DELETE' });
},
};
+33
View File
@@ -276,4 +276,37 @@ export interface Settings {
clinicCalendarId?: string;
googleApiKey?: string;
googleCalendarIds?: Record<string, string>; // dentistId -> calendarId
}
export interface ContratoItem {
id: string;
contratoId: string;
procedimentoNome: string;
quantidade: number;
valorUnitario: number;
valorTotal: number;
descricao?: string;
ordem: number;
}
export interface Contrato {
id: string;
pacienteId?: string;
pacienteNome: string;
dentistaId?: string;
dentistaNome?: string;
especialidadeId?: string;
especialidadeNome?: string;
titulo: string;
status: 'Rascunho' | 'Ativo' | 'Assinado' | 'Encerrado';
dataInicio?: string;
dataFim?: string;
valorTotal: number;
entrada?: number;
parcelas?: number;
formaPagamento?: string;
clausulas?: string;
observacoes?: string;
createdAt?: string;
items: ContratoItem[];
}
+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>