This commit is contained in:
2025-08-15 19:40:44 -04:00
9 changed files with 841 additions and 318 deletions
+47 -13
View File
@@ -70,6 +70,10 @@ class User(db.Model):
def has_staff_permissions(self):
"""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"""
@@ -2399,17 +2403,27 @@ 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
project_info = f" in project '{project.name}'" if project else ""
coord_info = f" with coordinates ({new_qr_code.coordinates_display})" if has_coordinates else ""
flash(f'QR Code "{name}" created successfully{project_info}{coord_info}! URL: {qr_url}', 'success')
return redirect(url_for('dashboard'))
@@ -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,9 +3113,14 @@ 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 = []
for record in attendance_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
+6 -30
View File
@@ -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);
}
+35 -17
View File
@@ -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();
+26
View File
@@ -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();
}
}
}
+150
View File
@@ -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);
};
}
+14 -23
View File
@@ -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 %}
+433 -168
View File
@@ -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);
@@ -646,19 +830,56 @@
align-items: flex-end;
gap: var(--spacing-2);
}
.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;
}
@@ -712,12 +928,11 @@
overflow: visible;
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,24 +1087,41 @@
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' }}
</span>
</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>
<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 %}
<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>
{% 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');
});
+9 -67
View File
@@ -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 %}
+121
View File
@@ -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 %}">