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)
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user