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:
+8
-3
@@ -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={() => {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
+736
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
|
After Width: | Height: | Size: 521 KiB |
Vendored
+38
@@ -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>
|
||||
@@ -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' });
|
||||
},
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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 Nº {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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user