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:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user