backend y front trabajar por version de historial de cambios
This commit is contained in:
@@ -1871,6 +1871,11 @@ function InspectionDetailModal({ inspection, user, onClose, onUpdate }) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [inspectionDetail, setInspectionDetail] = useState(null)
|
||||
const [isInactivating, setIsInactivating] = useState(false)
|
||||
const [editingAnswerId, setEditingAnswerId] = useState(null)
|
||||
const [editFormData, setEditFormData] = useState({})
|
||||
const [showAuditLog, setShowAuditLog] = useState(false)
|
||||
const [auditLogs, setAuditLogs] = useState([])
|
||||
const [loadingAudit, setLoadingAudit] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const loadInspectionDetails = async () => {
|
||||
@@ -1930,6 +1935,79 @@ function InspectionDetailModal({ inspection, user, onClose, onUpdate }) {
|
||||
return icons[category] || '📋'
|
||||
}
|
||||
|
||||
const loadAuditLog = async () => {
|
||||
setLoadingAudit(true)
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
const response = await fetch(`${API_URL}/api/inspections/${inspection.id}/audit-log`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setAuditLogs(data)
|
||||
setShowAuditLog(true)
|
||||
} else {
|
||||
alert('Error al cargar historial de cambios')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading audit log:', error)
|
||||
alert('Error al cargar historial de cambios')
|
||||
} finally {
|
||||
setLoadingAudit(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startEditAnswer = (answer) => {
|
||||
setEditingAnswerId(answer.id)
|
||||
setEditFormData({
|
||||
answer_value: answer.answer_value || '',
|
||||
status: answer.status || 'ok',
|
||||
comment: answer.comment || '',
|
||||
is_flagged: answer.is_flagged || false,
|
||||
edit_comment: ''
|
||||
})
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingAnswerId(null)
|
||||
setEditFormData({})
|
||||
}
|
||||
|
||||
const saveEdit = async (answerId) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
const response = await fetch(`${API_URL}/api/answers/${answerId}/admin-edit`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(editFormData)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Recargar detalles de inspección
|
||||
const inspectionResponse = await fetch(`${API_URL}/api/inspections/${inspection.id}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
if (inspectionResponse.ok) {
|
||||
const data = await inspectionResponse.json()
|
||||
setInspectionDetail(data)
|
||||
}
|
||||
setEditingAnswerId(null)
|
||||
setEditFormData({})
|
||||
alert('Respuesta actualizada correctamente')
|
||||
} else {
|
||||
alert('Error al actualizar respuesta')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving edit:', error)
|
||||
alert('Error al guardar cambios')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
@@ -2048,64 +2126,167 @@ function InspectionDetailModal({ inspection, user, onClose, onUpdate }) {
|
||||
|
||||
{answer && (
|
||||
<div className="mt-3 ml-10 space-y-2">
|
||||
{/* Answer Value */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Respuesta:</span>
|
||||
{question.type === 'pass_fail' ? (
|
||||
getStatusBadge(answer.status)
|
||||
) : (
|
||||
<span className="font-medium">{answer.answer_value}</span>
|
||||
)}
|
||||
{answer.is_flagged && (
|
||||
<span className="text-red-600 text-sm">🚩 Señalado</span>
|
||||
)}
|
||||
</div>
|
||||
{editingAnswerId === answer.id ? (
|
||||
// Modo Edición (solo admin)
|
||||
<div className="bg-blue-50 border border-blue-300 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-blue-700 font-semibold">✏️ Editando Respuesta</span>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Estado</label>
|
||||
<select
|
||||
value={editFormData.status}
|
||||
onChange={(e) => setEditFormData({...editFormData, status: e.target.value})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="ok">✓ OK</option>
|
||||
<option value="warning">⚠ Advertencia</option>
|
||||
<option value="critical">✗ Crítico</option>
|
||||
<option value="na">N/A</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Comment */}
|
||||
{answer.comment && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-2">
|
||||
<span className="text-xs text-yellow-800 font-medium">Observación:</span>
|
||||
<p className="text-sm text-yellow-900 mt-1">{answer.comment}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Answer Value (si aplica) */}
|
||||
{question.type !== 'pass_fail' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Valor de Respuesta</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editFormData.answer_value}
|
||||
onChange={(e) => setEditFormData({...editFormData, answer_value: e.target.value})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Photos - NUEVO: miniaturas de media_files */}
|
||||
{(answer.media_files && answer.media_files.length > 0) && (
|
||||
<div className="flex gap-2 flex-wrap mt-2">
|
||||
{answer.media_files.map((media, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={media.file_path}
|
||||
alt={`Foto ${idx + 1}`}
|
||||
className="w-20 h-20 object-cover rounded border border-gray-300 cursor-pointer hover:opacity-75"
|
||||
onClick={() => window.open(media.file_path, '_blank')}
|
||||
{/* Comment */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Observación</label>
|
||||
<textarea
|
||||
value={editFormData.comment}
|
||||
onChange={(e) => setEditFormData({...editFormData, comment: e.target.value})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
rows="2"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Photos - compatibilidad legacy */}
|
||||
{(answer.photos && answer.photos.length > 0) && (
|
||||
<div className="flex gap-2 flex-wrap mt-2">
|
||||
{answer.photos.map((photo, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={photo}
|
||||
alt={`Foto ${idx + 1}`}
|
||||
className="w-20 h-20 object-cover rounded border border-gray-300 cursor-pointer hover:opacity-75"
|
||||
onClick={() => window.open(photo, '_blank')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Points */}
|
||||
{question.points_value > 0 && (
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600">Puntos:</span>
|
||||
<span className="ml-2 font-medium text-blue-600">
|
||||
{answer.points_earned || 0}/{question.points_value}
|
||||
</span>
|
||||
{/* Flagged */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editFormData.is_flagged}
|
||||
onChange={(e) => setEditFormData({...editFormData, is_flagged: e.target.checked})}
|
||||
className="w-4 h-4 text-red-600 border-gray-300 rounded focus:ring-red-500"
|
||||
/>
|
||||
<label className="ml-2 text-sm text-gray-700">🚩 Marcar como señalado</label>
|
||||
</div>
|
||||
|
||||
{/* Edit Comment */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Motivo del cambio (obligatorio)
|
||||
</label>
|
||||
<textarea
|
||||
value={editFormData.edit_comment}
|
||||
onChange={(e) => setEditFormData({...editFormData, edit_comment: e.target.value})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
rows="2"
|
||||
placeholder="Explica por qué estás haciendo este cambio..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => saveEdit(answer.id)}
|
||||
disabled={!editFormData.edit_comment}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Guardar Cambios
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Modo Vista Normal
|
||||
<>
|
||||
{/* Answer Value */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Respuesta:</span>
|
||||
{question.type === 'pass_fail' ? (
|
||||
getStatusBadge(answer.status)
|
||||
) : (
|
||||
<span className="font-medium">{answer.answer_value}</span>
|
||||
)}
|
||||
{answer.is_flagged && (
|
||||
<span className="text-red-600 text-sm">🚩 Señalado</span>
|
||||
)}
|
||||
{/* Botón Editar (solo admin) */}
|
||||
{user?.role === 'admin' && inspection.status === 'completed' && (
|
||||
<button
|
||||
onClick={() => startEditAnswer(answer)}
|
||||
className="ml-2 px-2 py-1 text-xs bg-orange-100 text-orange-700 rounded hover:bg-orange-200 transition"
|
||||
>
|
||||
✏️ Editar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comment */}
|
||||
{answer.comment && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-2">
|
||||
<span className="text-xs text-yellow-800 font-medium">Observación:</span>
|
||||
<p className="text-sm text-yellow-900 mt-1">{answer.comment}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Photos - NUEVO: miniaturas de media_files */}
|
||||
{(answer.media_files && answer.media_files.length > 0) && (
|
||||
<div className="flex gap-2 flex-wrap mt-2">
|
||||
{answer.media_files.map((media, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={media.file_path}
|
||||
alt={`Foto ${idx + 1}`}
|
||||
className="w-20 h-20 object-cover rounded border border-gray-300 cursor-pointer hover:opacity-75"
|
||||
onClick={() => window.open(media.file_path, '_blank')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Photos - compatibilidad legacy */}
|
||||
{(answer.photos && answer.photos.length > 0) && (
|
||||
<div className="flex gap-2 flex-wrap mt-2">
|
||||
{answer.photos.map((photo, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={photo}
|
||||
alt={`Foto ${idx + 1}`}
|
||||
className="w-20 h-20 object-cover rounded border border-gray-300 cursor-pointer hover:opacity-75"
|
||||
onClick={() => window.open(photo, '_blank')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Points */}
|
||||
{question.points_value > 0 && (
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600">Puntos:</span>
|
||||
<span className="ml-2 font-medium text-blue-600">
|
||||
{answer.points_earned || 0}/{question.points_value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -2149,6 +2330,16 @@ function InspectionDetailModal({ inspection, user, onClose, onUpdate }) {
|
||||
{/* Footer */}
|
||||
<div className="border-t p-4 bg-gray-50">
|
||||
<div className="flex gap-3">
|
||||
{user?.role === 'admin' && (
|
||||
<button
|
||||
onClick={loadAuditLog}
|
||||
disabled={loadingAudit}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition flex items-center gap-2"
|
||||
>
|
||||
<span>📜</span>
|
||||
{loadingAudit ? 'Cargando...' : 'Ver Historial de Cambios'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
@@ -2246,6 +2437,120 @@ function InspectionDetailModal({ inspection, user, onClose, onUpdate }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal de Historial de Auditoría */}
|
||||
{showAuditLog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[60] p-4">
|
||||
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<div className="bg-purple-600 text-white p-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">📜 Historial de Cambios</h2>
|
||||
<p className="mt-1 opacity-90">Inspección #{inspection.id}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAuditLog(false)}
|
||||
className="text-white hover:bg-purple-700 rounded-lg p-2 transition"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{auditLogs.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-4xl mb-3">📝</div>
|
||||
<p className="text-gray-600">No hay cambios registrados en esta inspección</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Los cambios realizados por administradores aparecerán aquí
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{auditLogs.map((log) => (
|
||||
<div key={log.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">
|
||||
{log.action === 'created' && '➕'}
|
||||
{log.action === 'updated' && '✏️'}
|
||||
{log.action === 'deleted' && '🗑️'}
|
||||
{log.action === 'status_changed' && '🔄'}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-semibold text-gray-900">
|
||||
{log.user_name || `Usuario #${log.user_id}`}
|
||||
</span>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{new Date(log.created_at).toLocaleDateString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600">Acción: </span>
|
||||
<span className="font-medium capitalize">{log.action}</span>
|
||||
<span className="text-gray-400 mx-2">en</span>
|
||||
<span className="font-medium">{log.entity_type}</span>
|
||||
{log.answer_id && (
|
||||
<span className="text-gray-500"> (Respuesta #{log.answer_id})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{log.field_name && (
|
||||
<div className="mt-2 bg-gray-50 rounded p-3">
|
||||
<div className="text-xs font-semibold text-gray-600 mb-2">
|
||||
Campo modificado: <span className="text-purple-600">{log.field_name}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-1">Valor anterior:</div>
|
||||
<div className="bg-red-50 border border-red-200 rounded px-2 py-1 text-red-800">
|
||||
{log.old_value || '(vacío)'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-1">Valor nuevo:</div>
|
||||
<div className="bg-green-50 border border-green-200 rounded px-2 py-1 text-green-800">
|
||||
{log.new_value || '(vacío)'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.comment && (
|
||||
<div className="mt-2 bg-yellow-50 border border-yellow-200 rounded p-2">
|
||||
<div className="text-xs font-semibold text-yellow-800 mb-1">Motivo:</div>
|
||||
<div className="text-sm text-yellow-900">{log.comment}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t p-4 bg-gray-50">
|
||||
<button
|
||||
onClick={() => setShowAuditLog(false)}
|
||||
className="w-full px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user