Files
rx.scoreodonto.com/dental-server/dental-client/src/components/TransformModal.jsx
T
VPS 4 Deploy Agent d6ae290e56
continuous-integration/webhook Falha no deploy (VPS4)
feat: migrate vanilla js to react vite spa
2026-05-30 18:26:56 +02:00

323 lines
11 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react';
import Modal from './Modal';
import api from '../api/client';
import { useToast } from '../contexts/ToastContext';
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 [activeView, setActiveView] = useState('transform'); // 'transform' | 'gto'
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);
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)}`);
setGtos(data || []);
} catch {
setGtos([]);
} finally {
setLoadingGtos(false);
}
};
const adjustAngle = (delta) => {
setFineTuneAngle((prev) => prev + delta);
};
const resetAngle = () => setFineTuneAngle(0);
const saveTransform = async () => {
if (fineTuneAngle === 0) {
showToast('Nenhuma edição aplicada.', 'error');
return;
}
setSaving(true);
try {
await api.post(`/images/${image.id}/transform`, {
rotation: fineTuneAngle,
flipH: false,
flipV: false,
remark
});
showToast('Orientação salva!', '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);
}
};
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 = (
<div className="modal-actions-group">
<button
className={`btn btn-small ${activeView === 'transform' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setActiveView('transform')}
title="Ajustar Imagem"
>🔄</button>
<button
className={`btn btn-small ${activeView === 'gto' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setActiveView('gto')}
title="Gerenciar GTOs"
>📋</button>
<button
className={`btn btn-small ${showInfo ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setShowInfo(!showInfo)}
title="Informações"
></button>
{image.original_image_id && (
<button
className="btn btn-small btn-danger"
onClick={resetToOriginal}
disabled={resetting}
title="Resetar para Original"
></button>
)}
</div>
);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`${image.patient_name || 'Desconhecido'}${formatDate(image.created_at)}`}
large
headerExtras={headerExtras}
>
{/* Info extra */}
{showInfo && (
<div className="extra-info-panel">
<div style={{ marginBottom: 5 }}><strong>👨 Dentista:</strong> {image.doctor || 'Não informado'}</div>
<div><strong>📝 Obs:</strong> {image.remark || 'Nenhuma observação'}</div>
</div>
)}
{/* View: Transformação */}
{activeView === 'transform' && (
<div className="transform-view-wrapper">
<div className="new-transform-layout">
{/* Original */}
<div className="transform-panel panel-original">
<h3 className="panel-title">Original</h3>
<div className="img-container">
<img src={originalUrl || imageUrl} alt="Original" />
</div>
</div>
{/* Preview (com rotação aplicada) */}
<div className="transform-panel panel-preview">
<h3 className="panel-title">Preview</h3>
<div className="img-container">
<img
src={imageUrl}
alt="Preview"
style={{ transform: `rotate(${fineTuneAngle}deg)` }}
/>
</div>
</div>
{/* Controles de rotação */}
<div className="transform-sidebar">
<h3 className="sidebar-title" style={{ fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: 1, color: 'var(--text-muted)', marginBottom: 12 }}>Ajuste</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center', width: '100%' }}>
{[
{ label: '-5°', delta: -5, icon: '↺' },
{ label: '-1°', delta: -1, icon: '↺' },
].map(({ label, delta, icon }) => (
<button key={label} type="button" className="sidebar-btn" onClick={() => adjustAngle(delta)} title={`Rotacionar ${label}`}>
<span className="icon">{icon}</span>
<span className="label">{label}</span>
</button>
))}
<div className="fine-tune-display" style={{ margin: '8px 0' }}>
{fineTuneAngle > 0 ? '+' : ''}{fineTuneAngle}°
</div>
{[
{ label: '+1°', delta: 1, icon: '↻' },
{ label: '+5°', delta: 5, icon: '↻' },
].map(({ label, delta, icon }) => (
<button key={label} type="button" className="sidebar-btn" onClick={() => adjustAngle(delta)} title={`Rotacionar ${label}`}>
<span className="icon">{icon}</span>
<span className="label">{label}</span>
</button>
))}
{fineTuneAngle !== 0 && (
<button type="button" className="btn btn-danger btn-small" onClick={resetAngle} style={{ marginTop: 12, width: '100%' }}>
Reset
</button>
)}
</div>
</div>
</div>
{/* Área de ação */}
<div className="transform-action-area">
<div className="form-group">
<label>Nova Observação / Detalhes (Opcional):</label>
<textarea
className="form-control"
rows={2}
value={remark}
onChange={(e) => setRemark(e.target.value)}
placeholder="Ex: Raio-X Dente 45..."
/>
</div>
<div className="form-actions-inline">
<button className="btn btn-secondary" onClick={onClose}>Cancelar</button>
<button
className="btn btn-primary"
onClick={saveTransform}
disabled={saving}
>
{saving ? 'Salvando...' : 'Salvar Nova Imagem'}
</button>
</div>
</div>
</div>
)}
{/* View: GTOs */}
{activeView === 'gto' && (
<div>
{/* Criar GTO */}
<div className="gto-create-section">
<h3 className="section-title">Cadastrar Nova GTO</h3>
<div className="form-actions-inline" style={{ marginTop: 12 }}>
<input
type="text" className="form-control"
placeholder="Número/ID da GTO"
value={gtoNumber}
onChange={(e) => setGtoNumber(e.target.value)}
style={{ flex: 1 }}
/>
<input
type="text" className="form-control"
placeholder="Descrição (Opcional)"
value={gtoDesc}
onChange={(e) => setGtoDesc(e.target.value)}
style={{ flex: 2 }}
/>
<button className="btn btn-primary" onClick={createGto}>Cadastrar</button>
</div>
</div>
{/* Lista de GTOs */}
<div className="gto-list-container">
{loadingGtos ? (
<div style={{ padding: 20, textAlign: 'center', color: '#888' }}>Carregando GTOs...</div>
) : gtos.length === 0 ? (
<div style={{ padding: 20, textAlign: 'center', color: '#888' }}>Nenhuma GTO cadastrada para este paciente.</div>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{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={{ 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>
</li>
))}
</ul>
)}
</div>
</div>
)}
</Modal>
);
}