Implement photo verification for violation check-in/out
This commit is contained in:
@@ -40,6 +40,11 @@ app.config['SESSION_COOKIE_SECURE'] = os.environ.get('SESSION_COOKIE_SECURE')
|
|||||||
app.config['SESSION_COOKIE_HTTPONLY'] = os.environ.get('SESSION_COOKIE_HTTPONLY')
|
app.config['SESSION_COOKIE_HTTPONLY'] = os.environ.get('SESSION_COOKIE_HTTPONLY')
|
||||||
app.config['SESSION_COOKIE_SAMESITE'] = os.environ.get('SESSION_COOKIE_SAMESITE')
|
app.config['SESSION_COOKIE_SAMESITE'] = os.environ.get('SESSION_COOKIE_SAMESITE')
|
||||||
|
|
||||||
|
# Photo Verification Configuration
|
||||||
|
PHOTO_VERIFICATION_ENABLED = os.environ.get('ENABLE_PHOTO_VERIFICATION', 'true').lower() == 'true'
|
||||||
|
DISTANCE_THRESHOLD_FOR_VERIFICATION = float(os.environ.get('PHOTO_VERIFICATION_DISTANCE_THRESHOLD', '0.3'))
|
||||||
|
VERIFICATION_PHOTO_MAX_SIZE = int(os.environ.get('VERIFICATION_PHOTO_MAX_SIZE', str(5 * 1024 * 1024)))
|
||||||
|
|
||||||
# Initialize database
|
# Initialize database
|
||||||
db = SQLAlchemy(app)
|
db = SQLAlchemy(app)
|
||||||
|
|
||||||
@@ -4279,7 +4284,9 @@ def qr_checkin(qr_url):
|
|||||||
altitude=location_data['altitude'],
|
altitude=location_data['altitude'],
|
||||||
location_source=location_data['source'],
|
location_source=location_data['source'],
|
||||||
address=location_data['address'],
|
address=location_data['address'],
|
||||||
status='present'
|
status='present',
|
||||||
|
verification_required=False, # Will be set below if needed
|
||||||
|
verification_status=None
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"✅ Created base attendance record")
|
print(f"✅ Created base attendance record")
|
||||||
@@ -4333,6 +4340,47 @@ def qr_checkin(qr_url):
|
|||||||
else:
|
else:
|
||||||
print(f"⚠️ Could not calculate location accuracy - calculation returned None")
|
print(f"⚠️ Could not calculate location accuracy - calculation returned None")
|
||||||
|
|
||||||
|
# CHECK DISTANCE THRESHOLD FOR PHOTO VERIFICATION
|
||||||
|
print(f"\n📸 CHECKING PHOTO VERIFICATION REQUIREMENT:")
|
||||||
|
print(f" Photo Verification Enabled: {PHOTO_VERIFICATION_ENABLED}")
|
||||||
|
requires_verification = False
|
||||||
|
verification_photo_data = None
|
||||||
|
|
||||||
|
if PHOTO_VERIFICATION_ENABLED and location_accuracy is not None and location_accuracy > DISTANCE_THRESHOLD_FOR_VERIFICATION:
|
||||||
|
print(f"⚠️ Distance ({location_accuracy:.3f} mi) exceeds threshold ({DISTANCE_THRESHOLD_FOR_VERIFICATION} mi)")
|
||||||
|
|
||||||
|
# Check if photo was provided
|
||||||
|
verification_photo_data = request.form.get('verification_photo', None)
|
||||||
|
|
||||||
|
if verification_photo_data:
|
||||||
|
print(f"✅ Verification photo provided (size: {len(verification_photo_data)} chars)")
|
||||||
|
|
||||||
|
# Validate photo data (basic validation)
|
||||||
|
if verification_photo_data.startswith('data:image/'):
|
||||||
|
attendance.verification_photo = verification_photo_data
|
||||||
|
attendance.verification_required = True
|
||||||
|
attendance.verification_status = 'pending'
|
||||||
|
attendance.verification_timestamp = datetime.utcnow()
|
||||||
|
print(f"✅ Photo verification set to PENDING status")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Invalid photo format provided")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Invalid photo format. Please try again.',
|
||||||
|
'requires_verification': True
|
||||||
|
}), 400
|
||||||
|
else:
|
||||||
|
print(f"❌ Photo verification REQUIRED but not provided")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Photo verification required. Distance from location is too far.',
|
||||||
|
'requires_verification': True,
|
||||||
|
'distance': round(location_accuracy, 3),
|
||||||
|
'threshold': DISTANCE_THRESHOLD_FOR_VERIFICATION
|
||||||
|
}), 400
|
||||||
|
else:
|
||||||
|
print(f"✅ Distance within threshold - no verification needed")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Error in location accuracy calculation: {e}")
|
print(f"❌ Error in location accuracy calculation: {e}")
|
||||||
print(f"❌ Full traceback: {traceback.format_exc()}")
|
print(f"❌ Full traceback: {traceback.format_exc()}")
|
||||||
@@ -4351,6 +4399,15 @@ def qr_checkin(qr_url):
|
|||||||
db.session.add(attendance)
|
db.session.add(attendance)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
# Log verification if required
|
||||||
|
if attendance.verification_required:
|
||||||
|
logger_handler.log_photo_verification(
|
||||||
|
employee_id=attendance.employee_id,
|
||||||
|
qr_code_id=qr_code.id,
|
||||||
|
distance=location_accuracy,
|
||||||
|
status='pending'
|
||||||
|
)
|
||||||
|
|
||||||
# VERIFICATION: Read back from database
|
# VERIFICATION: Read back from database
|
||||||
saved_record = AttendanceData.query.get(attendance.id)
|
saved_record = AttendanceData.query.get(attendance.id)
|
||||||
print(f"✅ Successfully saved attendance record with ID: {attendance.id}")
|
print(f"✅ Successfully saved attendance record with ID: {attendance.id}")
|
||||||
@@ -5228,6 +5285,118 @@ def delete_attendance(record_id):
|
|||||||
flash('Error deleting attendance record. Please try again.', 'error')
|
flash('Error deleting attendance record. Please try again.', 'error')
|
||||||
return redirect(url_for('attendance_report'))
|
return redirect(url_for('attendance_report'))
|
||||||
|
|
||||||
|
@app.route('/verification-review')
|
||||||
|
@login_required
|
||||||
|
def verification_review():
|
||||||
|
"""Admin page to review pending photo verifications"""
|
||||||
|
try:
|
||||||
|
# Only admins can access
|
||||||
|
if session.get('role') != 'admin':
|
||||||
|
flash('Unauthorized access.', 'error')
|
||||||
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
|
# Get filter parameters
|
||||||
|
status_filter = request.args.get('status', 'pending')
|
||||||
|
date_from = request.args.get('date_from', '')
|
||||||
|
date_to = request.args.get('date_to', '')
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
query = AttendanceData.query.filter(
|
||||||
|
AttendanceData.verification_required == True
|
||||||
|
)
|
||||||
|
|
||||||
|
if status_filter and status_filter != 'all':
|
||||||
|
query = query.filter(AttendanceData.verification_status == status_filter)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
query = query.filter(AttendanceData.check_in_date >= date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
query = query.filter(AttendanceData.check_in_date <= date_to)
|
||||||
|
|
||||||
|
# Get records with QR code information
|
||||||
|
verifications = query.join(QRCode).order_by(
|
||||||
|
AttendanceData.verification_timestamp.desc()
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Get counts for status badges
|
||||||
|
pending_count = AttendanceData.query.filter(
|
||||||
|
AttendanceData.verification_status == 'pending'
|
||||||
|
).count()
|
||||||
|
|
||||||
|
approved_count = AttendanceData.query.filter(
|
||||||
|
AttendanceData.verification_status == 'approved'
|
||||||
|
).count()
|
||||||
|
|
||||||
|
rejected_count = AttendanceData.query.filter(
|
||||||
|
AttendanceData.verification_status == 'rejected'
|
||||||
|
).count()
|
||||||
|
|
||||||
|
return render_template('verification_review.html',
|
||||||
|
verifications=verifications,
|
||||||
|
pending_count=pending_count,
|
||||||
|
approved_count=approved_count,
|
||||||
|
rejected_count=rejected_count,
|
||||||
|
status_filter=status_filter,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger_handler.logger.error(f"Error in verification review: {e}")
|
||||||
|
flash('Error loading verification review.', 'error')
|
||||||
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
|
@app.route('/verification-review/<int:record_id>/update', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@log_database_operations('verification_update')
|
||||||
|
def update_verification_status(record_id):
|
||||||
|
"""Update verification status (approve/reject)"""
|
||||||
|
try:
|
||||||
|
# Only admins can update
|
||||||
|
if session.get('role') != 'admin':
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Unauthorized access'
|
||||||
|
}), 403
|
||||||
|
|
||||||
|
record = AttendanceData.query.get_or_404(record_id)
|
||||||
|
|
||||||
|
new_status = request.json.get('status')
|
||||||
|
admin_note = request.json.get('note', '')
|
||||||
|
|
||||||
|
if new_status not in ['approved', 'rejected']:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Invalid status'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Update record
|
||||||
|
record.verification_status = new_status
|
||||||
|
record.edit_note = f"Verification {new_status} by {session.get('username')}. {admin_note}"
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Log the action
|
||||||
|
logger_handler.log_photo_verification(
|
||||||
|
employee_id=record.employee_id,
|
||||||
|
qr_code_id=record.qr_code_id,
|
||||||
|
distance=record.location_accuracy or 0,
|
||||||
|
status=new_status
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': f'Verification {new_status} successfully'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
logger_handler.logger.error(f"Error updating verification: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Error updating verification status'
|
||||||
|
}), 500
|
||||||
|
|
||||||
@app.route('/api/attendance/stats')
|
@app.route('/api/attendance/stats')
|
||||||
@admin_required
|
@admin_required
|
||||||
def attendance_stats_api():
|
def attendance_stats_api():
|
||||||
|
|||||||
@@ -473,6 +473,12 @@ class AppLogger:
|
|||||||
'data': event_data
|
'data': event_data
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
def log_photo_verification(self, employee_id, qr_code_id, distance, status='pending'):
|
||||||
|
"""Log photo verification event"""
|
||||||
|
self.logger.info(
|
||||||
|
f"Photo Verification - Employee: {employee_id}, QR: {qr_code_id}, Distance: {distance:.3f} mi, Status: {status}"
|
||||||
|
)
|
||||||
|
|
||||||
def log_qr_code_generated(self, data_length, fill_color, back_color, box_size, border, error_correction):
|
def log_qr_code_generated(self, data_length, fill_color, back_color, box_size, border, error_correction):
|
||||||
"""Log QR code generation with customization details"""
|
"""Log QR code generation with customization details"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ class AttendanceData(base.db.Model):
|
|||||||
altitude = base.db.Column(base.db.Float, nullable=True)
|
altitude = base.db.Column(base.db.Float, nullable=True)
|
||||||
location_source = base.db.Column(base.db.String(50), default='manual')
|
location_source = base.db.Column(base.db.String(50), default='manual')
|
||||||
address = base.db.Column(base.db.String(500), nullable=True)
|
address = base.db.Column(base.db.String(500), nullable=True)
|
||||||
|
verification_photo = base.db.Column(base.db.Text, nullable=True) # Base64 encoded image
|
||||||
|
verification_required = base.db.Column(base.db.Boolean, default=False)
|
||||||
|
verification_status = base.db.Column(base.db.String(20), nullable=True) # 'pending', 'approved', 'rejected'
|
||||||
|
verification_timestamp = base.db.Column(base.db.DateTime, nullable=True)
|
||||||
edit_note = base.db.Column(base.db.Text, nullable=True)
|
edit_note = base.db.Column(base.db.Text, nullable=True)
|
||||||
# Relationships
|
# Relationships
|
||||||
qr_code = base.db.relationship('QRCode', backref=base.db.backref('attendance_records', lazy='dynamic'))
|
qr_code = base.db.relationship('QRCode', backref=base.db.backref('attendance_records', lazy='dynamic'))
|
||||||
@@ -56,3 +60,18 @@ class AttendanceData(base.db.Model):
|
|||||||
return 'medium'
|
return 'medium'
|
||||||
else:
|
else:
|
||||||
return 'low'
|
return 'low'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def needs_photo_verification(self):
|
||||||
|
"""Check if this check-in requires photo verification"""
|
||||||
|
return self.verification_required == True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_verification_pending(self):
|
||||||
|
"""Check if photo verification is pending approval"""
|
||||||
|
return self.verification_status == 'pending'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_verification_approved(self):
|
||||||
|
"""Check if photo verification was approved"""
|
||||||
|
return self.verification_status == 'approved'
|
||||||
+242
-1
@@ -7,6 +7,11 @@
|
|||||||
let isSubmitting = false;
|
let isSubmitting = false;
|
||||||
let currentTime = new Date();
|
let currentTime = new Date();
|
||||||
|
|
||||||
|
// Camera verification variables
|
||||||
|
let cameraStream = null;
|
||||||
|
let capturedPhotoData = null;
|
||||||
|
let verificationAttemptData = null;
|
||||||
|
|
||||||
// Location tracking variables
|
// Location tracking variables
|
||||||
let userLocation = {
|
let userLocation = {
|
||||||
latitude: null,
|
latitude: null,
|
||||||
@@ -318,7 +323,14 @@ function submitCheckin() {
|
|||||||
return response.json();
|
return response.json();
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
|
// Check if photo verification is required
|
||||||
|
if (data.requires_verification) {
|
||||||
|
console.log('⚠️ Photo verification required');
|
||||||
|
verificationAttemptData = formData; // Store for retry with photo
|
||||||
|
showVerificationModal(data.distance || 0, data.threshold || 0.3);
|
||||||
|
} else {
|
||||||
handleCheckinResponse(data);
|
handleCheckinResponse(data);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
handleCheckinError(error);
|
handleCheckinError(error);
|
||||||
@@ -867,7 +879,7 @@ function checkLocationServicesStatus() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
enableHighAccuracy: false,
|
enableHighAccuracy: false,
|
||||||
timeout: 4000,
|
timeout: 10000,
|
||||||
maximumAge: 30000,
|
maximumAge: 30000,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -1131,3 +1143,232 @@ function initializeLocationServicesCheck() {
|
|||||||
}
|
}
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================================
|
||||||
|
// CAMERA VERIFICATION FUNCTIONALITY
|
||||||
|
// ===================================
|
||||||
|
|
||||||
|
function showVerificationModal(distance, threshold) {
|
||||||
|
console.log(`📸 Showing verification modal - Distance: ${distance}, Threshold: ${threshold}`);
|
||||||
|
|
||||||
|
const modal = document.getElementById('verificationModal');
|
||||||
|
const distanceSpan = document.getElementById('verificationDistance');
|
||||||
|
const thresholdSpan = document.getElementById('verificationThreshold');
|
||||||
|
|
||||||
|
if (distanceSpan) distanceSpan.textContent = distance.toFixed(3);
|
||||||
|
if (thresholdSpan) thresholdSpan.textContent = threshold.toFixed(3);
|
||||||
|
|
||||||
|
modal.classList.add('active');
|
||||||
|
startCamera();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideVerificationModal() {
|
||||||
|
const modal = document.getElementById('verificationModal');
|
||||||
|
modal.classList.remove('active');
|
||||||
|
stopCamera();
|
||||||
|
resetCameraInterface();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCamera() {
|
||||||
|
const video = document.getElementById('cameraVideo');
|
||||||
|
|
||||||
|
console.log('📸 Starting camera...');
|
||||||
|
|
||||||
|
const constraints = {
|
||||||
|
video: {
|
||||||
|
facingMode: 'environment', // Use back camera on mobile
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 }
|
||||||
|
},
|
||||||
|
audio: false
|
||||||
|
};
|
||||||
|
|
||||||
|
navigator.mediaDevices.getUserMedia(constraints)
|
||||||
|
.then(stream => {
|
||||||
|
cameraStream = stream;
|
||||||
|
video.srcObject = stream;
|
||||||
|
video.style.display = 'block';
|
||||||
|
console.log('✅ Camera started successfully');
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('❌ Camera error:', error);
|
||||||
|
alert('Unable to access camera. Please check permissions and try again. / No se puede acceder a la cámara.');
|
||||||
|
hideVerificationModal();
|
||||||
|
isSubmitting = false;
|
||||||
|
updateSubmitButton(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCamera() {
|
||||||
|
if (cameraStream) {
|
||||||
|
cameraStream.getTracks().forEach(track => track.stop());
|
||||||
|
cameraStream = null;
|
||||||
|
console.log('📸 Camera stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function capturePhoto() {
|
||||||
|
const video = document.getElementById('cameraVideo');
|
||||||
|
const canvas = document.getElementById('cameraCanvas');
|
||||||
|
const capturedImage = document.getElementById('capturedPhoto');
|
||||||
|
const captureBtn = document.getElementById('captureBtn');
|
||||||
|
const retakeBtn = document.getElementById('retakeBtn');
|
||||||
|
const submitBtn = document.getElementById('submitVerificationBtn');
|
||||||
|
|
||||||
|
// Set canvas dimensions to match video
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
|
||||||
|
// Draw video frame to canvas
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Get photo data as base64
|
||||||
|
capturedPhotoData = canvas.toDataURL('image/jpeg', 0.8);
|
||||||
|
|
||||||
|
// Show captured photo
|
||||||
|
capturedImage.src = capturedPhotoData;
|
||||||
|
capturedImage.style.display = 'block';
|
||||||
|
video.style.display = 'none';
|
||||||
|
|
||||||
|
// Update button visibility
|
||||||
|
captureBtn.style.display = 'none';
|
||||||
|
retakeBtn.style.display = 'inline-flex';
|
||||||
|
submitBtn.style.display = 'inline-flex';
|
||||||
|
|
||||||
|
console.log('📸 Photo captured');
|
||||||
|
}
|
||||||
|
|
||||||
|
function retakePhoto() {
|
||||||
|
const video = document.getElementById('cameraVideo');
|
||||||
|
const capturedImage = document.getElementById('capturedPhoto');
|
||||||
|
const captureBtn = document.getElementById('captureBtn');
|
||||||
|
const retakeBtn = document.getElementById('retakeBtn');
|
||||||
|
const submitBtn = document.getElementById('submitVerificationBtn');
|
||||||
|
|
||||||
|
// Reset interface
|
||||||
|
capturedImage.style.display = 'none';
|
||||||
|
video.style.display = 'block';
|
||||||
|
|
||||||
|
captureBtn.style.display = 'inline-flex';
|
||||||
|
retakeBtn.style.display = 'none';
|
||||||
|
submitBtn.style.display = 'none';
|
||||||
|
|
||||||
|
capturedPhotoData = null;
|
||||||
|
|
||||||
|
console.log('📸 Ready to retake photo');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCameraInterface() {
|
||||||
|
const video = document.getElementById('cameraVideo');
|
||||||
|
const capturedImage = document.getElementById('capturedPhoto');
|
||||||
|
const captureBtn = document.getElementById('captureBtn');
|
||||||
|
const retakeBtn = document.getElementById('retakeBtn');
|
||||||
|
const submitBtn = document.getElementById('submitVerificationBtn');
|
||||||
|
|
||||||
|
if (capturedImage) capturedImage.style.display = 'none';
|
||||||
|
if (video) video.style.display = 'block';
|
||||||
|
|
||||||
|
if (captureBtn) captureBtn.style.display = 'inline-flex';
|
||||||
|
if (retakeBtn) retakeBtn.style.display = 'none';
|
||||||
|
if (submitBtn) submitBtn.style.display = 'none';
|
||||||
|
|
||||||
|
capturedPhotoData = null;
|
||||||
|
verificationAttemptData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitWithVerification() {
|
||||||
|
if (!capturedPhotoData) {
|
||||||
|
alert('Please capture a photo first. / Por favor capture una foto primero.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📸 Submitting check-in with verification photo...');
|
||||||
|
|
||||||
|
const submitBtn = document.getElementById('submitVerificationBtn');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Submitting...';
|
||||||
|
|
||||||
|
// Use stored form data from initial attempt
|
||||||
|
if (verificationAttemptData) {
|
||||||
|
verificationAttemptData.append('verification_photo', capturedPhotoData);
|
||||||
|
|
||||||
|
const currentUrl = window.location.pathname;
|
||||||
|
const checkinUrl = `${currentUrl}/checkin`;
|
||||||
|
|
||||||
|
fetch(checkinUrl, {
|
||||||
|
method: "POST",
|
||||||
|
body: verificationAttemptData,
|
||||||
|
headers: {
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
hideVerificationModal();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showCustomStatusMessage(
|
||||||
|
"Submission successful! Photo pending review. / ¡Envío exitoso! Foto pendiente de revisión.",
|
||||||
|
"success"
|
||||||
|
);
|
||||||
|
handleCheckinSuccess(data);
|
||||||
|
} else {
|
||||||
|
showCustomStatusMessage(
|
||||||
|
data.message || "Submission failed / Envío fallido",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
hideVerificationModal();
|
||||||
|
console.error('❌ Verification submit error:', error);
|
||||||
|
showLocalizedStatusMessage("networkError", "error");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isSubmitting = false;
|
||||||
|
updateSubmitButton(false);
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-check"></i> Submit with Photo';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelVerification() {
|
||||||
|
hideVerificationModal();
|
||||||
|
isSubmitting = false;
|
||||||
|
updateSubmitButton(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize camera button event listeners
|
||||||
|
function initializeCameraButtons() {
|
||||||
|
const captureBtn = document.getElementById('captureBtn');
|
||||||
|
const retakeBtn = document.getElementById('retakeBtn');
|
||||||
|
const submitBtn = document.getElementById('submitVerificationBtn');
|
||||||
|
const cancelBtn = document.getElementById('cancelVerificationBtn');
|
||||||
|
|
||||||
|
if (captureBtn) {
|
||||||
|
captureBtn.addEventListener('click', capturePhoto);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retakeBtn) {
|
||||||
|
retakeBtn.addEventListener('click', retakePhoto);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.addEventListener('click', submitWithVerification);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelBtn) {
|
||||||
|
cancelBtn.addEventListener('click', cancelVerification);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📸 Camera verification buttons initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call initialization when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeCameraButtons);
|
||||||
|
} else {
|
||||||
|
initializeCameraButtons();
|
||||||
|
}
|
||||||
@@ -385,6 +385,146 @@
|
|||||||
transform: none !important;
|
transform: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Camera Verification Styles */
|
||||||
|
.verification-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
z-index: 10000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-header h2 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-header .distance-warning {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
background: #000;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-preview video {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-preview canvas {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn.capture {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn.capture:hover {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn.retake {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn.retake:hover {
|
||||||
|
background: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn.submit-verification {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn.submit-verification:hover {
|
||||||
|
background: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn.cancel {
|
||||||
|
background: #6b7280;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn.cancel:hover {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-instructions {
|
||||||
|
background: #fef3c7;
|
||||||
|
border-left: 4px solid #f59e0b;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-instructions p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
color: #78350f;
|
||||||
|
}
|
||||||
|
|
||||||
.success-card {
|
.success-card {
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: rgba(255, 255, 255, 0.95);
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
@@ -583,6 +723,159 @@
|
|||||||
background: #15803d;
|
background: #15803d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Camera Verification Styles */
|
||||||
|
.verification-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
z-index: 10000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-header h2 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-header .distance-warning {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
background: #000;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 1rem 0;
|
||||||
|
min-height: 300px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-preview video {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-preview canvas {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn.capture {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn.capture:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn.retake {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn.retake:hover {
|
||||||
|
background: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn.submit-verification {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn.submit-verification:hover {
|
||||||
|
background: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn.cancel {
|
||||||
|
background: #6b7280;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn.cancel:hover {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-instructions {
|
||||||
|
background: #fef3c7;
|
||||||
|
border-left: 4px solid #f59e0b;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-instructions p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
color: #78350f;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-instructions strong {
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive Design */
|
/* Responsive Design */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.destination-container {
|
.destination-container {
|
||||||
@@ -918,7 +1211,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Camera Verification Modal -->
|
||||||
|
<div id="verificationModal" class="verification-modal">
|
||||||
|
<div class="verification-content">
|
||||||
|
<div class="verification-header">
|
||||||
|
<h2>
|
||||||
|
<i class="fas fa-camera"></i>
|
||||||
|
Photo Verification Required
|
||||||
|
</h2>
|
||||||
|
<p class="verification-instructions">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<strong>You are checking in from a location that is too far from the designated area.</strong>
|
||||||
|
</p>
|
||||||
|
<p class="distance-warning">
|
||||||
|
Distance: <span id="verificationDistance">--</span> miles
|
||||||
|
(Threshold: <span id="verificationThreshold">0.3</span> miles)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="verification-instructions">
|
||||||
|
<p><strong>Please take a photo of your current location:</strong></p>
|
||||||
|
<p>• Make sure the photo clearly shows your surroundings</p>
|
||||||
|
<p>• The photo will be reviewed by management</p>
|
||||||
|
<p>• You can retake the photo if needed</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="camera-preview" id="cameraPreview">
|
||||||
|
<video id="cameraVideo" autoplay playsinline></video>
|
||||||
|
<canvas id="cameraCanvas"></canvas>
|
||||||
|
<img id="capturedPhoto" style="display: none;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="camera-controls">
|
||||||
|
<button type="button" class="camera-btn capture" id="captureBtn">
|
||||||
|
<i class="fas fa-camera"></i> Take Photo
|
||||||
|
</button>
|
||||||
|
<button type="button" class="camera-btn retake" id="retakeBtn" style="display: none;">
|
||||||
|
<i class="fas fa-redo"></i> Retake
|
||||||
|
</button>
|
||||||
|
<button type="button" class="camera-btn submit-verification" id="submitVerificationBtn" style="display: none;">
|
||||||
|
<i class="fas fa-check"></i> Submit with Photo
|
||||||
|
</button>
|
||||||
|
<button type="button" class="camera-btn cancel" id="cancelVerificationBtn">
|
||||||
|
<i class="fas fa-times"></i> Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Enhanced JavaScript with preserved functionality -->
|
<!-- Enhanced JavaScript with preserved functionality -->
|
||||||
<script src="{{ url_for('static', filename='js/qr_destination.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/qr_destination.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/android_location_handler.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/android_location_handler.js') }}"></script>
|
||||||
@@ -1206,6 +1546,14 @@
|
|||||||
"success"
|
"success"
|
||||||
);
|
);
|
||||||
showEnhancedSuccessCard(data);
|
showEnhancedSuccessCard(data);
|
||||||
|
} else if (data.requires_verification) {
|
||||||
|
// Photo verification required
|
||||||
|
console.log('⚠️ Photo verification required');
|
||||||
|
verificationAttemptData = formData; // Store for retry with photo
|
||||||
|
showVerificationModal(
|
||||||
|
data.distance || 0,
|
||||||
|
data.threshold || 0.3
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
showStatusMessage(
|
showStatusMessage(
|
||||||
data.message || "Submission failed / Envío fallido",
|
data.message || "Submission failed / Envío fallido",
|
||||||
|
|||||||
@@ -0,0 +1,597 @@
|
|||||||
|
{% extends "base_authenticated.html" %}
|
||||||
|
|
||||||
|
{% block title %}Photo Verification Review{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>
|
||||||
|
<i class="fas fa-camera-retro"></i>
|
||||||
|
Photo Verification Review
|
||||||
|
</h1>
|
||||||
|
<p>Review and approve/reject check-ins requiring photo verification</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Summary Cards -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon pending">
|
||||||
|
<i class="fas fa-clock"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>{{ pending_count }}</h3>
|
||||||
|
<p>Pending Review</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon approved">
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>{{ approved_count }}</h3>
|
||||||
|
<p>Approved</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon rejected">
|
||||||
|
<i class="fas fa-times-circle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>{{ rejected_count }}</h3>
|
||||||
|
<p>Rejected</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters Section -->
|
||||||
|
<div class="filter-section">
|
||||||
|
<form method="GET" action="{{ url_for('verification_review') }}">
|
||||||
|
<div class="filter-grid">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="status">Status</label>
|
||||||
|
<select name="status" id="status" class="filter-select">
|
||||||
|
<option value="all" {% if status_filter == 'all' %}selected{% endif %}>All Status</option>
|
||||||
|
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>Pending</option>
|
||||||
|
<option value="approved" {% if status_filter == 'approved' %}selected{% endif %}>Approved</option>
|
||||||
|
<option value="rejected" {% if status_filter == 'rejected' %}selected{% endif %}>Rejected</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="date_from">From Date</label>
|
||||||
|
<input type="date" name="date_from" id="date_from" value="{{ date_from }}" class="filter-input">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="date_to">To Date</label>
|
||||||
|
<input type="date" name="date_to" id="date_to" value="{{ date_to }}" class="filter-input">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<button type="submit" class="btn-filter">
|
||||||
|
<i class="fas fa-filter"></i> Apply Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Verifications List -->
|
||||||
|
<div class="verifications-section">
|
||||||
|
{% if verifications %}
|
||||||
|
<div class="verifications-grid">
|
||||||
|
{% for record in verifications %}
|
||||||
|
<div class="verification-card" data-record-id="{{ record.id }}">
|
||||||
|
<div class="verification-header">
|
||||||
|
<div class="verification-info">
|
||||||
|
<h3>
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
Employee {{ record.employee_id }}
|
||||||
|
</h3>
|
||||||
|
<div class="verification-meta">
|
||||||
|
<span class="meta-item">
|
||||||
|
<i class="fas fa-calendar"></i>
|
||||||
|
{{ record.check_in_date.strftime('%b %d, %Y') }}
|
||||||
|
</span>
|
||||||
|
<span class="meta-item">
|
||||||
|
<i class="fas fa-clock"></i>
|
||||||
|
{{ record.check_in_time.strftime('%I:%M %p') }}
|
||||||
|
</span>
|
||||||
|
<span class="meta-item">
|
||||||
|
<i class="fas fa-map-marker-alt"></i>
|
||||||
|
{{ record.location_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="verification-status">
|
||||||
|
{% if record.verification_status == 'pending' %}
|
||||||
|
<span class="status-badge pending">
|
||||||
|
<i class="fas fa-clock"></i> Pending
|
||||||
|
</span>
|
||||||
|
{% elif record.verification_status == 'approved' %}
|
||||||
|
<span class="status-badge approved">
|
||||||
|
<i class="fas fa-check-circle"></i> Approved
|
||||||
|
</span>
|
||||||
|
{% elif record.verification_status == 'rejected' %}
|
||||||
|
<span class="status-badge rejected">
|
||||||
|
<i class="fas fa-times-circle"></i> Rejected
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="verification-body">
|
||||||
|
<div class="photo-section">
|
||||||
|
<div class="photo-container">
|
||||||
|
{% if record.verification_photo %}
|
||||||
|
<img src="{{ record.verification_photo }}" alt="Verification Photo" class="verification-photo" />
|
||||||
|
{% else %}
|
||||||
|
<div class="no-photo">
|
||||||
|
<i class="fas fa-image"></i>
|
||||||
|
<p>No photo available</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="details-section">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">
|
||||||
|
<i class="fas fa-ruler"></i> Distance from Location:
|
||||||
|
</span>
|
||||||
|
<span class="detail-value distance-value">
|
||||||
|
{% if record.location_accuracy %}
|
||||||
|
{{ "%.3f"|format(record.location_accuracy) }} miles
|
||||||
|
{% else %}
|
||||||
|
N/A
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">
|
||||||
|
<i class="fas fa-map-pin"></i> QR Location:
|
||||||
|
</span>
|
||||||
|
<span class="detail-value">
|
||||||
|
{{ record.qr_code.location_address if record.qr_code.location_address else 'N/A' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">
|
||||||
|
<i class="fas fa-location-arrow"></i> Check-in Address:
|
||||||
|
</span>
|
||||||
|
<span class="detail-value">
|
||||||
|
{{ record.address if record.address else 'N/A' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if record.latitude and record.longitude %}
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">
|
||||||
|
<i class="fas fa-globe"></i> GPS Coordinates:
|
||||||
|
</span>
|
||||||
|
<span class="detail-value">
|
||||||
|
<a href="https://www.google.com/maps?q={{ record.latitude }},{{ record.longitude }}" target="_blank" class="coordinates-link">
|
||||||
|
{{ "%.6f"|format(record.latitude) }}, {{ "%.6f"|format(record.longitude) }}
|
||||||
|
<i class="fas fa-external-link-alt"></i>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">
|
||||||
|
<i class="fas fa-clock"></i> Verification Submitted:
|
||||||
|
</span>
|
||||||
|
<span class="detail-value">
|
||||||
|
{{ record.verification_timestamp.strftime('%b %d, %Y %I:%M %p') if record.verification_timestamp else 'N/A' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if record.edit_note %}
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">
|
||||||
|
<i class="fas fa-sticky-note"></i> Note:
|
||||||
|
</span>
|
||||||
|
<span class="detail-value">
|
||||||
|
{{ record.edit_note }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if record.verification_status == 'pending' %}
|
||||||
|
<div class="verification-actions">
|
||||||
|
<button type="button" class="btn-action approve" onclick="updateVerificationStatus({{ record.id }}, 'approved')">
|
||||||
|
<i class="fas fa-check"></i> Approve
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-action reject" onclick="updateVerificationStatus({{ record.id }}, 'rejected')">
|
||||||
|
<i class="fas fa-times"></i> Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-inbox"></i>
|
||||||
|
<h3>No Verifications Found</h3>
|
||||||
|
<p>There are no photo verifications matching your current filters.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon.pending {
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon.approved {
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon.rejected {
|
||||||
|
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info h3 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info p {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
background: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select,
|
||||||
|
.filter-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-filter {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 1.5rem;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-filter:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verifications-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-info h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.pending {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.approved {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.rejected {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 400px 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.verification-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-container {
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-photo {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-photo:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-photo {
|
||||||
|
text-align: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-photo i {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distance-value {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinates-link {
|
||||||
|
color: #3b82f6;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinates-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-actions {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action.approve {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action.approve:hover {
|
||||||
|
background: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action.reject {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action.reject:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function updateVerificationStatus(recordId, status) {
|
||||||
|
const card = document.querySelector(`[data-record-id="${recordId}"]`);
|
||||||
|
const statusText = status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
|
|
||||||
|
if (!confirm(`Are you sure you want to ${status} this verification?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Add note
|
||||||
|
let note = '';
|
||||||
|
if (status === 'rejected') {
|
||||||
|
note = prompt('Optional: Add a note explaining why this was rejected:');
|
||||||
|
if (note === null) return; // User cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable buttons
|
||||||
|
const buttons = card.querySelectorAll('.btn-action');
|
||||||
|
buttons.forEach(btn => btn.disabled = true);
|
||||||
|
|
||||||
|
fetch(`/verification-review/${recordId}/update`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: status,
|
||||||
|
note: note
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
alert(`Verification ${status} successfully!`);
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert(`Error: ${data.message}`);
|
||||||
|
buttons.forEach(btn => btn.disabled = false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Network error. Please try again.');
|
||||||
|
buttons.forEach(btn => btn.disabled = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click photo to view full size
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const photos = document.querySelectorAll('.verification-photo');
|
||||||
|
photos.forEach(photo => {
|
||||||
|
photo.addEventListener('click', function() {
|
||||||
|
window.open(this.src, '_blank');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user