feat(gtos): adicionar edicao inline de observacao de imagem na GTO
continuous-integration/webhook Deploy concluído (VPS4)
continuous-integration/webhook Deploy concluído (VPS4)
This commit is contained in:
@@ -40,12 +40,99 @@ function StatusBadge({ sent, imageCount }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Modal de detalhes/edição de GTO ─────────────────────────── */
|
/* ── Modal de detalhes/edição de GTO ─────────────────────────── */
|
||||||
function GtoDetailModal({ isOpen, onClose, gto, onUpdated, onOpenImage }) {
|
function GtoDetailModal({ isOpen, onClose, gto, onUpdated }) {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [detail, setDetail] = useState(null);
|
const [detail, setDetail] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [marking, setMarking] = 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(() => {
|
useEffect(() => {
|
||||||
if (isOpen && gto?.id) loadDetail();
|
if (isOpen && gto?.id) loadDetail();
|
||||||
}, [isOpen, gto?.id]);
|
}, [isOpen, gto?.id]);
|
||||||
@@ -144,18 +231,18 @@ function GtoDetailModal({ isOpen, onClose, gto, onUpdated, onOpenImage }) {
|
|||||||
className="preview-img"
|
className="preview-img"
|
||||||
style={{ width:'100%', height:90, objectFit:'cover', display:'block', cursor:'pointer' }}
|
style={{ width:'100%', height:90, objectFit:'cover', display:'block', cursor:'pointer' }}
|
||||||
label=""
|
label=""
|
||||||
onClick={() => onOpenImage?.(img)}
|
onClick={() => setSelectedImage(img)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
style={{ padding:'4px 6px', fontSize:'0.65rem', color:'var(--text-secondary)', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis', cursor:'pointer' }}
|
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}
|
{img.patient_name || img.filename}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="Abrir imagem"
|
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' }}
|
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>
|
||||||
<button
|
<button
|
||||||
@@ -172,6 +259,97 @@ function GtoDetailModal({ isOpen, onClose, gto, onUpdated, onOpenImage }) {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -259,66 +437,6 @@ export default function GtosPage() {
|
|||||||
const [showSync, setShowSync] = useState(false);
|
const [showSync, setShowSync] = useState(false);
|
||||||
const [syncTab, setSyncTab] = useState('devices');
|
const [syncTab, setSyncTab] = useState('devices');
|
||||||
const [showNewPatient, setShowNewPatient] = useState(false);
|
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);
|
const searchTimerRef = useRef(null);
|
||||||
|
|
||||||
@@ -631,53 +749,12 @@ export default function GtosPage() {
|
|||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
<CreateGtoModal isOpen={showCreate} onClose={() => setShowCreate(false)} onCreated={reload} />
|
<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)} />
|
<CreatePatientModal isOpen={showNewPatient} onClose={() => setShowNewPatient(false)} />
|
||||||
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
|
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
|
||||||
<UserManagementModal isOpen={showUsers} onClose={() => setShowUsers(false)} />
|
<UserManagementModal isOpen={showUsers} onClose={() => setShowUsers(false)} />
|
||||||
<PluginsModal isOpen={showPlugins} onClose={() => setShowPlugins(false)} />
|
<PluginsModal isOpen={showPlugins} onClose={() => setShowPlugins(false)} />
|
||||||
<SyncModal isOpen={showSync} onClose={() => setShowSync(false)} defaultTab={syncTab} />
|
<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>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// PUT /api/images/:id/patient-info - Atualizar dados do paciente
|
||||||
// ================================================================
|
// ================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user