import React, { useState, useEffect, useCallback } from 'react'; import { ScanLine, Upload, RefreshCw, X, Image as ImageIcon, Loader2, Users, FileText, CheckCircle2, Clock, AlertCircle, ZoomIn, ChevronRight, Calendar, Camera as CameraIcon } from 'lucide-react'; import { PageHeader } from '../../components/PageHeader.tsx'; import { useToast } from '../../contexts/ToastContext.tsx'; import { HybridBackend } from '../../services/backend.ts'; import { CameraCapture } from '../../components/CameraCapture.tsx'; const FALLBACK_URL = 'https://rx.scoreodonto.com'; // Runtime RX config — loaded once from backend, then cached let _rxBase: string = FALLBACK_URL; let _rxToken: string = ''; let _rxConfigLoaded = false; async function loadRxConfig(): Promise { if (_rxConfigLoaded) return; const cfg = await HybridBackend.getRxConfig(); if (cfg) { _rxBase = cfg.rx_url?.replace(/\/$/, '') || FALLBACK_URL; // O token do RX agora vive só no backend (config global do superadmin). // TODO: visualização autenticada de imagens deve passar por proxy no backend // (ex.: GET /api/rx/images?clinicaId=&paciente=) em vez de Bearer no navegador. _rxToken = ''; } _rxConfigLoaded = true; } function thumbUrl(relPath: string): string { return `${_rxBase}${relPath}`; } // clinicaId ativo (= clinic_name no RX). Superadmin sem workspace → '' (vê tudo). const activeClinicaId = (): string => HybridBackend.getActiveWorkspace()?.id || ''; const rxFetch = (path: string, opts: RequestInit = {}) => fetch(`${_rxBase}/api${path}`, { ...opts, headers: { 'Authorization': `Bearer ${_rxToken}`, ...(opts.headers || {}), }, }); // ─── Types ─────────────────────────────────────────────────────────────────── interface RxPatient { patient_name: string; doctor: string; remark: string; client_name: string; image_count: string; // the API returns this as a string last_date: string; latest_image_id: number; thumb_url: string; // relative path, e.g. /api/images/5386/thumbnail pending_count: string; } interface RxImage { id: number; filename: string; thumb_filename: string; thumb_url: string; // relative path — use thumbUrl() to build absolute file_url: string; // relative path to the signed-URL endpoint doctor: string; remark: string; image_remark: string | null; created_at: string; } interface RxGTO { id: number; gto_number: string; patient_name: string; description: string; sent: boolean; sent_at: string | null; created_at: string; image_count: number; thumb_url: string | null; } // ─── Sub-components ─────────────────────────────────────────────────────────── const StatCard: React.FC<{ label: string; value: number | string; icon: React.ReactNode; color: string }> = ({ label, value, icon, color }) => (
{icon}

{label}

{value}

); // ─── Image tile: uses thumb_url directly (no extra API call needed) ─────────── const RxImageTile: React.FC<{ image: RxImage; onClick: (image: RxImage) => void }> = ({ image, onClick }) => ( ); // ─── Fullscreen viewer: loads signed URL on demand ──────────────────────────── const FullscreenViewer: React.FC<{ image: RxImage; onClose: () => void }> = ({ image, onClose }) => { const [url, setUrl] = useState(null); const [err, setErr] = useState(false); useEffect(() => { rxFetch(`/images/${image.id}/file-url`) .then(r => r.json()) .then(d => { if (d.url) setUrl(d.url); else setErr(true); }) .catch(() => setErr(true)); }, [image.id]); return (
{image.image_remark && (
{image.image_remark}
)} {err ? (

ERRO AO CARREGAR IMAGEM

) : !url ? (

CARREGANDO...

) : ( {image.filename} e.stopPropagation()} /> )}
); }; // ─── Image Gallery Modal ────────────────────────────────────────────────────── const ImageGalleryModal: React.FC<{ patient: RxPatient; onClose: () => void; onUploadClick: (name: string) => void; }> = ({ patient, onClose, onUploadClick }) => { const [images, setImages] = useState([]); const [loading, setLoading] = useState(true); const [fullscreen, setFullscreen] = useState(null); useEffect(() => { // Leitura via proxy do backend (token global), escopo clinicaId = clinic_name. HybridBackend.getRxPatientImages(activeClinicaId(), patient.patient_name) .then(d => setImages(Array.isArray(d) ? d : [])) .catch(() => setImages([])) .finally(() => setLoading(false)); }, [patient.patient_name]); return (
{/* Mobile drag handle */}

