Files
rx.scoreodonto.com/dental-server/dental-client/src/pages/ImageDetailsPage.jsx
T

709 lines
34 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import api from '../api/client';
import { useToast } from '../contexts/ToastContext';
import CollapsibleSection from '../components/CollapsibleSection';
import Sidebar from '../components/Sidebar';
/** Detecta viewport mobile (<= 1023px) */
function useIsMobile() {
const [mobile, setMobile] = useState(() =>
typeof window !== 'undefined' ? window.innerWidth <= 1023 : false
);
useEffect(() => {
const mql = window.matchMedia('(max-width: 1023px)');
const handler = (e) => setMobile(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
return mobile;
}
function formatDate(dateString) {
if (!dateString) return '—';
return new Date(dateString).toLocaleString('pt-BR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
export default function ImageDetailsPage() {
const { patientName, imageId } = useParams();
const navigate = useNavigate();
const { showToast } = useToast();
const isMobile = useIsMobile();
const [image, setImage] = useState(null);
const [loading, setLoading] = useState(true);
const [activeView, setActiveView] = useState('transform'); // 'transform' | 'gto'
const [step, setStep] = useState('choose'); // 'choose' (4 posições) | 'finetune'
const [choosePage, setChoosePage] = useState(0); // 0=rotações, 1=flip H, 2=flip V
// Controle de paginação interna de orientações no mobile (quando for horizontal exibe só 2)
const [orientationSubpage, setOrientationSubpage] = useState(0);
const [isPortrait, setIsPortrait] = useState(false);
const [baseRotation, setBaseRotation] = useState(0); // 0 | 90 | 180 | 270
const [baseFlipH, setBaseFlipH] = useState(false);
const [baseFlipV, setBaseFlipV] = useState(false);
const [showInfo, setShowInfo] = useState(false);
const [fineTuneAngle, setFineTuneAngle] = useState(0);
const [remark, setRemark] = useState('');
const [saving, setSaving] = useState(false);
const [originalUrl, setOriginalUrl] = useState('');
const [gtos, setGtos] = useState([]);
const [gtoNumber, setGtoNumber] = useState('');
const [gtoDesc, setGtoDesc] = useState('');
const [loadingGtos, setLoadingGtos] = useState(false);
const [gtoSearchQuery, setGtoSearchQuery] = useState('');
const [resetting, setResetting] = useState(false);
// Busca dados da imagem ao montar a página
useEffect(() => {
const fetchImage = async () => {
try {
setLoading(true);
const { data } = await api.get(`/images/${imageId}`);
setImage(data);
} catch (err) {
showToast('Erro ao carregar a imagem', 'error');
navigate(-1); // Volta se falhar
} finally {
setLoading(false);
}
};
fetchImage();
}, [imageId, navigate, showToast]);
// Quando a imagem é carregada/atualizada, inicializa os estados dependentes
useEffect(() => {
if (!image) return;
setFineTuneAngle(0);
setBaseRotation(0);
setBaseFlipH(false);
setBaseFlipV(false);
setChoosePage(0);
setOrientationSubpage(0);
setStep('choose');
setRemark(image.remark || ''); // carrega remark existente se houver
setShowInfo(false);
setActiveView('transform');
const currentImageUrl = image.filename?.startsWith('http')
? image.filename
: `/uploads/${image.filename}`;
// Descobre se a imagem é Portrait (vertical) ou Landscape (horizontal)
const imgObj = new Image();
imgObj.onload = () => {
setIsPortrait(imgObj.naturalHeight > imgObj.naturalWidth);
};
imgObj.src = currentImageUrl;
// Carrega URL da imagem original (mãe) se existir
if (image.original_image_id) {
api.get(`/images/${image.original_image_id}/file-url`)
.then(({ data }) => setOriginalUrl(data.url || currentImageUrl))
.catch(() => setOriginalUrl(currentImageUrl));
} else {
setOriginalUrl(currentImageUrl);
}
// Carrega GTOs do paciente
loadGtos(image.patient_name);
}, [image]);
if (loading || !image) {
return (
<div className="app-container">
<Sidebar />
<main className="main-content" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p>Carregando imagem...</p>
</main>
</div>
);
}
const imageUrl = image.filename?.startsWith('http')
? image.filename
: `/uploads/${image.filename}`;
const loadGtos = async (pName, q = '') => {
setLoadingGtos(true);
try {
const url = q
? `/gtos?search=${encodeURIComponent(q)}&limit=100`
: `/gtos?patient=${encodeURIComponent(pName)}&limit=100`;
const { data } = await api.get(url);
setGtos(Array.isArray(data) ? data : (data.rows || []));
} catch {
setGtos([]);
} finally {
setLoadingGtos(false);
}
};
const handleGtoSearch = () => {
loadGtos(image.patient_name, gtoSearchQuery);
};
const adjustAngle = (delta) => setFineTuneAngle((prev) => prev + delta);
const resetAngle = () => setFineTuneAngle(0);
const totalRotation = ((baseRotation + fineTuneAngle) % 360 + 360) % 360;
const saveTransform = async () => {
if (totalRotation === 0 && !baseFlipH && !baseFlipV && remark === (image.remark || '')) {
showToast('Nenhuma edição aplicada.', 'error');
return;
}
setSaving(true);
try {
const { data } = await api.post(`/images/${image.id}/transform`, {
rotation: totalRotation,
flipH: baseFlipH,
flipV: baseFlipV,
remark
});
showToast('Imagem e orientação salvas!', 'success');
// Atualiza a rota com o novo ID gerado em background (sem adicionar ao histórico de navegação)
navigate(`/${encodeURIComponent(patientName)}/${data.imageId}`, { replace: true });
} catch {
showToast('Erro ao salvar orientação', 'error');
} finally {
setSaving(false);
}
};
const resetToOriginal = async () => {
if (!window.confirm('Restaurar esta imagem para o estado original? Rotações serão descartadas.')) return;
setResetting(true);
try {
await api.post(`/images/${image.id}/transform`, {
rotation: 0, flipH: false, flipV: false, remark: ''
});
showToast('Imagem restaurada para o original!', 'success');
navigate('/' + encodeURIComponent(patientName));
} catch {
showToast('Erro ao restaurar a imagem.', 'error');
} finally {
setResetting(false);
}
};
const cssTransform = (deg, flipH, flipV) =>
`scaleX(${flipH ? -1 : 1}) scaleY(${flipV ? -1 : 1}) rotate(${deg}deg)`;
const initialOptions = [
{ id: 'normal', deg: 0, flipH: false, flipV: false, label: '0°' },
{ id: 'fliph', deg: 0, flipH: true, flipV: false, label: 'Espelho ⇆' },
{ id: 'flipv', deg: 0, flipH: false, flipV: true, label: 'Espelho ⇅' },
{ id: 'rot90', deg: 90, flipH: false, flipV: false, label: 'Rotacionar +90°' },
];
const pickOrientation = (opt) => {
setBaseRotation(opt.deg);
setBaseFlipH(opt.flipH);
setBaseFlipV(opt.flipV);
setFineTuneAngle(0);
setStep('finetune');
};
const downloadImage = (applyTransform) => {
const url = applyTransform ? imageUrl : (originalUrl || imageUrl);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const w = img.naturalWidth, h = img.naturalHeight;
const deg = applyTransform ? totalRotation : 0;
const fH = applyTransform ? baseFlipH : false;
const fV = applyTransform ? baseFlipV : false;
const rad = (deg * Math.PI) / 180;
const cos = Math.abs(Math.cos(rad)), sin = Math.abs(Math.sin(rad));
const cw = Math.round(w * cos + h * sin);
const ch = Math.round(w * sin + h * cos);
const canvas = document.createElement('canvas');
canvas.width = cw; canvas.height = ch;
const ctx = canvas.getContext('2d');
ctx.translate(cw / 2, ch / 2);
ctx.scale(fH ? -1 : 1, fV ? -1 : 1);
ctx.rotate(rad);
ctx.drawImage(img, -w / 2, -h / 2);
canvas.toBlob((blob) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
const safe = (image.patient_name || 'rx').replace(/[^\w\-]+/g, '_');
a.download = `${safe}_${applyTransform ? deg + 'deg' : 'original'}.png`;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
}, 'image/png');
} catch (e) {
window.open(url, '_blank');
}
};
img.onerror = () => window.open(url, '_blank');
img.src = url;
};
const copyImageToClipboard = () => {
const url = imageUrl;
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const w = img.naturalWidth, h = img.naturalHeight;
const deg = totalRotation;
const fH = baseFlipH;
const fV = baseFlipV;
const rad = (deg * Math.PI) / 180;
const cos = Math.abs(Math.cos(rad)), sin = Math.abs(Math.sin(rad));
const cw = Math.round(w * cos + h * sin);
const ch = Math.round(w * sin + h * cos);
const canvas = document.createElement('canvas');
canvas.width = cw; canvas.height = ch;
const ctx = canvas.getContext('2d');
ctx.translate(cw / 2, ch / 2);
ctx.scale(fH ? -1 : 1, fV ? -1 : 1);
ctx.rotate(rad);
ctx.drawImage(img, -w / 2, -h / 2);
canvas.toBlob(async (blob) => {
try {
await navigator.clipboard.write([
new ClipboardItem({
'image/png': blob
})
]);
showToast('Imagem copiada para a área de transferência!', 'success');
} catch (err) {
showToast('Erro ao copiar imagem para clipboard.', 'error');
console.error(err);
}
}, 'image/png');
} catch (e) {
showToast('Erro ao processar imagem para cópia.', 'error');
console.error(e);
}
};
img.onerror = () => showToast('Erro ao carregar imagem para cópia.', 'error');
img.src = url;
};
const markGtoSent = async (gtoId, sent) => {
try {
await api.post(`/gtos/${gtoId}/sent`, { sent });
showToast(sent ? 'GTO marcada como enviada!' : 'Marca de envio removida.', 'success');
loadGtos(image.patient_name, gtoSearchQuery);
} catch {
showToast('Erro ao atualizar status da GTO.', 'error');
}
};
const createGto = async () => {
if (!gtoNumber.trim()) { showToast('O Número da GTO é obrigatório.', 'error'); return; }
try {
await api.post('/gtos', {
gto_number: gtoNumber.trim(),
description: gtoDesc.trim(),
patient_name: image.patient_name
});
showToast('GTO criada com sucesso!', 'success');
setGtoNumber('');
setGtoDesc('');
loadGtos(image.patient_name, gtoSearchQuery);
} catch {
showToast('Erro ao criar GTO.', 'error');
}
};
const linkToGto = async (gtoId) => {
try {
await api.post(`/gtos/${gtoId}/images`, { image_id: image.id });
showToast('Imagem vinculada à GTO!', 'success');
} catch (e) {
showToast(e.response?.data?.error || 'Erro ao vincular', 'error');
}
};
// Gerencia opções (4 no total). No mobile exibe APENAS UMA GIGANTE por vez.
const useOneCard = isMobile;
const currentOptions = useOneCard ? [initialOptions[orientationSubpage]] : initialOptions;
return (
<div className="app-container">
<Sidebar />
<main className="main-content">
{/* Top Header para navegação / Voltar */}
{/* Top Header para navegação / Voltar */}
<header className="header" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%' }}>
<button className="btn btn-secondary" onClick={() => navigate('/' + encodeURIComponent(patientName))} style={{ padding: '8px 12px' }} title="Voltar ao Painel">
{isMobile ? '←' : '← Voltar'}
</button>
<div style={{ flex: 1, minWidth: 0 }}>
<h1 style={{ fontSize: '1.1rem', margin: 0, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{image.patient_name || 'Desconhecido'}
</h1>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
{formatDate(image.created_at)}
</div>
</div>
{!isMobile && (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{activeView === 'transform' && step === 'finetune' && (
<button type="button" className="btn btn-secondary btn-small" onClick={() => setStep('choose')} title="Voltar para escolher a orientação"> Posições</button>
)}
<button className={`btn btn-small ${activeView === 'transform' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setActiveView('transform')} title="Ajustar Imagem">🔄 Ajustar</button>
<button className={`btn btn-small ${activeView === 'gto' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setActiveView('gto')} title="Gerenciar GTOs">📋 GTOs</button>
<button className={`btn btn-small ${showInfo ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setShowInfo(!showInfo)} title="Informações"> Info</button>
{image.original_image_id && (
<button className="btn btn-small btn-danger" onClick={resetToOriginal} disabled={resetting} title="Resetar para Original"> Reset</button>
)}
</div>
)}
</div>
{isMobile && (
<div style={{ display: 'flex', gap: 8, width: '100%', overflowX: 'auto', paddingBottom: 4 }}>
{activeView === 'transform' && step === 'finetune' && (
<button type="button" className="btn btn-secondary btn-small" onClick={() => setStep('choose')} style={{ flexShrink: 0 }}> Posições</button>
)}
<button className={`btn btn-small ${activeView === 'transform' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setActiveView('transform')} style={{ flex: 1, flexShrink: 0 }}>🔄 Ajustar</button>
<button className={`btn btn-small ${activeView === 'gto' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setActiveView('gto')} style={{ flex: 1, flexShrink: 0 }}>📋 GTOs</button>
<button className={`btn btn-small ${showInfo ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setShowInfo(!showInfo)} style={{ flex: 1, flexShrink: 0 }}> Info</button>
{image.original_image_id && (
<button className="btn btn-small btn-danger" onClick={resetToOriginal} disabled={resetting} style={{ flexShrink: 0 }}> Reset</button>
)}
</div>
)}
</header>
<div className="content-scroll" style={{ padding: isMobile ? '10px' : '20px' }}>
<div style={{ maxWidth: 1000, margin: '0 auto', background: 'var(--bg-surface)', borderRadius: 'var(--radius-lg)', padding: isMobile ? '16px' : '20px', border: '1px solid var(--glass-border)' }}>
{/* Info extra */}
{showInfo && (
<div className="extra-info-panel" style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 5 }}><strong>👨 Dentista:</strong> {image.doctor || 'Não informado'}</div>
<div><strong>📝 Obs:</strong> {image.remark || 'Nenhuma observação'}</div>
</div>
)}
{/* View: Transformação — PASSO 1: escolher orientações */}
{activeView === 'transform' && step === 'choose' && (
<div className="transform-view-wrapper">
<p style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.95rem', margin: '4px 0 16px' }}>
Escolha a melhor orientação inicial.
</p>
{/* Controles de Rotação (Mobile exibe 1 por vez) */}
{useOneCard && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 16, marginBottom: 16 }}>
<button
className="btn btn-primary btn-small"
onClick={() => setOrientationSubpage((p) => (p - 1 + 4) % 4)}
> Opção Anterior</button>
<span style={{ fontSize: '0.9rem', color: 'var(--text-primary)', fontWeight: 600 }}>{orientationSubpage + 1} de 4</span>
<button
className="btn btn-primary btn-small"
onClick={() => setOrientationSubpage((p) => (p + 1) % 4)}
>Próxima Opção </button>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: useOneCard ? '1fr' : 'repeat(4, 1fr)', gap: 16 }}>
{currentOptions.map((opt) => (
<div
key={opt.id}
style={{
border: '1px solid var(--border-color)', borderRadius: 10, background: 'var(--bg-surface)',
padding: useOneCard ? 0 : 16, display: 'flex', flexDirection: 'column',
alignItems: 'center', transition: 'all .2s ease', overflow: 'hidden'
}}
>
<div style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: useOneCard ? 20 : 0, background: 'var(--bg-surface)' }}>
<img
src={imageUrl}
alt={opt.label}
loading="lazy"
style={{ width: '100%', maxHeight: useOneCard ? '50vh' : 280, objectFit: 'contain', transform: cssTransform(opt.deg, opt.flipH, opt.flipV), transformOrigin: 'center' }}
/>
</div>
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center', width: '100%', background: 'var(--bg-surface)' }}>
<span style={{ fontSize: '1rem', fontWeight: 600, color: 'var(--text-primary)' }}>
{opt.label}
</span>
<button className="btn btn-primary" style={{ width: '100%' }} onClick={() => pickOrientation(opt)}>
Escolher esta
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* View: Transformação — PASSO 2: ajuste fino (escolhida + original) */}
{activeView === 'transform' && step === 'finetune' && (
<div className="transform-view-wrapper">
<div className="new-transform-layout" style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 20 }}>
{/* Original */}
{!isMobile && (
<div className="transform-panel panel-original" style={{ flex: 1, padding: 16, border: '1px solid var(--border-color)', borderRadius: 'var(--radius-md)' }}>
<h3 className="panel-title" style={{ marginTop: 0, marginBottom: 12, fontSize: '0.9rem', color: 'var(--text-secondary)' }}>Original</h3>
<div className="img-container" style={{ display: 'flex', justifyContent: 'center', background: '#0b1220', padding: 10, borderRadius: 'var(--radius-sm)' }}>
<img src={originalUrl || imageUrl} alt="Original" style={{ maxHeight: 350, objectFit: 'contain' }} />
</div>
</div>
)}
{/* Preview */}
<div className="transform-panel panel-preview" style={{ flex: 1, padding: 16, border: '1px solid var(--primary-color)', borderRadius: 'var(--radius-md)', background: 'rgba(59, 130, 246, 0.03)' }}>
<h3 className="panel-title" style={{ marginTop: 0, marginBottom: 12, fontSize: '0.9rem', color: 'var(--primary-color)' }}>
Preview ({totalRotation}°{baseFlipH ? ' ⇆' : ''}{baseFlipV ? ' ⇅' : ''})
</h3>
<div className="img-container" style={{ display: 'flex', justifyContent: 'center', background: '#0b1220', padding: 10, borderRadius: 'var(--radius-sm)' }}>
<img
src={imageUrl}
alt="Preview"
style={{ maxHeight: 350, objectFit: 'contain', transform: cssTransform(baseRotation + fineTuneAngle, baseFlipH, baseFlipV), transition: 'transform 0.1s' }}
/>
</div>
</div>
</div>
{/* Original colapsável (mobile) */}
{isMobile && (
<div style={{ marginTop: 16 }}>
<CollapsibleSection title="Ver Imagem Original" compact>
<div style={{ display: 'flex', justifyContent: 'center', padding: '12px', background: '#0b1220', borderRadius: 'var(--radius-sm)' }}>
<img
src={originalUrl || imageUrl}
alt="Original"
style={{ maxWidth: '90%', maxHeight: 250, objectFit: 'contain' }}
/>
</div>
</CollapsibleSection>
</div>
)}
{/* Controles Profissionais: Ajuste Fino e Ações */}
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 20, marginTop: 24 }}>
{/* Bloco de Rotação Fina */}
<div style={{ flex: 1, padding: 20, background: 'var(--bg-color)', border: '1px solid var(--glass-border)', borderRadius: 'var(--radius-lg)', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}>
<h3 style={{ marginTop: 0, fontSize: '1.05rem', marginBottom: 16, color: 'var(--text-primary)', textAlign: 'center' }}>
Ajuste Fino de Rotação
</h3>
{/* Botões Modernos */}
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 12, flexWrap: 'nowrap' }}>
<button className="btn btn-secondary" onClick={() => adjustAngle(-5)} style={{ width: 50, height: 50, borderRadius: '50%', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '1.1rem' }}>
-5°
</button>
<button className="btn btn-secondary" onClick={() => adjustAngle(-1)} style={{ width: 50, height: 50, borderRadius: '50%', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '1.1rem' }}>
-1°
</button>
<div style={{ fontSize: '1.6rem', fontWeight: 700, width: 80, textAlign: 'center', color: 'var(--primary-color)' }}>
{fineTuneAngle > 0 ? '+' : ''}{fineTuneAngle}°
</div>
<button className="btn btn-secondary" onClick={() => adjustAngle(1)} style={{ width: 50, height: 50, borderRadius: '50%', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '1.1rem' }}>
+1°
</button>
<button className="btn btn-secondary" onClick={() => adjustAngle(5)} style={{ width: 50, height: 50, borderRadius: '50%', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '1.1rem' }}>
+5°
</button>
</div>
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 20 }}>
<button type="button" className="btn btn-danger" onClick={resetAngle} disabled={fineTuneAngle === 0} style={{ padding: '8px 30px' }}>
Restaurar Rotação Fina
</button>
</div>
</div>
{/* Observações e Ação Principal */}
<div style={{ flex: 1, padding: 20, background: 'var(--bg-color)', border: '1px solid var(--glass-border)', borderRadius: 'var(--radius-lg)', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}>
<div className="form-group" style={{ marginBottom: 16 }}>
<label style={{ fontSize: '1.05rem', color: 'var(--text-primary)', display: 'block', marginBottom: 8 }}>📝 Observação / Detalhes:</label>
<textarea
className="form-control"
rows={3}
value={remark}
onChange={(e) => setRemark(e.target.value)}
placeholder="Ex: Raio-X periapical do dente 45..."
style={{ resize: 'vertical' }}
/>
</div>
<button
className="btn btn-primary"
onClick={saveTransform}
disabled={saving}
style={{ width: '100%', height: 50, fontSize: '1.1rem', fontWeight: 600, letterSpacing: '0.5px' }}
>
{saving ? '⏳ Salvando Alterações...' : '💾 Salvar'}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={copyImageToClipboard}
style={{ width: '100%', height: 44, marginTop: 12, fontSize: '1rem', fontWeight: 600, display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'center' }}
>
📋 Copiar Imagem (Área de Transferência)
</button>
</div>
</div>
</div>
)}
{/* View: GTOs */}
{activeView === 'gto' && (
<div>
<div style={{ marginBottom: 20, display: 'flex', gap: 12, flexWrap: 'wrap', padding: '16px', background: 'var(--bg-color)', borderRadius: 'var(--radius-md)', border: '1px solid var(--border-color)' }}>
<div style={{ display: 'flex', alignItems: 'center', marginRight: 'auto' }}>
<h3 style={{ margin: 0, fontSize: '1.2rem', color: 'var(--primary-color)' }}>Vínculo de GTO</h3>
</div>
<button className="btn btn-secondary" onClick={() => downloadImage(false)} title="Baixar a imagem original">
Baixar Original
</button>
{step === 'finetune' && (
<button className="btn btn-secondary" onClick={() => downloadImage(true)} title="Baixar a imagem orientada">
Baixar Orientada
</button>
)}
</div>
{isMobile ? (
<CollapsibleSection title="Cadastrar Nova GTO" defaultOpen>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<input
id="gto-number" name="gtoNumber"
type="text" className="form-control"
placeholder="Número/ID da GTO"
value={gtoNumber}
onChange={(e) => setGtoNumber(e.target.value)}
/>
<input
id="gto-desc" name="gtoDesc"
type="text" className="form-control"
placeholder="Descrição (Opcional)"
value={gtoDesc}
onChange={(e) => setGtoDesc(e.target.value)}
/>
<button className="btn btn-primary" onClick={createGto}>Cadastrar Nova GTO</button>
</div>
</CollapsibleSection>
) : (
<div className="gto-create-section" style={{ padding: 20, border: '1px solid var(--border-color)', borderRadius: 'var(--radius-lg)', background: 'var(--bg-color)' }}>
<h3 style={{ marginTop: 0, fontSize: '1.1rem' }}>Cadastrar Nova GTO</h3>
<div className="form-actions-inline" style={{ marginTop: 12, display: 'flex', gap: 16 }}>
<input
id="gto-number" name="gtoNumber"
type="text" className="form-control"
placeholder="Número/ID da GTO"
value={gtoNumber}
onChange={(e) => setGtoNumber(e.target.value)}
style={{ flex: 1 }}
/>
<input
id="gto-desc" name="gtoDesc"
type="text" className="form-control"
placeholder="Descrição (Opcional)"
value={gtoDesc}
onChange={(e) => setGtoDesc(e.target.value)}
style={{ flex: 2 }}
/>
<button className="btn btn-primary" onClick={createGto} style={{ minWidth: 180 }}>Cadastrar</button>
</div>
</div>
)}
<div className="gto-list-container" style={{ marginTop: 32 }}>
<h3 style={{ marginBottom: 16, fontSize: '1.2rem', color: 'var(--text-primary)' }}>Buscar e Vincular GTOs</h3>
{/* Busca de GTOs Globais */}
<div style={{ display: 'flex', gap: 10, marginBottom: 20 }}>
<input
type="search"
className="form-control"
placeholder="🔍 Pesquisar por paciente ou nº GTO..."
value={gtoSearchQuery}
onChange={e => setGtoSearchQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleGtoSearch()}
style={{ flex: 1 }}
/>
<button className="btn btn-primary" onClick={handleGtoSearch}>Buscar</button>
</div>
{loadingGtos ? (
<div style={{ padding: 30, textAlign: 'center', color: 'var(--text-muted)' }}>Carregando GTOs...</div>
) : gtos.length === 0 ? (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-muted)', background: 'var(--bg-color)', borderRadius: 'var(--radius-lg)', border: '1px solid var(--glass-border)' }}>
Nenhuma GTO encontrada para a pesquisa atual. <br /> (Mostrando GTOs de "{image.patient_name}" por padrão)
</div>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 16 }}>
{gtos.map((gto) => (
<li
key={gto.id}
style={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
justifyContent: 'space-between',
alignItems: isMobile ? 'stretch' : 'center',
padding: 20,
background: 'var(--bg-color)',
border: '1px solid var(--glass-border)',
borderRadius: 'var(--radius-lg)',
gap: isMobile ? 16 : 0,
boxShadow: '0 2px 4px rgba(0,0,0,0.05)'
}}
>
<div>
<div style={{ fontWeight: 700, fontSize: '1.1rem', display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
#{gto.gto_number}
{gto.sent && (
<span style={{ fontSize: '0.75rem', fontWeight: 800, color: '#fff', background: '#10b981', padding: '4px 10px', borderRadius: 999 }}>
ENVIADA
</span>
)}
</div>
<div style={{ color: 'var(--text-primary)', fontSize: '0.95rem', marginTop: 8 }}>
<strong>Paciente:</strong> {gto.patient_name || 'Não informado'}
</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginTop: 4 }}>
{gto.description || 'Sem descrição vinculada.'}
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginTop: 8 }}>
📅 {formatDate(gto.created_at)}
</div>
</div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={() => linkToGto(gto.id)} style={{ flex: isMobile ? 1 : 'none', padding: '10px 20px' }}>
Vincular GTO
</button>
<button
className={`btn ${gto.sent ? 'btn-secondary' : 'btn-success'}`}
onClick={() => markGtoSent(gto.id, !gto.sent)}
title={gto.sent ? 'Desmarcar envio' : 'Marcar GTO como enviada'}
style={{ flex: isMobile ? 1 : 'none', padding: '10px 20px' }}
>
{gto.sent ? '↩️ Desfazer Envio' : '📤 Marcar Enviada'}
</button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
)}
</div>
</div>
</main>
</div>
);
}