Files
scoreodonto.com/frontend/views/plugins/RXPlugin.tsx
T
VPS 4 Builder ba062e92d0 fix(login): render standalone views edge-to-edge e esconde scrollbar do carrossel de onboarding
- App.tsx: wrappers de padding (p-4/md:p-8) e max-w-7xl/mx-auto agora só se aplicam quando não é tela standalone; login/landing/public renderizam full-bleed
- OnboardingChat.tsx: classe ob-noscroll esconde a barra de rolagem do slide intro (mantém scroll), substituindo a custom-scrollbar indefinida no fluxo de login

Inclui também demais alterações pendentes do working tree (snapshot do estado atual de dev).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:23:19 +02:00

614 lines
29 KiB
TypeScript

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<void> {
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 }) => (
<div className="bg-white rounded-xl sm:rounded-2xl p-3 sm:p-5 border border-gray-100 flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 shadow-sm">
<div className="w-8 h-8 sm:w-11 sm:h-11 rounded-lg sm:rounded-xl flex items-center justify-center flex-shrink-0 [&>svg]:w-4 [&>svg]:h-4 sm:[&>svg]:w-5 sm:[&>svg]:h-5" style={{ backgroundColor: `${color}15`, color }}>
{icon}
</div>
<div>
<p className="text-[8px] sm:text-[9px] font-black text-gray-400 uppercase tracking-widest leading-tight">{label}</p>
<p className="text-lg sm:text-2xl font-black text-gray-900">{value}</p>
</div>
</div>
);
// ─── Image tile: uses thumb_url directly (no extra API call needed) ───────────
const RxImageTile: React.FC<{ image: RxImage; onClick: (image: RxImage) => void }> = ({ image, onClick }) => (
<button
onClick={() => onClick(image)}
className="aspect-square rounded-xl overflow-hidden border border-gray-100 hover:border-purple-300 group relative transition-all shadow-sm"
>
<img
src={thumbUrl(image.thumb_url)}
alt={image.filename}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center">
<ZoomIn size={20} className="text-white opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
{(image.image_remark || image.doctor) && (
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-[8px] font-bold px-2 py-1 truncate opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
{image.image_remark || image.doctor}
</div>
)}
</button>
);
// ─── Fullscreen viewer: loads signed URL on demand ────────────────────────────
const FullscreenViewer: React.FC<{ image: RxImage; onClose: () => void }> = ({ image, onClose }) => {
const [url, setUrl] = useState<string | null>(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 (
<div className="fixed inset-0 z-[80] bg-black/95 flex items-center justify-center p-4" onClick={onClose}>
<button className="absolute top-4 right-4 text-white/70 hover:text-white p-2 rounded-full hover:bg-white/10 transition-colors" onClick={onClose}>
<X size={24} />
</button>
{image.image_remark && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-black/60 text-white text-xs font-bold px-4 py-2 rounded-full uppercase">
{image.image_remark}
</div>
)}
{err ? (
<div className="flex flex-col items-center gap-3 text-white/60">
<AlertCircle size={40} /><p className="text-sm font-bold uppercase">ERRO AO CARREGAR IMAGEM</p>
</div>
) : !url ? (
<div className="flex flex-col items-center gap-3 text-white/60">
<Loader2 size={40} className="animate-spin" /><p className="text-sm font-bold uppercase">CARREGANDO...</p>
</div>
) : (
<img src={url} alt={image.filename} className="max-w-full max-h-full object-contain rounded-xl shadow-2xl" onClick={e => e.stopPropagation()} />
)}
</div>
);
};
// ─── Image Gallery Modal ──────────────────────────────────────────────────────
const ImageGalleryModal: React.FC<{
patient: RxPatient;
onClose: () => void;
onUploadClick: (name: string) => void;
}> = ({ patient, onClose, onUploadClick }) => {
const [images, setImages] = useState<RxImage[]>([]);
const [loading, setLoading] = useState(true);
const [fullscreen, setFullscreen] = useState<RxImage | null>(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 (
<div className="fixed inset-0 bg-black/70 z-[70] flex items-end sm:items-center justify-center backdrop-blur-sm p-0 sm:p-4">
<div className="bg-white rounded-t-2xl sm:rounded-2xl w-full sm:max-w-3xl max-h-[92vh] flex flex-col overflow-hidden shadow-2xl">
<div className="px-4 sm:px-6 py-3 sm:py-4 border-b border-gray-100 flex-shrink-0" style={{ background: 'linear-gradient(to right, #7c3aed10, transparent)' }}>
{/* Mobile drag handle */}
<div className="w-10 h-1 bg-gray-200 rounded-full mx-auto mb-3 sm:hidden" />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm sm:text-base font-black text-gray-900 uppercase tracking-tight truncate">{patient.patient_name}</h3>
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 mt-0.5">
<p className="text-[10px] text-purple-600 font-bold uppercase">{images.length} RADIOGRAFIA{images.length !== 1 ? 'S' : ''}</p>
{patient.doctor && <p className="text-[10px] text-gray-400 font-bold uppercase">· {patient.doctor}</p>}
{patient.remark && <p className="text-[10px] text-gray-400 font-medium truncate">· {patient.remark}</p>}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<button
onClick={() => onUploadClick(patient.patient_name)}
className="flex items-center gap-1.5 px-3 sm:px-4 py-2 bg-purple-600 text-white rounded-xl text-[10px] font-black uppercase hover:bg-purple-700 active:bg-purple-800 transition-colors"
>
<Upload size={13} /> <span className="hidden sm:inline">UPLOAD RX</span><span className="sm:hidden">UPLOAD</span>
</button>
<button onClick={onClose} className="p-2 hover:bg-gray-100 active:bg-gray-200 rounded-xl transition-colors"><X size={18} /></button>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 sm:p-6">
{loading ? (
<div className="flex items-center justify-center py-16 gap-3 text-gray-400">
<Loader2 size={22} className="animate-spin" /><span className="text-sm font-bold uppercase">CARREGANDO IMAGENS...</span>
</div>
) : images.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-gray-400">
<ImageIcon size={40} /><p className="text-sm font-bold uppercase">NENHUMA IMAGEM ENCONTRADA</p>
</div>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 sm:gap-3">
{images.map(img => <RxImageTile key={img.id} image={img} onClick={setFullscreen} />)}
</div>
)}
</div>
</div>
{fullscreen && <FullscreenViewer image={fullscreen} onClose={() => setFullscreen(null)} />}
</div>
);
};
// ─── 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<File | null>(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 (
<div className="fixed inset-0 bg-black/60 z-[70] flex items-center justify-center backdrop-blur-sm p-4">
<form onSubmit={handleSubmit} className="bg-white rounded-2xl w-full max-w-md shadow-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between" style={{ background: 'linear-gradient(to right, #7c3aed10, transparent)' }}>
<div>
<h3 className="text-base font-black text-gray-900 uppercase">UPLOAD DE RADIOGRAFIA</h3>
<p className="text-[10px] font-bold uppercase tracking-widest" style={{ color: '#7c3aed' }}>RX SCOREODONTO</p>
</div>
<button type="button" onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl"><X size={18} /></button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">PACIENTE *</label>
<input value={patientName} onChange={e => setPatientName(e.target.value)} className={inputCls} placeholder="NOME COMPLETO DO PACIENTE" required />
</div>
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">MÉDICO / DENTISTA</label>
<input value={doctor} onChange={e => setDoctor(e.target.value)} className={inputCls} placeholder="DR. NOME" />
</div>
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">OBSERVAÇÃO / TIPO</label>
<input value={remark} onChange={e => setRemark(e.target.value)} className={inputCls} placeholder="EX: RAIO-X PERIAPICAL" />
</div>
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">ARQUIVO *</label>
<div
className="border-2 border-dashed border-gray-200 rounded-xl p-6 text-center hover:border-purple-300 transition-colors cursor-pointer"
onClick={() => document.getElementById('rx-file-input')?.click()}
>
{file ? (
<div className="flex items-center justify-center gap-2 text-purple-600">
<CheckCircle2 size={20} /><span className="text-sm font-bold uppercase truncate max-w-xs">{file.name}</span>
</div>
) : (
<div className="text-gray-400">
<ImageIcon size={32} className="mx-auto mb-2" />
<p className="text-xs font-bold uppercase">CLIQUE PARA SELECIONAR</p>
<p className="text-[9px] font-medium text-gray-300 mt-1">PNG, JPG, DICOM</p>
</div>
)}
<input id="rx-file-input" type="file" accept="image/*,.dcm" className="hidden" onChange={e => setFile(e.target.files?.[0] || null)} />
</div>
<button type="button" onClick={() => setShowCam(true)} className="mt-2 w-full py-2.5 border border-gray-200 rounded-xl text-xs font-black uppercase text-gray-600 hover:bg-gray-50 transition-colors flex items-center justify-center gap-2">
<CameraIcon size={14} /> ABRIR CÂMERA
</button>
</div>
</div>
<div className="px-6 pb-6 flex gap-3">
<button type="button" onClick={onClose} className="flex-1 py-3 border border-gray-200 rounded-xl text-xs font-black uppercase text-gray-500 hover:bg-gray-50 transition-colors">
CANCELAR
</button>
<button type="submit" disabled={uploading} className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase disabled:opacity-50 transition-colors flex items-center justify-center gap-2" style={{ backgroundColor: '#7c3aed' }}>
{uploading ? <><Loader2 size={16} className="animate-spin" /> ENVIANDO...</> : <><Upload size={16} /> ENVIAR</>}
</button>
</div>
</form>
{showCam && (
<CameraCapture
title="RAIO-X"
onCapture={(f) => { setFile(f); setShowCam(false); }}
onClose={() => setShowCam(false)}
/>
)}
</div>
);
};
// ─── GTO Tab ──────────────────────────────────────────────────────────────────
const GTOTab: React.FC = () => {
const toast = useToast();
const [gtos, setGtos] = useState<RxGTO[]>([]);
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState<number | null>(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 (
<div className="flex items-center justify-center py-20 gap-3 text-gray-400">
<Loader2 size={22} className="animate-spin" /><span className="text-sm font-bold uppercase">CARREGANDO GTOs...</span>
</div>
);
if (gtos.length === 0) return (
<div className="flex flex-col items-center justify-center py-20 gap-3 text-gray-400">
<FileText size={40} /><p className="text-sm font-bold uppercase">NENHUMA GTO ENCONTRADA</p>
</div>
);
const pendentes = gtos.filter(g => !g.sent).length;
return (
<div className="space-y-3">
{pendentes > 0 && (
<div className="flex items-center gap-2 px-4 py-2 bg-amber-50 border border-amber-200 rounded-2xl text-amber-700">
<Clock size={14} />
<p className="text-[10px] font-black uppercase">{pendentes} GTO{pendentes !== 1 ? 'S' : ''} PENDENTE{pendentes !== 1 ? 'S' : ''} DE ENVIO</p>
</div>
)}
{gtos.map(gto => (
<div key={gto.id} className="bg-white rounded-2xl p-3 sm:p-4 border border-gray-100 flex items-center gap-3 sm:gap-4 hover:border-purple-100 transition-colors shadow-sm">
{/* Thumbnail */}
{gto.thumb_url ? (
<img src={thumbUrl(gto.thumb_url)} alt="thumb" className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl object-cover flex-shrink-0 border border-gray-100" />
) : (
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl flex-shrink-0 flex items-center justify-center bg-gray-50 border border-gray-100">
<FileText size={18} className="text-gray-300" />
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-black text-gray-900 uppercase">#{gto.gto_number}</p>
<span className={`text-[8px] font-black px-2 py-0.5 rounded-full uppercase flex-shrink-0 ${gto.sent ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'}`}>
{gto.sent ? 'ENVIADA' : 'PENDENTE'}
</span>
</div>
<p className="text-[11px] font-bold text-gray-700 uppercase truncate">{gto.patient_name}</p>
{gto.description && <p className="text-[9px] text-gray-400 uppercase truncate mt-0.5">{gto.description}</p>}
<div className="flex items-center gap-3 mt-1">
<span className="text-[9px] text-gray-400 flex items-center gap-1">
<ImageIcon size={9} /> {gto.image_count} img
</span>
<span className="text-[9px] text-gray-400 flex items-center gap-1">
<Calendar size={9} /> {new Date(gto.created_at).toLocaleDateString('pt-BR')}
</span>
</div>
</div>
{!gto.sent && (
<button
onClick={() => markSent(gto)}
disabled={sending === gto.id}
className="p-2 sm:px-4 sm:py-2 text-white rounded-xl text-[10px] font-black uppercase disabled:opacity-50 transition-colors flex items-center gap-1.5 flex-shrink-0"
style={{ backgroundColor: '#7c3aed' }}
>
{sending === gto.id ? <Loader2 size={14} className="animate-spin" /> : <CheckCircle2 size={14} />}
<span className="hidden sm:inline">ENVIADA</span>
</button>
)}
</div>
))}
</div>
);
};
// ─── 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 (
<button
onClick={() => onClick(patient)}
className="bg-white rounded-2xl border border-gray-100 hover:border-purple-200 active:border-purple-300 hover:shadow-md text-left transition-all group flex items-center gap-4 overflow-hidden"
>
<div className="w-16 h-16 flex-shrink-0 overflow-hidden bg-gray-100 flex items-center justify-center">
{imgError ? (
<ScanLine size={22} className="text-purple-300" />
) : (
<img
src={thumbUrl(patient.thumb_url)}
alt={patient.patient_name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
onError={() => setImgError(true)}
/>
)}
</div>
<div className="min-w-0 flex-1 py-3 pr-3">
<p className="text-sm font-black text-gray-900 uppercase truncate leading-tight">{patient.patient_name}</p>
<p className="text-[10px] font-bold text-gray-400 uppercase mt-0.5">
{parseInt(patient.image_count, 10)} IMAGEM{parseInt(patient.image_count, 10) !== 1 ? 'NS' : ''}
{patient.doctor ? ` · ${patient.doctor}` : ''}
</p>
{patient.remark && <p className="text-[9px] text-gray-400 truncate mt-0.5">{patient.remark}</p>}
</div>
<ChevronRight size={16} className="text-gray-300 group-hover:text-purple-500 mr-3 flex-shrink-0 transition-colors" />
</button>
);
};
// ─── Main RXPlugin View ───────────────────────────────────────────────────────
export const RXPlugin: React.FC = () => {
const toast = useToast();
const [tab, setTab] = useState<'pacientes' | 'gtos'>('pacientes');
const [patients, setPatients] = useState<RxPatient[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [selectedPatient, setSelectedPatient] = useState<RxPatient | null>(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: <Users size={14} /> },
{ id: 'gtos' as const, label: 'GTOs', icon: <FileText size={14} /> },
];
return (
<div className="space-y-6 animate-in fade-in duration-500">
<PageHeader title="RX / RADIOGRAFIAS" description="Gerenciamento de imagens radiográficas integrado ao RX ScoreOdonto.">
<div className="flex items-center gap-2">
<button
onClick={handleSync}
disabled={syncing}
className="flex items-center gap-2 px-4 py-2.5 border border-purple-200 text-purple-600 rounded-xl text-[10px] font-black uppercase hover:bg-purple-50 disabled:opacity-50 transition-colors"
>
<RefreshCw size={14} className={syncing ? 'animate-spin' : ''} /> SINCRONIZAR
</button>
<button
onClick={() => handleUploadClick()}
className="flex items-center gap-2 px-4 py-2.5 text-white rounded-xl text-[10px] font-black uppercase hover:opacity-90 transition-all shadow-sm"
style={{ backgroundColor: '#7c3aed' }}
>
<Upload size={14} /> UPLOAD RX
</button>
</div>
</PageHeader>
{/* Stats */}
<div className="grid grid-cols-3 gap-2 sm:gap-4">
<StatCard label="TOTAL PACIENTES" value={patients.length} icon={<Users size={20} />} color="#7c3aed" />
<StatCard label="TOTAL DE IMAGENS" value={totalImages} icon={<ScanLine size={20} />} color="#059669" />
<StatCard label="IMAGENS PENDENTES" value={patients.reduce((a, p) => a + parseInt(p.pending_count || '0', 10), 0)} icon={<Clock size={20} />} color="#d97706" />
</div>
{/* Tabs */}
<div className="flex gap-1 p-1 bg-gray-100 rounded-2xl w-fit">
{TABS.map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`flex items-center gap-2 px-5 py-2 rounded-xl text-[10px] font-black uppercase transition-all ${tab === t.id ? 'bg-white shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
style={tab === t.id ? { color: '#7c3aed' } : {}}
>
{t.icon}{t.label}
</button>
))}
</div>
{/* PACIENTES */}
{tab === 'pacientes' && (
<div className="space-y-4">
<input
value={search}
onChange={e => 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 ? (
<div className="flex items-center justify-center py-20 gap-3 text-gray-400">
<Loader2 size={22} className="animate-spin" /><span className="text-sm font-bold uppercase">CARREGANDO PACIENTES...</span>
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-3 text-gray-400">
<Users size={40} />
<p className="text-sm font-bold uppercase">{search ? 'NENHUM PACIENTE ENCONTRADO' : 'SEM PACIENTES COM IMAGENS'}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{filtered.map(p => (
<PatientCard key={p.patient_name} patient={p} onClick={setSelectedPatient} />
))}
</div>
)}
</div>
)}
{/* GTOs */}
{tab === 'gtos' && <GTOTab />}
{/* Modals */}
{selectedPatient && (
<ImageGalleryModal
patient={selectedPatient}
onClose={() => setSelectedPatient(null)}
onUploadClick={name => { setSelectedPatient(null); handleUploadClick(name); }}
/>
)}
{uploadOpen && (
<UploadModal
initialPatient={uploadPatient}
onClose={() => setUploadOpen(false)}
onSuccess={loadPatients}
/>
)}
</div>
);
};