feat(mobile): improve search UX and ImageDetails orientation UX
continuous-integration/webhook Deploy concluído (VPS4)
continuous-integration/webhook Deploy concluído (VPS4)
This commit is contained in:
@@ -1 +1 @@
|
|||||||
2.1.46
|
2.1.48
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import DownloadPage from './pages/DownloadPage';
|
|||||||
import PatientsPage from './pages/PatientsPage';
|
import PatientsPage from './pages/PatientsPage';
|
||||||
import SettingsPage from './pages/SettingsPage';
|
import SettingsPage from './pages/SettingsPage';
|
||||||
import GtosPage from './pages/GtosPage';
|
import GtosPage from './pages/GtosPage';
|
||||||
|
import ImageDetailsPage from './pages/ImageDetailsPage';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -76,6 +77,12 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
<Route path="/patients/:patientName/image/:imageId" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ImageDetailsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Catch-all: redireciona para home */}
|
{/* Catch-all: redireciona para home */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,628 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import Modal from './Modal';
|
|
||||||
import CollapsibleSection from './CollapsibleSection';
|
|
||||||
import api from '../api/client';
|
|
||||||
import { useToast } from '../contexts/ToastContext';
|
|
||||||
|
|
||||||
/** 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 TransformModal({ isOpen, onClose, image, onSaved }) {
|
|
||||||
const { showToast } = useToast();
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
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
|
|
||||||
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 [resetting, setResetting] = useState(false);
|
|
||||||
|
|
||||||
// Quando a imagem muda, reinicia estado
|
|
||||||
useEffect(() => {
|
|
||||||
if (!image) return;
|
|
||||||
setFineTuneAngle(0);
|
|
||||||
setBaseRotation(0);
|
|
||||||
setBaseFlipH(false);
|
|
||||||
setBaseFlipV(false);
|
|
||||||
setChoosePage(0);
|
|
||||||
setStep('choose');
|
|
||||||
setRemark('');
|
|
||||||
setShowInfo(false);
|
|
||||||
setActiveView('transform');
|
|
||||||
|
|
||||||
// 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 || imageUrl))
|
|
||||||
.catch(() => setOriginalUrl(imageUrl));
|
|
||||||
} else {
|
|
||||||
setOriginalUrl(imageUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Carrega GTOs do paciente
|
|
||||||
loadGtos(image.patient_name);
|
|
||||||
}, [image]);
|
|
||||||
|
|
||||||
if (!image) return null;
|
|
||||||
|
|
||||||
const imageUrl = image.filename?.startsWith('http')
|
|
||||||
? image.filename
|
|
||||||
: `/uploads/${image.filename}`;
|
|
||||||
|
|
||||||
const loadGtos = async (patientName) => {
|
|
||||||
setLoadingGtos(true);
|
|
||||||
try {
|
|
||||||
const { data } = await api.get(`/gtos?patient=${encodeURIComponent(patientName)}&limit=100`);
|
|
||||||
// Suporta tanto o formato novo { rows } quanto o antigo array (retrocompatibilidade)
|
|
||||||
setGtos(Array.isArray(data) ? data : (data.rows || []));
|
|
||||||
} catch {
|
|
||||||
setGtos([]);
|
|
||||||
} finally {
|
|
||||||
setLoadingGtos(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const adjustAngle = (delta) => {
|
|
||||||
setFineTuneAngle((prev) => prev + delta);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetAngle = () => setFineTuneAngle(0);
|
|
||||||
|
|
||||||
const totalRotation = ((baseRotation + fineTuneAngle) % 360 + 360) % 360;
|
|
||||||
|
|
||||||
const saveTransform = async () => {
|
|
||||||
if (totalRotation === 0) {
|
|
||||||
showToast('Nenhuma edição aplicada.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await api.post(`/images/${image.id}/transform`, {
|
|
||||||
rotation: totalRotation,
|
|
||||||
flipH: baseFlipH,
|
|
||||||
flipV: baseFlipV,
|
|
||||||
remark
|
|
||||||
});
|
|
||||||
showToast('Imagem e orientação salvas!', 'success');
|
|
||||||
onClose();
|
|
||||||
onSaved?.();
|
|
||||||
} catch {
|
|
||||||
showToast('Erro ao salvar orientação', 'error');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetToOriginal = async () => {
|
|
||||||
if (!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');
|
|
||||||
onClose();
|
|
||||||
onSaved?.();
|
|
||||||
} catch {
|
|
||||||
showToast('Erro ao restaurar a imagem.', 'error');
|
|
||||||
} finally {
|
|
||||||
setResetting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// CSS de preview/escolha — casa com a ordem do backend (rotate depois flip):
|
|
||||||
// flips listados antes do rotate ⇒ rotate aplicado primeiro, depois o espelho.
|
|
||||||
const cssTransform = (deg, flipH, flipV) =>
|
|
||||||
`scaleX(${flipH ? -1 : 1}) scaleY(${flipV ? -1 : 1}) rotate(${deg}deg)`;
|
|
||||||
|
|
||||||
// Páginas do passo "escolher": rotações puras, espelho horizontal, espelho vertical
|
|
||||||
const choosePages = [
|
|
||||||
{ flipH: false, flipV: false, label: 'Rotações' },
|
|
||||||
{ flipH: true, flipV: false, label: 'Espelhado ⇆ (horizontal)' },
|
|
||||||
{ flipH: false, flipV: true, label: 'Espelhado ⇅ (vertical)' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const pickOrientation = (deg) => {
|
|
||||||
const pg = choosePages[choosePage];
|
|
||||||
setBaseRotation(deg);
|
|
||||||
setBaseFlipH(pg.flipH);
|
|
||||||
setBaseFlipV(pg.flipV);
|
|
||||||
setFineTuneAngle(0);
|
|
||||||
setStep('finetune');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Download client-side via canvas (gera blob same-origin, sem problema de CORS).
|
|
||||||
// applyTransform=true baixa a versão orientada; false baixa a original.
|
|
||||||
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) {
|
|
||||||
// canvas "tainted" (imagem cross-origin sem CORS) — abre em nova aba
|
|
||||||
window.open(url, '_blank');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
img.onerror = () => window.open(url, '_blank');
|
|
||||||
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);
|
|
||||||
} 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);
|
|
||||||
} 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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const headerExtras = (
|
|
||||||
<div className="modal-actions-group">
|
|
||||||
<button
|
|
||||||
className={`btn btn-small ${activeView === 'transform' ? 'btn-primary' : 'btn-secondary'}`}
|
|
||||||
onClick={() => setActiveView('transform')}
|
|
||||||
title="Ajustar Imagem"
|
|
||||||
>🔄</button>
|
|
||||||
<button
|
|
||||||
className={`btn btn-small ${activeView === 'gto' ? 'btn-primary' : 'btn-secondary'}`}
|
|
||||||
onClick={() => setActiveView('gto')}
|
|
||||||
title="Gerenciar GTOs"
|
|
||||||
>📋</button>
|
|
||||||
<button
|
|
||||||
className={`btn btn-small ${showInfo ? 'btn-primary' : 'btn-secondary'}`}
|
|
||||||
onClick={() => setShowInfo(!showInfo)}
|
|
||||||
title="Informações"
|
|
||||||
>ℹ️</button>
|
|
||||||
{image.original_image_id && (
|
|
||||||
<button
|
|
||||||
className="btn btn-small btn-danger"
|
|
||||||
onClick={resetToOriginal}
|
|
||||||
disabled={resetting}
|
|
||||||
title="Resetar para Original"
|
|
||||||
>↩️</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const titlePrefix = activeView === 'transform' && step === 'finetune' ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary btn-small"
|
|
||||||
onClick={() => setStep('choose')}
|
|
||||||
style={{ marginRight: 8, flexShrink: 0 }}
|
|
||||||
title="Voltar para escolher a orientação"
|
|
||||||
>← Posições</button>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={`${image.patient_name || 'Desconhecido'} — ${formatDate(image.created_at)}`}
|
|
||||||
large
|
|
||||||
headerExtras={headerExtras}
|
|
||||||
titlePrefix={titlePrefix}
|
|
||||||
>
|
|
||||||
{/* Info extra */}
|
|
||||||
{showInfo && (
|
|
||||||
<div className="extra-info-panel">
|
|
||||||
<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 entre 4 posições */}
|
|
||||||
{activeView === 'transform' && step === 'choose' && (
|
|
||||||
<div className="transform-view-wrapper">
|
|
||||||
<p style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.85rem', margin: '4px 0 8px' }}>
|
|
||||||
Escolha a melhor orientação. Depois você faz o ajuste fino.
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 12 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary btn-small"
|
|
||||||
onClick={() => setChoosePage((p) => Math.max(0, p - 1))}
|
|
||||||
disabled={choosePage === 0}
|
|
||||||
>← Voltar</button>
|
|
||||||
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-secondary)' }}>
|
|
||||||
{choosePages[choosePage].label} ({choosePage + 1}/{choosePages.length})
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary btn-small"
|
|
||||||
onClick={() => setChoosePage((p) => Math.min(choosePages.length - 1, p + 1))}
|
|
||||||
disabled={choosePage === choosePages.length - 1}
|
|
||||||
>Avançar →</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12 }}>
|
|
||||||
{[0, 90, 180, 270].map((deg) => {
|
|
||||||
const pg = choosePages[choosePage];
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={deg}
|
|
||||||
type="button"
|
|
||||||
onClick={() => pickOrientation(deg)}
|
|
||||||
style={{
|
|
||||||
border: '2px solid var(--glass-border)', borderRadius: 10, background: '#0b1220',
|
|
||||||
padding: 8, cursor: 'pointer', display: 'flex', flexDirection: 'column',
|
|
||||||
alignItems: 'center', gap: 6, transition: 'border-color .2s'
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.borderColor = 'var(--primary-color)')}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.borderColor = 'var(--glass-border)')}
|
|
||||||
title={`Usar ${deg}° ${pg.flipH ? '(espelho H)' : pg.flipV ? '(espelho V)' : ''}`}
|
|
||||||
>
|
|
||||||
{/* max 70% = 1/√2: garante que ao rotacionar 90° a imagem cabe no quadrado sem cortar */}
|
|
||||||
<div style={{ width: '100%', aspectRatio: '1 / 1', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<img
|
|
||||||
src={imageUrl}
|
|
||||||
alt={`${deg}°`}
|
|
||||||
loading="lazy"
|
|
||||||
style={{ maxWidth: '70%', maxHeight: '70%', objectFit: 'contain', transform: cssTransform(deg, pg.flipH, pg.flipV), transformOrigin: 'center' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-secondary)' }}>
|
|
||||||
{deg}°{pg.flipH ? ' ⇆' : pg.flipV ? ' ⇅' : ''}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</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">
|
|
||||||
{/* Original — escondido no mobile, mostrado via CollapsibeSection */}
|
|
||||||
{!isMobile && (
|
|
||||||
<div className="transform-panel panel-original">
|
|
||||||
<h3 className="panel-title">Original</h3>
|
|
||||||
<div className="img-container">
|
|
||||||
<img src={originalUrl || imageUrl} alt="Original" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Preview (orientação escolhida + ajuste fino) */}
|
|
||||||
<div className="transform-panel panel-preview">
|
|
||||||
<h3 className="panel-title">
|
|
||||||
Preview ({totalRotation}°{baseFlipH ? ' ⇆' : ''}{baseFlipV ? ' ⇅' : ''})
|
|
||||||
</h3>
|
|
||||||
<div className="img-container">
|
|
||||||
<img
|
|
||||||
src={imageUrl}
|
|
||||||
alt="Preview"
|
|
||||||
style={{ transform: cssTransform(baseRotation + fineTuneAngle, baseFlipH, baseFlipV) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controles de rotação */}
|
|
||||||
<div className="transform-sidebar">
|
|
||||||
{!isMobile && (
|
|
||||||
<h3 className="sidebar-title" style={{ fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: 1, color: 'var(--text-muted)', marginBottom: 12 }}>Ajuste</h3>
|
|
||||||
)}
|
|
||||||
<div style={{ display: 'flex', flexDirection: isMobile ? 'row' : 'column', gap: 8, alignItems: 'center', width: isMobile ? 'auto' : '100%' }}>
|
|
||||||
{[
|
|
||||||
{ label: '-5°', delta: -5, icon: '↺' },
|
|
||||||
{ label: '-1°', delta: -1, icon: '↺' },
|
|
||||||
].map(({ label, delta, icon }) => (
|
|
||||||
<button key={label} type="button" className="sidebar-btn" onClick={() => adjustAngle(delta)} title={`Rotacionar ${label}`}>
|
|
||||||
<span className="icon">{icon}</span>
|
|
||||||
<span className="label">{label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="fine-tune-display" style={{ margin: isMobile ? '0 4px' : '8px 0' }}>
|
|
||||||
{fineTuneAngle > 0 ? '+' : ''}{fineTuneAngle}°
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{[
|
|
||||||
{ label: '+1°', delta: 1, icon: '↻' },
|
|
||||||
{ label: '+5°', delta: 5, icon: '↻' },
|
|
||||||
].map(({ label, delta, icon }) => (
|
|
||||||
<button key={label} type="button" className="sidebar-btn" onClick={() => adjustAngle(delta)} title={`Rotacionar ${label}`}>
|
|
||||||
<span className="icon">{icon}</span>
|
|
||||||
<span className="label">{label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{fineTuneAngle !== 0 && (
|
|
||||||
<button type="button" className="btn btn-danger btn-small" onClick={resetAngle} style={{ marginTop: isMobile ? 0 : 12, width: isMobile ? 'auto' : '100%' }}>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Original colapsável — apenas no mobile */}
|
|
||||||
{isMobile && (
|
|
||||||
<CollapsibleSection title="Ver Original" compact>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
|
|
||||||
<img
|
|
||||||
src={originalUrl || imageUrl}
|
|
||||||
alt="Original"
|
|
||||||
style={{ maxWidth: '80%', maxHeight: 200, objectFit: 'contain', borderRadius: 'var(--radius-sm)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CollapsibleSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Área de ação */}
|
|
||||||
{isMobile ? (
|
|
||||||
<CollapsibleSection title="Ações e Observação">
|
|
||||||
<div className="form-group" style={{ marginBottom: 12 }}>
|
|
||||||
<label htmlFor="transform-remark">Observação (Opcional):</label>
|
|
||||||
<textarea
|
|
||||||
id="transform-remark"
|
|
||||||
name="remark"
|
|
||||||
className="form-control"
|
|
||||||
rows={2}
|
|
||||||
value={remark}
|
|
||||||
onChange={(e) => setRemark(e.target.value)}
|
|
||||||
placeholder="Ex: Raio-X Dente 45..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={saveTransform}
|
|
||||||
disabled={saving}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
>
|
|
||||||
{saving ? 'Salvando...' : '💾 Salvar imagem e orientação'}
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-secondary" onClick={() => setActiveView('gto')} style={{ width: '100%' }}>
|
|
||||||
📋 Vincular à GTO
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-secondary" onClick={() => downloadImage(true)} style={{ width: '100%' }}>
|
|
||||||
⬇️ Baixar orientada
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-secondary" onClick={() => downloadImage(false)} style={{ width: '100%' }}>
|
|
||||||
⬇️ Baixar original
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-secondary" onClick={onClose} style={{ width: '100%' }}>
|
|
||||||
Cancelar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</CollapsibleSection>
|
|
||||||
) : (
|
|
||||||
<div className="transform-action-area">
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="transform-remark">Nova Observação / Detalhes (Opcional):</label>
|
|
||||||
<textarea
|
|
||||||
id="transform-remark"
|
|
||||||
name="remark"
|
|
||||||
className="form-control"
|
|
||||||
rows={2}
|
|
||||||
value={remark}
|
|
||||||
onChange={(e) => setRemark(e.target.value)}
|
|
||||||
placeholder="Ex: Raio-X Dente 45..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-actions-inline" style={{ flexWrap: 'wrap', gap: 8 }}>
|
|
||||||
<button className="btn btn-secondary" onClick={onClose}>Cancelar</button>
|
|
||||||
<button className="btn btn-secondary" onClick={() => downloadImage(true)} title="Baixar a imagem com a orientação aplicada">
|
|
||||||
⬇️ Baixar orientada
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-secondary" onClick={() => downloadImage(false)} title="Baixar a imagem original sem alterações">
|
|
||||||
⬇️ Baixar original
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-secondary" onClick={() => setActiveView('gto')} title="Vincular esta imagem a uma GTO">
|
|
||||||
📋 Vincular à GTO
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={saveTransform}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
{saving ? 'Salvando...' : '💾 Salvar imagem e orientação'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* View: GTOs */}
|
|
||||||
{activeView === 'gto' && (
|
|
||||||
<div>
|
|
||||||
{/* Baixar imagem atual */}
|
|
||||||
<div style={{ marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
<button className="btn btn-secondary btn-small" onClick={() => downloadImage(false)} title="Baixar a imagem original">
|
|
||||||
⬇️ Baixar imagem
|
|
||||||
</button>
|
|
||||||
{step === 'finetune' && (
|
|
||||||
<button className="btn btn-secondary btn-small" onClick={() => downloadImage(true)} title="Baixar a imagem orientada">
|
|
||||||
⬇️ Baixar orientada
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Criar GTO — colapsável no mobile */}
|
|
||||||
{isMobile ? (
|
|
||||||
<CollapsibleSection title="Cadastrar Nova GTO">
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
||||||
<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} style={{ width: '100%' }}>Cadastrar</button>
|
|
||||||
</div>
|
|
||||||
</CollapsibleSection>
|
|
||||||
) : (
|
|
||||||
<div className="gto-create-section">
|
|
||||||
<h3 className="section-title">Cadastrar Nova GTO</h3>
|
|
||||||
<div className="form-actions-inline" style={{ marginTop: 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)}
|
|
||||||
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}>Cadastrar</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Lista de GTOs */}
|
|
||||||
<div className="gto-list-container" style={{ marginTop: 12 }}>
|
|
||||||
{loadingGtos ? (
|
|
||||||
<div style={{ padding: 20, textAlign: 'center', color: '#888' }}>Carregando GTOs...</div>
|
|
||||||
) : gtos.length === 0 ? (
|
|
||||||
<div style={{ padding: 20, textAlign: 'center', color: '#888' }}>Nenhuma GTO cadastrada para este paciente.</div>
|
|
||||||
) : (
|
|
||||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
|
||||||
{gtos.map((gto, idx) => (
|
|
||||||
<li
|
|
||||||
key={gto.id}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: isMobile ? 'column' : 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: isMobile ? 'stretch' : 'center',
|
|
||||||
padding: isMobile ? 12 : 15,
|
|
||||||
background: idx % 2 === 0 ? '#fff' : '#f9f9f9',
|
|
||||||
borderBottom: '1px solid #eee',
|
|
||||||
gap: isMobile ? 10 : 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: isMobile ? '0.95rem' : '1.05rem', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
{gto.gto_number}
|
|
||||||
{gto.sent && (
|
|
||||||
<span style={{ fontSize: '0.7rem', fontWeight: 700, color: '#fff', background: '#10b981', padding: '2px 8px', borderRadius: 999 }}>
|
|
||||||
✅ ENVIADA
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ color: '#666', fontSize: '0.85rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: isMobile ? '70vw' : 'none' }}>
|
|
||||||
{gto.description || 'Sem descrição'}
|
|
||||||
</div>
|
|
||||||
<div style={{ color: '#999', fontSize: '0.75rem', marginTop: 3 }}>📅 {formatDate(gto.created_at)}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
||||||
<button className="btn btn-primary btn-small" onClick={() => linkToGto(gto.id)} style={{ flex: isMobile ? 1 : 'none' }}>
|
|
||||||
✅ Vincular
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`btn btn-small ${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' }}
|
|
||||||
>
|
|
||||||
{gto.sent ? '↩️ Desmarcar' : '📤 Enviada'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import api from '../api/client';
|
|||||||
import { useSocket } from '../contexts/SocketContext';
|
import { useSocket } from '../contexts/SocketContext';
|
||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import TransformModal from '../components/TransformModal';
|
|
||||||
import ThumbImg from '../components/ThumbImg';
|
import ThumbImg from '../components/ThumbImg';
|
||||||
import CreatePatientModal from '../components/CreatePatientModal';
|
import CreatePatientModal from '../components/CreatePatientModal';
|
||||||
import SettingsModal from '../components/SettingsModal';
|
import SettingsModal from '../components/SettingsModal';
|
||||||
@@ -51,12 +51,13 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [isSearchMobileOpen, setIsSearchMobileOpen] = useState(false);
|
||||||
const [selectedClient, setSelectedClient] = useState('');
|
const [selectedClient, setSelectedClient] = useState('');
|
||||||
const [showDisabled, setShowDisabled] = useState(false);
|
const [showDisabled, setShowDisabled] = useState(false);
|
||||||
const [clientsList, setClientsList] = useState([]);
|
const [clientsList, setClientsList] = useState([]);
|
||||||
|
|
||||||
// Modals
|
// Modals
|
||||||
const [transformImage, setTransformImage] = useState(null);
|
|
||||||
const [showCreatePatient, setShowCreatePatient] = useState(false);
|
const [showCreatePatient, setShowCreatePatient] = useState(false);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [showUsers, setShowUsers] = useState(false);
|
const [showUsers, setShowUsers] = useState(false);
|
||||||
@@ -278,7 +279,7 @@ export default function DashboardPage() {
|
|||||||
{/* Busca sempre visível */}
|
{/* Busca sempre visível */}
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
className="search-input"
|
className="search-input hide-mobile"
|
||||||
placeholder="🔍 Pesquisar paciente..."
|
placeholder="🔍 Pesquisar paciente..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
@@ -316,6 +317,7 @@ export default function DashboardPage() {
|
|||||||
label="Ações"
|
label="Ações"
|
||||||
buttonClassName="btn btn-primary"
|
buttonClassName="btn btn-primary"
|
||||||
items={[
|
items={[
|
||||||
|
{ label: 'Pesquisar', icon: '🔍', onClick: () => setIsSearchMobileOpen(!isSearchMobileOpen) },
|
||||||
{ label: showDisabled ? 'Ver Ativas' : 'Ver Ocultas', icon: '👁️', onClick: () => setShowDisabled(!showDisabled) },
|
{ label: showDisabled ? 'Ver Ativas' : 'Ver Ocultas', icon: '👁️', onClick: () => setShowDisabled(!showDisabled) },
|
||||||
{ label: 'Atualizar', icon: '🔄', onClick: () => loadPatients() },
|
{ label: 'Atualizar', icon: '🔄', onClick: () => loadPatients() },
|
||||||
]}
|
]}
|
||||||
@@ -346,6 +348,19 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{view === 'patients' && isSearchMobileOpen && (
|
||||||
|
<div className="show-mobile" style={{ padding: '0 20px 15px', width: '100%' }}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="🔍 Pesquisar paciente..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
style={{ width: '100%', height: 44, fontSize: '1rem' }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Scrollable content */}
|
{/* Scrollable content */}
|
||||||
@@ -432,7 +447,7 @@ export default function DashboardPage() {
|
|||||||
<button
|
<button
|
||||||
className="btn btn-primary btn-small"
|
className="btn btn-primary btn-small"
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
onClick={() => setTransformImage(image)}
|
onClick={() => navigate('/patients/' + encodeURIComponent(selectedPatient.patient_name) + '/image/' + image.id)}
|
||||||
>🔄 Orientar</button>
|
>🔄 Orientar</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-small"
|
className="btn btn-small"
|
||||||
@@ -459,12 +474,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* MODALS */}
|
{/* MODALS */}
|
||||||
<TransformModal
|
|
||||||
isOpen={!!transformImage}
|
|
||||||
onClose={() => setTransformImage(null)}
|
|
||||||
image={transformImage}
|
|
||||||
onSaved={() => selectedPatient && loadPatientImages(selectedPatient.patient_name)}
|
|
||||||
/>
|
|
||||||
<CreatePatientModal isOpen={showCreatePatient} onClose={() => setShowCreatePatient(false)} onCreated={loadPatients} />
|
<CreatePatientModal isOpen={showCreatePatient} onClose={() => setShowCreatePatient(false)} onCreated={loadPatients} />
|
||||||
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
|
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
|
||||||
<UserManagementModal isOpen={showUsers} onClose={() => setShowUsers(false)} />
|
<UserManagementModal isOpen={showUsers} onClose={() => setShowUsers(false)} />
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ export default function GtosPage() {
|
|||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [pages, setPages] = useState(1);
|
const [pages, setPages] = useState(1);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isSearchMobileOpen, setIsSearchMobileOpen] = useState(false);
|
||||||
const [stats, setStats] = useState({ all:0, vinculada:0, enviada:0 });
|
const [stats, setStats] = useState({ all:0, vinculada:0, enviada:0 });
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -336,7 +337,7 @@ export default function GtosPage() {
|
|||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
className="search-input"
|
className="search-input hide-mobile"
|
||||||
placeholder="🔍 Buscar por paciente ou número..."
|
placeholder="🔍 Buscar por paciente ou número..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
@@ -355,6 +356,7 @@ export default function GtosPage() {
|
|||||||
label="Ações"
|
label="Ações"
|
||||||
buttonClassName="btn btn-primary"
|
buttonClassName="btn btn-primary"
|
||||||
items={[
|
items={[
|
||||||
|
{ label:'Pesquisar', icon:'🔍', onClick:() => setIsSearchMobileOpen(!isSearchMobileOpen) },
|
||||||
{ label:'Nova GTO', icon:'➕', onClick:() => setShowCreate(true) },
|
{ label:'Nova GTO', icon:'➕', onClick:() => setShowCreate(true) },
|
||||||
{ label:'Atualizar lista', icon:'🔄', onClick:reload },
|
{ label:'Atualizar lista', icon:'🔄', onClick:reload },
|
||||||
]}
|
]}
|
||||||
@@ -362,6 +364,19 @@ export default function GtosPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isSearchMobileOpen && (
|
||||||
|
<div className="show-mobile" style={{ padding: '0 20px 15px', width: '100%' }}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="🔍 Buscar por paciente ou número..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
style={{ width: '100%', height: 44, fontSize: '1rem' }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="content-scroll">
|
<div className="content-scroll">
|
||||||
|
|||||||
@@ -0,0 +1,688 @@
|
|||||||
|
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="layout">
|
||||||
|
<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 && remark === (image.remark || '')) {
|
||||||
|
showToast('Nenhuma edição aplicada.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.post(`/images/${image.id}/transform`, {
|
||||||
|
rotation: totalRotation,
|
||||||
|
flipH: baseFlipH,
|
||||||
|
flipV: baseFlipV,
|
||||||
|
remark
|
||||||
|
});
|
||||||
|
showToast('Imagem e orientação salvas!', 'success');
|
||||||
|
navigate('/patients'); // Volta para painel após salvar
|
||||||
|
} 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('/patients');
|
||||||
|
} 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 choosePages = [
|
||||||
|
{ flipH: false, flipV: false, label: 'Rotações' },
|
||||||
|
{ flipH: true, flipV: false, label: 'Espelhado ⇆' },
|
||||||
|
{ flipH: false, flipV: true, label: 'Espelhado ⇅' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const pickOrientation = (deg) => {
|
||||||
|
const pg = choosePages[choosePage];
|
||||||
|
setBaseRotation(deg);
|
||||||
|
setBaseFlipH(pg.flipH);
|
||||||
|
setBaseFlipV(pg.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 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). Se mobile + landscape(horizontal) divide em 2 páginas.
|
||||||
|
const allRotations = [0, 90, 180, 270];
|
||||||
|
const useTwoCards = isMobile && !isPortrait;
|
||||||
|
const currentRotations = useTwoCards
|
||||||
|
? allRotations.slice(orientationSubpage * 2, (orientationSubpage * 2) + 2)
|
||||||
|
: allRotations;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="layout">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="main-content">
|
||||||
|
{/* Top Header para navegação / Voltar */}
|
||||||
|
<header className="header" style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
<button className="btn btn-secondary" onClick={() => navigate('/patients')} title="Voltar ao Painel">
|
||||||
|
← Voltar
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ fontSize: '1.1rem', margin: 0, color: 'var(--text-primary)' }}>
|
||||||
|
{image.patient_name || 'Desconhecido'}
|
||||||
|
</h1>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
||||||
|
{formatDate(image.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginLeft: 'auto', 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>
|
||||||
|
</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 Principais de Categoria (Rotações, Espelhos) */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 16, marginBottom: 20 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => { setChoosePage((p) => Math.max(0, p - 1)); setOrientationSubpage(0); }}
|
||||||
|
disabled={choosePage === 0}
|
||||||
|
>← Categoria</button>
|
||||||
|
<span style={{ fontSize: '1rem', fontWeight: 600, color: 'var(--text-secondary)' }}>
|
||||||
|
{choosePages[choosePage].label} ({choosePage + 1}/{choosePages.length})
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => { setChoosePage((p) => Math.min(choosePages.length - 1, p + 1)); setOrientationSubpage(0); }}
|
||||||
|
disabled={choosePage === choosePages.length - 1}
|
||||||
|
>Categoria →</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controles de Sub-página (quando horizontal no mobile para mostrar só 2) */}
|
||||||
|
{useTwoCards && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 16, marginBottom: 16 }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-small"
|
||||||
|
disabled={orientationSubpage === 0}
|
||||||
|
onClick={() => setOrientationSubpage(0)}
|
||||||
|
>← Opções Anteriores</button>
|
||||||
|
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{orientationSubpage + 1}/2</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-small"
|
||||||
|
disabled={orientationSubpage === 1}
|
||||||
|
onClick={() => setOrientationSubpage(1)}
|
||||||
|
>Mais Opções →</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: useTwoCards ? '1fr' : (isMobile ? 'repeat(2, 1fr)' : 'repeat(4, 1fr)'), gap: 16 }}>
|
||||||
|
{currentRotations.map((deg) => {
|
||||||
|
const pg = choosePages[choosePage];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={deg}
|
||||||
|
type="button"
|
||||||
|
onClick={() => pickOrientation(deg)}
|
||||||
|
style={{
|
||||||
|
border: '2px solid var(--glass-border)', borderRadius: 10, background: '#0b1220',
|
||||||
|
padding: useTwoCards ? 20 : 16, cursor: 'pointer', display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', gap: 12, transition: 'all .2s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.borderColor = 'var(--primary-color)'; e.currentTarget.style.transform = 'translateY(-2px)'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'var(--glass-border)'; e.currentTarget.style.transform = 'translateY(0)'; }}
|
||||||
|
title={`Usar ${deg}° ${pg.flipH ? '(espelho H)' : pg.flipV ? '(espelho V)' : ''}`}
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%', aspectRatio: useTwoCards ? 'auto' : '1 / 1', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={`${deg}°`}
|
||||||
|
loading="lazy"
|
||||||
|
style={{ width: useTwoCards ? '90%' : '80%', maxHeight: useTwoCards ? 300 : '100%', objectFit: 'contain', transform: cssTransform(deg, pg.flipH, pg.flipV), transformOrigin: 'center' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '0.9rem', fontWeight: 600, color: 'var(--text-secondary)' }}>
|
||||||
|
{deg}°{pg.flipH ? ' ⇆' : pg.flipV ? ' ⇅' : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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 e Voltar'}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ export default function PatientsPage() {
|
|||||||
const [patients, setPatients] = useState([]);
|
const [patients, setPatients] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [isSearchMobileOpen, setIsSearchMobileOpen] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [version, setVersion] = useState('');
|
const [version, setVersion] = useState('');
|
||||||
@@ -112,7 +113,7 @@ export default function PatientsPage() {
|
|||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
className="search-input"
|
className="search-input hide-mobile"
|
||||||
placeholder="🔍 Pesquisar paciente..."
|
placeholder="🔍 Pesquisar paciente..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
@@ -131,6 +132,7 @@ export default function PatientsPage() {
|
|||||||
label="Ações"
|
label="Ações"
|
||||||
buttonClassName="btn btn-primary"
|
buttonClassName="btn btn-primary"
|
||||||
items={[
|
items={[
|
||||||
|
{ label: 'Pesquisar', icon: '🔍', onClick: () => setIsSearchMobileOpen(!isSearchMobileOpen) },
|
||||||
{ label: 'Novo Paciente', icon: '➕', onClick: () => setShowCreate(true) },
|
{ label: 'Novo Paciente', icon: '➕', onClick: () => setShowCreate(true) },
|
||||||
{ label: 'Atualizar lista', icon: '🔄', onClick: () => loadPatients(true) },
|
{ label: 'Atualizar lista', icon: '🔄', onClick: () => loadPatients(true) },
|
||||||
]}
|
]}
|
||||||
@@ -138,6 +140,19 @@ export default function PatientsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isSearchMobileOpen && (
|
||||||
|
<div className="show-mobile" style={{ padding: '0 20px 15px', width: '100%' }}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="🔍 Pesquisar paciente..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
style={{ width: '100%', height: 44, fontSize: '1rem' }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="content-scroll" onScroll={handleScroll}>
|
<div className="content-scroll" onScroll={handleScroll}>
|
||||||
|
|||||||
Reference in New Issue
Block a user