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>
348 lines
16 KiB
TypeScript
348 lines
16 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { Monitor, Plus, Copy, Download, CheckCircle2, Loader2, Wifi, WifiOff, RefreshCw, Tag, Server, Save, KeyRound, Link2, AlertTriangle } from 'lucide-react';
|
|
import { HybridBackend } from '../services/backend.ts';
|
|
import { PageHeader } from '../components/PageHeader.tsx';
|
|
import { useToast } from '../contexts/ToastContext.tsx';
|
|
|
|
export const RxConfigView: React.FC = () => {
|
|
const workspace = HybridBackend.getActiveWorkspace();
|
|
const currentRole = HybridBackend.getCurrentRole();
|
|
const isSuperadmin = currentRole === 'superadmin';
|
|
const { addToast } = useToast();
|
|
|
|
// Escopo da lista de dispositivos: superadmin vê tudo; clínica vê os seus.
|
|
const clinicaId: string = workspace?.id || '';
|
|
const clinicName: string = workspace?.nome || workspace?.name || '';
|
|
|
|
const [devices, setDevices] = useState<any[]>([]);
|
|
const [loadingDevices, setLoadingDevices] = useState(true);
|
|
const [newPcName, setNewPcName] = useState('');
|
|
const [newEmail, setNewEmail] = useState('');
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [creating, setCreating] = useState(false);
|
|
const [newToken, setNewToken] = useState<{ token: string; pcName: string } | null>(null);
|
|
const [copied, setCopied] = useState(false);
|
|
const [rxVersion, setRxVersion] = useState<{ server: string; client: string; client_url: string } | null>(null);
|
|
|
|
// Configuração GLOBAL de conexão (definida pelo superadmin).
|
|
const [rxUrl, setRxUrl] = useState('https://rx.scoreodonto.com');
|
|
const [apiToken, setApiToken] = useState('');
|
|
const [hasToken, setHasToken] = useState(false);
|
|
const [configured, setConfigured] = useState(false);
|
|
const [savingCfg, setSavingCfg] = useState(false);
|
|
|
|
const fetchDevices = async () => {
|
|
setLoadingDevices(true);
|
|
// Superadmin: todos os dispositivos; clínica: só os da sua clinicaId.
|
|
const list = await HybridBackend.getRxDevices(isSuperadmin ? '' : clinicaId);
|
|
setDevices(Array.isArray(list) ? list : []);
|
|
setLoadingDevices(false);
|
|
};
|
|
|
|
const fetchConfig = async () => {
|
|
const cfg = await HybridBackend.getRxConfig();
|
|
if (!cfg) return;
|
|
setRxUrl(cfg.rx_url || 'https://rx.scoreodonto.com');
|
|
setHasToken(!!cfg.has_token);
|
|
setConfigured(!!cfg.configured);
|
|
if (cfg.rx_url) {
|
|
try {
|
|
const res = await fetch(`${cfg.rx_url}/api/version`);
|
|
if (res.ok) setRxVersion(await res.json());
|
|
} catch (_) {}
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchConfig();
|
|
fetchDevices();
|
|
}, [clinicaId, isSuperadmin]);
|
|
|
|
const handleSaveConfig = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!isSuperadmin) return;
|
|
if (!rxUrl.trim() || (!apiToken.trim() && !hasToken)) {
|
|
addToast('Informe a URL e o token de API do servidor RX.', 'error');
|
|
return;
|
|
}
|
|
setSavingCfg(true);
|
|
const r = await HybridBackend.putRxConfig({
|
|
rx_url: rxUrl.trim(),
|
|
...(apiToken.trim() ? { api_token: apiToken.trim() } : {}),
|
|
});
|
|
setSavingCfg(false);
|
|
if (!r.success) {
|
|
addToast(r.message || 'Erro ao salvar configuração.', 'error');
|
|
return;
|
|
}
|
|
setApiToken('');
|
|
setHasToken(true);
|
|
setConfigured(!!r.configured);
|
|
addToast('Configuração geral do RX salva.', 'success');
|
|
fetchConfig();
|
|
fetchDevices();
|
|
};
|
|
|
|
const handleCreate = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!newPcName.trim() || !clinicaId) return;
|
|
if (!configured) {
|
|
addToast('O RX ainda não foi configurado pelo superadmin.', 'error');
|
|
return;
|
|
}
|
|
setCreating(true);
|
|
const result = await HybridBackend.createRxDevice(clinicaId, newPcName.trim(), {
|
|
email: newEmail.trim() || undefined,
|
|
password: newPassword.trim() || undefined,
|
|
});
|
|
setCreating(false);
|
|
if (!result.success) {
|
|
addToast(result.message || 'Erro ao criar dispositivo.', 'error');
|
|
return;
|
|
}
|
|
setNewToken({ token: result.machine_token!, pcName: newPcName.trim() });
|
|
setNewPcName('');
|
|
setNewEmail('');
|
|
setNewPassword('');
|
|
fetchDevices();
|
|
};
|
|
|
|
const copyToken = (token: string) => {
|
|
navigator.clipboard.writeText(token).then(() => {
|
|
setCopied(true);
|
|
addToast('Token copiado!', 'success');
|
|
setTimeout(() => setCopied(false), 2000);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader
|
|
title="DISPOSITIVOS RX"
|
|
description={isSuperadmin
|
|
? 'Configuração geral do servidor RX e dispositivos de todas as clínicas'
|
|
: 'Gerencie os PCs que enviam radiografias para esta clínica'}
|
|
/>
|
|
|
|
{/* Configuração GERAL do servidor RX — só o superadmin edita */}
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="font-black text-gray-700 uppercase text-sm flex items-center gap-2">
|
|
<Server size={16} /> CONFIGURAÇÃO GERAL DO RX
|
|
</h3>
|
|
<span className={`text-[10px] font-black px-2.5 py-1 rounded-full uppercase flex items-center gap-1 ${configured ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'}`}>
|
|
{configured ? <><CheckCircle2 size={11} /> CONECTADO</> : <><AlertTriangle size={11} /> NÃO CONFIGURADO</>}
|
|
</span>
|
|
</div>
|
|
|
|
{isSuperadmin ? (
|
|
<form onSubmit={handleSaveConfig} className="space-y-3">
|
|
<label className="block">
|
|
<span className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1 mb-1"><Link2 size={11} /> URL do servidor RX</span>
|
|
<input
|
|
type="url"
|
|
value={rxUrl}
|
|
onChange={(e) => setRxUrl(e.target.value)}
|
|
placeholder="https://rx.scoreodonto.com"
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium focus:ring-2 focus:ring-teal-500 outline-none"
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1 mb-1"><KeyRound size={11} /> Token de API (conta-mestre)</span>
|
|
<input
|
|
type="password"
|
|
value={apiToken}
|
|
onChange={(e) => setApiToken(e.target.value)}
|
|
placeholder={hasToken ? '•••••••• (mantém o atual)' : 'token estático de API do RX'}
|
|
autoComplete="new-password"
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium focus:ring-2 focus:ring-teal-500 outline-none"
|
|
/>
|
|
</label>
|
|
<div className="flex items-center justify-between gap-3 pt-1">
|
|
<p className="text-[10px] text-gray-400 font-bold uppercase leading-tight">
|
|
Token estático da conta-mestre. Vale para todas as clínicas; o token não é exibido depois de salvo.
|
|
</p>
|
|
<button
|
|
type="submit"
|
|
disabled={savingCfg}
|
|
className="flex-shrink-0 bg-gray-800 hover:bg-gray-900 text-white px-4 py-2 rounded-lg text-sm font-bold flex items-center gap-2 transition-colors disabled:opacity-60"
|
|
>
|
|
{savingCfg ? <Loader2 className="animate-spin" size={16} /> : <Save size={16} />}
|
|
{savingCfg ? 'SALVANDO...' : 'SALVAR'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
) : (
|
|
<p className="text-xs text-gray-500 font-bold uppercase">
|
|
{configured
|
|
? 'O servidor RX está configurado pelo superadmin. Você já pode cadastrar PCs abaixo.'
|
|
: 'Aguardando o superadmin configurar o servidor RX. Sem isso não é possível cadastrar PCs.'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Token gerado */}
|
|
{newToken && (
|
|
<div className="bg-green-50 border border-green-200 rounded-2xl p-6">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<CheckCircle2 size={20} className="text-green-600" />
|
|
<h3 className="font-black text-green-800 uppercase text-sm">PC "{newToken.pcName}" CADASTRADO!</h3>
|
|
</div>
|
|
<p className="text-xs text-green-700 font-bold uppercase mb-3">
|
|
Cole este token no client Windows ao configurar. Ele só aparece uma vez.
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<code className="flex-1 bg-white border border-green-300 rounded-lg px-3 py-2 text-xs font-mono text-gray-700 break-all">
|
|
{newToken.token}
|
|
</code>
|
|
<button
|
|
onClick={() => copyToken(newToken.token)}
|
|
className="flex-shrink-0 bg-green-600 hover:bg-green-700 text-white px-3 py-2 rounded-lg text-xs font-bold flex items-center gap-1 transition-colors"
|
|
>
|
|
{copied ? <CheckCircle2 size={14} /> : <Copy size={14} />}
|
|
{copied ? 'COPIADO' : 'COPIAR'}
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={() => setNewToken(null)}
|
|
className="mt-3 text-xs text-green-600 hover:text-green-800 font-bold uppercase underline"
|
|
>
|
|
Já salvei o token
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Download client */}
|
|
<div className="bg-teal-50 border border-teal-200 rounded-2xl p-5 flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<h3 className="font-black text-teal-800 uppercase text-sm">PC-RX CLIENT WINDOWS</h3>
|
|
{rxVersion?.client && (
|
|
<span className="flex items-center gap-1 bg-teal-100 text-teal-700 text-[10px] font-black px-2 py-0.5 rounded-full uppercase">
|
|
<Tag size={10} /> v{rxVersion.client}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-teal-600 font-bold">
|
|
Instale no computador do consultório. Use o token gerado para configurar.
|
|
</p>
|
|
{rxVersion?.server && (
|
|
<p className="text-[10px] text-teal-400 font-bold mt-1 uppercase">
|
|
Servidor RX v{rxVersion.server}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<a
|
|
href={rxVersion?.client_url || `${rxUrl}/download-client`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex-shrink-0 bg-teal-600 hover:bg-teal-700 text-white px-4 py-2 rounded-lg text-xs font-black uppercase flex items-center gap-2 transition-colors shadow-sm"
|
|
>
|
|
<Download size={14} /> BAIXAR{rxVersion?.client ? ` v${rxVersion.client}` : ' CLIENT'}
|
|
</a>
|
|
</div>
|
|
|
|
{/* Adicionar novo PC — só para usuários de clínica (têm workspace) */}
|
|
{!isSuperadmin && clinicaId && (
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
|
<h3 className="font-black text-gray-700 uppercase text-sm mb-4 flex items-center gap-2">
|
|
<Plus size={16} /> ADICIONAR PC
|
|
</h3>
|
|
{!configured && (
|
|
<p className="text-xs text-amber-600 font-bold uppercase mb-3 flex items-center gap-1">
|
|
<AlertTriangle size={13} /> O RX ainda não foi configurado pelo superadmin.
|
|
</p>
|
|
)}
|
|
<form onSubmit={handleCreate} className="space-y-3">
|
|
<input
|
|
type="text"
|
|
value={newPcName}
|
|
onChange={(e) => setNewPcName(e.target.value)}
|
|
placeholder="Nome do PC (ex: CONSULTÓRIO 1)"
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium focus:ring-2 focus:ring-teal-500 outline-none uppercase"
|
|
/>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<input
|
|
type="email"
|
|
value={newEmail}
|
|
onChange={(e) => setNewEmail(e.target.value)}
|
|
placeholder="E-mail de contato (opcional)"
|
|
autoComplete="off"
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium focus:ring-2 focus:ring-teal-500 outline-none"
|
|
/>
|
|
<input
|
|
type="password"
|
|
value={newPassword}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
placeholder="Senha de acesso (opcional)"
|
|
autoComplete="new-password"
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium focus:ring-2 focus:ring-teal-500 outline-none"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-[10px] text-gray-400 font-bold uppercase leading-tight">
|
|
Clínica: <span className="text-gray-600">{clinicName || clinicaId}</span>. E-mail/senha em branco usam um padrão por clínica.
|
|
</p>
|
|
<button
|
|
type="submit"
|
|
disabled={creating || !newPcName.trim() || !configured}
|
|
className="flex-shrink-0 bg-teal-600 hover:bg-teal-700 text-white px-4 py-2 rounded-lg text-sm font-bold flex items-center gap-2 transition-colors disabled:opacity-60"
|
|
>
|
|
{creating ? <Loader2 className="animate-spin" size={16} /> : <Plus size={16} />}
|
|
{creating ? 'CRIANDO...' : 'CRIAR E GERAR TOKEN'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{/* Lista de dispositivos */}
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
|
<div className="p-5 border-b border-gray-100 flex items-center justify-between">
|
|
<h3 className="font-black text-gray-700 uppercase text-sm flex items-center gap-2">
|
|
<Monitor size={16} /> {isSuperadmin ? 'PCS DE TODAS AS CLÍNICAS' : 'PCS CADASTRADOS'} ({devices.length})
|
|
</h3>
|
|
<button
|
|
onClick={fetchDevices}
|
|
disabled={loadingDevices}
|
|
className="p-1.5 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors"
|
|
>
|
|
<RefreshCw size={14} className={loadingDevices ? 'animate-spin' : ''} />
|
|
</button>
|
|
</div>
|
|
|
|
{loadingDevices ? (
|
|
<div className="flex justify-center py-10">
|
|
<Loader2 className="animate-spin text-teal-500" size={28} />
|
|
</div>
|
|
) : devices.length === 0 ? (
|
|
<div className="text-center py-10 text-gray-400">
|
|
<Monitor size={36} className="mx-auto mb-3 opacity-30" />
|
|
<p className="text-xs font-bold uppercase">Nenhum PC cadastrado ainda</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-50">
|
|
{devices.map((d: any) => (
|
|
<div key={d.id} className="flex items-center gap-4 px-5 py-4">
|
|
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${d.is_active ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'}`}>
|
|
{d.is_active ? <Wifi size={16} /> : <WifiOff size={16} />}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-black text-gray-800 text-sm uppercase">{d.pc_name}</p>
|
|
<p className="text-xs text-gray-400 font-bold">
|
|
{isSuperadmin && d.clinic_name ? `Clínica: ${d.clinic_name} · ` : ''}
|
|
{d.last_ip ? `Último acesso: ${d.last_ip}` : 'Nunca conectou'} · {d.is_active ? 'ATIVO' : 'INATIVO'}
|
|
</p>
|
|
</div>
|
|
<span className={`text-xs font-black px-2 py-1 rounded-full ${d.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
|
{d.is_active ? 'ATIVO' : 'INATIVO'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|