feat(mobile): improve search UX and ImageDetails orientation UX
continuous-integration/webhook Deploy concluído (VPS4)

This commit is contained in:
VPS 4 Deploy Agent
2026-05-31 21:08:27 +02:00
parent cacacfe9e7
commit f8ddbc0743
7 changed files with 748 additions and 641 deletions
@@ -1 +1 @@
2.1.46
2.1.48
+7
View File
@@ -14,6 +14,7 @@ import DownloadPage from './pages/DownloadPage';
import PatientsPage from './pages/PatientsPage';
import SettingsPage from './pages/SettingsPage';
import GtosPage from './pages/GtosPage';
import ImageDetailsPage from './pages/ImageDetailsPage';
export default function App() {
return (
@@ -76,6 +77,12 @@ export default function App() {
</ProtectedRoute>
} />
<Route path="/patients/:patientName/image/:imageId" element={
<ProtectedRoute>
<ImageDetailsPage />
</ProtectedRoute>
} />
{/* Catch-all: redireciona para home */}
<Route path="*" element={<Navigate to="/" replace />} />
</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 { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import TransformModal from '../components/TransformModal';
import ThumbImg from '../components/ThumbImg';
import CreatePatientModal from '../components/CreatePatientModal';
import SettingsModal from '../components/SettingsModal';
@@ -51,12 +51,13 @@ export default function DashboardPage() {
// Filters
const [search, setSearch] = useState('');
const [isSearchMobileOpen, setIsSearchMobileOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState('');
const [showDisabled, setShowDisabled] = useState(false);
const [clientsList, setClientsList] = useState([]);
// Modals
const [transformImage, setTransformImage] = useState(null);
const [showCreatePatient, setShowCreatePatient] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showUsers, setShowUsers] = useState(false);
@@ -278,7 +279,7 @@ export default function DashboardPage() {
{/* Busca sempre visível */}
<input
type="search"
className="search-input"
className="search-input hide-mobile"
placeholder="🔍 Pesquisar paciente..."
value={search}
onChange={(e) => setSearch(e.target.value)}
@@ -316,6 +317,7 @@ export default function DashboardPage() {
label="Ações"
buttonClassName="btn btn-primary"
items={[
{ label: 'Pesquisar', icon: '🔍', onClick: () => setIsSearchMobileOpen(!isSearchMobileOpen) },
{ label: showDisabled ? 'Ver Ativas' : 'Ver Ocultas', icon: '👁️', onClick: () => setShowDisabled(!showDisabled) },
{ label: 'Atualizar', icon: '🔄', onClick: () => loadPatients() },
]}
@@ -346,6 +348,19 @@ export default function DashboardPage() {
</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>
{/* Scrollable content */}
@@ -432,7 +447,7 @@ export default function DashboardPage() {
<button
className="btn btn-primary btn-small"
style={{ flex: 1 }}
onClick={() => setTransformImage(image)}
onClick={() => navigate('/patients/' + encodeURIComponent(selectedPatient.patient_name) + '/image/' + image.id)}
>🔄 Orientar</button>
<button
className="btn btn-small"
@@ -459,12 +474,7 @@ export default function DashboardPage() {
</div>
{/* MODALS */}
<TransformModal
isOpen={!!transformImage}
onClose={() => setTransformImage(null)}
image={transformImage}
onSaved={() => selectedPatient && loadPatientImages(selectedPatient.patient_name)}
/>
<CreatePatientModal isOpen={showCreatePatient} onClose={() => setShowCreatePatient(false)} onCreated={loadPatients} />
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
<UserManagementModal isOpen={showUsers} onClose={() => setShowUsers(false)} />
@@ -230,6 +230,7 @@ export default function GtosPage() {
const [total, setTotal] = useState(0);
const [pages, setPages] = useState(1);
const [loading, setLoading] = useState(true);
const [isSearchMobileOpen, setIsSearchMobileOpen] = useState(false);
const [stats, setStats] = useState({ all:0, vinculada:0, enviada:0 });
const [search, setSearch] = useState('');
@@ -336,7 +337,7 @@ export default function GtosPage() {
<div className="header-actions">
<input
type="search"
className="search-input"
className="search-input hide-mobile"
placeholder="🔍 Buscar por paciente ou número..."
value={search}
onChange={e => setSearch(e.target.value)}
@@ -355,6 +356,7 @@ export default function GtosPage() {
label="Ações"
buttonClassName="btn btn-primary"
items={[
{ label:'Pesquisar', icon:'🔍', onClick:() => setIsSearchMobileOpen(!isSearchMobileOpen) },
{ label:'Nova GTO', icon:'', onClick:() => setShowCreate(true) },
{ label:'Atualizar lista', icon:'🔄', onClick:reload },
]}
@@ -362,6 +364,19 @@ export default function GtosPage() {
</span>
</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>
<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 [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [isSearchMobileOpen, setIsSearchMobileOpen] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [version, setVersion] = useState('');
@@ -112,7 +113,7 @@ export default function PatientsPage() {
<div className="header-actions">
<input
type="search"
className="search-input"
className="search-input hide-mobile"
placeholder="🔍 Pesquisar paciente..."
value={search}
onChange={e => setSearch(e.target.value)}
@@ -131,6 +132,7 @@ export default function PatientsPage() {
label="Ações"
buttonClassName="btn btn-primary"
items={[
{ label: 'Pesquisar', icon: '🔍', onClick: () => setIsSearchMobileOpen(!isSearchMobileOpen) },
{ label: 'Novo Paciente', icon: '', onClick: () => setShowCreate(true) },
{ label: 'Atualizar lista', icon: '🔄', onClick: () => loadPatients(true) },
]}
@@ -138,6 +140,19 @@ export default function PatientsPage() {
</span>
</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>
<div className="content-scroll" onScroll={handleScroll}>