{patient.patient_name}

{images.length} RADIOGRAFIA{images.length !== 1 ? 'S' : ''}

{patient.doctor &&

· {patient.doctor}

} {patient.remark &&

· {patient.remark}

}
{loading ? (
CARREGANDO IMAGENS...
) : images.length === 0 ? (

NENHUMA IMAGEM ENCONTRADA

) : (
{images.map(img => )}
)}
{fullscreen && setFullscreen(null)} />}
); }; // ─── Upload Modal ───────────────────────────────────────────────────────────── const UploadModal: React.FC<{ initialPatient?: string; onClose: () => void; onSuccess: () => void }> = ({ initialPatient, onClose, onSuccess }) => { const toast = useToast(); const [patientName, setPatientName] = useState(initialPatient || ''); const [doctor, setDoctor] = useState(''); const [remark, setRemark] = useState(''); const [file, setFile] = useState(null); const [uploading, setUploading] = useState(false); const [showCam, setShowCam] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!file || !patientName.trim()) { toast.error('SELECIONE UM ARQUIVO E INFORME O NOME DO PACIENTE.'); return; } setUploading(true); try { const fd = new FormData(); fd.append('image', file); fd.append('patientName', patientName.trim().toUpperCase()); fd.append('doctor', doctor.trim().toUpperCase()); fd.append('remark', remark.trim().toUpperCase()); const res = await rxFetch('/images/upload-file', { method: 'POST', body: fd }); const data = await res.json(); if (data.success || data.filename) { toast.success('RADIOGRAFIA ENVIADA COM SUCESSO!'); onSuccess(); onClose(); } else { toast.error('ERRO AO ENVIAR IMAGEM.'); } } catch { toast.error('ERRO DE CONEXÃO COM O SERVIDOR RX.'); } finally { setUploading(false); } }; const inputCls = "w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm font-bold uppercase focus:outline-none focus:border-purple-400 focus:ring-2 focus:ring-purple-100 bg-white"; return (

UPLOAD DE RADIOGRAFIA

RX SCOREODONTO

