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.

+
+ + + {choosePages[choosePage].label} ({choosePage + 1}/{choosePages.length}) + + +
- {[0, 90, 180, 270].map((deg) => ( - - ))} + {[0, 90, 180, 270].map((deg) => { + const pg = choosePages[choosePage]; + return ( + + ); + })}
)} @@ -236,12 +335,14 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) { {/* Preview (orientação escolhida + ajuste fino) */}
-

Preview ({totalRotation}°)

+

+ Preview ({totalRotation}°{baseFlipH ? ' ⇆' : ''}{baseFlipV ? ' ⇅' : ''}) +

Preview
@@ -302,14 +403,23 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) { placeholder="Ex: Raio-X Dente 45..." /> -
+
+ + +
@@ -319,6 +429,18 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) { {/* View: GTOs */} {activeView === 'gto' && (
+ {/* Baixar imagem atual */} +
+ + {step === 'finetune' && ( + + )} +
+ {/* Criar GTO */}

Cadastrar Nova GTO

@@ -352,13 +474,29 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) { {gtos.map((gto, idx) => (
  • -
    {gto.gto_number}
    +
    + {gto.gto_number} + {gto.sent && ( + + ✅ ENVIADA + + )} +
    {gto.description || 'Sem descrição'}
    📅 {formatDate(gto.created_at)}
    - +
    + + +
  • ))} diff --git a/dental-server/routes/gtos.js b/dental-server/routes/gtos.js index 410f430..911a745 100644 --- a/dental-server/routes/gtos.js +++ b/dental-server/routes/gtos.js @@ -154,4 +154,23 @@ router.delete('/:id/images/:imageId', async (req, res) => { } }); +// ================================================================ +// POST /api/gtos/:id/sent — marcar/desmarcar GTO como enviada +// Body: { sent: true|false } +// ================================================================ +router.post('/:id/sent', async (req, res) => { + try { + const sent = req.body.sent !== false; // default true + await db.run( + 'UPDATE gtos SET sent = $1, sent_at = CASE WHEN $1 THEN NOW() ELSE NULL END WHERE id = $2', + [sent, req.params.id] + ); + const gto = await db.get('SELECT * FROM gtos WHERE id = $1', [req.params.id]); + res.json(gto); + } catch (err) { + console.error('Erro ao marcar GTO como enviada:', err); + res.status(500).json({ error: err.message }); + } +}); + module.exports = router;