Merge branch 'master' of https://github.com/nguyen-ngo/QR_Code_Management
This commit is contained in:
@@ -71,6 +71,10 @@ class User(db.Model):
|
||||
"""Check if user has staff-level permissions (includes new roles)"""
|
||||
return self.role in STAFF_LEVEL_ROLES
|
||||
|
||||
def has_export_permissions(user_role):
|
||||
"""Check if user role has export permissions"""
|
||||
return user_role in ['admin', 'payroll']
|
||||
|
||||
def get_role_display_name(self):
|
||||
"""Get user-friendly role name"""
|
||||
role_names = {
|
||||
@@ -2399,11 +2403,21 @@ def create_qr_code():
|
||||
'longitude': address_longitude,
|
||||
'coordinate_accuracy': coordinate_accuracy
|
||||
}
|
||||
logger_handler.logger.info(f"User {session['username']} created QR code: {json.dumps(log_data)}")
|
||||
logger_handler.log_user_action(
|
||||
f'QR Code created: {name} with event type: {location_event}',
|
||||
'create',
|
||||
additional_info={'location_event': location_event}
|
||||
qr_data_for_logging = {
|
||||
'location': location,
|
||||
'location_address': location_address,
|
||||
'location_event': location_event,
|
||||
'has_coordinates': has_coordinates,
|
||||
'latitude': address_latitude,
|
||||
'longitude': address_longitude,
|
||||
'coordinate_accuracy': coordinate_accuracy
|
||||
}
|
||||
|
||||
logger_handler.log_qr_code_created(
|
||||
qr_code_id=new_qr_code.id,
|
||||
qr_code_name=name,
|
||||
created_by_user_id=session['user_id'],
|
||||
qr_data=qr_data_for_logging
|
||||
)
|
||||
|
||||
# Success message with coordinates info
|
||||
@@ -2991,7 +3005,7 @@ def toggle_qr_status_api(qr_id):
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
@app.route('/attendance')
|
||||
# @admin_required
|
||||
@login_required
|
||||
def attendance_report():
|
||||
"""Safe attendance report with backward compatibility for location_accuracy and fixed datetime handling"""
|
||||
try:
|
||||
@@ -2999,6 +3013,7 @@ def attendance_report():
|
||||
|
||||
# Log attendance report access
|
||||
try:
|
||||
user_role = session.get('role', 'unknown')
|
||||
logger_handler.logger.info(f"User {session.get('username', 'unknown')} accessed attendance report")
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
@@ -3080,7 +3095,7 @@ def attendance_report():
|
||||
conditions.append("ad.employee_id LIKE :employee")
|
||||
params['employee'] = f"%{employee_filter}%"
|
||||
|
||||
# FIXED: Apply project filter using SQL approach (not ORM)
|
||||
# Apply project filter using SQL approach (not ORM)
|
||||
if project_filter:
|
||||
conditions.append("qc.project_id = :project_id")
|
||||
params['project_id'] = int(project_filter)
|
||||
@@ -3098,8 +3113,13 @@ def attendance_report():
|
||||
# Execute query
|
||||
query_result = db.session.execute(text(base_query), params)
|
||||
attendance_records = query_result.fetchall()
|
||||
|
||||
print(f"✅ Found {len(attendance_records)} attendance records")
|
||||
# Log first 3 records to verify QR address data
|
||||
for i, record in enumerate(attendance_records[:3]):
|
||||
print(f"📊 Record {i+1}: Employee={record.employee_id}")
|
||||
print(f" QR Address: {getattr(record, 'qr_address', 'NOT_FOUND')}")
|
||||
print(f" Check-in Address: {getattr(record, 'checked_in_address', 'NOT_FOUND')}")
|
||||
print(f" Location Accuracy: {getattr(record, 'location_accuracy', 'NOT_FOUND')}")
|
||||
|
||||
# FIXED: Process records to add calculated fields with proper datetime handling
|
||||
processed_records = []
|
||||
@@ -3264,7 +3284,8 @@ def attendance_report():
|
||||
project_filter=project_filter,
|
||||
today_date=today_date,
|
||||
current_date_formatted=current_date_formatted,
|
||||
has_location_accuracy_feature=has_location_accuracy)
|
||||
has_location_accuracy_feature=has_location_accuracy,
|
||||
user_role=user_role)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error loading attendance report: {e}")
|
||||
@@ -3476,14 +3497,21 @@ def attendance_stats_api():
|
||||
return jsonify({'error': 'Failed to fetch attendance statistics'}), 500
|
||||
|
||||
@app.route('/export-configuration')
|
||||
@admin_required
|
||||
@login_required
|
||||
def export_configuration():
|
||||
"""Route to display export configuration page"""
|
||||
try:
|
||||
print("📊 Export configuration route accessed")
|
||||
# Check if user has export permissions
|
||||
user_role = session.get('role')
|
||||
if user_role not in ['admin', 'payroll']:
|
||||
logger_handler.logger.warning(f"User {session.get('username', 'unknown')} (role: {user_role}) attempted to access export configuration without permissions")
|
||||
flash('Access denied. Only administrators and payroll staff can access export configuration.', 'error')
|
||||
return redirect(url_for('attendance_report'))
|
||||
|
||||
# Log export configuration access using your existing logger
|
||||
try:
|
||||
logger_handler.logger.info(f"User {session.get('username', 'unknown')} (role: {user_role}) accessed export configuration")
|
||||
logger_handler.logger.info(f"User {session.get('username', 'unknown')} accessed export configuration page")
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
@@ -3558,10 +3586,16 @@ def export_configuration():
|
||||
return redirect(url_for('attendance_report'))
|
||||
|
||||
@app.route('/generate-excel-export', methods=['POST'])
|
||||
@admin_required
|
||||
@login_required
|
||||
def generate_excel_export():
|
||||
"""Generate and download Excel file with selected columns in specified order"""
|
||||
try:
|
||||
user_role = session.get('role')
|
||||
if user_role not in ['admin', 'payroll']:
|
||||
logger_handler.logger.warning(f"User {session.get('username', 'unknown')} (role: {user_role}) attempted unauthorized Excel export")
|
||||
flash('Access denied. Only administrators and payroll staff can export data.', 'error')
|
||||
return redirect(url_for('attendance_report'))
|
||||
|
||||
print("📊 Excel export generation started")
|
||||
|
||||
# Log export action using your existing logger
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
/* Page Container */
|
||||
.attendance-page {
|
||||
max-width: 1400px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-6);
|
||||
min-height: 100vh;
|
||||
@@ -993,40 +993,16 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Location accuracy level colors - based on distance */
|
||||
.location-accuracy-badge.accuracy-excellent {
|
||||
background: var(--success-light);
|
||||
color: var(--success-color);
|
||||
border: 1px solid rgba(5, 150, 105, 0.3);
|
||||
}
|
||||
|
||||
/* Location accuracy level colors - 2-level system based on 0.5 mile threshold */
|
||||
.location-accuracy-badge.accuracy-accurate {
|
||||
background: var(--success-light);
|
||||
color: var(--success-color);
|
||||
border: 1px solid rgba(5, 150, 105, 0.3);
|
||||
}
|
||||
|
||||
.location-accuracy-badge.accuracy-good {
|
||||
background: #ecfdf5;
|
||||
background: #dcfce7;
|
||||
color: #059669;
|
||||
border: 1px solid rgba(5, 150, 105, 0.2);
|
||||
}
|
||||
|
||||
.location-accuracy-badge.accuracy-fair {
|
||||
background: var(--warning-light);
|
||||
color: var(--warning-color);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.location-accuracy-badge.accuracy-poor {
|
||||
background: var(--danger-light);
|
||||
color: var(--danger-color);
|
||||
border: 1px solid rgba(220, 38, 38, 0.3);
|
||||
border: 1px solid rgba(5, 150, 105, 0.3);
|
||||
}
|
||||
|
||||
.location-accuracy-badge.accuracy-inaccurate {
|
||||
background: var(--danger-light);
|
||||
color: var(--danger-color);
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
border: 1px solid rgba(220, 38, 38, 0.3);
|
||||
}
|
||||
|
||||
|
||||
@@ -657,16 +657,14 @@ function extractLocationAccuracy(cell) {
|
||||
}
|
||||
|
||||
function extractLocationAccuracyLevel(cell) {
|
||||
const text = cell.textContent.toLowerCase();
|
||||
if (
|
||||
text.includes("high") ||
|
||||
text.includes("excellent") ||
|
||||
text.includes("good")
|
||||
)
|
||||
return "High";
|
||||
if (text.includes("medium") || text.includes("fair")) return "Medium";
|
||||
if (text.includes("low") || text.includes("poor")) return "Low";
|
||||
return "Unknown";
|
||||
// Get the numerical accuracy value from the cell
|
||||
const accuracy = extractLocationAccuracy(cell);
|
||||
|
||||
// Return 2-level accuracy based on 0.5-mile threshold
|
||||
if (accuracy !== null && accuracy !== undefined) {
|
||||
return accuracy <= 0.5 ? "accurate" : "inaccurate";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function createTableRow(record, displayIndex) {
|
||||
@@ -690,17 +688,17 @@ function createTableRow(record, displayIndex) {
|
||||
}"
|
||||
title="Distance between QR location and check-in location: ${
|
||||
record.location_accuracy
|
||||
} miles">
|
||||
} miles - ${record.accuracy_level}">
|
||||
<i class="fas fa-ruler"></i>
|
||||
${record.location_accuracy.toFixed(3)} mi
|
||||
<small>(${record.accuracy_level})</small>
|
||||
</span>`
|
||||
</span>`
|
||||
: `<span class="location-accuracy-badge accuracy-unknown" title="Location accuracy could not be calculated">
|
||||
<i class="fas fa-question-circle"></i>
|
||||
Unknown
|
||||
</span>`;
|
||||
</span>`;
|
||||
|
||||
// FIXED: Address display logic based on location accuracy
|
||||
// Address display logic based on location accuracy
|
||||
let addressDisplayHTML = "";
|
||||
let addressToShow = record.checked_in_address;
|
||||
let addressIcon = "fas fa-location-arrow";
|
||||
@@ -812,8 +810,8 @@ function createTableRow(record, displayIndex) {
|
||||
</td>
|
||||
<td>
|
||||
<div class="address-info qr-address">
|
||||
<i class="fas fa-qrcode"></i>
|
||||
<span title="${record.qr_address}">
|
||||
<i class="fas fa-qrcode" style="color: #6366f1; margin-right: 4px;" title="QR Code Address (Fixed)"></i>
|
||||
<span title="QR Address: ${record.qr_address}">
|
||||
${
|
||||
record.qr_address.length > 50
|
||||
? record.qr_address.substring(0, 50) + "..."
|
||||
@@ -1019,8 +1017,19 @@ function exportAttendanceWithAccuracy() {
|
||||
}
|
||||
|
||||
function exportAttendance() {
|
||||
// Check user role before proceeding
|
||||
const userRole = window.userRole; // Read from global variable set in template
|
||||
console.log("Template - session.role:", '{{ session.role }}');
|
||||
console.log("Template - window.userRole set to:", window.userRole);
|
||||
|
||||
if (!['admin', 'payroll'].includes(userRole)) {
|
||||
console.log("Export access denied - insufficient privileges");
|
||||
alert("Access denied. Only administrators and payroll staff can export data.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Log export action
|
||||
console.log("Export button clicked - redirecting to configuration page");
|
||||
console.log(`Export button clicked by ${userRole} - redirecting to configuration page`);
|
||||
|
||||
// Get current filters
|
||||
const currentFilters = getCurrentFilters();
|
||||
@@ -1055,6 +1064,15 @@ function getCurrentFilters() {
|
||||
|
||||
// Add a quick CSV export function as backup (keep existing functionality)
|
||||
function exportAttendanceCSV() {
|
||||
// Check user role before proceeding
|
||||
const userRole = window.userRole;
|
||||
|
||||
if (!['admin', 'payroll'].includes(userRole)) {
|
||||
console.log("CSV export access denied - insufficient privileges");
|
||||
alert("Access denied. Only administrators and payroll staff can export data.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build export URL with current filters for CSV
|
||||
const params = new URLSearchParams();
|
||||
const filters = getCurrentFilters();
|
||||
|
||||
@@ -535,6 +535,13 @@ function loadSavedPreferences() {
|
||||
const savedPrefs = localStorage.getItem('exportPreferences');
|
||||
if (!savedPrefs) {
|
||||
console.log('No saved preferences found');
|
||||
// Check if there are already selected columns on page load and update preview
|
||||
const alreadySelected = document.querySelectorAll('input[name="selected_columns"]:checked');
|
||||
if (alreadySelected.length > 0) {
|
||||
console.log('Found pre-selected columns, updating preview');
|
||||
updateSelectedColumnsList();
|
||||
updatePreview();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -547,6 +554,13 @@ function loadSavedPreferences() {
|
||||
if (savedDate < thirtyDaysAgo) {
|
||||
localStorage.removeItem('exportPreferences');
|
||||
console.log('Saved preferences are too old, removed');
|
||||
// Check if there are already selected columns on page load and update preview
|
||||
const alreadySelected = document.querySelectorAll('input[name="selected_columns"]:checked');
|
||||
if (alreadySelected.length > 0) {
|
||||
console.log('Found pre-selected columns, updating preview');
|
||||
updateSelectedColumnsList();
|
||||
updatePreview();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -585,6 +599,10 @@ function loadSavedPreferences() {
|
||||
|
||||
console.log('Preferences loaded:', prefs);
|
||||
|
||||
// Update preview after loading preferences
|
||||
updateSelectedColumnsList();
|
||||
updatePreview();
|
||||
|
||||
} catch (e) {
|
||||
console.warn('Could not load saved preferences:', e);
|
||||
try {
|
||||
@@ -592,6 +610,14 @@ function loadSavedPreferences() {
|
||||
} catch (removeError) {
|
||||
console.warn('Could not remove invalid preferences:', removeError);
|
||||
}
|
||||
|
||||
// Check if there are already selected columns on page load and update preview
|
||||
const alreadySelected = document.querySelectorAll('input[name="selected_columns"]:checked');
|
||||
if (alreadySelected.length > 0) {
|
||||
console.log('Found pre-selected columns after preference error, updating preview');
|
||||
updateSelectedColumnsList();
|
||||
updatePreview();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
|
||||
// CRITICAL: Initialize systems in correct order
|
||||
initializeLanguage();
|
||||
initializeLocationServicesCheck();
|
||||
initializeStaffIdPersistence();
|
||||
initializeForm();
|
||||
initializeLocation();
|
||||
@@ -733,3 +734,152 @@ function startClock() {
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
}
|
||||
|
||||
function checkLocationServicesStatus() {
|
||||
console.log("📱 Checking location services status...");
|
||||
|
||||
// Check if geolocation is supported
|
||||
if (!navigator.geolocation) {
|
||||
console.log("❌ Geolocation not supported by this browser");
|
||||
showLocationServicesWarning("not_supported");
|
||||
return;
|
||||
}
|
||||
|
||||
// Test location access with a quick check
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.log("⏰ Location permission check timed out");
|
||||
showLocationServicesWarning("timeout");
|
||||
}, 3000); // 3 second timeout
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
// Success - location services are working
|
||||
clearTimeout(timeoutId);
|
||||
console.log("✅ Location services are available and enabled");
|
||||
hideLocationServicesWarning();
|
||||
},
|
||||
(error) => {
|
||||
// Error - location services may be disabled
|
||||
clearTimeout(timeoutId);
|
||||
console.log("❌ Location services error:", error.message);
|
||||
|
||||
switch (error.code) {
|
||||
case error.PERMISSION_DENIED:
|
||||
showLocationServicesWarning("permission_denied");
|
||||
break;
|
||||
case error.POSITION_UNAVAILABLE:
|
||||
showLocationServicesWarning("position_unavailable");
|
||||
break;
|
||||
case error.TIMEOUT:
|
||||
showLocationServicesWarning("timeout");
|
||||
break;
|
||||
default:
|
||||
showLocationServicesWarning("unknown_error");
|
||||
break;
|
||||
}
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: false,
|
||||
timeout: 2500,
|
||||
maximumAge: 60000
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show location services warning banner
|
||||
*/
|
||||
function showLocationServicesWarning(errorType) {
|
||||
// Remove existing warning if present
|
||||
hideLocationServicesWarning();
|
||||
|
||||
const warningMessages = {
|
||||
en: {
|
||||
not_supported: "Location services are not supported by your browser.<br>Los servicios de ubicación no son compatibles con su navegador.",
|
||||
permission_denied: "Location access has been denied. Please enable location services for accurate check-in.<br>Se ha denegado el acceso a la ubicación. Habilite los servicios de ubicación para un registro preciso.",
|
||||
position_unavailable: "Location services appear to be disabled. Please turn on location services for accurate check-in.<br>Los servicios de ubicación parecen estar deshabilitados. Active los servicios de ubicación para un registro preciso.",
|
||||
timeout: "Location services may be disabled. Please check your location settings for accurate check-in.<br>Los servicios de ubicación pueden estar deshabilitados. Verifique su configuración de ubicación para un registro preciso.",
|
||||
unknown_error: "Unable to access location services. Please check your location settings.<br>No se puede acceder a los servicios de ubicación. Verifique su configuración de ubicación."
|
||||
},
|
||||
es: {
|
||||
not_supported: "Los servicios de ubicación no son compatibles con su navegador.",
|
||||
permission_denied: "Se ha denegado el acceso a la ubicación. Habilite los servicios de ubicación para un registro preciso.",
|
||||
position_unavailable: "Los servicios de ubicación parecen estar deshabilitados. Active los servicios de ubicación para un registro preciso.",
|
||||
timeout: "Los servicios de ubicación pueden estar deshabilitados. Verifique su configuración de ubicación para un registro preciso.",
|
||||
unknown_error: "No se puede acceder a los servicios de ubicación. Verifique su configuración de ubicación."
|
||||
}
|
||||
};
|
||||
|
||||
const currentLang = currentLanguage || 'en';
|
||||
const message = warningMessages[currentLang][errorType] || warningMessages['en'][errorType];
|
||||
|
||||
// Create warning banner
|
||||
const warningBanner = document.createElement('div');
|
||||
warningBanner.id = 'locationServicesWarning';
|
||||
warningBanner.className = 'location-warning-banner';
|
||||
warningBanner.innerHTML = `
|
||||
<div class="warning-content">
|
||||
<i class="fas fa-exclamation-triangle warning-icon"></i>
|
||||
<div class="warning-text">
|
||||
<span class="warning-message">${message}</span>
|
||||
<div class="warning-actions">
|
||||
<button type="button" class="warning-retry-btn" onclick="checkLocationServicesStatus()">
|
||||
<i class="fas fa-redo"></i>
|
||||
<span class="english-text">Retry</span>
|
||||
<span class="language-separator">/</span>
|
||||
<span class="spanish-text">Reintentar</span>
|
||||
</button>
|
||||
<button type="button" class="warning-dismiss-btn" onclick="hideLocationServicesWarning()">
|
||||
<i class="fas fa-times"></i>
|
||||
<span class="english-text">Dismiss</span>
|
||||
<span class="language-separator">/</span>
|
||||
<span class="spanish-text">Descartar</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Insert warning at the top of the page
|
||||
const container = document.querySelector('.destination-container');
|
||||
if (container) {
|
||||
container.insertBefore(warningBanner, container.firstChild);
|
||||
}
|
||||
|
||||
// Log warning event
|
||||
console.log(`⚠️ Location services warning displayed: ${errorType}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide location services warning banner
|
||||
*/
|
||||
function hideLocationServicesWarning() {
|
||||
const existingWarning = document.getElementById('locationServicesWarning');
|
||||
if (existingWarning) {
|
||||
existingWarning.remove();
|
||||
console.log("✅ Location services warning hidden");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modified DOMContentLoaded event handler
|
||||
* Add this to your existing initialization
|
||||
*/
|
||||
function initializeLocationServicesCheck() {
|
||||
// Check location services status when page loads
|
||||
setTimeout(() => {
|
||||
checkLocationServicesStatus();
|
||||
}, 1000); // Small delay to ensure page is fully loaded
|
||||
|
||||
// Also check before form submission
|
||||
const originalHandleFormSubmit = handleFormSubmit;
|
||||
window.handleFormSubmit = function(event) {
|
||||
// Quick location check before submission
|
||||
checkLocationServicesStatus();
|
||||
|
||||
// Continue with original form submission after brief delay
|
||||
setTimeout(() => {
|
||||
originalHandleFormSubmit.call(this, event);
|
||||
}, 500);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="attendance-page">
|
||||
<div class="attendance-page" data-user-role="{{ session.role }}">
|
||||
<!-- Header Section -->
|
||||
<div class="attendance-header">
|
||||
<div class="header-content">
|
||||
@@ -22,10 +22,12 @@
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
{% if session.role in ['admin', 'payroll'] %}
|
||||
<button onclick="exportAttendance()" class="btn btn-success">
|
||||
<i class="fas fa-download"></i>
|
||||
Export Data
|
||||
</button>
|
||||
{% endif %}
|
||||
<button onclick="refreshReport()" class="btn btn-secondary">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
Refresh
|
||||
@@ -223,28 +225,16 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="address-column">
|
||||
<div class="address-info checkin-address">
|
||||
<i class="fas fa-location-arrow"></i>
|
||||
{% if record.address_source == 'qr' %}
|
||||
<!-- Using QR address due to high accuracy (≤ 0.5 miles) -->
|
||||
<span title="QR Address (High Accuracy ≤ 0.5 mi): {{ record.qr_address }}"
|
||||
class="address-high-accuracy">
|
||||
<i class="fas fa-check-circle" style="color: #059669; margin-right: 4px;"
|
||||
title="High accuracy - showing QR location"></i>
|
||||
{{ record.qr_address[:45] }}{% if record.qr_address|length > 45 %}...{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<!-- Using actual check-in address due to lower accuracy (> 0.5 miles) or no accuracy data -->
|
||||
<span title="Check-in Address{% if record.location_accuracy %} (Accuracy: {{ '%.3f'|format(record.location_accuracy) }} mi){% endif %}: {{ record.checked_in_address }}"
|
||||
class="address-normal-accuracy">
|
||||
{% if record.location_accuracy and record.location_accuracy > 0.5 %}
|
||||
<i class="fas fa-exclamation-triangle" style="color: #f59e0b; margin-right: 4px;"
|
||||
title="Lower accuracy - showing actual check-in location"></i>
|
||||
{% endif %}
|
||||
{{ record.checked_in_address[:45] }}{% if record.checked_in_address|length > 45 %}...{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
<td>
|
||||
<div class="address-info qr-address">
|
||||
<i class="fas fa-qrcode" style="color: #6366f1; margin-right: 4px;" title="QR Code Address (Fixed)"></i>
|
||||
<span title="QR Address: {{ record.qr_address or 'N/A' }}">
|
||||
{% if record.qr_address %}
|
||||
{{ record.qr_address[:50] }}{{ '...' if record.qr_address|length > 50 else '' }}
|
||||
{% else %}
|
||||
N/A
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="address-column">
|
||||
@@ -634,5 +624,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('✅ Admin/Payroll edit/delete buttons have been activated');
|
||||
}
|
||||
});
|
||||
window.userRole = '{{ session.role }}';
|
||||
</script>
|
||||
{% endblock %}
|
||||
+427
-162
@@ -173,11 +173,12 @@
|
||||
|
||||
.project-qr-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-6);
|
||||
}
|
||||
|
||||
/* QR Card Base Styles */
|
||||
.qr-card {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--gray-200);
|
||||
@@ -199,14 +200,17 @@
|
||||
background: linear-gradient(135deg, var(--white), #f8fafc);
|
||||
}
|
||||
|
||||
.qr-card-header {
|
||||
/* Compact QR Card Layout for Unassigned Cards */
|
||||
.qr-card-compact {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
margin-bottom: var(--spacing-3);
|
||||
width: 100%;
|
||||
padding: var(--spacing-3);
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.qr-preview {
|
||||
.qr-preview-compact {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
flex-shrink: 0;
|
||||
@@ -214,49 +218,140 @@
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--gray-200);
|
||||
transition: var(--transition);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.qr-preview img {
|
||||
.qr-preview-compact img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.qr-card:hover .qr-preview {
|
||||
.qr-card:hover .qr-preview-compact {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.qr-info {
|
||||
.qr-info-compact {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.qr-name-compact {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.qr-details-row {
|
||||
display: flex;
|
||||
gap: var(--spacing-3);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qr-url-short {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.qr-status-compact {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qr-actions-compact {
|
||||
display: flex;
|
||||
gap: var(--spacing-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Standard QR Card Layout for Project Cards */
|
||||
.qr-card-layout {
|
||||
display: flex;
|
||||
gap: var(--spacing-3);
|
||||
margin-bottom: var(--spacing-3);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.qr-preview-large {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--gray-200);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.qr-preview-large img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.qr-card:hover .qr-preview-large {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.qr-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.qr-name {
|
||||
font-size: var(--font-size-base);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
margin: 0 0 var(--spacing-1) 0;
|
||||
line-height: 1.4;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.qr-location {
|
||||
color: var(--gray-600);
|
||||
font-size: var(--font-size-sm);
|
||||
.qr-detail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
margin-bottom: var(--spacing-2);
|
||||
gap: 0.4rem;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--gray-600);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.qr-detail-item i {
|
||||
font-size: 0.65rem;
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
color: var(--gray-400);
|
||||
}
|
||||
|
||||
.qr-detail-item span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.qr-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
padding: 0.25rem 0.5rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
margin-top: var(--spacing-1);
|
||||
}
|
||||
|
||||
.qr-status-badge.active {
|
||||
@@ -269,23 +364,18 @@
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.qr-actions {
|
||||
position: absolute;
|
||||
top: var(--spacing-3);
|
||||
right: var(--spacing-3);
|
||||
.qr-card-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-1);
|
||||
opacity: 0;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.qr-card:hover .qr-actions {
|
||||
opacity: 1;
|
||||
justify-content: flex-start;
|
||||
padding-top: var(--spacing-2);
|
||||
border-top: 1px solid var(--gray-200);
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
|
||||
.qr-action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
display: flex;
|
||||
@@ -293,7 +383,7 @@
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
font-size: var(--font-size-sm);
|
||||
font-size: 0.75rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -317,16 +407,6 @@
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.qr-action-btn.delete {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.qr-action-btn.delete:hover {
|
||||
background: #dc2626;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.qr-action-btn.toggle {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #d97706;
|
||||
@@ -337,6 +417,7 @@
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
/* Empty States */
|
||||
.empty-project-qr {
|
||||
text-align: center;
|
||||
padding: var(--spacing-8) var(--spacing-4);
|
||||
@@ -368,6 +449,41 @@
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.dashboard-empty {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.dashboard-empty .empty-icon {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto 2rem;
|
||||
background: var(--gray-100);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
color: var(--gray-400);
|
||||
}
|
||||
|
||||
.dashboard-empty h3 {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-empty p {
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 2rem;
|
||||
max-width: 500px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* Unassigned QR Section */
|
||||
.unassigned-qr-section {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--gray-200);
|
||||
@@ -404,6 +520,7 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
@@ -517,26 +634,26 @@
|
||||
padding-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.qr-detail-item {
|
||||
.qr-detail-section .qr-detail-item {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
grid-template-columns: 100px 1fr;
|
||||
gap: var(--spacing-3);
|
||||
margin-bottom: var(--spacing-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.qr-detail-item:last-child {
|
||||
.qr-detail-section .qr-detail-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.qr-detail-item label {
|
||||
.qr-detail-section .qr-detail-item label {
|
||||
font-weight: 600;
|
||||
color: var(--gray-700);
|
||||
font-size: var(--font-size-sm);
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.qr-detail-item span {
|
||||
.qr-detail-section .qr-detail-item span {
|
||||
color: var(--gray-900);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1.5;
|
||||
@@ -576,40 +693,106 @@
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.dashboard-empty {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--gray-600);
|
||||
/* Image Lightbox Styles */
|
||||
.image-lightbox {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-empty .empty-icon {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto 2rem;
|
||||
background: var(--gray-100);
|
||||
.lightbox-content {
|
||||
position: relative;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
background: var(--white);
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.lightbox-close {
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
right: 0;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 1.2rem;
|
||||
color: var(--gray-700);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
color: var(--gray-400);
|
||||
}
|
||||
|
||||
.dashboard-empty h3 {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
.lightbox-close:hover {
|
||||
background: var(--white);
|
||||
color: var(--gray-900);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-empty p {
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 2rem;
|
||||
max-width: 500px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
.lightbox-info {
|
||||
color: var(--white);
|
||||
margin-top: var(--spacing-4);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.lightbox-hint {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-top: var(--spacing-2);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* Make QR images clickable */
|
||||
.qr-preview-large,
|
||||
.qr-preview-compact {
|
||||
cursor: zoom-in;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.qr-preview-large::after,
|
||||
.qr-preview-compact::after {
|
||||
content: '🔍';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.qr-card:hover .qr-preview-large::after,
|
||||
.qr-card:hover .qr-preview-compact::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
@@ -626,6 +809,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.project-header {
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
@@ -648,17 +832,54 @@
|
||||
}
|
||||
|
||||
.project-qr-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.qr-card-header {
|
||||
.qr-card-compact {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2);
|
||||
}
|
||||
|
||||
.qr-preview {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
.qr-info-compact {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qr-details-row {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qr-status-compact {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.qr-actions-compact {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.qr-card-layout {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qr-preview-large {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.qr-details {
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qr-detail-item {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.unassigned-header {
|
||||
@@ -691,11 +912,6 @@
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.qr-detail-item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.qr-modal-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -713,11 +929,10 @@
|
||||
text-overflow: unset;
|
||||
}
|
||||
|
||||
.qr-actions {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
margin-top: var(--spacing-2);
|
||||
justify-content: flex-end;
|
||||
.project-qr-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-3);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@@ -872,16 +1087,33 @@
|
||||
data-qr-event="{{ qr.location_event }}"
|
||||
data-qr-url="{{ qr.qr_url if qr.qr_url else '' }}"
|
||||
onclick="openQRModalFromData(this)">
|
||||
<div class="qr-card-header">
|
||||
<div class="qr-preview">
|
||||
|
||||
<div class="qr-card-layout">
|
||||
<div class="qr-preview-large" onclick="event.stopPropagation(); openImageLightbox(this, '{{ qr.name }}')">
|
||||
<img src="data:image/png;base64,{{ qr.qr_code_image }}" alt="QR Code for {{ qr.name }}" loading="lazy">
|
||||
</div>
|
||||
<div class="qr-info">
|
||||
|
||||
<div class="qr-details">
|
||||
<h4 class="qr-name">{{ qr.name }}</h4>
|
||||
<p class="qr-location">
|
||||
<div class="qr-detail-item">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
{{ qr.location }}
|
||||
</p>
|
||||
<span>{{ qr.location }}</span>
|
||||
</div>
|
||||
|
||||
{% if qr.location_address %}
|
||||
<div class="qr-detail-item">
|
||||
<i class="fas fa-home"></i>
|
||||
<span>{{ qr.location_address }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if qr.qr_url %}
|
||||
<div class="qr-detail-item">
|
||||
<i class="fas fa-link"></i>
|
||||
<span>{{ qr.qr_url }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<span class="qr-status-badge {{ 'active' if qr.active_status else 'inactive' }}">
|
||||
<i class="fas {{ 'fa-check-circle' if qr.active_status else 'fa-pause-circle' }}"></i>
|
||||
{{ 'Active' if qr.active_status else 'Inactive' }}
|
||||
@@ -889,7 +1121,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="qr-actions">
|
||||
<div class="qr-card-actions">
|
||||
<button class="qr-action-btn download" onclick="event.stopPropagation(); downloadQRFromCard(this)" title="Download QR Code">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
@@ -902,10 +1134,6 @@
|
||||
<button class="qr-action-btn toggle" onclick="event.stopPropagation(); toggleQRCodeStatus({{ qr.id }})" title="{{ 'Deactivate' if qr.active_status else 'Activate' }} QR Code">
|
||||
<i class="fas {{ 'fa-pause' if qr.active_status else 'fa-play' }}"></i>
|
||||
</button>
|
||||
|
||||
<button class="qr-action-btn delete" onclick="event.stopPropagation(); deleteQRCode({{ qr.id }}, '{{ qr.name }}')" title="Delete QR Code">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -952,41 +1180,58 @@
|
||||
data-qr-event="{{ qr.location_event }}"
|
||||
data-qr-url="{{ qr.qr_url if qr.qr_url else '' }}"
|
||||
onclick="openQRModalFromData(this)">
|
||||
<div class="qr-card-header">
|
||||
<div class="qr-preview">
|
||||
|
||||
<div class="qr-card-compact">
|
||||
<div class="qr-preview-compact" onclick="event.stopPropagation(); openImageLightbox(this, '{{ qr.name }}')">
|
||||
<img src="data:image/png;base64,{{ qr.qr_code_image }}" alt="QR Code for {{ qr.name }}" loading="lazy">
|
||||
</div>
|
||||
<div class="qr-info">
|
||||
<h4 class="qr-name">{{ qr.name }}</h4>
|
||||
<p class="qr-location">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
{{ qr.location }}
|
||||
</p>
|
||||
|
||||
<div class="qr-info-compact">
|
||||
<h4 class="qr-name-compact">{{ qr.name }}</h4>
|
||||
<div class="qr-details-row">
|
||||
<div class="qr-detail-item">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
<span>{{ qr.location }}</span>
|
||||
</div>
|
||||
|
||||
{% if qr.location_address %}
|
||||
<div class="qr-detail-item">
|
||||
<i class="fas fa-home"></i>
|
||||
<span>{{ qr.location_address }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if qr.qr_url %}
|
||||
<div class="qr-detail-item">
|
||||
<i class="fas fa-link"></i>
|
||||
<span class="qr-url-short">{{ qr.qr_url[:50] }}{% if qr.qr_url|length > 50 %}...{% endif %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="qr-status-compact">
|
||||
<span class="qr-status-badge {{ 'active' if qr.active_status else 'inactive' }}">
|
||||
<i class="fas {{ 'fa-check-circle' if qr.active_status else 'fa-pause-circle' }}"></i>
|
||||
{{ 'Active' if qr.active_status else 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="qr-actions">
|
||||
<button class="qr-action-btn download" onclick="event.stopPropagation(); downloadQRFromCard(this)" title="Download QR Code">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
<div class="qr-actions-compact">
|
||||
<button class="qr-action-btn download" onclick="event.stopPropagation(); downloadQRFromCard(this)" title="Download QR Code">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
|
||||
<a href="{{ url_for('edit_qr_code', qr_id=qr.id) }}" class="qr-action-btn edit" onclick="event.stopPropagation()" title="Edit QR Code">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<a href="{{ url_for('edit_qr_code', qr_id=qr.id) }}" class="qr-action-btn edit" onclick="event.stopPropagation()" title="Edit QR Code">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
|
||||
{% if session.role == 'admin' %}
|
||||
<button class="qr-action-btn toggle" onclick="event.stopPropagation(); toggleQRCodeStatus({{ qr.id }})" title="{{ 'Deactivate' if qr.active_status else 'Activate' }} QR Code">
|
||||
<i class="fas {{ 'fa-pause' if qr.active_status else 'fa-play' }}"></i>
|
||||
</button>
|
||||
|
||||
<button class="qr-action-btn delete" onclick="event.stopPropagation(); deleteQRCode({{ qr.id }}, '{{ qr.name }}')" title="Delete QR Code">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if session.role == 'admin' %}
|
||||
<button class="qr-action-btn toggle" onclick="event.stopPropagation(); toggleQRCodeStatus({{ qr.id }})" title="{{ 'Deactivate' if qr.active_status else 'Activate' }} QR Code">
|
||||
<i class="fas {{ 'fa-pause' if qr.active_status else 'fa-play' }}"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -1018,6 +1263,18 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Image Lightbox -->
|
||||
<div id="imageLightbox" class="image-lightbox" onclick="closeImageLightbox()">
|
||||
<div class="lightbox-content" onclick="event.stopPropagation()">
|
||||
<button class="lightbox-close" onclick="closeImageLightbox()">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
<img id="lightboxImage" src="" alt="" class="lightbox-image">
|
||||
<div class="lightbox-info" id="lightboxInfo"></div>
|
||||
<div class="lightbox-hint">Click anywhere outside the image to close</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced QR Code Modal -->
|
||||
<div id="qrModal" class="modal" onclick="closeQRModal()">
|
||||
<div class="modal-content qr-modal-content" onclick="event.stopPropagation()">
|
||||
@@ -1199,50 +1456,10 @@ class ProjectDashboardManager {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteQRCode(qrId, qrName) {
|
||||
const confirmMessage = `Are you sure you want to permanently delete the QR code "${qrName}"?\n\nThis action cannot be undone.`;
|
||||
|
||||
if (!confirm(confirmMessage)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/qr-codes/${qrId}/delete`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
if (window.showToast) {
|
||||
window.showToast(`QR code "${qrName}" deleted successfully`, 'success');
|
||||
}
|
||||
|
||||
const qrCard = document.querySelector(`[data-qr-id="${qrId}"]`);
|
||||
if (qrCard) {
|
||||
qrCard.style.transition = 'all 0.3s ease';
|
||||
qrCard.style.opacity = '0';
|
||||
qrCard.style.transform = 'translateY(-20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
qrCard.remove();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
} else {
|
||||
throw new Error('Failed to delete QR code');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete failed:', error);
|
||||
if (window.showToast) {
|
||||
window.showToast('Failed to delete QR code', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
openQRModalFromData(element) {
|
||||
const qrData = {
|
||||
name: element.dataset.qrName,
|
||||
image: element.querySelector('.qr-preview img').src,
|
||||
image: element.querySelector('img').src,
|
||||
location: element.dataset.qrLocation,
|
||||
address: element.dataset.qrAddress,
|
||||
event: element.dataset.qrEvent,
|
||||
@@ -1321,11 +1538,10 @@ class ProjectDashboardManager {
|
||||
|
||||
downloadQRFromCard(button) {
|
||||
const qrCard = button.closest('.qr-card');
|
||||
const img = qrCard.querySelector('.qr-preview img');
|
||||
const img = qrCard.querySelector('img');
|
||||
const qrName = qrCard.dataset.qrName;
|
||||
|
||||
if (img && img.src) {
|
||||
// Extract base64 data from the image src
|
||||
const base64Data = img.src.split('base64,')[1];
|
||||
this.downloadQR(base64Data, qrName);
|
||||
}
|
||||
@@ -1412,6 +1628,54 @@ class ProjectDashboardManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Image Lightbox Functions
|
||||
openImageLightbox(previewElement, qrName) {
|
||||
console.log('Opening lightbox for:', qrName); // Debug log
|
||||
|
||||
const img = previewElement.querySelector('img');
|
||||
|
||||
if (img && img.src) {
|
||||
const lightbox = document.getElementById('imageLightbox');
|
||||
const lightboxImage = document.getElementById('lightboxImage');
|
||||
const lightboxInfo = document.getElementById('lightboxInfo');
|
||||
|
||||
console.log('Lightbox elements found:', !!lightbox, !!lightboxImage, !!lightboxInfo); // Debug log
|
||||
|
||||
if (lightbox && lightboxImage && lightboxInfo) {
|
||||
lightboxImage.src = img.src;
|
||||
lightboxImage.alt = img.alt;
|
||||
lightboxInfo.textContent = `QR Code: ${qrName}`;
|
||||
|
||||
lightbox.style.display = 'flex';
|
||||
console.log('Lightbox should be visible now'); // Debug log
|
||||
|
||||
// Add keyboard listener for ESC key
|
||||
document.addEventListener('keydown', this.handleLightboxKeydown.bind(this));
|
||||
} else {
|
||||
console.error('Lightbox elements not found');
|
||||
}
|
||||
} else {
|
||||
console.error('Image element not found or no src');
|
||||
}
|
||||
}
|
||||
|
||||
closeImageLightbox() {
|
||||
console.log('Closing lightbox'); // Debug log
|
||||
const lightbox = document.getElementById('imageLightbox');
|
||||
if (lightbox) {
|
||||
lightbox.style.display = 'none';
|
||||
|
||||
// Remove keyboard listener
|
||||
document.removeEventListener('keydown', this.handleLightboxKeydown.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
handleLightboxKeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
this.closeImageLightbox();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dashboardManager;
|
||||
@@ -1421,13 +1685,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
window.toggleProject = (projectId) => dashboardManager.toggleProject(projectId);
|
||||
window.toggleQRCodeStatus = (qrId) => dashboardManager.toggleQRCodeStatus(qrId);
|
||||
window.deleteQRCode = (qrId, qrName) => dashboardManager.deleteQRCode(qrId, qrName);
|
||||
window.openQRModalFromData = (element) => dashboardManager.openQRModalFromData(element);
|
||||
window.closeQRModal = () => dashboardManager.closeQRModal();
|
||||
window.downloadModalQR = () => dashboardManager.downloadModalQR();
|
||||
window.copyModalQRData = () => dashboardManager.copyModalQRData();
|
||||
window.copyQRDestination = () => dashboardManager.copyQRDestination();
|
||||
window.downloadQRFromCard = (button) => dashboardManager.downloadQRFromCard(button);
|
||||
window.openImageLightbox = (element, qrName) => dashboardManager.openImageLightbox(element, qrName);
|
||||
window.closeImageLightbox = () => dashboardManager.closeImageLightbox();
|
||||
|
||||
console.log('Project Dashboard initialized successfully');
|
||||
});
|
||||
|
||||
@@ -212,11 +212,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Load saved preferences if available
|
||||
loadSavedPreferences();
|
||||
|
||||
// Update preview on page load
|
||||
updatePreview();
|
||||
|
||||
// Initialize drag & drop
|
||||
initializeDragDrop();
|
||||
|
||||
// Ensure preview is updated after all initialization is complete
|
||||
setTimeout(() => {
|
||||
const selectedColumns = document.querySelectorAll('input[name="selected_columns"]:checked');
|
||||
if (selectedColumns.length > 0) {
|
||||
console.log('Final check: ensuring preview is shown for selected columns');
|
||||
updatePreview();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
function goBackToReport() {
|
||||
@@ -236,74 +242,10 @@ function goBackToReport() {
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
function loadSavedPreferences() {
|
||||
// Load preferences from localStorage or session
|
||||
try {
|
||||
const savedPrefs = localStorage.getItem('exportPreferences');
|
||||
if (savedPrefs) {
|
||||
const prefs = JSON.parse(savedPrefs);
|
||||
|
||||
// Apply saved column selections
|
||||
if (prefs.selected_columns) {
|
||||
const checkboxes = document.querySelectorAll('input[name="selected_columns"]');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = prefs.selected_columns.includes(cb.value);
|
||||
toggleColumnName(cb.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply saved column names
|
||||
if (prefs.column_names) {
|
||||
Object.keys(prefs.column_names).forEach(key => {
|
||||
const input = document.getElementById('name_' + key);
|
||||
if (input) {
|
||||
input.value = prefs.column_names[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Apply saved column order
|
||||
if (prefs.column_order) {
|
||||
// Column order will be applied when selected columns are updated
|
||||
window.savedColumnOrder = prefs.column_order;
|
||||
}
|
||||
|
||||
updateSelectedColumnsList();
|
||||
updatePreview();
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('No saved preferences found');
|
||||
}
|
||||
}
|
||||
|
||||
// Form submission handler to save preferences
|
||||
document.getElementById('exportForm').addEventListener('submit', function() {
|
||||
savePreferences();
|
||||
updateColumnOrderField();
|
||||
});
|
||||
|
||||
function savePreferences() {
|
||||
const selectedColumns = Array.from(document.querySelectorAll('input[name="selected_columns"]:checked'))
|
||||
.map(cb => cb.value);
|
||||
|
||||
const columnNames = {};
|
||||
selectedColumns.forEach(col => {
|
||||
const input = document.getElementById('name_' + col);
|
||||
if (input) {
|
||||
columnNames[col] = input.value;
|
||||
}
|
||||
});
|
||||
|
||||
// Get current column order from the sortable list
|
||||
const columnOrder = getCurrentColumnOrder();
|
||||
|
||||
const prefs = {
|
||||
selected_columns: selectedColumns,
|
||||
column_names: columnNames,
|
||||
column_order: columnOrder
|
||||
};
|
||||
|
||||
localStorage.setItem('exportPreferences', JSON.stringify(prefs));
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -601,6 +601,127 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
.location-warning-banner {
|
||||
background: linear-gradient(135deg, #fef3c7, #fde68a);
|
||||
border: 2px solid #f59e0b;
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
animation: slideInWarning 0.5s ease-out;
|
||||
}
|
||||
|
||||
.warning-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
color: #d97706;
|
||||
font-size: 1.25rem;
|
||||
margin-top: 0.125rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.warning-message {
|
||||
display: block;
|
||||
color: #92400e;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.warning-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.warning-retry-btn,
|
||||
.warning-dismiss-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: calc(var(--border-radius) * 0.75);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.warning-retry-btn {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.warning-retry-btn:hover {
|
||||
background: #d97706;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.warning-dismiss-btn {
|
||||
background: rgba(156, 163, 175, 0.2);
|
||||
color: #6b7280;
|
||||
border: 1px solid rgba(156, 163, 175, 0.3);
|
||||
}
|
||||
|
||||
.warning-dismiss-btn:hover {
|
||||
background: rgba(156, 163, 175, 0.3);
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
/* Warning animation */
|
||||
@keyframes slideInWarning {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Language styling for warning buttons */
|
||||
.warning-retry-btn .language-separator,
|
||||
.warning-dismiss-btn .language-separator {
|
||||
opacity: 0.7;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.warning-retry-btn .spanish-text,
|
||||
.warning-dismiss-btn .spanish-text {
|
||||
font-style: italic;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Responsive adjustments for warning */
|
||||
@media (max-width: 640px) {
|
||||
.warning-content {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.warning-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.warning-retry-btn,
|
||||
.warning-dismiss-btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="{% if qr_code.location_event == 'Check Out' %}check-out{% else %}check-in{% endif %}">
|
||||
|
||||
Reference in New Issue
Block a user