Files
VPS 4 Builder 3e64514b08 feat(financeiro+salas): motor financeiro V1/V2, glosas, GTO→financeiro, contratos de convênio e redesenho de Salas
Financeiro V1 (Épicos 1–4):
- FKs de origem no lançamento (paciente/profissional/procedimento/agendamento/contrato/realizado), soft delete e cron "Atrasado"
- prontuário de procedimentos realizados (fotos antes/depois) + regra sem-comissão (garantia/reabertura do mesmo profissional)
- normalizeFinanceiro: corrige CHECK capitalizado vs enforceUppercase (lançamentos não salvavam pela UI)
- baixa de pagamento (pago_em), despesas (fornecedor/centro de custo/recorrente), competência por data de conclusão
- relatório financeiro (período, paciente, profissional, forma, convênio, glosa)
- orçamento Assinado -> contas a receber; contrato vigente -> cobrança recorrente; renovação automática de contrato

Financeiro V2 (Comissão/Repasse):
- regras de % (padrão/procedimento/override por profissional) + custo de laboratório
- base selecionável (recebido/produção), fechamento persistido, pagar (gera despesa) + comprovante + estorno

Glosas de convênio: por item de GTO, recurso/protocolo/prazo, recuperar/estornar, impacto no relatório

GTO -> Financeiro: finalizar gera RECEITA (convênio a receber); LancarGTO passa a persistir; convênios reais (planos)

Contratos: modelo "Atendimento a Convênios / Repasse (Dentista Credenciado)" + variáveis de convênio

Salas (redesenho): perfil "Dono de Sala"; locação sempre ativa; menus MINHAS/ALUGAR SALAS;
sala como workspace isolado (seletor agrupado Clínicas x Salas, tenantGuard por dono); financeiro de
locação (reserva confirmada -> recebível); Painel da Sala (KPIs, agenda visual, recebíveis, relatório por sala)

Docs: BACKLOG-FINANCEIRO-V1, AUDITORIA-FUNCIONAL-SCOREODONTO, CHECKLIST-DEPLOY-PRODUCAO

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 00:28:07 +02:00

