709 lines
34 KiB
React
709 lines
34 KiB
React
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>
|
||
);
|
||
}
|