feat(transform): páginas de orientação (flip H/V), downloads (orientada+original), vincular GTO no ajuste fino e marcar GTO enviada
continuous-integration/webhook Deploy concluído (VPS4)

- Passo de escolha: Avançar/Voltar entre rotações, espelho H e espelho V (12 orientações)
- Ajuste fino: botões Baixar orientada/original (canvas), Vincular à GTO, Salvar imagem e orientação
- View GTO: badge ENVIADA + botão 'Imagem enviada' (persiste via POST /gtos/:id/sent), baixar imagem
- Backend: coluna gtos.sent/sent_at + rota POST /gtos/:id/sent
This commit is contained in:
VPS 4 Deploy Agent
2026-05-30 21:04:42 +02:00
parent 79c6012fbe
commit 74a8fa7fc4
3 changed files with 196 additions and 37 deletions
@@ -15,7 +15,10 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
const { showToast } = useToast();
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('');
@@ -32,6 +35,9 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
if (!image) return;
setFineTuneAngle(0);
setBaseRotation(0);
setBaseFlipH(false);
setBaseFlipV(false);
setChoosePage(0);
setStep('choose');
setRemark('');
setShowInfo(false);
@@ -85,11 +91,11 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
try {
await api.post(`/images/${image.id}/transform`, {
rotation: totalRotation,
flipH: false,
flipV: false,
flipH: baseFlipH,
flipV: baseFlipV,
remark
});
showToast('Orientação salva!', 'success');
showToast('Imagem e orientação salvas!', 'success');
onClose();
onSaved?.();
} catch {
@@ -116,6 +122,77 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
}
};
// 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 {
@@ -189,35 +266,57 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
{/* 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 16px' }}>
<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) => (
<button
key={deg}
type="button"
onClick={() => { setBaseRotation(deg); setFineTuneAngle(0); setStep('finetune'); }}
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}°`}
>
<div style={{ width: '100%', aspectRatio: '1 / 1', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
<img
src={imageUrl}
alt={`${deg}°`}
loading="lazy"
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', transform: `rotate(${deg}deg)` }}
/>
</div>
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-secondary)' }}>{deg}°</span>
</button>
))}
{[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)' : ''}`}
>
<div style={{ width: '100%', aspectRatio: '1 / 1', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
<img
src={imageUrl}
alt={`${deg}°`}
loading="lazy"
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', transform: cssTransform(deg, pg.flipH, pg.flipV) }}
/>
</div>
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-secondary)' }}>
{deg}°{pg.flipH ? ' ⇆' : pg.flipV ? ' ⇅' : ''}
</span>
</button>
);
})}
</div>
</div>
)}
@@ -236,12 +335,14 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
{/* Preview (orientação escolhida + ajuste fino) */}
<div className="transform-panel panel-preview">
<h3 className="panel-title">Preview ({totalRotation}°)</h3>
<h3 className="panel-title">
Preview ({totalRotation}°{baseFlipH ? ' ⇆' : ''}{baseFlipV ? ' ⇅' : ''})
</h3>
<div className="img-container">
<img
src={imageUrl}
alt="Preview"
style={{ transform: `rotate(${baseRotation + fineTuneAngle}deg)` }}
style={{ transform: cssTransform(baseRotation + fineTuneAngle, baseFlipH, baseFlipV) }}
/>
</div>
</div>
@@ -302,14 +403,23 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
placeholder="Ex: Raio-X Dente 45..."
/>
</div>
<div className="form-actions-inline">
<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 Nova Imagem'}
{saving ? 'Salvando...' : '💾 Salvar imagem e orientação'}
</button>
</div>
</div>
@@ -319,6 +429,18 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
{/* View: GTOs */}
{activeView === 'gto' && (
<div>
{/* Baixar imagem atual */}
<div className="form-actions-inline" style={{ marginBottom: 12, 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 */}
<div className="gto-create-section">
<h3 className="section-title">Cadastrar Nova GTO</h3>
@@ -352,13 +474,29 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
{gtos.map((gto, idx) => (
<li key={gto.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 15, background: idx % 2 === 0 ? '#fff' : '#f9f9f9', borderBottom: '1px solid #eee' }}>
<div>
<div style={{ fontWeight: 600, fontSize: '1.05rem' }}>{gto.gto_number}</div>
<div style={{ fontWeight: 600, fontSize: '1.05rem', display: 'flex', alignItems: 'center', gap: 8 }}>
{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.9rem' }}>{gto.description || 'Sem descrição'}</div>
<div style={{ color: '#999', fontSize: '0.8rem', marginTop: 4 }}>📅 {formatDate(gto.created_at)}</div>
</div>
<button className="btn btn-primary btn-small" onClick={() => linkToGto(gto.id)}>
Vincular
</button>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button className="btn btn-primary btn-small" onClick={() => linkToGto(gto.id)}>
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'}
>
{gto.sent ? '↩️ Desmarcar' : '📤 Imagem enviada'}
</button>
</div>
</li>
))}
</ul>