This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user