feat(gtos): adicionar edicao inline de observacao de imagem na GTO
continuous-integration/webhook Deploy concluído (VPS4)

This commit is contained in:
VPS 4 Deploy Agent
2026-06-01 03:10:22 +02:00
parent 8ba1691a33
commit 0e71d2a88b
2 changed files with 199 additions and 106 deletions
+183 -106
View File
@@ -40,12 +40,99 @@ function StatusBadge({ sent, imageCount }) {
}
/* ── Modal de detalhes/edição de GTO ─────────────────────────── */
function GtoDetailModal({ isOpen, onClose, gto, onUpdated, onOpenImage }) {
function GtoDetailModal({ isOpen, onClose, gto, onUpdated }) {
const { showToast } = useToast();
const [detail, setDetail] = useState(null);
const [loading, setLoading] = useState(false);
const [marking, setMarking] = useState(false);
// Estados para Visualização e Edição de Observação da Imagem
const [selectedImage, setSelectedImage] = useState(null);
const [isEditingRemark, setIsEditingRemark] = useState(false);
const [tempRemark, setTempRemark] = useState('');
const [savingRemark, setSavingRemark] = useState(false);
useEffect(() => {
if (selectedImage) {
setTempRemark(selectedImage.remark || '');
setIsEditingRemark(false);
}
}, [selectedImage]);
const handleDownload = async (img) => {
try {
const url = img.filename?.startsWith('http')
? img.filename
: `/uploads/${img.filename}`;
const response = await fetch(url);
const blob = await response.blob();
const pName = (img.patient_name || 'paciente').trim().replace(/[\/\\:*?"<>|]/g, '').replace(/\s+/g, '_');
const obs = (img.remark || '').trim().replace(/[\/\\:*?"<>|]/g, '').replace(/\s+/g, '_');
const fileName = obs ? `${pName}-${obs}.jpg` : `${pName}.jpg`;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fileName;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
} catch (err) {
showToast('Erro ao baixar imagem', 'error');
}
};
const handleCopy = (img) => {
const url = img.filename?.startsWith('http')
? img.filename
: `/uploads/${img.filename}`;
const imageObj = new Image();
imageObj.crossOrigin = 'anonymous';
imageObj.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = imageObj.naturalWidth;
canvas.height = imageObj.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(imageObj, 0, 0);
canvas.toBlob(async (blob) => {
try {
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob })
]);
showToast('Imagem copiada para a área de transferência!', 'success');
} catch (err) {
showToast('Erro ao copiar imagem para área de transferência.', 'error');
console.error(err);
}
}, 'image/png');
} catch (e) {
showToast('Erro ao processar imagem para cópia.', 'error');
console.error(e);
}
};
imageObj.onerror = () => showToast('Erro ao carregar imagem para cópia.', 'error');
imageObj.src = url;
};
const saveImageRemark = async () => {
setSavingRemark(true);
try {
await api.put(`/images/${selectedImage.id}/remark`, { remark: tempRemark });
showToast('Observação atualizada com sucesso!', 'success');
setSelectedImage(prev => ({ ...prev, remark: tempRemark }));
setIsEditingRemark(false);
loadDetail();
onUpdated?.();
} catch (err) {
showToast('Erro ao atualizar observação.', 'error');
} finally {
setSavingRemark(false);
}
};
useEffect(() => {
if (isOpen && gto?.id) loadDetail();
}, [isOpen, gto?.id]);
@@ -144,18 +231,18 @@ function GtoDetailModal({ isOpen, onClose, gto, onUpdated, onOpenImage }) {
className="preview-img"
style={{ width:'100%', height:90, objectFit:'cover', display:'block', cursor:'pointer' }}
label=""
onClick={() => onOpenImage?.(img)}
onClick={() => setSelectedImage(img)}
/>
<div
style={{ padding:'4px 6px', fontSize:'0.65rem', color:'var(--text-secondary)', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis', cursor:'pointer' }}
onClick={() => onOpenImage?.(img)}
onClick={() => setSelectedImage(img)}
>
{img.patient_name || img.filename}
</div>
<button
type="button"
title="Abrir imagem"
onClick={() => onOpenImage?.(img)}
onClick={() => setSelectedImage(img)}
style={{ position:'absolute', top:4, left:4, background:'rgba(59,130,246,0.85)', color:'#fff', border:'none', borderRadius:4, width:22, height:22, cursor:'pointer', fontSize:11, display:'flex', alignItems:'center', justifyContent:'center' }}
>👁</button>
<button
@@ -172,6 +259,97 @@ function GtoDetailModal({ isOpen, onClose, gto, onUpdated, onOpenImage }) {
</div>
) : null}
{selectedImage && (
<Modal
isOpen={!!selectedImage}
onClose={() => setSelectedImage(null)}
title={selectedImage.patient_name || 'Visualizar Imagem'}
large
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, alignItems: 'center' }}>
<div style={{ width: '100%', background: '#0b1220', borderRadius: 8, padding: 12, display: 'flex', justifyContent: 'center' }}>
<img
src={selectedImage.filename?.startsWith('http') ? selectedImage.filename : `/uploads/${selectedImage.filename}`}
alt={selectedImage.patient_name}
style={{ maxHeight: '60vh', maxWidth: '100%', objectFit: 'contain' }}
/>
</div>
{/* Seção de observação com edição inline */}
<div style={{ width: '100%', padding: '12px 16px', background: 'var(--bg-color)', borderRadius: 8, border: '1px solid var(--border-color)', display: 'flex', flexDirection: 'column', gap: 8 }}>
<label style={{ fontSize: '0.9rem', fontWeight: 600, color: 'var(--text-secondary)' }}>📝 Observação da Imagem:</label>
{isEditingRemark ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<textarea
className="form-control"
rows={2}
value={tempRemark}
onChange={(e) => setTempRemark(e.target.value)}
placeholder="Adicione uma observação para esta imagem..."
style={{ resize: 'vertical', width: '100%', fontSize: '0.9rem' }}
/>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
type="button"
className="btn btn-secondary btn-small"
onClick={() => setIsEditingRemark(false)}
style={{ padding: '6px 12px', fontSize: '0.8rem' }}
>
Cancelar
</button>
<button
type="button"
className="btn btn-primary"
onClick={saveImageRemark}
disabled={savingRemark}
style={{ padding: '6px 16px', fontSize: '0.8rem' }}
>
{savingRemark ? 'Salvando...' : 'Salvar'}
</button>
</div>
</div>
) : (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div style={{ fontSize: '0.95rem', color: selectedImage.remark ? 'var(--text-primary)' : 'var(--text-muted)', fontStyle: selectedImage.remark ? 'normal' : 'italic' }}>
{selectedImage.remark || 'Nenhuma observação adicionada.'}
</div>
<button
type="button"
className="btn btn-secondary btn-small"
onClick={() => {
setTempRemark(selectedImage.remark || '');
setIsEditingRemark(true);
}}
style={{ padding: '4px 10px', fontSize: '0.8rem', flexShrink: 0 }}
>
Editar
</button>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 12, width: '100%' }}>
<button
type="button"
className="btn btn-primary"
onClick={() => handleDownload(selectedImage)}
style={{ flex: 1, height: 46, fontSize: '0.95rem', fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}
>
💾 Baixar
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => handleCopy(selectedImage)}
style={{ flex: 1, height: 46, fontSize: '0.95rem', fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}
>
📋 Copiar para Área de Transferência
</button>
</div>
</div>
</Modal>
)}
</Modal>
);
}
@@ -259,66 +437,6 @@ export default function GtosPage() {
const [showSync, setShowSync] = useState(false);
const [syncTab, setSyncTab] = useState('devices');
const [showNewPatient, setShowNewPatient] = useState(false);
const [selectedImage, setSelectedImage] = useState(null);
const handleDownload = async (img) => {
try {
const url = img.filename?.startsWith('http')
? img.filename
: `/uploads/${img.filename}`;
const response = await fetch(url);
const blob = await response.blob();
// Sanitizar nome do paciente e observação
const pName = (img.patient_name || 'paciente').trim().replace(/[\/\\:*?"<>|]/g, '').replace(/\s+/g, '_');
const obs = (img.remark || '').trim().replace(/[\/\\:*?"<>|]/g, '').replace(/\s+/g, '_');
const fileName = obs ? `${pName}-${obs}.jpg` : `${pName}.jpg`;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fileName;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
} catch (err) {
showToast('Erro ao baixar imagem', 'error');
}
};
const handleCopy = (img) => {
const url = img.filename?.startsWith('http')
? img.filename
: `/uploads/${img.filename}`;
const imageObj = new Image();
imageObj.crossOrigin = 'anonymous';
imageObj.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = imageObj.naturalWidth;
canvas.height = imageObj.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(imageObj, 0, 0);
canvas.toBlob(async (blob) => {
try {
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob })
]);
showToast('Imagem copiada para a área de transferência!', 'success');
} catch (err) {
showToast('Erro ao copiar imagem para área de transferência.', 'error');
console.error(err);
}
}, 'image/png');
} catch (e) {
showToast('Erro ao processar imagem para cópia.', 'error');
console.error(e);
}
};
imageObj.onerror = () => showToast('Erro ao carregar imagem para cópia.', 'error');
imageObj.src = url;
};
const searchTimerRef = useRef(null);
@@ -631,53 +749,12 @@ export default function GtosPage() {
{/* Modals */}
<CreateGtoModal isOpen={showCreate} onClose={() => setShowCreate(false)} onCreated={reload} />
<GtoDetailModal isOpen={!!detailGto} gto={detailGto} onClose={() => setDetailGto(null)} onUpdated={reload} onOpenImage={(img) => setSelectedImage(img)} />
<GtoDetailModal isOpen={!!detailGto} gto={detailGto} onClose={() => setDetailGto(null)} onUpdated={reload} />
<CreatePatientModal isOpen={showNewPatient} onClose={() => setShowNewPatient(false)} />
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
<UserManagementModal isOpen={showUsers} onClose={() => setShowUsers(false)} />
<PluginsModal isOpen={showPlugins} onClose={() => setShowPlugins(false)} />
<SyncModal isOpen={showSync} onClose={() => setShowSync(false)} defaultTab={syncTab} />
{selectedImage && (
<Modal
isOpen={!!selectedImage}
onClose={() => setSelectedImage(null)}
title={selectedImage.patient_name || 'Visualizar Imagem'}
large
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, alignItems: 'center' }}>
<div style={{ width: '100%', background: '#0b1220', borderRadius: 8, padding: 12, display: 'flex', justifyContent: 'center' }}>
<img
src={selectedImage.filename?.startsWith('http') ? selectedImage.filename : `/uploads/${selectedImage.filename}`}
alt={selectedImage.patient_name}
style={{ maxHeight: '60vh', maxWidth: '100%', objectFit: 'contain' }}
/>
</div>
{selectedImage.remark && (
<div style={{ width: '100%', padding: '10px 14px', background: 'var(--bg-color)', borderRadius: 8, fontSize: '0.95rem' }}>
<strong>📝 Observação:</strong> {selectedImage.remark}
</div>
)}
<div style={{ display: 'flex', gap: 12, width: '100%' }}>
<button
type="button"
className="btn btn-primary"
onClick={() => handleDownload(selectedImage)}
style={{ flex: 1, height: 46, fontSize: '0.95rem', fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}
>
💾 Baixar
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => handleCopy(selectedImage)}
style={{ flex: 1, height: 46, fontSize: '0.95rem', fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}
>
📋 Copiar para Área de Transferência
</button>
</div>
</div>
</Modal>
)}
</>
);
}
+16
View File
@@ -433,6 +433,22 @@ router.post('/:id/transform', async (req, res) => {
}
});
// ================================================================
// PUT /api/images/:id/remark - Atualizar observação da imagem
// ================================================================
router.put('/:id/remark', async (req, res) => {
try {
const { remark } = req.body;
await db.run(
'UPDATE images SET remark = $1 WHERE id = $2',
[remark, req.params.id]
);
res.json({ success: true, message: 'Observação da imagem atualizada!' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ================================================================
// PUT /api/images/:id/patient-info - Atualizar dados do paciente
// ================================================================