Files
scoreodonto.com/frontend/views/RxConfigView.tsx
T
VPS 4 Builder f084656712 feat(agenda): auditoria/autoria, faltas, a-reagendar, presença + RBAC por cargo
Agenda (multi-secretária):
- audit_log genérico + autoria (created/updated/canceled_by) + soft-delete + version (lock otimista)
- status novo vocabulário + índice único PARCIAL (libera slot ao cancelar/faltar)
- endpoints: falta, remarcar, restaurar, historico, pendencias (a reagendar), faltas, familia
- presença: heartbeat (DB última visita + Dragonfly ao vivo, janela 45s)
- frontend: StackModal, AgendaDetailModal, AgendaPresence; lista a-reagendar; pacienteId no fluxo de criação; chips de família + badge de faltas no modal

RBAC por cargo: funcoes.permissoes/nivel; gate de menu por cargo (array vazio herda role); hierarquia só-gerencia-abaixo no backend

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

205 lines
8.7 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { Monitor, Plus, Copy, Download, CheckCircle2, Loader2, Wifi, WifiOff, RefreshCw, Tag } 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 clinicaId = workspace?.id;
const { addToast } = useToast();
const [devices, setDevices] = useState<any[]>([]);
const [loadingDevices, setLoadingDevices] = useState(true);
const [newPcName, setNewPcName] = 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);
const fetchDevices = async () => {
if (!clinicaId) return;
setLoadingDevices(true);
const list = await HybridBackend.getRxDevices(clinicaId);
setDevices(list);
setLoadingDevices(false);
};
const fetchRxVersion = async () => {
const cfg = await HybridBackend.getRxConfig(clinicaId!);
if (!cfg?.rx_url) return;
try {
const res = await fetch(`${cfg.rx_url}/api/version`);
if (res.ok) setRxVersion(await res.json());
} catch (_) {}
};
useEffect(() => {
fetchDevices();
if (clinicaId) fetchRxVersion();
}, [clinicaId]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!newPcName.trim() || !clinicaId) return;
setCreating(true);
const result = await HybridBackend.createRxDevice(clinicaId, newPcName.trim());
setCreating(false);
if (!result.success) {
addToast(result.message || 'Erro ao criar dispositivo.', 'error');
return;
}
setNewToken({ token: result.machine_token!, pcName: newPcName.trim() });
setNewPcName('');
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="Gerencie os PCs que enviam radiografias para esta clínica"
/>
{/* 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 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"
>
salvei o token
</button>
</div>
)}
{/* Download client */}
<div className="bg-blue-50 border border-blue-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-blue-800 uppercase text-sm">PC-RX CLIENT WINDOWS</h3>
{rxVersion?.client && (
<span className="flex items-center gap-1 bg-blue-100 text-blue-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-blue-600 font-bold">
Instale no computador do consultório. Use o token gerado para configurar.
</p>
{rxVersion?.server && (
<p className="text-[10px] text-blue-400 font-bold mt-1 uppercase">
Servidor RX v{rxVersion.server}
</p>
)}
</div>
<a
href={rxVersion?.client_url || 'https://rx.scoreodonto.com/download-client'}
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0 bg-blue-600 hover:bg-blue-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 */}
<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>
<form onSubmit={handleCreate} className="flex gap-3">
<input
type="text"
value={newPcName}
onChange={(e) => setNewPcName(e.target.value)}
placeholder="Nome do PC (ex: CONSULTÓRIO 1)"
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium focus:ring-2 focus:ring-blue-500 outline-none uppercase"
/>
<button
type="submit"
disabled={creating || !newPcName.trim()}
className="bg-blue-600 hover:bg-blue-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'}
</button>
</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} /> 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-blue-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">
{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>
);
};