548 lines
36 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
FileSignature, Plus, Pencil, Copy, Trash2, X, Save, RefreshCw, Search, Tag, Library, FileText,
Zap, Printer, Eye, ChevronLeft, CalendarClock, Send, PenLine, DollarSign,
} from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { useConfirm } from '../contexts/ConfirmContext.tsx';
import { PageHeader } from '../components/PageHeader.tsx';
const COLOR = '#0d9488'; // teal
interface Modelo {
id: string;
clinica_id: string | null;
nome: string;
categoria: string | null;
tipo_relacao: string | null;
corpo: string;
variaveis: string[];
vigencia_dias: number | null;
global: boolean;
}
interface Variavel { chave: string; desc: string; }
const CAT_LABEL: Record<string, string> = {
servico: 'Serviço', locacao: 'Locação', parceria: 'Parceria', remuneracao: 'Remuneração',
};
const CAT_STYLE: Record<string, string> = {
servico: 'bg-blue-100 text-blue-700', locacao: 'bg-amber-100 text-amber-700',
parceria: 'bg-violet-100 text-violet-700', remuneracao: 'bg-emerald-100 text-emerald-700',
};
const inputCls = 'w-full px-3 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-sm font-medium text-gray-700 outline-none focus:border-teal-400 focus:bg-white transition-colors';
const labelCls = 'text-[11px] font-bold text-gray-500 mb-1.5 block';
// ─── Editor de modelo ─────────────────────────────────────────────────────────
const ModeloEditor: React.FC<{ modelo: Modelo | null; variaveis: Variavel[]; onClose: () => void; onSaved: () => void }> = ({ modelo, variaveis, onClose, onSaved }) => {
const toast = useToast();
const [f, setF] = useState({
id: modelo?.id, nome: modelo?.nome || '', categoria: modelo?.categoria || 'servico',
tipo_relacao: modelo?.tipo_relacao || '', corpo: modelo?.corpo || '',
vigencia_dias: modelo?.vigencia_dias ?? '',
});
const [saving, setSaving] = useState(false);
const corpoRef = useRef<HTMLTextAreaElement>(null);
const inserirVar = (chave: string) => {
const ta = corpoRef.current;
if (!ta) { setF(p => ({ ...p, corpo: p.corpo + chave })); return; }
const start = ta.selectionStart, end = ta.selectionEnd;
const novo = f.corpo.slice(0, start) + chave + f.corpo.slice(end);
setF(p => ({ ...p, corpo: novo }));
requestAnimationFrame(() => { ta.focus(); ta.selectionStart = ta.selectionEnd = start + chave.length; });
};
const salvar = async () => {
if (!f.nome.trim() || !f.corpo.trim()) { toast.error('NOME E CORPO SÃO OBRIGATÓRIOS.'); return; }
setSaving(true);
try {
await HybridBackend.saveContratoModelo({
id: f.id, nome: f.nome, categoria: f.categoria, tipo_relacao: f.tipo_relacao || null,
corpo: f.corpo, vigencia_dias: f.vigencia_dias === '' ? null : Number(f.vigencia_dias),
});
toast.success('MODELO SALVO!');
onSaved(); onClose();
} catch (e: any) { toast.error((e.message || 'ERRO AO SALVAR').toUpperCase()); }
finally { setSaving(false); }
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl w-full max-w-4xl shadow-2xl overflow-hidden max-h-[92vh] flex flex-col">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<h3 className="text-sm font-black text-gray-900 uppercase">{f.id ? 'Editar modelo' : 'Novo modelo de contrato'}</h3>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl transition-colors"><X size={18} /></button>
</div>
<div className="p-6 grid grid-cols-1 lg:grid-cols-3 gap-5 overflow-y-auto">
<div className="lg:col-span-2 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="sm:col-span-2">
<label className={labelCls}>Nome do modelo</label>
<input className={inputCls} value={f.nome} onChange={e => setF(p => ({ ...p, nome: e.target.value }))} placeholder="Ex.: Locação de sala mensal" />
</div>
<div>
<label className={labelCls}>Categoria</label>
<select className={inputCls} value={f.categoria} onChange={e => setF(p => ({ ...p, categoria: e.target.value }))}>
{Object.entries(CAT_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className={labelCls}>Tipo de relação (opcional)</label>
<input className={inputCls} value={f.tipo_relacao} onChange={e => setF(p => ({ ...p, tipo_relacao: e.target.value }))} placeholder="Ex.: dentista-clinica" />
</div>
<div>
<label className={labelCls}>Vigência sugerida (dias)</label>
<input type="number" min="0" className={inputCls} value={f.vigencia_dias} onChange={e => setF(p => ({ ...p, vigencia_dias: e.target.value }))} placeholder="Ex.: 365" />
</div>
</div>
<div>
<label className={labelCls}>Corpo do contrato · use as variáveis ao lado</label>
<textarea ref={corpoRef} className={`${inputCls} font-mono text-xs leading-relaxed`} rows={16} value={f.corpo} onChange={e => setF(p => ({ ...p, corpo: e.target.value }))} placeholder="Cláusulas do contrato com {{VARIÁVEIS}}…" />
</div>
</div>
{/* Variáveis */}
<div>
<label className={labelCls}>Variáveis disponíveis</label>
<div className="border border-gray-100 rounded-xl p-2 max-h-[28rem] overflow-y-auto space-y-1 bg-gray-50/50">
{variaveis.map(v => (
<button key={v.chave} type="button" onClick={() => inserirVar(v.chave)}
className="w-full text-left px-2.5 py-2 rounded-lg hover:bg-teal-50 border border-transparent hover:border-teal-200 transition-colors">
<span className="text-[11px] font-black text-teal-700 font-mono">{v.chave}</span>
<span className="block text-[10px] text-gray-400 font-medium">{v.desc}</span>
</button>
))}
</div>
</div>
</div>
<div className="px-6 py-4 border-t border-gray-100 flex justify-end gap-3">
<button onClick={onClose} className="px-5 py-2.5 border border-gray-200 rounded-xl text-xs font-black uppercase text-gray-500 hover:bg-gray-50 transition-colors">Cancelar</button>
<button onClick={salvar} disabled={saving} className="flex items-center gap-2 px-5 py-2.5 text-white rounded-xl text-xs font-black uppercase tracking-widest hover:opacity-90 transition-opacity disabled:opacity-60" style={{ backgroundColor: COLOR }}>
{saving ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />} Salvar
</button>
</div>
</div>
</div>
);
};
// ─── Visualizar (read-only) ───────────────────────────────────────────────────
const ModeloViewer: React.FC<{ modelo: Modelo; onClose: () => void }> = ({ modelo, onClose }) => (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
<div className="bg-white rounded-2xl w-full max-w-2xl shadow-2xl overflow-hidden max-h-[90vh] flex flex-col">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<h3 className="text-sm font-black text-gray-900 uppercase">{modelo.nome}</h3>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl transition-colors"><X size={18} /></button>
</div>
<div className="p-6 overflow-y-auto">
<pre className="whitespace-pre-wrap font-sans text-xs text-gray-700 leading-relaxed">{modelo.corpo}</pre>
</div>
</div>
</div>
);
// ─── Helpers de instância ─────────────────────────────────────────────────────
const AUTO_VARS = ['CONTRATANTE', 'CONTRATADO', 'CPF', 'CNPJ', 'DATA_INICIO', 'DATA_FIM', 'DATA_HOJE'];
const renderCorpo = (corpo: string, valores: Record<string, string>) =>
String(corpo || '').replace(/\{\{(\w+)\}\}/g, (m, k) => (valores[k] != null && String(valores[k]).trim() !== '') ? valores[k] : m);
const varsNoCorpo = (corpo: string): string[] => {
const set = new Set<string>(); const re = /\{\{(\w+)\}\}/g; let mt;
while ((mt = re.exec(corpo || ''))) set.add(mt[1]);
return [...set];
};
const fmtBR = (d?: string) => d ? new Date(d + 'T12:00').toLocaleDateString('pt-BR') : '';
const ST_INST: Record<string, { label: string; cls: string }> = {
rascunho: { label: 'Rascunho', cls: 'bg-amber-100 text-amber-700' },
aguardando_assinatura: { label: 'Aguardando assinatura', cls: 'bg-blue-100 text-blue-700' },
assinado: { label: 'Assinado', cls: 'bg-emerald-100 text-emerald-700' },
vigente: { label: 'Vigente', cls: 'bg-teal-100 text-teal-700' },
encerrado: { label: 'Encerrado', cls: 'bg-gray-100 text-gray-500' },
cancelado: { label: 'Cancelado', cls: 'bg-red-100 text-red-600' },
};
// ─── Gerador de contrato ──────────────────────────────────────────────────────
const GeradorModal: React.FC<{ modelos: Modelo[]; variaveis: Variavel[]; onClose: () => void; onSaved: () => void }> = ({ modelos, variaveis, onClose, onSaved }) => {
const toast = useToast();
const hoje = new Date().toISOString().split('T')[0];
const [modeloId, setModeloId] = useState('');
const [titulo, setTitulo] = useState('');
const [contratante, setContratante] = useState({ nome: '', doc: '' });
const [contratado, setContratado] = useState({ nome: '', doc: '' });
const [vigIni, setVigIni] = useState(hoje);
const [vigFim, setVigFim] = useState('');
const [extra, setExtra] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
// H4.2 — cobrança recorrente
const [cobrAuto, setCobrAuto] = useState(false);
const [valorRec, setValorRec] = useState('');
const [periodicidade, setPeriodicidade] = useState('mensal');
const [diaCobranca, setDiaCobranca] = useState('5');
const modelo = modelos.find(m => m.id === modeloId) || null;
const onPick = (id: string) => {
setModeloId(id);
const m = modelos.find(x => x.id === id);
if (m && !titulo) setTitulo(m.nome);
setExtra({});
};
const extraVars = modelo ? varsNoCorpo(modelo.corpo).filter(v => !AUTO_VARS.includes(v)) : [];
const descVar = (k: string) => variaveis.find(v => v.chave === `{{${k}}}`)?.desc || k;
const merged = (): Record<string, string> => ({
CONTRATANTE: contratante.nome, CONTRATADO: contratado.nome,
CNPJ: contratante.doc, CPF: contratado.doc,
DATA_INICIO: fmtBR(vigIni), DATA_FIM: fmtBR(vigFim), DATA_HOJE: fmtBR(hoje),
...extra,
});
const gerar = async () => {
if (!modelo) { toast.error('ESCOLHA UM MODELO.'); return; }
if (!titulo.trim()) { toast.error('INFORME O TÍTULO.'); return; }
setSaving(true);
try {
await HybridBackend.saveContratoInstancia({
modelo_id: modelo.id, titulo, tipo_relacao: modelo.tipo_relacao, valores: merged(),
data_inicio: vigIni || null, data_fim: vigFim || null,
cobranca_automatica: cobrAuto,
valor_recorrente: cobrAuto && valorRec !== '' ? Number(valorRec) : null,
periodicidade: cobrAuto ? periodicidade : null,
dia_cobranca: cobrAuto && diaCobranca !== '' ? parseInt(diaCobranca) : null,
partes: [
{ papel: 'CONTRATANTE', nome: contratante.nome, doc: contratante.doc },
{ papel: 'CONTRATADO', nome: contratado.nome, doc: contratado.doc },
],
});
toast.success('CONTRATO GERADO!');
onSaved(); onClose();
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
finally { setSaving(false); }
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl w-full max-w-5xl shadow-2xl overflow-hidden max-h-[92vh] flex flex-col">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<h3 className="text-sm font-black text-gray-900 uppercase flex items-center gap-2"><Zap size={16} className="text-teal-600" /> Gerar contrato</h3>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl transition-colors"><X size={18} /></button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-0 overflow-hidden flex-1">
{/* Form */}
<div className="p-6 space-y-3 overflow-y-auto border-r border-gray-100">
<div>
<label className={labelCls}>Modelo</label>
<select className={inputCls} value={modeloId} onChange={e => onPick(e.target.value)}>
<option value="">Selecione um modelo</option>
{modelos.map(m => <option key={m.id} value={m.id}>{m.nome}{m.global ? ' · catálogo' : ''}</option>)}
</select>
</div>
<div>
<label className={labelCls}>Título do contrato</label>
<input className={inputCls} value={titulo} onChange={e => setTitulo(e.target.value)} placeholder="Ex.: Locação Sala 2 — Dr. João" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className={labelCls}>Contratante · nome</label>
<input className={inputCls} value={contratante.nome} onChange={e => setContratante(p => ({ ...p, nome: e.target.value }))} />
<input className={`${inputCls} mt-2`} value={contratante.doc} onChange={e => setContratante(p => ({ ...p, doc: e.target.value }))} placeholder="CPF/CNPJ" />
</div>
<div>
<label className={labelCls}>Contratado · nome</label>
<input className={inputCls} value={contratado.nome} onChange={e => setContratado(p => ({ ...p, nome: e.target.value }))} />
<input className={`${inputCls} mt-2`} value={contratado.doc} onChange={e => setContratado(p => ({ ...p, doc: e.target.value }))} placeholder="CPF/CNPJ" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div><label className={labelCls}>Início</label><input type="date" className={inputCls} value={vigIni} onChange={e => setVigIni(e.target.value)} /></div>
<div><label className={labelCls}>Término</label><input type="date" className={inputCls} value={vigFim} onChange={e => setVigFim(e.target.value)} /></div>
</div>
{/* H4.2 — cobrança recorrente */}
<div className="pt-1 border-t border-gray-100 mt-1">
<label className="inline-flex items-center gap-2 cursor-pointer mt-2">
<input type="checkbox" checked={cobrAuto} onChange={e => setCobrAuto(e.target.checked)} className="w-4 h-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500" />
<span className="text-[11px] font-black text-gray-600 uppercase tracking-wide">Cobrança recorrente no financeiro</span>
</label>
{cobrAuto && (
<div className="grid grid-cols-3 gap-3 mt-2">
<div><label className={labelCls}>Valor (R$)</label><input type="number" min="0" step="0.01" className={inputCls} value={valorRec} onChange={e => setValorRec(e.target.value)} placeholder="0,00" /></div>
<div><label className={labelCls}>Periodicidade</label>
<select className={inputCls} value={periodicidade} onChange={e => setPeriodicidade(e.target.value)}>
<option value="mensal">Mensal</option>
<option value="anual">Anual</option>
</select>
</div>
<div><label className={labelCls}>Dia venc.</label><input type="number" min="1" max="28" className={inputCls} value={diaCobranca} onChange={e => setDiaCobranca(e.target.value)} /></div>
</div>
)}
{cobrAuto && <p className="text-[10px] text-gray-400 font-bold mt-2 uppercase">Gera 1 lançamento por competência quando o contrato estiver vigente (não duplica).</p>}
</div>
{extraVars.length > 0 && (
<div className="pt-1">
<label className={labelCls}>Outras variáveis do modelo</label>
<div className="space-y-2">
{extraVars.map(k => (
<div key={k}>
<input className={inputCls} value={extra[k] || ''} onChange={e => setExtra(p => ({ ...p, [k]: e.target.value }))} placeholder={`${descVar(k)} (${k})`} />
</div>
))}
</div>
</div>
)}
</div>
{/* Preview */}
<div className="bg-gray-50 p-6 overflow-y-auto">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2">Prévia</p>
{modelo
? <pre className="whitespace-pre-wrap font-sans text-[11px] text-gray-700 leading-relaxed">{renderCorpo(modelo.corpo, merged())}</pre>
: <p className="text-xs text-gray-400 mt-10 text-center">Escolha um modelo para ver a prévia.</p>}
</div>
</div>
<div className="px-6 py-4 border-t border-gray-100 flex justify-end gap-3">
<button onClick={onClose} className="px-5 py-2.5 border border-gray-200 rounded-xl text-xs font-black uppercase text-gray-500 hover:bg-gray-50 transition-colors">Cancelar</button>
<button onClick={gerar} disabled={saving} className="flex items-center gap-2 px-5 py-2.5 text-white rounded-xl text-xs font-black uppercase tracking-widest hover:opacity-90 transition-opacity disabled:opacity-60" style={{ backgroundColor: COLOR }}>
{saving ? <RefreshCw size={14} className="animate-spin" /> : <Zap size={14} />} Gerar contrato
</button>
</div>
</div>
</div>
);
};
// ─── Visualizar / imprimir uma instância ──────────────────────────────────────
const ContratoPrintView: React.FC<{ id: string; onClose: () => void; onChanged: () => void }> = ({ id, onClose, onChanged }) => {
const toast = useToast();
const confirm = useConfirm();
const [c, setC] = useState<any | null>(null);
const [busy, setBusy] = useState(false);
const recarregar = useCallback(() => HybridBackend.getContratoInstancia(id).then(setC), [id]);
useEffect(() => { recarregar(); }, [recarregar]);
const sigDe = (parteId: string) => (c?.assinaturas || []).find((a: any) => a.parte_id === parteId);
const acao = async (fn: () => Promise<any>, ok: string) => {
setBusy(true);
try { await fn(); toast.success(ok); await recarregar(); onChanged(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
finally { setBusy(false); }
};
const cancelar = async () => { if (!await confirm('CANCELAR este contrato?')) return; acao(() => HybridBackend.cancelarContrato(id), 'CONTRATO CANCELADO.'); };
const podeAssinar = c?.status === 'aguardando_assinatura';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 print:p-0 print:bg-white">
<div className="bg-white rounded-2xl w-full max-w-3xl shadow-2xl overflow-hidden max-h-[92vh] flex flex-col print:max-w-full print:rounded-none print:max-h-full">
<div className="px-6 py-3 border-b border-gray-100 flex items-center gap-2 flex-wrap print:hidden">
<button onClick={onClose} 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 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} /> PDF</button>
{c?.status === 'rascunho' && <button disabled={busy} onClick={() => acao(() => HybridBackend.enviarContrato(id), 'ENVIADO PARA ASSINATURA.')} className="flex items-center gap-1.5 px-4 py-2 text-white rounded-lg text-xs font-black uppercase tracking-wider disabled:opacity-60" style={{ backgroundColor: COLOR }}><Send size={13} /> Enviar p/ assinatura</button>}
{c && !['encerrado', 'cancelado'].includes(c.status) && <button disabled={busy} onClick={cancelar} className="flex items-center gap-1.5 px-3 py-2 border border-red-200 text-red-500 rounded-lg text-xs font-black uppercase hover:bg-red-50 disabled:opacity-60">Cancelar</button>}
{c && <span className={`ml-auto text-[9px] font-black px-2 py-1 rounded-full uppercase ${ST_INST[c.status]?.cls || 'bg-gray-100'}`}>{ST_INST[c.status]?.label || c.status}</span>}
</div>
<div className="overflow-y-auto p-8 print:p-0">
{!c ? <div className="flex justify-center py-12"><RefreshCw size={22} className="animate-spin text-teal-500" /></div> : (
<div className="max-w-2xl mx-auto">
<h1 className="text-lg font-black text-gray-900 uppercase text-center border-b-2 border-gray-800 pb-4 mb-6">{c.titulo}</h1>
<pre className="whitespace-pre-wrap font-sans text-xs text-gray-800 leading-relaxed">{c.corpo_renderizado}</pre>
<div className="mt-12 grid grid-cols-1 sm:grid-cols-2 gap-8">
{(c.partes || []).slice(0, 2).map((p: any) => {
const sig = sigDe(p.id);
const assinado = sig?.status === 'assinado';
return (
<div key={p.id} className="text-center">
{assinado
? <div className="h-14 flex items-end justify-center pb-1"><span className="font-mono text-sm text-teal-700 border-b border-teal-300 px-3"> assinado eletronicamente</span></div>
: <div className="h-14 border-b border-gray-400 mb-2" />}
<p className="text-[10px] font-black text-gray-500 uppercase">{p.papel}</p>
{p.nome_snapshot && <p className="text-[10px] text-gray-400 uppercase">{p.nome_snapshot}</p>}
{assinado
? <p className="text-[9px] text-teal-600 mt-0.5 print:text-gray-400">por {sig.assinante_nome || '—'} · {sig.assinado_em ? new Date(sig.assinado_em).toLocaleDateString('pt-BR') : ''}</p>
: podeAssinar && <button disabled={busy} onClick={() => acao(() => HybridBackend.assinarContrato(id, p.id), 'ASSINATURA REGISTRADA.')} className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-black uppercase text-white print:hidden disabled:opacity-60" style={{ backgroundColor: COLOR }}><PenLine size={11} /> Assinar como {p.papel}</button>}
</div>
);
})}
</div>
{/* Log de assinaturas (não imprime) */}
{(c.assinaturas || []).some((a: any) => a.status === 'assinado') && (
<div className="mt-10 border-t border-gray-100 pt-4 print:hidden">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2">Registro de aceite</p>
<div className="space-y-1">
{(c.assinaturas || []).filter((a: any) => a.status === 'assinado').map((a: any) => (
<p key={a.id} className="text-[10px] text-gray-500 font-medium"> {a.assinante_nome || '—'} · {a.assinado_em ? new Date(a.assinado_em).toLocaleString('pt-BR') : ''} · IP {a.ip || '—'}</p>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
};
// ─── View principal: abas Contratos (instâncias) + Modelos ────────────────────
type Tab = 'contratos' | 'modelos';
export const ContratosView: React.FC = () => {
const toast = useToast();
const confirm = useConfirm();
const [tab, setTab] = useState<Tab>('contratos');
const [modelos, setModelos] = useState<Modelo[]>([]);
const [variaveis, setVariaveis] = useState<Variavel[]>([]);
const [instancias, setInstancias] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [editor, setEditor] = useState<Modelo | null | 'novo'>(null);
const [viewer, setViewer] = useState<Modelo | null>(null);
const [gerar, setGerar] = useState(false);
const [printId, setPrintId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const carregar = useCallback(async () => {
setLoading(true);
try {
const [ms, vs, is] = await Promise.all([
HybridBackend.getContratoModelos(), HybridBackend.getContratoVariaveis(), HybridBackend.getContratoInstancias(),
]);
setModelos(ms); setVariaveis(vs); setInstancias(is);
} catch { /* silent */ }
finally { setLoading(false); }
}, []);
useEffect(() => { carregar(); }, [carregar]);
const duplicar = async (m: Modelo) => {
try { await HybridBackend.duplicarContratoModelo(m.id); toast.success('MODELO DUPLICADO PARA SUA UNIDADE.'); carregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
};
const excluirModelo = async (m: Modelo) => {
if (!await confirm(`EXCLUIR o modelo "${m.nome}"?`)) return;
try { await HybridBackend.deleteContratoModelo(m.id); toast.success('MODELO EXCLUÍDO.'); carregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
};
const excluirInst = async (c: any) => {
if (!await confirm(`EXCLUIR o contrato "${c.titulo}"?`)) return;
try { await HybridBackend.deleteContratoInstancia(c.id); toast.success('CONTRATO EXCLUÍDO.'); carregar(); }
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
};
const gerarCobranca = async (c: any) => {
try {
const r = await HybridBackend.gerarCobrancaContrato(c.id);
toast.success(r?.ja_existia ? 'COBRANÇA DESTA COMPETÊNCIA JÁ EXISTE.' : 'COBRANÇA GERADA NO FINANCEIRO.');
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
};
const filtrados = modelos.filter(m => !search.trim() || m.nome.toLowerCase().includes(search.toLowerCase()));
const globais = filtrados.filter(m => m.global);
const meus = filtrados.filter(m => !m.global);
const ModeloCard: React.FC<{ m: Modelo }> = ({ m }) => (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm hover:shadow-md transition-all flex flex-col">
<div className="p-4 flex-1">
<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">{m.nome}</h3>
{m.global
? <span className="text-[9px] font-black px-2 py-0.5 rounded-full uppercase bg-gray-100 text-gray-500 flex items-center gap-1 shrink-0"><Library size={9} /> Catálogo</span>
: <span className="text-[9px] font-black px-2 py-0.5 rounded-full uppercase bg-teal-100 text-teal-700 shrink-0">Meu</span>}
</div>
<div className="flex flex-wrap gap-1 mb-2">
{m.categoria && <span className={`text-[9px] font-bold px-2 py-0.5 rounded-full uppercase ${CAT_STYLE[m.categoria] || 'bg-gray-100 text-gray-500'}`}>{CAT_LABEL[m.categoria] || m.categoria}</span>}
{m.tipo_relacao && <span className="text-[9px] font-bold px-2 py-0.5 rounded-full uppercase bg-gray-50 text-gray-400 flex items-center gap-1"><Tag size={9} />{m.tipo_relacao}</span>}
</div>
<p className="text-[11px] text-gray-500 font-medium line-clamp-3 leading-snug">{m.corpo.replace(/\n+/g, ' ').slice(0, 180)}</p>
</div>
<div className="flex border-t border-gray-100 text-[10px] font-bold uppercase">
<button onClick={() => setViewer(m)} className="flex-1 flex items-center justify-center gap-1.5 py-2 text-gray-600 hover:bg-gray-50 rounded-bl-2xl transition-colors"><Eye size={12} /> Ver</button>
<div className="border-l border-gray-100" />
<button onClick={() => duplicar(m)} className="flex-1 flex items-center justify-center gap-1.5 py-2 text-gray-600 hover:bg-gray-50 transition-colors"><Copy size={12} /> Duplicar</button>
{!m.global && <>
<div className="border-l border-gray-100" />
<button onClick={() => setEditor(m)} className="flex-1 flex items-center justify-center gap-1.5 py-2 text-teal-600 hover:bg-teal-50 transition-colors"><Pencil size={12} /> Editar</button>
<div className="border-l border-gray-100" />
<button onClick={() => excluirModelo(m)} className="flex-1 flex items-center justify-center gap-1.5 py-2 text-red-500 hover:bg-red-50 rounded-br-2xl transition-colors"><Trash2 size={12} /></button>
</>}
</div>
</div>
);
return (
<div className="space-y-6 animate-in fade-in duration-500">
<PageHeader title="CONTRATOS" description="Motor contratual — gere contratos a partir de modelos e gerencie a biblioteca.">
{tab === 'contratos'
? <button onClick={() => setGerar(true)} className="bg-teal-600 hover:bg-teal-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors uppercase font-bold text-sm shadow-sm"><Zap size={18} /> GERAR CONTRATO</button>
: <button onClick={() => setEditor('novo')} className="bg-teal-600 hover:bg-teal-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 MODELO</button>}
</PageHeader>
<div className="flex gap-2">
{([['contratos', 'Contratos', FileSignature], ['modelos', 'Modelos', Library]] as const).map(([id, label, Icon]) => (
<button key={id} onClick={() => setTab(id)} className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-[11px] font-black uppercase tracking-wider transition-all ${tab === id ? 'text-white shadow-md' : 'bg-white text-gray-500 border border-gray-200 hover:bg-gray-50'}`} style={tab === id ? { backgroundColor: COLOR } : {}}>
<Icon size={14} /> {label}
</button>
))}
</div>
{loading ? <div className="flex justify-center py-16"><RefreshCw size={24} className="animate-spin" style={{ color: COLOR }} /></div> : (
<>
{tab === 'contratos' && (
instancias.length === 0
? <div className="text-center py-16 text-gray-400"><FileSignature size={40} className="mx-auto mb-3 opacity-40" /><p className="font-bold text-sm uppercase">Nenhum contrato gerado</p><p className="text-xs mt-1">Clique em "Gerar Contrato" para começar.</p></div>
: <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{instancias.map(c => (
<div key={c.id} className="bg-white rounded-2xl border border-gray-100 shadow-sm hover:shadow-md transition-all flex flex-col">
<div className="p-4 flex-1">
<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.titulo}</h3>
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase shrink-0 ${ST_INST[c.status]?.cls || 'bg-gray-100 text-gray-500'}`}>{ST_INST[c.status]?.label || c.status}</span>
</div>
{c.tipo_relacao && <span className="text-[9px] font-bold px-2 py-0.5 rounded-full uppercase bg-gray-50 text-gray-400 inline-flex items-center gap-1"><Tag size={9} />{c.tipo_relacao}</span>}
{(c.data_inicio || c.data_fim) && (
<p className="text-[11px] text-gray-500 font-medium mt-2 flex items-center gap-1.5"><CalendarClock size={12} className="text-gray-400" />{fmtBR(c.data_inicio)} {c.data_fim ? `${fmtBR(c.data_fim)}` : ''}</p>
)}
{c.cobranca_automatica && (
<p className="text-[10px] font-black text-teal-600 mt-2 flex items-center gap-1.5 uppercase"><Zap size={11} /> Recorrente · R$ {Number(c.valor_recorrente || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</p>
)}
</div>
{c.status === 'vigente' && c.cobranca_automatica && (
<button onClick={() => gerarCobranca(c)} className="border-t border-gray-100 py-2 text-[10px] font-black uppercase text-teal-700 hover:bg-teal-50 flex items-center justify-center gap-1.5 transition-colors"><DollarSign size={12} /> Gerar cobrança do mês</button>
)}
<div className="flex border-t border-gray-100 text-[10px] font-bold uppercase">
<button onClick={() => setPrintId(c.id)} className="flex-1 flex items-center justify-center gap-1.5 py-2 text-gray-600 hover:bg-gray-50 rounded-bl-2xl transition-colors"><Printer size={12} /> Ver / Imprimir</button>
<div className="border-l border-gray-100" />
<button onClick={() => excluirInst(c)} className="flex-1 flex items-center justify-center gap-1.5 py-2 text-red-500 hover:bg-red-50 rounded-br-2xl transition-colors"><Trash2 size={12} /> Excluir</button>
</div>
</div>
))}
</div>
)}
{tab === 'modelos' && (
<>
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" size={16} />
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Buscar modelo…" className="w-full pl-9 pr-3 py-2 border border-gray-200 rounded-lg text-sm outline-none focus:border-teal-300 bg-white" />
</div>
{meus.length > 0 && (
<section>
<h2 className="text-[11px] font-black text-gray-500 uppercase tracking-widest mb-3 flex items-center gap-1.5"><FileSignature size={13} /> Meus modelos</h2>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">{meus.map(m => <ModeloCard key={m.id} m={m} />)}</div>
</section>
)}
<section>
<h2 className="text-[11px] font-black text-gray-500 uppercase tracking-widest mb-3 flex items-center gap-1.5"><Library size={13} /> Catálogo padrão</h2>
{globais.length === 0
? <p className="text-xs text-gray-400 font-bold uppercase">Nenhum modelo no catálogo.</p>
: <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">{globais.map(m => <ModeloCard key={m.id} m={m} />)}</div>}
</section>
</>
)}
</>
)}
{editor && <ModeloEditor modelo={editor === 'novo' ? null : editor} variaveis={variaveis} onClose={() => setEditor(null)} onSaved={carregar} />}
{viewer && <ModeloViewer modelo={viewer} onClose={() => setViewer(null)} />}
{gerar && <GeradorModal modelos={modelos} variaveis={variaveis} onClose={() => setGerar(false)} onSaved={carregar} />}
{printId && <ContratoPrintView id={printId} onClose={() => setPrintId(null)} onChanged={carregar} />}
</div>
);
};