3e64514b08
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>
548 lines
36 KiB
TypeScript
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>
|
|
);
|
|
};
|