ba062e92d0
- 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>
614 lines
29 KiB
TypeScript
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>
|
|
);
|
|
};
|