setPatientName(e.target.value)} className={inputCls} placeholder="NOME COMPLETO DO PACIENTE" required />
setDoctor(e.target.value)} className={inputCls} placeholder="DR. NOME" />
setRemark(e.target.value)} className={inputCls} placeholder="EX: RAIO-X PERIAPICAL" />
document.getElementById('rx-file-input')?.click()} > {file ? (
{file.name}
) : (

CLIQUE PARA SELECIONAR

PNG, JPG, DICOM

)} setFile(e.target.files?.[0] || null)} />
{showCam && ( { setFile(f); setShowCam(false); }} onClose={() => setShowCam(false)} /> )}
); }; // ─── GTO Tab ────────────────────────────────────────────────────────────────── const GTOTab: React.FC = () => { const toast = useToast(); const [gtos, setGtos] = useState([]); const [loading, setLoading] = useState(true); const [sending, setSending] = useState(null); const load = useCallback(() => { setLoading(true); rxFetch('/gtos') .then(r => r.json()) // real response: { rows: [...], total, page, limit, pages } .then(d => setGtos(Array.isArray(d.rows) ? d.rows : [])) .catch(() => setGtos([])) .finally(() => setLoading(false)); }, []); useEffect(() => { load(); }, [load]); const markSent = async (gto: RxGTO) => { setSending(gto.id); try { const res = await rxFetch(`/gtos/${gto.id}/sent`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sent: true }) }); if (res.ok) { toast.success(`GTO ${gto.gto_number} MARCADA COMO ENVIADA!`); load(); } else toast.error('ERRO AO ATUALIZAR GTO.'); } catch { toast.error('ERRO DE CONEXÃO.'); } finally { setSending(null); } }; if (loading) return (
CARREGANDO GTOs...
); if (gtos.length === 0) return (

NENHUMA GTO ENCONTRADA

); const pendentes = gtos.filter(g => !g.sent).length; return (
{pendentes > 0 && (

{pendentes} GTO{pendentes !== 1 ? 'S' : ''} PENDENTE{pendentes !== 1 ? 'S' : ''} DE ENVIO

)} {gtos.map(gto => (
{/* Thumbnail */} {gto.thumb_url ? ( thumb ) : (
)}

#{gto.gto_number}

{gto.sent ? 'ENVIADA' : 'PENDENTE'}

{gto.patient_name}

{gto.description &&

{gto.description}

}
{gto.image_count} img {new Date(gto.created_at).toLocaleDateString('pt-BR')}
{!gto.sent && ( )}
))}
); }; // ─── Patient card with proper image error state ─────────────────────────────── const PatientCard: React.FC<{ patient: RxPatient; onClick: (p: RxPatient) => void }> = ({ patient, onClick }) => { const [imgError, setImgError] = useState(false); return ( ); }; // ─── Main RXPlugin View ─────────────────────────────────────────────────────── export const RXPlugin: React.FC = () => { const toast = useToast(); const [tab, setTab] = useState<'pacientes' | 'gtos'>('pacientes'); const [patients, setPatients] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [selectedPatient, setSelectedPatient] = useState(null); const [uploadOpen, setUploadOpen] = useState(false); const [uploadPatient, setUploadPatient] = useState(''); const [syncing, setSyncing] = useState(false); const [configReady, setConfigReady] = useState(_rxConfigLoaded); const loadPatients = useCallback(() => { setLoading(true); // Leitura via proxy do backend (token global), escopo clinicaId = clinic_name. // A busca por texto continua client-side (ver `filtered`). HybridBackend.getRxPatients(activeClinicaId()) .then(d => setPatients(Array.isArray(d) ? d : [])) .catch(() => setPatients([])) .finally(() => setLoading(false)); }, []); useEffect(() => { if (!_rxConfigLoaded) { loadRxConfig().then(() => { setConfigReady(true); loadPatients(); }); } else { loadPatients(); } }, [loadPatients]); const handleSync = async () => { setSyncing(true); try { const res = await rxFetch('/system/trigger-sync', { method: 'POST' }); const d = await res.json(); if (d.success) toast.success(d.message || 'SINCRONIZAÇÃO ENVIADA!'); else toast.error('ERRO AO SINCRONIZAR.'); } catch { toast.error('ERRO DE CONEXÃO.'); } finally { setSyncing(false); } }; const handleUploadClick = (name = '') => { setUploadPatient(name); setUploadOpen(true); }; // image_count comes as string from the API const totalImages = patients.reduce((acc, p) => acc + parseInt(p.image_count || '0', 10), 0); const filtered = patients.filter(p => !search.trim() || p.patient_name.toLowerCase().includes(search.toLowerCase())); const TABS = [ { id: 'pacientes' as const, label: 'PACIENTES', icon: }, { id: 'gtos' as const, label: 'GTOs', icon: }, ]; return (
{/* Stats */}
} color="#7c3aed" /> } color="#059669" /> a + parseInt(p.pending_count || '0', 10), 0)} icon={} color="#d97706" />
{/* Tabs */}
{TABS.map(t => ( ))}
{/* PACIENTES */} {tab === 'pacientes' && (
setSearch(e.target.value)} placeholder="BUSCAR PACIENTE..." className="w-full border border-gray-200 rounded-2xl px-5 py-3 text-sm font-bold uppercase focus:outline-none focus:border-purple-400 focus:ring-2 focus:ring-purple-100 bg-white" /> {loading ? (
CARREGANDO PACIENTES...
) : filtered.length === 0 ? (

{search ? 'NENHUM PACIENTE ENCONTRADO' : 'SEM PACIENTES COM IMAGENS'}

) : (
{filtered.map(p => ( ))}
)}
)} {/* GTOs */} {tab === 'gtos' && } {/* Modals */} {selectedPatient && ( setSelectedPatient(null)} onUploadClick={name => { setSelectedPatient(null); handleUploadClick(name); }} /> )} {uploadOpen && ( setUploadOpen(false)} onSuccess={loadPatients} /> )}
); };