323 lines
11 KiB
React
323 lines
11 KiB
React
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>
|
||
);
|
||
}
|