From f8ddbc07431d2b4d920bbf934d66098aa2b72ad8 Mon Sep 17 00:00:00 2001 From: VPS 4 Deploy Agent Date: Sun, 31 May 2026 21:08:27 +0200 Subject: [PATCH] feat(mobile): improve search UX and ImageDetails orientation UX --- .../dental-client/public/version.txt | 2 +- dental-server/dental-client/src/App.jsx | 7 + .../src/components/TransformModal.jsx | 628 ---------------- .../dental-client/src/pages/DashboardPage.jsx | 30 +- .../dental-client/src/pages/GtosPage.jsx | 17 +- .../src/pages/ImageDetailsPage.jsx | 688 ++++++++++++++++++ .../dental-client/src/pages/PatientsPage.jsx | 17 +- 7 files changed, 748 insertions(+), 641 deletions(-) delete mode 100644 dental-server/dental-client/src/components/TransformModal.jsx create mode 100644 dental-server/dental-client/src/pages/ImageDetailsPage.jsx diff --git a/dental-server/dental-client/public/version.txt b/dental-server/dental-client/public/version.txt index 96d270e..7ec3b24 100644 --- a/dental-server/dental-client/public/version.txt +++ b/dental-server/dental-client/public/version.txt @@ -1 +1 @@ -2.1.46 +2.1.48 diff --git a/dental-server/dental-client/src/App.jsx b/dental-server/dental-client/src/App.jsx index c3db12d..69d6f5e 100644 --- a/dental-server/dental-client/src/App.jsx +++ b/dental-server/dental-client/src/App.jsx @@ -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() { } /> + + + + } /> + {/* Catch-all: redireciona para home */} } /> diff --git a/dental-server/dental-client/src/components/TransformModal.jsx b/dental-server/dental-client/src/components/TransformModal.jsx deleted file mode 100644 index a5924c6..0000000 --- a/dental-server/dental-client/src/components/TransformModal.jsx +++ /dev/null @@ -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 = ( -
- - - - {image.original_image_id && ( - - )} -
- ); - - const titlePrefix = activeView === 'transform' && step === 'finetune' ? ( - - ) : null; - - return ( - - {/* Info extra */} - {showInfo && ( -
-
👨‍⚕️ Dentista: {image.doctor || 'Não informado'}
-
📝 Obs: {image.remark || 'Nenhuma observação'}
-
- )} - - {/* 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) => { - const pg = choosePages[choosePage]; - return ( - - ); - })} -
-
- )} - - {/* View: Transformação — PASSO 2: ajuste fino (escolhida + original) */} - {activeView === 'transform' && step === 'finetune' && ( -
-
- {/* Original — escondido no mobile, mostrado via CollapsibeSection */} - {!isMobile && ( -
-

Original

-
- Original -
-
- )} - - {/* Preview (orientação escolhida + ajuste fino) */} -
-

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

-
- Preview -
-
- - {/* Controles de rotação */} -
- {!isMobile && ( -

Ajuste

- )} -
- {[ - { label: '-5°', delta: -5, icon: '↺' }, - { label: '-1°', delta: -1, icon: '↺' }, - ].map(({ label, delta, icon }) => ( - - ))} - -
- {fineTuneAngle > 0 ? '+' : ''}{fineTuneAngle}° -
- - {[ - { label: '+1°', delta: 1, icon: '↻' }, - { label: '+5°', delta: 5, icon: '↻' }, - ].map(({ label, delta, icon }) => ( - - ))} - - {fineTuneAngle !== 0 && ( - - )} -
-
-
- - {/* Original colapsável — apenas no mobile */} - {isMobile && ( - -
- Original -
-
- )} - - {/* Área de ação */} - {isMobile ? ( - -
- -