diff --git a/dental-server/database.js b/dental-server/database.js index e08dbf5..c523b6a 100644 --- a/dental-server/database.js +++ b/dental-server/database.js @@ -107,6 +107,8 @@ async function createTables() { ) `); await client.query('CREATE INDEX IF NOT EXISTS idx_gtos_patient ON gtos(patient_name)'); + await client.query('ALTER TABLE gtos ADD COLUMN IF NOT EXISTS sent BOOLEAN DEFAULT FALSE;'); + await client.query('ALTER TABLE gtos ADD COLUMN IF NOT EXISTS sent_at TIMESTAMP;'); console.log('✅ Tabela gtos criada/verificada no PostgreSQL'); // Tabela de vínculo GTO-Images diff --git a/dental-server/dental-client/src/components/TransformModal.jsx b/dental-server/dental-client/src/components/TransformModal.jsx index 4002b1c..30c8db0 100644 --- a/dental-server/dental-client/src/components/TransformModal.jsx +++ b/dental-server/dental-client/src/components/TransformModal.jsx @@ -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' && (
+
Escolha a melhor orientação. Depois você faz o ajuste fino.
+