feat: migrate vanilla js to react vite spa
continuous-integration/webhook Falha no deploy (VPS4)

This commit is contained in:
VPS 4 Deploy Agent
2026-05-30 18:26:56 +02:00
parent 3bc1161d95
commit d6ae290e56
47 changed files with 5374 additions and 24 deletions
@@ -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>
);
}