From d92cff81e512269aaa289f579c954e9b80bb6693 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Fri, 11 Sep 2026 22:42:45 -0400 Subject: [PATCH] Sep 11 - Reupload the code --- .claude/settings.json | 9 + .gitignore | 209 ++ Claude.md | 1260 +++++++++ LICENSE | 21 + address_normalization_fix.py | 556 ++++ advanced_security_middleware.py | 476 ++++ app.py | 454 ++++ app_performance_middleware.py | 347 +++ config.py | 140 + db_audit_tables.py | 97 + db_health_check.py | 186 ++ db_maintenance.py | 526 ++++ db_performance_optimization.py | 217 ++ employee_data_merger.py | 364 +++ employee_duplicate_remove.py | 340 +++ employee_sync_scheduler.py | 124 + employee_table_sync.py | 507 ++++ extensions.py | 39 + legacy_attendance_service.py | 328 +++ location_logging.py | 293 ++ logger_handler.py | 984 +++++++ models/__init__.py | 24 + models/attendance.py | 81 + models/base.py | 7 + models/employee.py | 82 + models/permissions.py | 48 + models/project.py | 40 + models/qrcode.py | 118 + models/time_attendance.py | 132 + models/user.py | 70 + qr_code_import_service.py | 392 +++ requirements.txt | 67 + routes/__init__.py | 0 routes/admin.py | 580 ++++ routes/attendance.py | 866 ++++++ routes/attendance_edit.py | 348 +++ routes/attendance_export.py | 962 +++++++ routes/auth.py | 301 +++ routes/dashboard.py | 250 ++ routes/employees.py | 379 +++ routes/legacy_attendance.py | 129 + routes/projects.py | 170 ++ routes/qr_codes.py | 1440 ++++++++++ routes/statistics.py | 315 +++ routes/time_attendance.py | 1694 ++++++++++++ routes/time_attendance_export.py | 2385 +++++++++++++++++ routes/users.py | 974 +++++++ routes/verification.py | 422 +++ single_checkin_calculator.py | 479 ++++ static/css/admin_logs.css | 830 ++++++ static/css/attendance.css | 1749 ++++++++++++ static/css/attendance_fullscreen.css | 642 +++++ static/css/auth.css | 287 ++ static/css/dashboard.css | 1580 +++++++++++ static/css/employees.css | 823 ++++++ static/css/export_configuration.css | 897 +++++++ static/css/projects.css | 554 ++++ static/css/qr_destination.css | 974 +++++++ static/css/statistics.css | 774 ++++++ static/css/style.css | 1499 +++++++++++ static/css/theme-gov.css | 121 + static/css/time_attendance.css | 1037 +++++++ static/css/users.css | 723 +++++ static/favicon.ico | Bin 0 -> 15406 bytes static/images/favicon.ico | Bin 0 -> 15406 bytes static/js/android_location_handler.js | 445 +++ static/js/attendance_fullscreen.js | 471 ++++ static/js/attendance_report.js | 1243 +++++++++ static/js/dashboard.js | 703 +++++ static/js/export_configuration.js | 862 ++++++ static/js/qr_destination.js | 1510 +++++++++++ static/js/script.js | 613 +++++ static/js/users.js | 747 ++++++ templates/add_manual_attendance.html | 503 ++++ templates/admin_logs.html | 2068 ++++++++++++++ templates/attendance_report.html | 1109 ++++++++ templates/base.html | 105 + templates/base_authenticated.html | 254 ++ templates/bulk_qr_import.html | 618 +++++ templates/confirm_delete_qr.html | 610 +++++ templates/create_employee.html | 380 +++ templates/create_project.html | 160 ++ templates/create_qr_code.html | 1530 +++++++++++ templates/create_user.html | 671 +++++ templates/dashboard.html | 2070 ++++++++++++++ templates/edit_attendance.html | 539 ++++ templates/edit_employee.html | 453 ++++ templates/edit_project.html | 199 ++ templates/edit_qr_code.html | 1577 +++++++++++ templates/edit_user.html | 731 +++++ templates/employee_detail.html | 511 ++++ templates/employees.html | 432 +++ templates/errors/403.html | 79 + templates/errors/404.html | 73 + templates/errors/500.html | 77 + templates/export_configuration.html | 276 ++ templates/legacy_attendance_dashboard.html | 90 + templates/legacy_attendance_records.html | 262 ++ templates/login.html | 146 + templates/profile.html | 1184 ++++++++ templates/project_qr_codes.html | 1032 +++++++ templates/projects.html | 197 ++ templates/qr_destination.html | 2237 ++++++++++++++++ templates/qr_not_found.html | 79 + templates/register.html | 182 ++ templates/statistics.html | 659 +++++ templates/time_attendance_batch_detail.html | 211 ++ templates/time_attendance_dashboard.html | 547 ++++ .../time_attendance_duplicate_review.html | 644 +++++ templates/time_attendance_import.html | 1005 +++++++ .../time_attendance_import_progress.html | 307 +++ templates/time_attendance_import_result.html | 853 ++++++ templates/time_attendance_invalid_review.html | 518 ++++ templates/time_attendance_record_detail.html | 903 +++++++ templates/time_attendance_records.html | 1208 +++++++++ templates/users.html | 575 ++++ templates/verification_review.html | 971 +++++++ templates/verification_review_detail.html | 620 +++++ time_attendance_import_service.py | 1326 +++++++++ tools/migration_PM_permissions.py | 121 + tools/migration_dynamic_qr_locations.py | 150 ++ ...ration_legacy_attendance_remote_indexes.py | 94 + tools/migration_photo_verification_toggle.py | 122 + tools/optimize_time_attendance_db.py | 600 +++++ turnstile_utils.py | 65 + utils/__init__.py | 0 utils/geocoding.py | 824 ++++++ utils/helpers.py | 522 ++++ utils/template_helpers.py | 75 + working_hours_calculator.py | 838 ++++++ 130 files changed, 73508 insertions(+) create mode 100644 .claude/settings.json create mode 100644 .gitignore create mode 100644 Claude.md create mode 100644 LICENSE create mode 100644 address_normalization_fix.py create mode 100644 advanced_security_middleware.py create mode 100644 app.py create mode 100644 app_performance_middleware.py create mode 100644 config.py create mode 100644 db_audit_tables.py create mode 100644 db_health_check.py create mode 100644 db_maintenance.py create mode 100644 db_performance_optimization.py create mode 100644 employee_data_merger.py create mode 100644 employee_duplicate_remove.py create mode 100644 employee_sync_scheduler.py create mode 100644 employee_table_sync.py create mode 100644 extensions.py create mode 100644 legacy_attendance_service.py create mode 100644 location_logging.py create mode 100644 logger_handler.py create mode 100644 models/__init__.py create mode 100644 models/attendance.py create mode 100644 models/base.py create mode 100644 models/employee.py create mode 100644 models/permissions.py create mode 100644 models/project.py create mode 100644 models/qrcode.py create mode 100644 models/time_attendance.py create mode 100644 models/user.py create mode 100644 qr_code_import_service.py create mode 100644 requirements.txt create mode 100644 routes/__init__.py create mode 100644 routes/admin.py create mode 100644 routes/attendance.py create mode 100644 routes/attendance_edit.py create mode 100644 routes/attendance_export.py create mode 100644 routes/auth.py create mode 100644 routes/dashboard.py create mode 100644 routes/employees.py create mode 100644 routes/legacy_attendance.py create mode 100644 routes/projects.py create mode 100644 routes/qr_codes.py create mode 100644 routes/statistics.py create mode 100644 routes/time_attendance.py create mode 100644 routes/time_attendance_export.py create mode 100644 routes/users.py create mode 100644 routes/verification.py create mode 100644 single_checkin_calculator.py create mode 100644 static/css/admin_logs.css create mode 100644 static/css/attendance.css create mode 100644 static/css/attendance_fullscreen.css create mode 100644 static/css/auth.css create mode 100644 static/css/dashboard.css create mode 100644 static/css/employees.css create mode 100644 static/css/export_configuration.css create mode 100644 static/css/projects.css create mode 100644 static/css/qr_destination.css create mode 100644 static/css/statistics.css create mode 100644 static/css/style.css create mode 100644 static/css/theme-gov.css create mode 100644 static/css/time_attendance.css create mode 100644 static/css/users.css create mode 100644 static/favicon.ico create mode 100644 static/images/favicon.ico create mode 100644 static/js/android_location_handler.js create mode 100644 static/js/attendance_fullscreen.js create mode 100644 static/js/attendance_report.js create mode 100644 static/js/dashboard.js create mode 100644 static/js/export_configuration.js create mode 100644 static/js/qr_destination.js create mode 100644 static/js/script.js create mode 100644 static/js/users.js create mode 100644 templates/add_manual_attendance.html create mode 100644 templates/admin_logs.html create mode 100644 templates/attendance_report.html create mode 100644 templates/base.html create mode 100644 templates/base_authenticated.html create mode 100644 templates/bulk_qr_import.html create mode 100644 templates/confirm_delete_qr.html create mode 100644 templates/create_employee.html create mode 100644 templates/create_project.html create mode 100644 templates/create_qr_code.html create mode 100644 templates/create_user.html create mode 100644 templates/dashboard.html create mode 100644 templates/edit_attendance.html create mode 100644 templates/edit_employee.html create mode 100644 templates/edit_project.html create mode 100644 templates/edit_qr_code.html create mode 100644 templates/edit_user.html create mode 100644 templates/employee_detail.html create mode 100644 templates/employees.html create mode 100644 templates/errors/403.html create mode 100644 templates/errors/404.html create mode 100644 templates/errors/500.html create mode 100644 templates/export_configuration.html create mode 100644 templates/legacy_attendance_dashboard.html create mode 100644 templates/legacy_attendance_records.html create mode 100644 templates/login.html create mode 100644 templates/profile.html create mode 100644 templates/project_qr_codes.html create mode 100644 templates/projects.html create mode 100644 templates/qr_destination.html create mode 100644 templates/qr_not_found.html create mode 100644 templates/register.html create mode 100644 templates/statistics.html create mode 100644 templates/time_attendance_batch_detail.html create mode 100644 templates/time_attendance_dashboard.html create mode 100644 templates/time_attendance_duplicate_review.html create mode 100644 templates/time_attendance_import.html create mode 100644 templates/time_attendance_import_progress.html create mode 100644 templates/time_attendance_import_result.html create mode 100644 templates/time_attendance_invalid_review.html create mode 100644 templates/time_attendance_record_detail.html create mode 100644 templates/time_attendance_records.html create mode 100644 templates/users.html create mode 100644 templates/verification_review.html create mode 100644 templates/verification_review_detail.html create mode 100644 time_attendance_import_service.py create mode 100644 tools/migration_PM_permissions.py create mode 100644 tools/migration_dynamic_qr_locations.py create mode 100644 tools/migration_legacy_attendance_remote_indexes.py create mode 100644 tools/migration_photo_verification_toggle.py create mode 100644 tools/optimize_time_attendance_db.py create mode 100644 turnstile_utils.py create mode 100644 utils/__init__.py create mode 100644 utils/geocoding.py create mode 100644 utils/helpers.py create mode 100644 utils/template_helpers.py create mode 100644 working_hours_calculator.py diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..924ac09 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python -c \"import ast;ast.parse\\(open\\('routes/attendance.py',encoding='utf-8'\\).read\\(\\)\\);print\\('AST OK'\\)\")", + "Bash(python -c \"import ast;[ast.parse\\(open\\(f,encoding='utf-8'\\).read\\(\\)\\) for f in ['routes/attendance.py','routes/attendance_export.py']];print\\('AST OK'\\)\")", + "Bash(python -c ' *)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef83942 --- /dev/null +++ b/.gitignore @@ -0,0 +1,209 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +.stignore + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ \ No newline at end of file diff --git a/Claude.md b/Claude.md new file mode 100644 index 0000000..f27c3a4 --- /dev/null +++ b/Claude.md @@ -0,0 +1,1260 @@ +# CLAUDE.md — QR Code Attendance Management System +# Unified reference — LT Services, Inc. & GOV Services, Inc. + +This file is the authoritative reference for Claude across all sessions. +Every session must treat this file as ground truth for architecture, conventions, +domain rules, and developer preferences. Read it fully before making any changes. + +--- + +## 1. Project Overview + +A Flask/Python web application for **employee attendance tracking via QR codes** with +GPS validation, Excel import/export, payroll calculations, role-based access control, +and per-QR photo verification toggle. + +**Deployed for two companies on separate servers:** +- **LT Services, Inc.** — `THEME_NAME` empty (blue theme). Production: `test.ltservicesinc.com`. Gitea: `gitea.ngodanguyen.tech/nngo/LT_QR_Codes_Management`. +- **GOV Services, Inc.** — `THEME_NAME=gov` (green theme). Production: `qr.govservicesinc.com`. Key `.env` differences: `COMPANY_NAME=GOV. Services, Inc`, `THEME_NAME=gov`, `QR_BASE_URL=https://qr.govservicesinc.com`. + +**Both codebases are structurally identical** in all Python files, routes, models, and templates. +They differ only in `.env` values and `static/css/theme-gov.css`. +**Every code fix must be applied to both instances.** + +- **Stack:** Flask 3.1, SQLAlchemy 2.0 / MySQL (PyMySQL only — `mysql-connector-python` removed), openpyxl 3.1, pandas 2.2, Python 3.12 +- **Frontend:** Jinja2 templates + vanilla JS + Font Awesome 6 icons +- **Auth guard:** Cloudflare Turnstile (optional, toggled via `.env`) +- **Production server:** Gunicorn + gevent workers, Nginx reverse proxy, Ubuntu Server + +--- + +## 2. Folder Structure + +``` +QR_Code_Management/ +│ +├── app.py # Application factory (create_app) + startup entry point +├── config.py # All env-var reads — single source of truth +├── extensions.py # Shared singletons: db (SQLAlchemy) + logger_handler (AppLogger) +├── logger_handler.py # AppLogger class + log_user_activity / log_database_operations decorators +├── location_logging.py # Android GPS debug routes (/api/log-location-action, /api/location-debug-info) +├── turnstile_utils.py # Cloudflare Turnstile verification helper (TurnstileUtils class) +├── advanced_security_middleware.py # SecurityManager (CSRF token gen, rate limiting, suspicious IP tracking) +│ # Wired into create_app() — do not import separately in blueprints +├── working_hours_calculator.py # CANONICAL calculator for Excel exports (WorkingHoursCalculator) +├── single_checkin_calculator.py # Legacy calculator — NOT used for exports; never switch to this +├── time_attendance_import_service.py # Excel import pipeline with duplicate detection +├── qr_code_import_service.py # Bulk QR code import from Excel +├── address_normalization_fix.py # normalize_address() + addresses_are_similar() helpers +├── app_performance_middleware.py # PerformanceMonitor — dev-mode only; lazy-imported inside __main__ +├── db_audit_tables.py # DB audit table helpers (standalone operational script) +├── db_health_check.py # DB connectivity health check (standalone) +├── db_maintenance.py # DB maintenance utilities (standalone) +├── db_performance_optimization.py # Index/query optimization helpers +├── employee_data_merger.py # Merge duplicate employee records (standalone) +├── employee_duplicate_remove.py # Remove employee duplicates (standalone) +├── employee_sync_scheduler.py # Scheduled employee sync (schedule library) +├── employee_table_sync.py # Employee table sync logic +├── requirements.txt # All pinned Python dependencies (single MySQL driver: PyMySQL) +│ +├── models/ +│ ├── __init__.py # set_db(db) → unpacks and returns all model classes +│ ├── base.py # Shared db reference (base.db); all models import from here +│ ├── user.py # User model (table: users) +│ ├── employee.py # Employee model (table: employee) +│ ├── attendance.py # AttendanceData model (table: attendance_data) +│ ├── time_attendance.py # TimeAttendance model (table: time_attendance) +│ ├── qrcode.py # QRCode, QRCodeStyle, QRCodeLocation models +│ ├── project.py # Project model (table: projects) +│ └── permissions.py # UserProjectPermission, UserLocationPermission models +│ +├── routes/ +│ ├── __init__.py # Empty (intentional) +│ ├── auth.py # Blueprint 'auth': /, /register (admin-only), /login, /logout, /profile +│ ├── dashboard.py # Blueprint 'dashboard': /dashboard, project QR views, stats APIs +│ ├── users.py # Blueprint 'users': /users/*, user management APIs +│ ├── admin.py # Blueprint 'admin': /admin/logs, /api/logs/* +│ ├── projects.py # Blueprint 'projects': /projects/* +│ ├── qr_codes.py # Blueprint 'qr_codes': /qr-codes/*, /qr/ check-in flow +│ ├── attendance.py # Blueprint 'attendance': /attendance report + API endpoints +│ ├── attendance_edit.py # Side-effect module: edit, add manual, save, delete routes +│ │ # Imports bp FROM attendance.py — does NOT define its own blueprint +│ ├── verification.py # Side-effect module: /verification-review/* routes +│ │ # Imports bp FROM attendance.py — does NOT define its own blueprint +│ ├── attendance_export.py # Side-effect module: /export-configuration, /generate-excel-export +│ │ # Imports bp FROM attendance.py — does NOT define its own blueprint +│ ├── statistics.py # Blueprint 'statistics': /statistics +│ ├── employees.py # Blueprint 'employees': /employees/* +│ ├── time_attendance.py # Blueprint 'time_attendance': /time-attendance/* (all TA routes) +│ └── time_attendance_export.py # Plain module (NO Blueprint): export helper functions only +│ +├── utils/ +│ ├── __init__.py +│ ├── helpers.py # Role decorators, QR generation, role/permission helpers +│ ├── geocoding.py # Haversine distance, Google Maps client, reverse geocode +│ └── template_helpers.py # Context processors: get_employee_name, format_hours, etc. +│ +├── static/ +│ ├── css/ +│ │ ├── style.css # MASTER stylesheet — all CSS variables and layout +│ │ ├── theme-gov.css # GOV Services brand theme overrides (loaded when THEME_NAME=gov) +│ │ └── *.css # Page-specific stylesheets +│ └── js/ +│ ├── script.js # Global JS — includes CSRF fetch wrapper +│ ├── qr_destination.js # Check-in page: GPS, bilingual UI, camera — CSRF-EXEMPT +│ ├── attendance_report.js # Attendance report: pagination, sorting, charts +│ ├── dashboard.js +│ ├── export_configuration.js +│ ├── users.js +│ ├── attendance_fullscreen.js +│ └── android_location_handler.js # Android-specific GPS workaround +│ +├── templates/ +│ ├── base.html # Base for unauthenticated pages — loads theme CSS conditionally +│ ├── base_authenticated.html # Base for protected pages — loads theme CSS conditionally +│ │ # Brand text uses {{ COMPANY_NAME }} — no hardcoded strings +│ ├── [all other page templates] # All POST forms include {{ csrf_token() }} hidden field +│ └── errors/ +│ ├── 403.html / 404.html / 500.html +│ +└── tools/ + ├── migration_photo_verification_toggle.py # Adds photo_verification_enabled to qr_codes + ├── migration_PM_permissions.py # One-time: user_project/location_permissions tables + ├── migration_dynamic_qr_locations.py # One-time: qr_type column + qr_code_locations table + └── optimize_time_attendance_db.py # Standalone DB index optimization script +``` + +--- + +## 3. Database Tables + +| Table | Model | Purpose | +|---|---|---| +| `users` | `User` | System user accounts with RBAC | +| `employee` | `Employee` | Employee master data (firstName, lastName, title, contractId) | +| `attendance_data` | `AttendanceData` | QR check-in records with GPS, photo verification | +| `time_attendance` | `TimeAttendance` | Imported time-clock records from Excel | +| `qr_codes` | `QRCode` | QR code definitions (standard and dynamic types) | +| `qr_code_styles` | `QRCodeStyle` | Reusable QR code visual styles | +| `qr_code_locations` | `QRCodeLocation` | Selectable locations for dynamic QR codes | +| `projects` | `Project` | Projects that group QR codes and employees | +| `user_project_permissions` | `UserProjectPermission` | Project-level access for Project Managers | +| `user_location_permissions` | `UserLocationPermission` | Location-level access for Project Managers | +| `log_events` | (raw SQL) | Application event log (created by AppLogger) | + +### `qr_codes` — key columns + +| Column | Type | Notes | +|---|---|---| +| `qr_type` | VARCHAR(20) | `'standard'` or `'dynamic'` | +| `photo_verification_enabled` | TINYINT(1) DEFAULT 1 | Per-QR photo verification toggle (added May 2026) | +| `active_status` | BOOLEAN | | +| `project_id` | FK → projects | | + +### `attendance_data` — key columns + +| Column | Type | Notes | +|---|---|---| +| `employee_id` | VARCHAR(50) | Always stored uppercased; carries the work-type code (`1234SP`) — see §11 | +| `location_name` | VARCHAR(100) | Resolved location — never stores `'Dynamic'` | +| `location_accuracy` | FLOAT | Haversine distance in miles | +| `is_dynamic_qr` | BOOLEAN | True when checked in via dynamic QR | +| `verification_photo` | TEXT | Base64 encoded image | +| `verification_required` | BOOLEAN | | +| `verification_status` | VARCHAR(20) | `pending` / `approved` / `rejected` | + +**Key relationships:** +- `Employee.contractId` → `Project.id` +- `QRCode.project_id` → `Project.id` +- `AttendanceData.qr_code_id` → `QRCode.id` (CASCADE DELETE) +- `TimeAttendance.project_id` → `Project.id` + +--- + +## 4. User Roles & Access Control + +```python +VALID_ROLES = ['admin', 'staff', 'payroll', 'project_manager', 'accounting'] +STAFF_LEVEL_ROLES = ['staff', 'payroll', 'project_manager', 'accounting'] +``` + +| Role | Key Access | +|---|---| +| `admin` | Full access; only role that sees System Logs, Users, all export tools | +| `staff` | Create/edit QR codes; view dashboard and reports; no delete, no admin | +| `payroll` | Same as staff + Time Attendance section | +| `accounting` | Same as payroll (identical menu items) | +| `project_manager` | Reports only; scoped to assigned projects and locations via permission tables | + +**Auth decorators** (in `utils/helpers.py`): +- `@login_required` — redirects to `/login` if no session +- `@admin_required` — 403 if role is not `admin` +- `@staff_or_admin_required` — 403 if not admin or staff-level + +**`/register` is restricted to `@admin_required`** — public self-registration is disabled. + +--- + +## 5. Application Factory & Initialization Order + +``` +1. load_dotenv() +2. from extensions import db, init_logger +3. create_app(): + a. app.config.from_object(get_config()) + b. SECRET_KEY guard — sys.exit(1) if default value in non-debug mode + c. db.init_app(app) + d. set_db(db) → unpacks all model classes + e. init_logger(app, db) → binds logger_handler in extensions.py + f. register_blueprints() — in canonical order (see §6) + + side-effect import of attendance_edit, verification, attendance_export + g. create_location_logging_routes() ← from location_logging.py + h. SecurityManager.init_app() ← wires CSRF before_request + rate limiting + i. inject_csrf_token() context processor + j. inject_company_name() context processor (COMPANY_NAME, THEME_NAME, CURRENT_YEAR) + k. inject_logging_status(), inject_turnstile() context processors + l. template_filters (strftime, days_since, time_ago) + m. before_request: adjust_session_lifetime + g.start_time + suspicious UA scan + n. after_request: slow-query detection + error response logging + o. Error handlers: 403, 404, 500 + p. Startup init: create_tables() + update_existing_qr_codes() ← runs under Gunicorn too +4. if __name__ == '__main__': + a. lazy import PerformanceMonitor (dev-mode only) + b. initialize_performance_optimizations() + c. app.run() +``` + +**Critical notes:** +- `create_tables()` and `update_existing_qr_codes()` run inside `with app.app_context()` inside `create_app()` — they execute under gunicorn, not only under `__main__`. +- `PerformanceMonitor` is imported lazily inside `__main__` only — gunicorn workers never load it. +- `update_existing_qr_codes()` prefers `QR_BASE_URL` from `.env`. Falls back to `FLASK_HOST`/`FLASK_PORT`. Never uses `request.url_root`. + +--- + +## 6. Blueprint Registration Order + +```python +auth_bp, dashboard_bp, users_bp, admin_bp, projects_bp, +qr_codes_bp, attendance_bp, statistics_bp, +employees_bp, time_attendance_bp +``` + +**Attendance blueprint split:** +`attendance.py` defines `bp = Blueprint('attendance', __name__)` once. +`attendance_edit.py`, `verification.py`, and `attendance_export.py` each do: +```python +from routes.attendance import bp # shared blueprint — do not redefine +``` +`app.py` registers only `attendance_bp` once. Sub-modules are loaded as side-effect imports: +```python +import routes.attendance_edit # noqa: F401 +import routes.verification # noqa: F401 +import routes.attendance_export # noqa: F401 +``` + +`time_attendance_export.py` is **not a Blueprint**. Plain module providing export helper functions. Never register it separately. + +--- + +## 7. All Routes + +### auth (Blueprint: `auth`) +| URL | Endpoint | Notes | +|---|---|---| +| `/` | `auth.index` | | +| `/register` | `auth.register` | `@admin_required` — not public | +| `/login` | `auth.login` | Rate-limited via SecurityManager | +| `/logout` | `auth.logout` | | +| `/profile` | `auth.profile` | | + +### dashboard (Blueprint: `dashboard`) +| URL | Endpoint | +|---|---| +| `/dashboard` | `dashboard.dashboard` | +| `/project//qr-codes` | `dashboard.project_qr_codes` | +| `/dashboard/search` | `dashboard.search_qr_codes` | +| `/api/dashboard/stats` | `dashboard.dashboard_stats_api` | +| `/api/dashboard/realtime` | `dashboard.dashboard_realtime_api` | + +### users (Blueprint: `users`) +| URL | Endpoint | +|---|---| +| `/users` | `users.users` | +| `/users/create` | `users.create_user` | +| `/users//edit` | `users.edit_user` | +| `/users//delete` | `users.delete_user` | +| `/users//reactivate` | `users.reactivate_user` | +| `/users//promote` | `users.promote_user` | +| `/users//demote` | `users.demote_user` | +| `/users//toggle-status` | `users.toggle_user_status` | +| `/users//activate` | `users.activate_user` | +| `/users//deactivate` | `users.deactivate_user` | +| `/users//permanently-delete` | `users.permanently_delete_user` | +| `/api/users/stats` | `users.user_stats_api` | +| `/api/locations-by-projects` | `users.get_locations_by_projects` | +| `/api/roles/permissions` | `users.role_permissions_api` | +| `/api/geocode` | `users.geocode_address_api` | +| `/api/reverse-geocode` | `users.reverse_geocode_api` | + +### admin (Blueprint: `admin`) +| URL | Endpoint | +|---|---| +| `/admin/logs` | `admin.admin_logs` | +| `/admin/health/google-maps` | `admin.google_maps_health` | +| `/api/logs/recent` | `admin.api_recent_logs` | +| `/api/logs/stats` | `admin.api_log_stats` | +| `/api/logs/cleanup` | `admin.api_cleanup_logs` | +| `/api/logs/clear` | `admin.api_clear_logs` | +| `/api/logs/clear-old` | `admin.api_clear_old_logs` | +| `/api/logs/export` | `admin.api_export_logs` | + +### projects (Blueprint: `projects`) +| URL | Endpoint | +|---|---| +| `/projects` | `projects.projects` | +| `/projects/create` | `projects.create_project` | +| `/projects//edit` | `projects.edit_project` | +| `/projects//toggle` | `projects.toggle_project` | +| `/api/projects/active` | `projects.api_active_projects` | + +### qr_codes (Blueprint: `qr_codes`) +| URL | Endpoint | Notes | +|---|---|---| +| `/qr-codes/create` | `qr_codes.create_qr_code` | | +| `/qr-codes/bulk-import` | `qr_codes.import_bulk_qr_codes` | | +| `/qr-codes/bulk-import/template` | `qr_codes.download_qr_import_template` | | +| `/qr-codes//edit` | `qr_codes.edit_qr_code` | | +| `/qr-codes//delete` | `qr_codes.delete_qr_code` | | +| `/qr-codes//toggle-status` | `qr_codes.toggle_qr_status` | | +| `/qr-codes//activate` | `qr_codes.activate_qr_code` | | +| `/qr-codes//deactivate` | `qr_codes.deactivate_qr_code` | | +| `/qr-codes//copy-url` | `qr_codes.copy_qr_url` | | +| `/qr-codes//open-link` | `qr_codes.open_qr_link` | | +| `/qr/` | `qr_codes.qr_destination` | | +| `/qr//checkin` | `qr_codes.qr_checkin` | **CSRF-exempt** — public unauthenticated | +| `/qr//locations` | `qr_codes.qr_get_locations` | | +| `/qr//last-work-type` | `qr_codes.qr_last_work_type` | GET, public — work type of the employee's open check-in | + +### attendance (Blueprint: `attendance` — split across 4 files) +| URL | Endpoint | File | +|---|---|---| +| `/attendance` | `attendance.attendance_report` | `attendance.py` | +| `/api/attendance/locations` | `attendance.attendance_locations_api` | `attendance.py` | +| `/api/attendance/stats` | `attendance.attendance_stats_api` | `attendance.py` | +| `/api/search_employees` | `attendance.search_employees_api` | `attendance.py` | +| `/api/get_project_locations` | `attendance.get_project_locations_api` | `attendance.py` | +| `/api/time-attendance/locations` | `attendance.time_attendance_locations_api` | `attendance.py` | +| `/attendance//edit` | `attendance.edit_attendance` | `attendance_edit.py` | +| `/attendance//delete` | `attendance.delete_attendance` | `attendance_edit.py` | +| `/attendance/add` | `attendance.add_manual_attendance` | `attendance_edit.py` | +| `/attendance/save_manual` | `attendance.save_manual_attendance` | `attendance_edit.py` | +| `/verification-review` | `attendance.verification_review` | `verification.py` | +| `/verification-review/` | `attendance.verification_review_detail` | `verification.py` | +| `/verification-review//update` | `attendance.update_verification_status` | `verification.py` | +| `/api/attendance//verification-details` | `attendance.get_verification_details` | `verification.py` | +| `/export-configuration` | `attendance.export_configuration` | `attendance_export.py` | +| `/generate-excel-export` | `attendance.generate_excel_export` | `attendance_export.py` | + +### statistics (Blueprint: `statistics`) +| URL | Endpoint | +|---|---| +| `/statistics` | `statistics.qr_statistics` | +| `/api/statistics/export` | `statistics.export_statistics` | + +### employees (Blueprint: `employees`) +| URL | Endpoint | +|---|---| +| `/employees` | `employees.employees` | +| `/employees/create` | `employees.create_employee` | +| `/employees//edit` | `employees.edit_employee` | +| `/employees//delete` | `employees.delete_employee` | +| `/employees/` | `employees.employee_detail` | +| `/api/employees/search` | `employees.api_employees_search` | + +### time_attendance (Blueprint: `time_attendance`) +| URL | Endpoint | +|---|---| +| `/time-attendance` | `time_attendance.time_attendance_dashboard` | +| `/time-attendance/import` | `time_attendance.import_time_attendance` | +| `/time-attendance/import/analyze-duplicates` | `time_attendance.analyze_import_duplicates` | +| `/time-attendance/import/analyze-invalid` | `time_attendance.analyze_import_invalid` | +| `/time-attendance/import/start` | `time_attendance.start_import_job` | +| `/time-attendance/import/stream/` | `time_attendance.stream_import_progress` | +| `/time-attendance/import/cancel-pending` | `time_attendance.cancel_pending_import` | +| `/time-attendance/import/validate` | `time_attendance.validate_import_file` | +| `/time-attendance/import/batch/` | `time_attendance.view_import_batch` | +| `/time-attendance/import/batch//delete` | `time_attendance.delete_import_batch` | +| `/time-attendance/import/download-template` | `time_attendance.download_import_template` | +| `/time-attendance/export` | `time_attendance.export_time_attendance` | +| `/time-attendance/export/excel` | `time_attendance.excel_export_time_attendance` | +| `/time-attendance/export-by-building` | `time_attendance.export_time_attendance_by_building` | +| `/time-attendance/records` | `time_attendance.time_attendance_records` | +| `/time-attendance/record/` | `time_attendance.time_attendance_record_detail` | +| `/time-attendance/delete/` | `time_attendance.delete_time_attendance_record` | +| `/api/time-attendance/employee/` | `time_attendance.api_time_attendance_by_employee` | +| `/api/time-attendance/location/` | `time_attendance.api_time_attendance_by_location` | + +### Location logging (registered directly on `app`, not a Blueprint) +| URL | Purpose | +|---|---| +| `/api/log-location-action` | Android GPS debug logging | +| `/api/location-debug-info` | Return GPS debug info | + +### Security API (registered by SecurityManager on `app`) +| URL | Purpose | +|---|---| +| `/api/security/status` | Admin-only security dashboard stats | +| `/api/security/clear-blocks` | Admin-only: clear rate-limit blocks | + +--- + +## 8. Template System + +### Two Base Templates +- **`base.html`** — unauthenticated pages (login, register, QR scan, errors). Body class: `login-layout`. +- **`base_authenticated.html`** — all protected pages. Body class: `has-sidebar`. Fixed collapsible sidebar, top header with user/role badge, flash message rendering. + +### Template Block Names +| Block | Purpose | +|---|---| +| `{% block title %}` | Page `` text only — **no CSS or JS here** | +| `{% block page_title %}` | Top header `<h1>` (authenticated only) | +| `{% block extra_head %}` | Page-specific CSS — inside `<head>` | +| `{% block content %}` | Main page body | +| `{% block extra_scripts %}` | Page-specific JS — before `</body>` | + +**Rule:** CSS always goes in `extra_head`. JS always goes in `extra_scripts`. **Never inject either into `{% block title %}`.** + +### Theme System + +Both `base.html` and `base_authenticated.html` load the theme override after `style.css`: +```html +<!-- Theme Override (set THEME_NAME in .env to activate, e.g. THEME_NAME=gov) --> +{% if THEME_NAME %} +<link rel="stylesheet" href="{{ url_for('static', filename='css/theme-' + THEME_NAME + '.css') }}" /> +{% endif %} +``` +Loaded **before** `{% block extra_head %}` so page-specific CSS loads after and can override. + +**`theme-gov.css` overrides:** `--primary-color: #16a34a` / `--primary-hover: #15803d` (institutional green). No CSS text hacks — brand names handled via `{{ COMPANY_NAME }}`. + +**Adding a third company theme:** Create `static/css/theme-{name}.css` and set `THEME_NAME={name}` in `.env`. No code changes required. + +### Global Context Variables +| Variable | Source | +|---|---| +| `COMPANY_NAME` | `.env` (`COMPANY_NAME`) | +| `THEME_NAME` | `.env` (`THEME_NAME`) — empty string when not set | +| `CURRENT_YEAR` | `datetime.now().year` | +| `csrf_token` | `generate_csrf_token()` from `advanced_security_middleware` | +| `is_admin` | `bool` from session role | +| `turnstile_enabled`, `turnstile_site_key` | Turnstile config | + +### CSRF in Templates +Every POST form: +```html +<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> +``` +Every AJAX POST: +```js +'X-CSRF-Token': (window.qrConfig && window.qrConfig.csrfToken) || '' +``` +Exception: `/qr/<url>/checkin` is CSRF-exempt (public, unauthenticated). + +--- + +## 9. Design System + +### CSS Custom Properties (defined in `style.css`) +```css +--primary-color: #2563eb /* Blue — LT default */ +--primary-hover: #1d4ed8 +--secondary-color: #64748b +--success-color: #10b981 +--warning-color: #f59e0b +--danger-color: #ef4444 +--info-color: #0891b2 +--gray-50: #f8fafc /* Page background */ +--gray-700: #334155 /* Body text */ +--gray-900: #0f172a /* Headings */ +``` + +**Light mode only** — `color-scheme: light only !important` enforced globally. + +### Layout +- Sidebar: `280px` expanded, `64px` collapsed. Gradient `#2563eb → #1d4ed8` (LT). GOV theme: `#16a34a → #15803d`. +- Header height: `64px`. +- Z-index: dropdown 1000, modal 1050, sidebar 1100, overlay 1200. + +### Excel Export Styling +- **Header rows:** white bold text on solid black fill (`000000`) +- **Miss-punch / amber:** `FFC000` fill +- **Font:** Aptos Narrow 11pt everywhere; 14pt report title, 12pt section summary + +--- + +## 10. Security Architecture + +### CSRF Protection +- `SecurityManager` from `advanced_security_middleware.py` wired via `init_app()` in `create_app()` +- `before_request` validates `csrf_token` form field or `X-CSRF-Token` header on every `POST/PUT/PATCH/DELETE` +- Token stored in `session['csrf_token']`; compared with `hmac.compare_digest()` +- **CSRF-exempt:** `auth.login`, `auth.register`, `qr_codes.qr_checkin`, `static` + +### Rate Limiting +- 5 failed login attempts within 15 minutes → IP blocked 15 minutes (in-memory per worker) +- `SecurityManager.create_secure_session()` called on login success (clears failed-attempt counter) + +### Session Security +- `session.clear()` before setting new keys on login (prevents session fixation) +- `before_request` hook `adjust_session_lifetime()`: `remember_me` → 30 days, default → 10 hours +- `SESSION_COOKIE_SECURE=True` requires HTTPS — HTTP-only deployments must set `false` or login loops + +### validate_session_security() — DO NOT USE in before_request +In-memory dict per worker — breaks under multi-worker gunicorn (login worker A, next request hits worker B with empty dict → 401). Intentionally not called. Flask signed cookie + CSRF handles integrity. + +### SQL Injection Prevention +All dynamic SQL uses SQLAlchemy parameterized queries: +```python +conditions, params = [], {} +conditions.append("ad.check_in_date >= :date_from") +params["date_from"] = date_from +db.session.execute(text("... WHERE 1=1 " + filter_clause), params) +``` + +--- + +## 11. QR Code System + +### QR Types +- **`standard`** (default) — fixed single location +- **`dynamic`** — employee selects location at scan time from list auto-generated from all active standard QR codes. No manual management UI — always queried live via `SELECT DISTINCT location, location_address FROM qr_codes WHERE qr_type='standard'`. + +### Photo Verification — Two-Layer Toggle +Both must be `True` for photo verification to trigger: +1. **Global:** `PHOTO_VERIFICATION_ENABLED` in `.env` +2. **Per-QR:** `qr_codes.photo_verification_enabled` (TINYINT(1) DEFAULT 1) + +```python +qr_photo_verification = getattr(qr_code, 'photo_verification_enabled', True) +if qr_photo_verification and current_app.config.get('PHOTO_VERIFICATION_ENABLED', True) \ + and location_accuracy > threshold: + # require photo +``` + +Toggle UI in `create_qr_code.html` and `edit_qr_code.html` — uses `addEventListener('change', ...)` in `extra_scripts` block. + +### Type of Work — Check-In Dropdown (Sept 2026) + +The check-in page carries a **Type of Work / Tipo de Trabajo** `<select>` directly after +the Employee ID field. The employee enters a **numeric-only** ID and picks the type; +`Regular` is selected by default. + +| Option value | Label shown (bilingual, one plain string) | +|---|---| +| `""` | Regular / Trabajo Regular | +| `PW` | Periodic Work / Trabajo Periódico (PW) | +| `SP` | Special Project / Proyecto Especial (SP) | +| `C` | Covering / Cobertura (C) | + +`<option>` cannot hold the coloured `english-text` / `spanish-text` spans, so both +languages share one label separated by `/`. + +**Storage — no new column.** `qr_checkin` appends the code to the numeric ID, keeping +the format every calculator and export already parses: + +```python +work_type = request.form.get('work_type', '').strip().upper() # '' = Regular +if work_type and work_type not in VALID_CHECKIN_WORK_TYPES: # ('SP','PW','PT','C') + return jsonify({...}), 400 # reject unknown codes +if employee_id and work_type: + base_employee_id, existing = parse_employee_id_for_work_type(employee_id) + employee_id = f"{base_employee_id}{work_type}" # never "1234SPSP" +``` + +`PT` is accepted server-side for backward compatibility but is **not** offered in the +dropdown. `WORK_TYPE_LABELS` + `_work_type_label()` in `routes/qr_codes.py` are the +source of the bilingual labels echoed back to the page. + +**Employee ID is numeric only** — `inputmode="numeric"`, `pattern="[0-9]*"`, an `input` +listener stripping non-digits (paste / autofill), a re-strip at submit, and a +digits-only guard before the POST. A `localStorage` value stored before this rule +(e.g. `1234SP`) is cleaned to `1234` on auto-fill. There is **no** `maxlength` cap — +capping at 4 would break any 5-digit employee ID. + +### Type of Work — Anti-Mistake Measures (Sept 2026) + +**1. ~~Work type echoed on the submit button~~ — REMOVED (Sept 2026).** +The button briefly carried a second line naming the selected type; it broke the button +layout and was reverted at the user's request. The label is plain `Check In / Entrada` or +`Check Out / Salida` again. **Do not re-add it.** `renderSubmitButton()` survives as the +single place that builds the button markup, so `resetEnhancedSubmitButton()` does not +duplicate it — it still returns early while the button is `disabled`, so it cannot +clobber the "Processing" spinner. Confirmation of the type is measures 2 and 3 only. + +**2. The success card shows what was stored.** +A **Type of Work / Tipo de Trabajo** row sits under Employee ID. Its value comes from the +check-in response (`data.work_type`, `data.work_type_label`), not the form, so a wrong +pick is visible immediately. Both renderers fill it: the inline one in +`qr_destination.html` and `handleCheckinSuccess()` in `qr_destination.js` +(`updateElement("successWorkType", ...)`). + +**3. Check-out inherits the open check-in's type.** +`GET /qr/<url>/last-work-type?employee_id=&selected_location_name=` returns the work type +of the employee's most recent **unpaired** check-in, and the page pre-selects it with an +amber bilingual reminder ("You checked in as Special Project at 07:12 AM (Bldg A). +Confirm or change the type of work."). + +- Answers **only on Check Out scans**. `_resolve_qr_event()` resolves the scan's effective + event exactly as `qr_checkin` does — a dynamic QR inherits the event of the standard QR + behind the selected location, and returns `None` while no location is chosen. +- `_resolve_record_event()` resolves a stored record's event the same way + (`attendance_data` does not store the event). +- Latest scan in a **one-day** window (so overnight shifts resolve). If it was a check-in + the pair is open → suggest its type; if it was a check-out the pair is closed → quiet. +- Matches every stored ID spelling via `expand_employee_id_filter()`. +- **Every failure path returns `{'work_type': None}`, HTTP 200** — a broken hint must + never block a check-out. +- `work_type: ""` means an open **Regular** check-in (still worth confirming); + `work_type: null` means "no suggestion, stay quiet". +- Fires 500 ms after ID entry, on load (the ID is usually auto-filled), and again after a + dynamic QR's location is confirmed. A manual dropdown change sets + `workTypeChosenByEmployee` and the suggestion never overwrites it afterwards. + +**The form is replaced 1.5 s after load — the biggest trap here.** +`initializeLocationServicesCheck()` in `qr_destination.js` clones `#checkinForm` and +swaps it in, to take over the submit handler. `cloneNode(true)` copies **attributes, not +live control state**, so the `<select>` reverted to the option carrying the `selected` +attribute (Regular) and typed input values were lost — 1.5 s after load, i.e. after every +re-apply had already run. The amber reminder survived because its text and inline style +are attributes on the cloned nodes, which is exactly what the symptom looked like: +*correct type shown briefly, then back to Regular, reminder still right.* + +Three things keep this working — do not remove any of them: +1. `preserveFormControlState(oldForm, newForm)` copies each control's live + `value` / `checked` into the clone (matched **by position**, since the clone is + structurally identical — no selector escaping). +2. The replace dispatches a `checkinFormReplaced` CustomEvent; the page re-binds + `attachEmployeeIdListeners()` + `attachWorkTypeListeners()` on the new nodes and + re-asserts the suggestion. Every listener bound to the old controls dies with them. +3. All re-attachable listeners live in those two functions — never bind directly to + form controls in an init function, or the binding is lost at 1.5 s. + +This also silently affected a **manual** pick: choosing Covering within 1.5 s of load and +submitting afterwards would have submitted Regular. + +**iOS Safari — do not undo these three guards.** On iPhone the dropdown reverted to +Regular while desktop browsers worked, because Safari restores form-control state *after* +load (i.e. after the fetch resolves) and caches plain GETs aggressively: +1. `suggestedWorkTypeCode` is retained and re-applied by `applyWorkTypeSuggestion()` — + on response, at +250 ms, at +900 ms, and on `pageshow`. A single set at response time + is not enough. +2. A `change` event only counts as the employee's choice when a real interaction + (`pointerdown` / `touchstart` / `focus` / `keydown`) preceded it — tracked by + `workTypeTouchedByEmployee`. Safari's restore fires `change` too, and without this + guard it permanently latched `workTypeChosenByEmployee`, blocking every suggestion. +3. The fetch uses `cache: "no-store"` and the endpoint replies through `_no_store_json()` + (`Cache-Control: no-store, no-cache, max-age=0, must-revalidate` + `Pragma: no-cache`) + on **all seven** return paths — a cached "no suggestion" is indistinguishable from the + bug this endpoint exists to prevent. +- The URL is built from `window.location.pathname` with trailing slashes stripped, so a + scanner that appends `/` cannot produce `//last-work-type`. +- The amber reminder is shown even if the dropdown did not take the value, so the employee + always sees what they checked in as. **Diagnostic:** reminder visible but dropdown on + Regular = the `<select>` is being overwritten; no reminder at all = the fetch failed or + was served stale. +- GET, so the CSRF `before_request` (POST/PUT/PATCH/DELETE only) does not apply. +- Public and unauthenticated like the check-in page: a guessed ID reveals whether it has + an open check-in, with time and location — the same disclosure class as the existing + 30-minute cooldown message. + +### Check-In Flow +1. Employee scans QR → `qr_destination.html` +2. Enters numeric ID and picks Type of Work (Regular by default); GPS captured by browser +3. On a Check Out scan the page pre-selects the open check-in's work type +4. Work-type code appended to the ID server-side (`1234` → `1234SP`) +5. 30-min interval guard (configurable via `TIME_INTERVAL`) +6. Dynamic QR: server rejects if `selected_location_name` is empty; `location_name` in record is always the resolved name, never `'Dynamic'` +7. Haversine distance calculated; photo required if beyond threshold (and both toggles enabled) +8. Server-side photo size check: rejects > `VERIFICATION_PHOTO_MAX_SIZE` with HTTP 413 +9. Record saved to `attendance_data`; success card echoes the stored Type of Work + +**Interval guard caveat:** the cooldown keys on the *composed* ID, so `1234` and `1234SP` +do not block each other — an employee can check in as Regular and again as Covering +within the interval. This is pre-existing behaviour for suffixed IDs. + +### Check-In Page Features +- Bilingual: English **and Spanish shown side by side** — coloured `english-text` / + `spanish-text` spans separated by `language-separator`. There is no toggle button in + this template; the `data-en` / `data-es` toggle code in `qr_destination.js` is inert here +- Type of Work dropdown + submit-button echo + check-out reminder (see above) +- Staff ID persistence: `localStorage` remembers last employee ID +- Android GPS: special handler (`android_location_handler.js`) +- QR URLs must be full `https://` absolute URLs — relative URLs parsed as search queries by phones +- Base URL constructed from `QR_BASE_URL` env var — never from `request.url_root` + +--- + +## 12. Time Attendance Import Pipeline + +### Excel File Requirements +- **Required columns:** `ID`, `Date`, `Time`, `Location Name`, `Action Description` +- **Optional columns:** `Name`, `Platform`, `Event Description`, `Recorded Address`, `Distance` + +### Import Flow +1. Upload → `validate_import_file` +2. Duplicate analysis → `analyze_import_duplicates` → user reviews +3. Invalid record analysis → `analyze_import_invalid` +4. Start job → `start_import_job` (SSE streaming progress via `stream_import_progress`) +5. Result in `time_attendance_import_result.html` + +### Special Handling +- `Recorded Address`: read via openpyxl directly (not pandas) to preserve HYPERLINK formulas +- Duplicate detection: hash of `employee_id + date + time + action_description` +- Import tracked by `import_batch_id` (UUID) +- `self.db` not `db` — in `TimeAttendanceImportService`, always access SQLAlchemy via `self.db` +- Validation `except` blocks must not silently swallow exceptions (fail-open prevention) + +--- + +## 13. Time Attendance Excel Export + +### Calculator — CRITICAL +**Always use `WorkingHoursCalculator`** from `working_hours_calculator.py`. +**Never switch to `SingleCheckInCalculator`** — legacy, causes silent calculation errors. + +### Rounding Rules +- **Daily / Weekly / Grand Total + SP/PW/PT summary columns:** `_qtr()` — quarter-hour rounding +- **Individual Hours/Building entry cells:** raw `round(..., 2)` — no quarter rounding +- `_qtr()` pipeline: decimal hours → minutes → `round_time_to_quarter_hour()` → `convert_minutes_to_base100()` → `round_base100_hours()` + +### Work Type Codes +- **SP** = Special Project, **PW** = Periodic Work, **PT** = Project Team (Part-Time), + **C** = Covering (added Sept 2026) +- Parsed from `employee_id` via `parse_employee_id_for_work_type()` +- Suffix and prefix forms, with or without a separator: `1234SP`, `1234 SP`, `SP1234`, + `1234.PW`. Two-letter codes are matched **before** the single-letter `C` +- Like SP/PW/PT, `C` hours are excluded from the 40-hour overtime rule +- **Codes are declared in four places — keep them in sync:** + | File | Symbol | + |---|---| + | `working_hours_calculator.py` | `work_type_codes` list in `parse_employee_id_for_work_type()` | + | `utils/helpers.py` | `WORK_TYPE_CODES` (drives report/export ID filters) | + | `routes/qr_codes.py` | `VALID_CHECKIN_WORK_TYPES` + `WORK_TYPE_LABELS` | + | `static/js/attendance_report.js` | regex alternations in `parseEmployeeIdWorkType()` | +- `single_checkin_calculator.py` is legacy and deliberately **not** updated — its + aggregation dicts are keyed `regular/SP/PW/PT`, so adding `C` to its parser alone + would raise `KeyError` + +### Overnight Shift Handling +**Rule 1 — Sort key:** early-morning OUTs (`hour <= 3`) use `_overnight_aware_sort_key()` which adds 86400 seconds — pushes them past midnight so they sort after same-day evening INs. + +**Rule 2 — Detection threshold:** `hour >= 12` (noon). Any IN at or after 12:00 PM is an overnight IN candidate if no matching OUT exists same day AND early-morning OUT (`hour <= 3`) exists next calendar day. + +**Orphan guard:** early-morning OUTs with `check_in_date <= current_day` are orphans — skip. Only OUTs with `check_in_date > current_day` (moved by overnight detection) pair with evening INs. + +### Cross-Type SP Pairs +`is_cross_type = True` when SP IN pairs with non-SP OUT (or vice versa). Accumulates separately in `cross_type_sp/pw/pt/c_hours` to prevent double-counting in summary rows. +`effective_work_type` prefers the OUT's code, so forgetting the type on **one** scan of a +pair still attributes the hours to the special type — forgetting on **both** is the real +payroll error, which is what the check-out reminder in §11 targets. + +### Summary Rows (SP / PW / PT / C + Regular) +``` +col8='SP' col9=_qtr(sp_hours) ← only if sp_hours > 0 +col8='PW' col9=_qtr(pw_hours) ← only if pw_hours > 0 +col8='PT' col9=_qtr(pt_hours) ← only if pt_hours > 0 +col8='C' col9=_qtr(c_hours) ← only if c_hours > 0 +col8='Regular' col9=_qtr(regular_only_hours) ← always when any special type exists +col7='GRAND TOTAL:' col9=_qtr(grand_regular) col10=_qtr(grand_ot) +``` +Both exports emit the `C` row: the main export from `grand_totals['c_hours'] + +cross_type_c_hours`, the by-building export from `_building_special_hours(emp_records, 'C')`. +`WorkingHoursCalculator` returns `c_hours` / `c_minutes` at daily, weekly, and grand-total +level alongside the existing sp/pw/pt keys. + +### Export Date Range +`_resolve_date_range()` enforces a **14-day cap by default**. Both export functions accept `unlimited=False`; pass `unlimited=True` to bypass. + +**UI:** "Unlimited" checkbox in `time_attendance_records.html` before export buttons. + +### Export by Building — Extra Sheets (Sept 2026) +`export_time_attendance_by_building_excel()` writes **Sheet0 unchanged**, then adds two sheets +built from row bookkeeping collected while Sheet0 is written (`_bb_blocks`, `_bb_sp_summary_rows`, +each employee's `weeks` dict) — Sheet0 is never re-read or modified: +1. **`Filtered Report`** (`_build_filtered_building_sheet`) — copy of Sheet0 minus every employee + block whose base ID is in `BUILDING_SUMMARY_EXCLUDED_EMPLOYEE_IDS` (project managers; leading + zeros ignored), minus the `SP` summary rows. Punch rows whose location carries `(SP)` and all + GRAND TOTAL rows are **kept**. Buildings left with no employee are dropped and the rest renumbered. +2. **`Weekly Hours by Location`** (`_build_weekly_hours_by_location_sheet`) — one row per + employee per building, hours per report week (anchored to the report start date like the Weekly + Total rows), a Location Totals table (SUMIF formulas) and the project total. **SP hours are + excluded** (pair is SP when its effective type — OUT's code first — is `SP`); PW/PT/C are counted. + Each day is `_qtr()`-rounded before summing, so non-SP employees match Sheet0's Weekly Total exactly. +Both are wrapped in try/except — a failure removes them and still delivers Sheet0. + +### Employee Name Format +`"Lastname, Firstname"` — `f"{emp.lastName}, {emp.firstName}"` + +### Export Date Iteration +Cap `sorted_dates` to `<= end_date` — prevents overnight buffer day from rendering as a display row. + +### Cross-Building Pairing +Same-day IN at Building A + OUT at Building B → pair and label `"IN: Building A → OUT: Building B"`. Suppress spurious Missed Punch rows. + +--- + +## 14. Logging System (`logger_handler.py`) + +Single `AppLogger` instance in `extensions.py`. Import everywhere as: +```python +from extensions import logger_handler +logger_handler.logger.info("...") +logger_handler.logger.error("...", exc_info=True) # always pass exc_info=True in except blocks +``` + +### Log Destinations +- `logs/application.log` — rotating 10MB/5 backups +- `logs/errors.log` — rotating 5MB/10 backups +- `logs/security.log` — rotating 2MB/20 backups +- `log_events` DB table — admin dashboard at `/admin/logs` + +### Log Every Action +All create, edit, and delete operations must include a log entry: +```python +logger_handler.logger.info(f"User {session['username']} created employee {new_employee.id}") +``` + +### traceback Convention +- Use `exc_info=True` on `logger.error()` — never `import traceback` inline +- `traceback.format_exc()` only acceptable when passing `stack_trace=` to `log_flask_error()` + +--- + +## 15. Configuration (`config.py` + `.env`) + +All env-var reads centralized in `config.py`. Blueprints use `current_app.config['KEY']`. + +### Key `.env` Variables +``` +DATABASE_URL # mysql+pymysql://user:pass@host/db — special chars in password OK +SECRET_KEY # MUST differ from default +COMPANY_NAME / CONTRACT_NAME +FLASK_HOST / FLASK_PORT / FLASK_ENV / DEBUG +SESSION_COOKIE_SECURE # MUST be 'false' for HTTP-only deployments +SESSION_COOKIE_HTTPONLY / SESSION_COOKIE_SAMESITE +TIME_INTERVAL # Check-in cooldown in minutes (default 30) +BUILDING_SUMMARY_EXCLUDED_EMPLOYEE_IDS # Export by Building PM IDs to filter (default 4921,4944,4816,3979; empty = none) +GOOGLE_MAPS_API_KEY # Optional; falls back to Haversine-only +TURNSTILE_ENABLED / TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY +ENABLE_PHOTO_VERIFICATION # Global toggle (default 'true') +PHOTO_VERIFICATION_DISTANCE_THRESHOLD # Miles (default 0.3) +VERIFICATION_PHOTO_MAX_SIZE # Bytes (default 5MB) +UPLOAD_FOLDER # Temp path (default /tmp) +DEFAULT_ADMIN_PASSWORD # CHANGE IN PRODUCTION +QR_BASE_URL # Public-facing domain for QR links — required behind reverse proxy +THEME_NAME # Activates static/css/theme-{THEME_NAME}.css +SYNC_INTERVAL_MINUTES +SQLALCHEMY_ENGINE_OPTIONS_POOL_SIZE / POOL_TIMEOUT / POOL_RECYCLE / MAX_OVERFLOW +``` + +--- + +## 16. Distance Calculation + +Uses **Haversine formula** only (straight-line geodesic distance). Google Maps Distance Matrix API removed. + +`utils/geocoding.py` initializes `gmaps_client` for geocoding (address → coordinates) if `GOOGLE_MAPS_API_KEY` is set. + +### Google Maps Guard — ALWAYS USE +```python +from utils.geocoding import is_gmaps_available, gmaps_client +if is_gmaps_available(): + result = gmaps_client.geocode(address) +else: + # fall back to OpenStreetMap / Haversine +``` +Never call `gmaps_client.method()` without a `None` guard. + +### Address Normalization +Multi-strategy matching: core street extraction → component matching → fuzzy fallback. Handles geocoding drift for near-identical addresses. + +--- + +## 17. Migration Scripts + +All in `tools/`. Always use **`pymysql` directly** — never import the Flask app or SQLAlchemy ORM. +ORM loads models at import time; if target column doesn't exist yet, it crashes on startup. + +**Pattern:** +```python +from dotenv import load_dotenv +load_dotenv() +import pymysql, re + +def parse_db_url(url): + """Use regex — urlparse breaks on special chars (@, :) in passwords.""" + url = re.sub(r'^mysql\+pymysql://', '', url) + m = re.match( + r'^(?P<user>[^:]+):(?P<password>.+)@(?P<host>[^@:/]+)(?::(?P<port>\d+))?/(?P<db>[^?]+)', + url + ) + return {'host': m.group('host'), 'port': int(m.group('port') or 3306), + 'user': m.group('user'), 'password': m.group('password'), 'database': m.group('db')} + +def run(): + conn = pymysql.connect(**parse_db_url(os.environ['DATABASE_URL']), charset='utf8mb4', autocommit=False) + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) FROM information_schema.COLUMNS WHERE ...") + if cur.fetchone()[0] > 0: + print("[SKIP] already exists"); return + cur.execute("ALTER TABLE `table` ADD COLUMN `col` TINYINT(1) NOT NULL DEFAULT 1") + conn.commit() +``` + +### Completed Migrations +| Script | What it adds | +|---|---| +| `migration_photo_verification_toggle.py` | `qr_codes.photo_verification_enabled` TINYINT(1) DEFAULT 1 | +| `migration_dynamic_qr_locations.py` | `qr_type` column + `qr_code_locations` table | +| `migration_PM_permissions.py` | `user_project_permissions` + `user_location_permissions` tables | + +--- + +## 18. Coding Conventions & Developer Preferences + +### Change Philosophy +- **Minimal, additive changes only** — preserve all route names, function names, endpoint names, variable names, URL patterns +- New functionality added alongside existing code, not replacing it +- Never rename routes, functions, endpoints, or variables +- **Every code fix applies to both LT and GOV instances** + +### File Delivery +- **≤ 4 files changed** → present each file individually +- **≥ 5 files changed** → deliver as a single zip archive + +### File Editing Approach +- Pull latest code at start of each session +- CRLF normalization first if needed: `content.replace('\r\n', '\n')` +- Use surgical `str_replace` edits — never rewrite large blocks wholesale +- AST parse check after every Python edit: `python3 -c "import ast; ast.parse(open(f).read())"` +- Simulate logic before and after any calculator or pairing logic changes + +### SQLAlchemy Patterns +```python +# Correct (SQLAlchemy 2.0): +record = db.session.get(Model, record_id) +if record is None: + abort(404) + +# Deprecated — do not use: +record = Model.query.get_or_404(record_id) +record = Model.query.get(record_id) + +# Raw SQL requires text(): +db.session.execute(text("SELECT ..."), params) +# Never pass raw strings to conn.execute() — ObjectNotExecutableError in SQLAlchemy 2.0 +``` + +### JavaScript Patterns +- Use `createElement` + `addEventListener` — never inline `onchange`, `onclick`, etc. +- Wrap in `DOMContentLoaded` +- CSS → `{% block extra_head %}`, JS → `{% block extra_scripts %}`, **never into `{% block title %}`** +- All AJAX POST: `'X-CSRF-Token': (window.qrConfig && window.qrConfig.csrfToken) || ''` +- No `localStorage` / `sessionStorage` in artifacts + +### Error Handling Pattern +Every route `except` block must: +1. `db.session.rollback()` +2. `logger_handler.logger.error(f"...: {e}", exc_info=True)` +3. `flash(...)` a user-facing message +4. Return redirect or error response + +No bare `except:` — always `except Exception as e:`. + +### QR Code Name — Locked After Creation +The QR code name **cannot be changed** after creation because it is used to generate the `qr_url` slug. +Changing the name would break all existing printed/distributed QR codes. + +- **Template (`edit_qr_code.html`):** name `<input>` has `readonly` attribute + `cursor: not-allowed` style + lock icon in label +- **Route (`routes/qr_codes.py`):** edit POST ignores `request.form['name']` — sets `new_name = qr_code.name` (original value). Do NOT change this back to reading from the form. + +```python +# CORRECT — name locked after creation: +new_name = qr_code.name # always use existing name, never request.form['name'] + +# WRONG — never do this in the edit route: +# new_name = request.form['name'] +``` + +### Employee Autocomplete (attendance and time-attendance) +- Visible text input + hidden `employee_id` field synced on numeric input +- Fetches `/api/search_employees` (includes unregistered IDs from `attendance_data`, not only Employee table) +- CSS inlined in `extra_head`; JS uses `createElement` + `addEventListener` + +### Timestamp Convention +Use `datetime.now()` (local time) throughout — **not** `datetime.utcnow()`. + +### Context Safety +- Use `has_request_context()` (not `if not request:`) to check Flask request context +- Capture `current_app._get_current_object()` in route body, not inside lazy generators + +--- + +## 19. Attendance Report + +Query fetches **1,001 rows**, trims to 1,000 if extra row present, sets `records_truncated = True`. +Template displays yellow banner when truncated: +``` +Showing the most recent 1,000 records. Narrow the date range or apply additional filters. +``` + +### Employee Filter — Two Layers, Both Must Be Work-Type Aware + +The employee filter is applied **twice**, and both passes must agree or extra-work rows +silently disappear: + +1. **Server** — `routes/attendance.py` expands each selected ID via + `expand_employee_id_filter()` into an `IN (...)` list plus REGEXP patterns. +2. **Client** — `applyFilters()` in `static/js/attendance_report.js` re-filters the + server-rendered rows on every page load (`initializeReport()` calls it). It uses + `parseEmployeeIdWorkType()` and compares **base IDs**, never whole strings. + +Semantics (identical on both sides, mirroring `_work_type_codes_for()`): +- a plain `1234` matches the regular record **and** `1234SP` / `1234PW` / `1234PT` / `1234C` +- an ID that already carries a code (`1234SP`) matches only that code +- leading zeros tolerated (`01234` = `1234`); `12345` must never match a `1234` filter + +**Do not reintroduce** `record.employeeId.toLowerCase() === id` in `applyFilters()` — that +exact-match test is what dropped every SP/PW/PT row the query had already returned. + +--- + +## 20. Known Bugs Fixed — Do Not Reintroduce + +### Set 1 — Critical +| File | Fix | +|---|---| +| `routes/time_attendance_export.py` | Removed stray second docstring | +| `app.py` | Error handlers wired to `render_template('errors/*.html')`; 403 added | +| `templates/errors/*.html` | `url_for('dashboard')` → `url_for('dashboard.dashboard')` | +| `routes/payroll.py` | Removed duplicate `get_employee_name`, `get_qr_code_checkin_count`, `@bp.context_processor` | +| `models/attendance.py` | `check_in_time` default: `datetime.now().time` → `lambda: datetime.now().time()` | +| `models/user.py` | Added `@staticmethod` to `has_export_permissions()` | + +### Set 2 — Moderate / Minor +| File | Fix | +|---|---| +| `app.py` | Merged duplicate before/after request hooks | +| `app.py` | `update_existing_qr_codes()` uses `QR_BASE_URL` first, not `request.url_root` | +| `config.py` | `SQLALCHEMY_ENGINE_OPTIONS` pool wiring | +| `utils/geocoding.py` | `is_gmaps_available()` helper | +| `working_hours_calculator.py` | CRLF → LF | + +### Set 3 — Structural +| File | Fix | +|---|---| +| `routes/time_attendance_export.py` | Removed 14 unused imports | +| `routes/dashboard.py`, `statistics.py`, `payroll.py` | `db.session.rollback()` in all except blocks | +| `models/employee.py` | `cls.id.like()` on BigInteger → `cast(cls.id, String).like()` | + +### Set 4 — Export Pipeline +| File | Fix | +|---|---| +| `routes/time_attendance_export.py` | Overnight IN threshold: `>= 19` → `>= 12` | +| `routes/time_attendance_export.py` | SP/PW/PT summary rows + `Regular` row | +| `routes/time_attendance_export.py` | `unlimited=False` parameter throughout | +| `templates/time_attendance_records.html` | "Unlimited" checkbox | + +### Set 5 — GOV Deployment +| File | Fix | +|---|---| +| `routes/qr_codes.py` | `_get_qr_base_url()` helper; replaced all `request.url_root` | +| `config.py` | `QR_BASE_URL` env var | +| nginx (GOV) | Removed duplicate `proxy_set_header Host` (doubled hostname in QR links) | + +### Set 6 — Theme System +| File | Fix | +|---|---| +| `static/css/theme-gov.css` | New file — GOV brand green overrides | +| `app.py` | `inject_company_name()` returns `THEME_NAME` and `CURRENT_YEAR` | +| `templates/base.html` + `base_authenticated.html` | Conditional theme CSS; `{{ COMPANY_NAME }}` for brand text; `{{ CURRENT_YEAR }}` in footer | + +### Set 7 — Security Hardening +| File | Fix | +|---|---| +| `routes/statistics.py`, `routes/payroll.py` | SQL injection: f-string filters → parameterized queries | +| `routes/auth.py` | Open redirect: `_is_safe_url()` on `next` param | +| `app.py` | `SECRET_KEY` default guard: `sys.exit(1)` | +| `routes/auth.py` | Session fixation: `session.clear()` before new keys | +| `routes/auth.py` | `@login_required` + `@admin_required` on `/register` | +| `app.py` | CSRF `SecurityManager` wired; `before_request` validator | +| All POST forms (22 templates) | `{{ csrf_token() }}` hidden field | +| All AJAX POST calls | `X-CSRF-Token` header | +| `advanced_security_middleware.py` | `request.json` guarded with content-type check (fixes 415) | +| `advanced_security_middleware.py` | Stable key from `SECRET_KEY` via SHA-256 (fixes per-worker warning) | +| `advanced_security_middleware.py` | `validate_session_security()` removed from `security_check()` (fixes 401 under multi-worker) | +| All route files | Bare `except:` → `except Exception` (11 locations) | +| `routes/qr_codes.py` | Server-side photo size enforcement; HTTP 413 on oversized payload | + +### Set 8 — Code Quality +| File | Fix | +|---|---| +| 7 route files | `Model.query.get_or_404()` → `db.session.get()` + `abort(404)` (21 call sites) | +| `config.py` | `PERMANENT_SESSION_LIFETIME` → `timedelta(hours=10)` | +| `routes/attendance.py` | Split into 4 files sharing one blueprint | +| `requirements.txt` | `mysql-connector-python` removed | +| `routes/attendance.py` | LIMIT 1001 + `records_truncated` flag + yellow banner | + +### Set 9 — Template Sync (May 2026) +| File | Fix | +|---|---| +| `templates/base.html` (GOV) | Footer hardcoded `2025 QR Code Management System` → `{{ CURRENT_YEAR }} {{ COMPANY_NAME }}` | +| `templates/base.html` (GOV) | Theme CSS block moved to after `style.css`, before Font Awesome (matches LT order) | +| `templates/base_authenticated.html` (GOV) | Indentation sync with LT | +| `templates/projects.html` (GOV) | Confirm dialog on project toggle + loading state on Edit buttons (sync with LT) | + +### Set 10 — Per-QR Photo Verification Toggle (May 2026) +| File | Change | +|---|---| +| `models/qrcode.py` | Added `photo_verification_enabled` TINYINT(1) DEFAULT 1 | +| `routes/qr_codes.py` | Create: reads toggle from form, passes to constructor + logs | +| `routes/qr_codes.py` | Edit: reads toggle from form, assigns to record + logs | +| `routes/qr_codes.py` | Checkin: checks per-QR AND global flag (both must be True) | +| `templates/create_qr_code.html` | Photo Verification toggle section added | +| `templates/edit_qr_code.html` | Photo Verification toggle section added (CSS in `extra_head`, JS in `extra_scripts` with `addEventListener`) | +| `tools/migration_photo_verification_toggle.py` | pymysql migration — adds column safely | + +### Set 11 — QR Name Lock Restored + CSRF Fix (May 2026) +| File | Change | +|---|---| +| `templates/edit_qr_code.html` | Name field restored to `readonly` — lock icon in label, `cursor: not-allowed`, help text explaining why | +| `routes/qr_codes.py` | Edit route: `new_name = qr_code.name` (never reads from form) — prevents name change server-side | +| `templates/create_qr_code.html` | `window.qrConfig` with `csrfToken` injected in `<head>` — fixes 403 on `/api/geocode` (standalone template, doesn't extend base) | +| `templates/base_authenticated.html` | `window.qrConfig` block moved before `{% block extra_scripts %}` — fixes token availability for all pages extending base | + +--- + +### Set 12 — Type of Work on Check-In + `C` Work Type (Sept 2026) +| File | Change | +|---|---| +| `templates/qr_destination.html` | Type of Work `<select>` (Regular / PW / SP / C) after Employee ID, bilingual labels | +| `templates/qr_destination.html` | Employee ID numeric-only: `inputmode`, `pattern`, input-strip, submit guard, stored-ID cleanup | +| `static/js/qr_destination.js` | Same numeric-only filter + `work_type` appended to the check-in `FormData` | +| `routes/qr_codes.py` | `VALID_CHECKIN_WORK_TYPES`; `work_type` validated and appended to the numeric ID; existing typed suffix stripped first | +| `working_hours_calculator.py` | `C` added to the parser and to every `regular/SP/PW/PT` dict; `c_hours` / `c_minutes` at daily, weekly, grand-total level | +| `utils/helpers.py` | `WORK_TYPE_CODES` includes `C` (report + export ID filters) | +| `routes/time_attendance_export.py` | `C` location suffix, `cross_type_c_hours`, excluded from `regular_only_hours`, `C` summary row in both exports | +| — | **No migration** — the code lives inside `attendance_data.employee_id` | + +### Set 13 — Attendance Report Client-Side Filter Dropped Extra-Work Rows (Sept 2026) +| File | Fix | +|---|---| +| `static/js/attendance_report.js` | `applyFilters()` compared `record.employeeId === id` exactly, filtering out the SP/PW/PT rows the SQL had correctly returned. Added `parseEmployeeIdWorkType()` + base-ID comparison — see §19 | + +### Set 14 — Type of Work Anti-Mistake Measures (Sept 2026) +| File | Change | +|---|---| +| `routes/qr_codes.py` | `WORK_TYPE_LABELS` + `_work_type_label()`; `work_type` / `work_type_label` added to the check-in success payload | +| `routes/qr_codes.py` | `_resolve_qr_event()`, `_resolve_record_event()`, `GET /qr/<url>/last-work-type` — check-out inherits the open check-in's work type | +| `templates/qr_destination.html` | `renderSubmitButton()` centralises the button markup; `resetEnhancedSubmitButton()` delegates to it (the work-type line it briefly carried was reverted — see Set 17) | +| `templates/qr_destination.html` | Type of Work row on the success card; `#workTypeHint` amber reminder; `initializeWorkTypeSelector()` + `loadOpenCheckInWorkType()` | +| `static/js/qr_destination.js` | `updateElement("successWorkType", ...)` in `handleCheckinSuccess()` | + +### Set 15 — iOS Safari Reset the Suggested Work Type (Sept 2026) +| File | Fix | +|---|---| +| `templates/qr_destination.html` | Suggestion retained in `suggestedWorkTypeCode` and re-applied via `applyWorkTypeSuggestion()` on response, +250 ms, +900 ms and `pageshow` — Safari's form restore lands after the fetch | +| `templates/qr_destination.html` | `change` counts as the employee's choice only after a real interaction (`workTypeTouchedByEmployee`); Safari's restore-fired `change` no longer latches the flag | +| `templates/qr_destination.html` | `fetch(..., { cache: "no-store" })`; URL built from `pathname` with trailing slashes stripped | +| `routes/qr_codes.py` | `_no_store_json()` sets `Cache-Control` / `Pragma` on all seven return paths of `qr_last_work_type` | + +### Set 16 — Form Clone Discarded the Selected Work Type (Sept 2026) +| File | Fix | +|---|---| +| `static/js/qr_destination.js` | `preserveFormControlState()` copies live `value` / `checked` into the clone before `replaceChild` — `cloneNode(true)` had reset the work-type `<select>` to Regular and cleared typed input values 1.5 s after load | +| `static/js/qr_destination.js` | Dispatches `checkinFormReplaced` after the swap so the page can re-bind listeners the clone destroyed | +| `templates/qr_destination.html` | Listeners split into re-attachable `attachEmployeeIdListeners()` / `attachWorkTypeListeners()`; `checkinFormReplaced` handler re-binds both, re-applies the suggestion, re-renders the button | + +### Set 17 — Submit Button Label Reverted to Plain Check In / Check Out (Sept 2026) +| File | Change | +|---|---| +| `templates/qr_destination.html` | Work-type line removed from the submit button — it broke the button layout. `.submit-work-type` CSS deleted, and the `renderSubmitButton()` calls that only existed to refresh it (dropdown `change`, `applyWorkTypeSuggestion`, form re-bind, init) dropped | +| — | The type of work is still confirmed by the amber check-out reminder and the success-card row — **do not re-add it to the button** | + +### Set 18 — Export by Building: Filtered Report + Weekly Hours by Location (Sept 2026) +| File | Change | +|---|---| +| `routes/time_attendance_export.py` | Row bookkeeping in the by-building loop; `_build_filtered_building_sheet()` + `_build_weekly_hours_by_location_sheet()` add two sheets after Sheet0 (see §13). Sheet0 verified cell-for-cell identical to the previous export | +| `config.py` | `BUILDING_SUMMARY_EXCLUDED_EMPLOYEE_IDS` (comma-separated, default `4921,4944,4816,3979`) | +| `templates/time_attendance_records.html` | Tooltip on the Export by Building button | +| — | Replaces the manual "Copilot" procedure (delete PM rows + SP rows, then build a weekly table). Decisions: PM IDs removed from both sheets; SP hours excluded from weekly hours | + +--- + +## 21. Infrastructure & Deployment + +- **Deploy user:** `qrcode` on both servers +- **Restart:** `sudo supervisorctl restart qrcode` +- **Template-only changes:** no restart needed +- **Python/model changes:** always run migration first, then deploy files, then restart +- **Gunicorn:** gevent workers — avoid threading-unsafe patterns + +### Deploy Order for DB Column Additions +1. Run `python3 tools/migration_<name>.py` (pymysql, safe to re-run) +2. Deploy updated Python files +3. Restart service + +--- + +## 22. Session Changelog + +### Sessions 1–8 — Foundational Work +See §20 Sets 1–8 for detailed bug fix history. Covers: initial critical fixes, security hardening (CSRF, SQL injection, session fixation, rate limiting, open redirect), export pipeline enhancements, GOV deployment, theme system, code quality sprint. + +### Session (May 2026) — Template Sync + Photo Verification Toggle +- Diffed LT and GOV codebases — confirmed only 3 template files differed (whitespace + minor logic) +- Synced `base.html`, `base_authenticated.html`, `projects.html` — GOV brought to parity with LT +- Added per-QR `photo_verification_enabled` toggle (DB column + model + routes + UI + migration) +- Fixed migration script: replaced `urlparse` with regex parser (handles special chars in DB passwords), replaced SQLAlchemy `conn.execute(str)` with `pymysql` direct (avoids ORM loading model before column exists) +- Fixed `edit_qr_code.html` toggle bug: JS was injected into `{% block title %}` (corrupted by earlier injection) — moved CSS to `extra_head`, JS to `extra_scripts` using `addEventListener`, removed inline `onchange` attribute +- Restored QR code name lock on edit page: field is `readonly` in template; route ignores submitted name (`new_name = qr_code.name`) +- Fixed `/api/geocode` 403: `create_qr_code.html` is standalone HTML (no `{% extends %}`), so `window.qrConfig` was never injected — added direct injection in `<head>`; also fixed ordering in `base_authenticated.html` for pages that do extend it + +### Session (September 2–3, 2026) — Type of Work on Check-In +- Added the **Type of Work** dropdown to the check-in page (Regular default, PW / SP / C) + and made the Employee ID numeric-only; the code is appended to the ID server-side so no + schema change or migration was needed (§11, §20 Set 12) +- Added **C (Covering)** as a first-class work type through the parser, the calculator's + daily/weekly/grand totals, the ID filters, and the `C` summary row in both Excel exports +- Deliberately left `single_checkin_calculator.py` untouched (legacy; its dicts would + `KeyError` on a `C` key) and did **not** cap the ID at 4 characters (would break + 5-digit IDs) +- Made the dropdown labels bilingual as one plain string per option — `<option>` cannot + hold the page's coloured `english-text` / `spanish-text` spans +- **Fixed the Attendance Report employee filter** (§20 Set 13): the July server-side fix + was being undone by an exact-match client-side re-filter in `attendance_report.js`, so + `1234SP` / `1234PT` rows were dropped after the query returned them +- Added two anti-mistake measures chosen from a set of options (§20 Set 14): the submit + button and success card now name the selected type, and a **Check Out** scan pre-selects + the work type of the employee's still-open check-in with a bilingual reminder +- Verification used throughout: `ast.parse` on every Python edit, Jinja parse of the + template, `node --check` on the JS (including the extracted inline script), an + end-to-end `WorkingHoursCalculator` run proving regular/OT totals unchanged, and + offline rule checks for the filter matcher and the `last-work-type` decision paths +- Both LT and GOV instances need these changes; only LT was reachable in this session + (the Gitea MCP server failed to connect) +- **iPhone follow-up:** the pre-selected work type reverted to Regular on iOS Safari only. + Cause was Safari-specific form-state restore (fires after the async response, and fires + a `change` event that was being read as an employee choice) plus aggressive GET caching. + Fixed with re-application on a schedule, interaction-gated `change` handling, and + `no-store` on both ends (§11, §20 Set 15) +- **iPhone follow-up 2 — the actual root cause:** the reminder was correct but the + dropdown reverted, because `qr_destination.js` clones and replaces the whole form 1.5 s + after load and `cloneNode(true)` drops live control state. Fixed with + `preserveFormControlState()` + a `checkinFormReplaced` event that re-binds the page's + listeners (§11, §20 Set 16). The Safari guards from Set 15 stay — they address a + different, real failure mode +- Reverted the work-type line on the submit button at the user's request (layout); the + plain `Check In` / `Check Out` label is back. The reminder and success-card row keep + the confirmation (§20 Set 17) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14ab8ea --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 NguyenND + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/address_normalization_fix.py b/address_normalization_fix.py new file mode 100644 index 0000000..3a2caec --- /dev/null +++ b/address_normalization_fix.py @@ -0,0 +1,556 @@ +""" +Address Normalization Fix for Distance Calculation Issues +========================================================== + +This module fixes the issue where nearly identical addresses are geocoded to +different coordinates, causing incorrect distance calculations. + +Issue: +- "7100 Gordon Rd" vs "7100 Gordons Rd, USA" → 1.5 miles apart (WRONG!) +- "3402 South Glebe Road" vs "3402, South Glebe Road, Aurora Hills" → 0.7 miles (WRONG!) + +Root Cause: +- Google Maps/OSM geocodes slightly different address strings to different coordinates +- Minor variations (plurals, commas, neighborhoods, "USA") create false distance + +Solution: +- Normalize addresses before geocoding +- Use fuzzy matching to detect identical locations +- Prevent re-geocoding of essentially the same address +- Extract and compare street number + street name as primary identifier +""" + +import re +from difflib import SequenceMatcher + +# Try to import logger_handler for logging (optional - won't break if not available) +try: + from logger_handler import AppLogger + logger_handler = AppLogger() + LOGGING_ENABLED = True +except ImportError: + logger_handler = None + LOGGING_ENABLED = False + + +def _log_activity(action, message): + """Helper function to log activity if logger is available""" + if LOGGING_ENABLED and logger_handler: + try: + logger_handler.log_user_activity(action, message) + except Exception: + pass # Ignore logging errors + + +def extract_street_address(address): + """ + Extract the core street address (number + street name) from an address string. + This is the most reliable identifier for location matching. + + Handles cases where: + - Street number is at the beginning: "735 18th St S" + - Building name comes first: "Aurora Hills Library, 735, 18th Street South" + + Args: + address: Normalized address string + + Returns: + Core street address string (e.g., "735 18th st s") + """ + if not address: + return "" + + addr_lower = address.lower() + + # Pattern to match: street number + optional directional + street name + street type + # This pattern searches ANYWHERE in the string, not just at the beginning + # Examples: "735 18th st s", "3402 south glebe road", "7100 gordon rd" + street_types = r'(?:rd|st|ave|dr|ln|ct|blvd|pkwy|cir|pl|ter|hwy|way|trail|pike|run|walk|path|loop|road|street|avenue|drive|lane|court|boulevard|parkway|circle|place|terrace|highway)' + + # Pattern: number + ordinal/street name + optional directional + street type + # Handles: "735 18th st s", "735, 18th street south" + street_pattern = rf'(\d+)[\s,]+(\d*(?:st|nd|rd|th)?\s*[\w\s]*?{street_types})(?:\s+([nsew]|north|south|east|west))?' + + match = re.search(street_pattern, addr_lower) + if match: + street_num = match.group(1).strip() + street_name = match.group(2).strip() + direction = match.group(3) if match.group(3) else "" + + # Clean up extra spaces and commas + street_name = re.sub(r'[\s,]+', ' ', street_name).strip() + + # Normalize direction + dir_map = {'north': 'n', 'south': 's', 'east': 'e', 'west': 'w'} + if direction: + direction = dir_map.get(direction, direction) + + result = f"{street_num} {street_name}" + if direction: + result += f" {direction}" + + return result + + # Fallback: try to find just a street number followed by some words + simple_pattern = r'(\d+)[\s,]+([\w\s]+)' + match = re.search(simple_pattern, addr_lower) + if match: + street_num = match.group(1).strip() + # Take words until we hit something that looks like a city/state + words = match.group(2).split() + street_words = [] + for word in words: + # Stop at state abbreviations or zip codes + if re.match(r'^[a-z]{2}$', word) and word in ['va', 'md', 'dc', 'ca', 'ny', 'tx', 'fl', 'pa', 'il', 'oh', 'ga', 'nc', 'nj']: + break + if re.match(r'^\d{5}', word): + break + street_words.append(word) + if street_words: + return f"{street_num} {' '.join(street_words[:4])}" # Limit to 4 words + + return address + + +def normalize_address(address): + """ + Normalize address string for better matching and geocoding accuracy + This helps prevent geocoding nearly identical addresses to different coordinates + + Args: + address: Raw address string + + Returns: + Normalized address string + + Examples: + "7100 Gordon Rd, Falls Church, VA 22043" + "7100 Gordons Rd, Falls Church, VA 22043, USA" + Both normalize to: "7100 gordon rd, falls church, va 22043" + + "3402 South Glebe Road Arlington VA 22202" + "3402, South Glebe Road, Aurora Hills, Arlington VA 22202" + Both normalize to: "3402 s glebe rd, arlington, va 22202" + + "Aurora Hills Branch Library, 735, 18th Street South, Arlington, VA 22202" + "735 18th St S, Arlington, VA 22202" + Both normalize to: "735 18th st s, arlington, va 22202" + """ + if not address or not isinstance(address, str): + return address + + # Convert to lowercase for consistent comparison + normalized = address.lower().strip() + + # Remove extra whitespace and normalize separators + normalized = re.sub(r'\s+', ' ', normalized) # Multiple spaces to single space + normalized = re.sub(r'\s*,\s*', ', ', normalized) # Normalize comma spacing + + # Remove building/location names that come BEFORE the street number + # Pattern: remove text before a street number if it looks like a building name + # Examples: "Aurora Hills Branch Library, 735" → "735" + # "Fire Station #7, 123 Main St" → "123 Main St" + building_pattern = r'^[^,\d]*(?:library|station|center|building|plaza|tower|hall|office|school|church|hospital|clinic|bank|hotel|restaurant|store|shop|mall|complex|headquarters|hq|branch)[^,\d]*,\s*' + normalized = re.sub(building_pattern, '', normalized, flags=re.IGNORECASE) + + # Standardize common street abbreviations to short forms + street_abbrev = { + r'\broad\b': 'rd', + r'\broads\b': 'rd', # Handle plural form (Gordon Rd vs Gordons Rd) + r'\bstreet\b': 'st', + r'\bavenue\b': 'ave', + r'\bdrive\b': 'dr', + r'\blane\b': 'ln', + r'\bcourt\b': 'ct', + r'\bboulevard\b': 'blvd', + r'\bparkway\b': 'pkwy', + r'\bcircle\b': 'cir', + r'\bplace\b': 'pl', + r'\bterrace\b': 'ter', + r'\bhighway\b': 'hwy' + } + + for full_form, abbrev in street_abbrev.items(): + normalized = re.sub(full_form, abbrev, normalized) + + # Standardize directionals to single letter + directionals = { + r'\bnorth\b': 'n', + r'\bsouth\b': 's', + r'\beast\b': 'e', + r'\bwest\b': 'w', + r'\bnortheast\b': 'ne', + r'\bnorthwest\b': 'nw', + r'\bsoutheast\b': 'se', + r'\bsouthwest\b': 'sw' + } + + for full_form, abbrev in directionals.items(): + normalized = re.sub(full_form, abbrev, normalized) + + # Convert full state names to abbreviations + state_names = { + r'\bvirginia\b': 'va', + r'\bmaryland\b': 'md', + r'\bdistrict of columbia\b': 'dc', + r'\bcalifornia\b': 'ca', + r'\bnew york\b': 'ny', + r'\btexas\b': 'tx', + r'\bflorida\b': 'fl', + r'\bpennsylvania\b': 'pa', + r'\billinois\b': 'il', + r'\bohio\b': 'oh', + r'\bgeorgia\b': 'ga', + r'\bnorth carolina\b': 'nc', + r'\bnew jersey\b': 'nj', + r'\bwashington\b': 'wa', + r'\bmassachusetts\b': 'ma', + r'\barizona\b': 'az', + r'\bcolorado\b': 'co', + r'\btennessee\b': 'tn', + r'\bindiana\b': 'in', + r'\bmissouri\b': 'mo', + r'\bwisconsin\b': 'wi', + r'\bminnesota\b': 'mn', + r'\bsouth carolina\b': 'sc', + r'\balabama\b': 'al', + r'\blouisiana\b': 'la', + r'\bkentucky\b': 'ky', + r'\boregon\b': 'or', + r'\boklahoma\b': 'ok', + r'\bconnecticut\b': 'ct', + r'\biowa\b': 'ia', + r'\bmississippi\b': 'ms', + r'\barkansas\b': 'ar', + r'\bkansas\b': 'ks', + r'\butah\b': 'ut', + r'\bnevada\b': 'nv', + r'\bnew mexico\b': 'nm', + r'\bwest virginia\b': 'wv', + r'\bnebraska\b': 'ne', + r'\bidaho\b': 'id', + r'\bhawaii\b': 'hi', + r'\bmaine\b': 'me', + r'\bnew hampshire\b': 'nh', + r'\brhode island\b': 'ri', + r'\bmontana\b': 'mt', + r'\bdelaware\b': 'de', + r'\bsouth dakota\b': 'sd', + r'\bnorth dakota\b': 'nd', + r'\balaska\b': 'ak', + r'\bvermont\b': 'vt', + r'\bwyoming\b': 'wy' + } + + for full_name, abbrev in state_names.items(): + normalized = re.sub(full_name, abbrev, normalized) + + # Remove neighborhood/district names that aren't essential for location + # Examples: "Aurora Hills", "Downtown", etc. + parts = [p.strip() for p in normalized.split(',')] + + # Keep: street address, city, state, zip + # Remove: neighborhood names, building names, country suffixes, county names + filtered_parts = [] + + # Known neighborhood keywords to remove (these don't affect geocoding) + # NOTE: These should only match if NOT followed by a street type suffix + neighborhood_keywords = ['hills', 'heights', 'village', 'estates', + 'manor', 'gardens', 'terrace', 'commons', 'plaza', + 'downtown', 'midtown', 'uptown', 'district', 'center', + 'crossing', 'corner', 'square', 'point', 'landing', + 'aurora', 'crystal', 'forest', 'lake', 'river', 'creek', + 'meadow', 'valley', 'ridge', 'grove', 'glen', 'woods', + 'addison', 'colonial', 'fairfax', 'heritage', 'liberty', + 'ballston', 'clarendon', 'rosslyn', 'shirlington'] + + # Street type suffixes - if a part contains these, it's likely a street address, not a neighborhood + street_type_suffixes = ['rd', 'st', 'ave', 'dr', 'ln', 'ct', 'blvd', 'pkwy', 'cir', + 'pl', 'ter', 'hwy', 'way', 'road', 'street', 'avenue', + 'drive', 'lane', 'court', 'boulevard', 'parkway', 'circle', + 'place', 'terrace', 'highway', 'trail', 'pike', 'run', + 'walk', 'path', 'loop'] + + # Country names and suffixes to remove (English and other languages) + country_suffixes = ['usa', 'us', 'united states', 'united states of america', + 'estados unidos', 'estados unidos de américa', 'estados unidos de america', + 'eeuu', 'e.u.', 'u.s.a.', 'u.s.', 'america', 'américas'] + + for i, part in enumerate(parts): + part_clean = part.strip() + + # Always keep first part (street address) - but only if it contains a number + if i == 0: + # Check if this looks like a building name (no street number) + if re.search(r'\d', part_clean): + filtered_parts.append(part_clean) + else: + print(f" Removing building name: '{part_clean}'") + continue + + # Skip empty parts + if not part_clean: + continue + + # Skip country suffixes (multiple languages) + if part_clean in country_suffixes: + print(f" Removing country: '{part_clean}'") + continue + + # Skip county names (e.g., "Arlington County", "Fairfax County") + if 'county' in part_clean: + print(f" Removing county: '{part_clean}'") + continue + + # Check if this part contains a street type suffix - if so, it's a street address, KEEP IT + has_street_suffix = False + for suffix in street_type_suffixes: + # Match word boundary to avoid partial matches (e.g., "dr" in "andra") + if re.search(rf'\b{suffix}\b', part_clean): + has_street_suffix = True + break + + if has_street_suffix: + # This is a street address (e.g., "n park dr", "18th st s"), keep it + filtered_parts.append(part_clean) + continue + + # Skip if it's a neighborhood name (contains neighborhood keywords but no numbers and no street suffix) + is_neighborhood = False + for keyword in neighborhood_keywords: + if keyword in part_clean and not re.search(r'\d', part_clean): + is_neighborhood = True + print(f" Removing neighborhood: '{part_clean}'") + break + + if is_neighborhood: + continue + + # Keep if it looks like state (2 letter abbrev) + if re.match(r'^[a-z]{2}$', part_clean): + filtered_parts.append(part_clean) + continue + + # Keep if it looks like zip code + if re.match(r'^\d{5}(-\d{4})?$', part_clean): + filtered_parts.append(part_clean) + continue + + # Keep if it's likely a city name (reasonable length, no special patterns) + if 3 <= len(part_clean) <= 30: + filtered_parts.append(part_clean) + + # Reconstruct address + normalized = ', '.join(filtered_parts) + + # Remove common country suffixes that don't affect location (final cleanup) + normalized = re.sub(r',?\s*(usa|united states|us|estados unidos.*?|eeuu|u\.s\.a?\.|america|américas?)$', '', normalized, flags=re.IGNORECASE) + + # Final cleanup: remove trailing commas and spaces + normalized = normalized.strip(', ') + + print(f"🔧 Address normalization:") + print(f" Original: {address}") + print(f" Normalized: {normalized}") + + return normalized + + +def extract_address_components(address): + """ + Extract key components from an address for comparison. + Handles addresses where the street number may not be at the beginning + (e.g., "Aurora Hills Library, 735, 18th Street South") + + Args: + address: Address string (raw or normalized) + + Returns: + Dictionary with extracted components: + - street_number: The street number (e.g., "735") + - street_name: The street name with type (e.g., "18th st s") + - city: City name if found + - state: State abbreviation if found + - zip_code: ZIP code if found + """ + if not address: + return {} + + addr_lower = address.lower().strip() + + components = { + 'street_number': None, + 'street_name': None, + 'city': None, + 'state': None, + 'zip_code': None + } + + # Extract ZIP code first (most reliable) + zip_match = re.search(r'\b(\d{5})(?:-\d{4})?\b', addr_lower) + if zip_match: + components['zip_code'] = zip_match.group(1) + + # Extract state (2-letter abbreviation, typically before zip or at end) + # Also handle full state names that might not have been normalized + valid_states = ['al', 'ak', 'az', 'ar', 'ca', 'co', 'ct', 'de', 'fl', 'ga', + 'hi', 'id', 'il', 'in', 'ia', 'ks', 'ky', 'la', 'me', 'md', + 'ma', 'mi', 'mn', 'ms', 'mo', 'mt', 'ne', 'nv', 'nh', 'nj', + 'nm', 'ny', 'nc', 'nd', 'oh', 'ok', 'or', 'pa', 'ri', 'sc', + 'sd', 'tn', 'tx', 'ut', 'vt', 'va', 'wa', 'wv', 'wi', 'wy', 'dc'] + + state_match = re.search(r'\b([a-z]{2})\s*(?:,?\s*\d{5}|,|$)', addr_lower) + if state_match: + potential_state = state_match.group(1) + if potential_state in valid_states: + components['state'] = potential_state + + # Extract street number - look for it ANYWHERE in the address + # Pattern: standalone number that's likely a street number (not a zip code or ordinal in street name) + # Match numbers like "735" or "3402" but not "22202" (zip) or "18th" (ordinal) + + # First, try to find a number followed by a street-like pattern + street_num_pattern = r'(?:^|,\s*)(\d{1,5})(?:\s*,\s*|\s+)(\d*(?:st|nd|rd|th)?\s*[\w\s]*?(?:rd|st|ave|dr|ln|ct|blvd|pkwy|cir|pl|ter|hwy|way|street|road|avenue|drive|lane|court|boulevard))' + + match = re.search(street_num_pattern, addr_lower) + if match: + components['street_number'] = match.group(1) + street_name_raw = match.group(2).strip() + # Clean up the street name + street_name_raw = re.sub(r'[\s,]+', ' ', street_name_raw) + components['street_name'] = street_name_raw + else: + # Fallback: try simpler pattern - just find a number at the start or after comma + simple_num_match = re.search(r'(?:^|,\s*)(\d{1,5})(?:\s*,|\s+)(?!\d{4,5}\b)', addr_lower) + if simple_num_match: + components['street_number'] = simple_num_match.group(1) + + # Try to extract street name after the number + remainder = addr_lower[simple_num_match.end():] + remainder = remainder.lstrip(', ') + + # Look for street type keywords + street_types = ['rd', 'st', 'ave', 'dr', 'ln', 'ct', 'blvd', 'pkwy', 'cir', + 'pl', 'ter', 'hwy', 'way', 'trail', 'pike', 'run', 'walk', + 'path', 'loop', 'road', 'street', 'avenue', 'drive', 'lane', + 'court', 'boulevard', 'parkway', 'circle', 'place', 'terrace', + 'highway'] + + for st_type in street_types: + pattern = rf'^([\w\s]+?\s*{st_type})\b' + st_match = re.search(pattern, remainder) + if st_match: + components['street_name'] = st_match.group(1).strip() + break + + return components + + +def addresses_are_similar(addr1, addr2, threshold=0.85): + """ + Check if two addresses are similar enough to be considered the same location + Uses multiple comparison strategies for robust matching: + 1. Direct street address comparison (highest priority) + 2. Component-based comparison + 3. Fuzzy string matching on normalized addresses + + Args: + addr1: First address string + addr2: Second address string + threshold: Similarity threshold (0-1), default 0.85 (85% similar) + + Returns: + Boolean indicating if addresses are similar + + Examples: + addresses_are_similar( + "7100 Gordon Rd, Falls Church, VA 22043", + "7100 Gordons Rd, Falls Church, VA 22043, USA" + ) → True (same location, minor spelling difference) + + addresses_are_similar( + "3402 South Glebe Road Arlington VA 22202", + "3402, South Glebe Road, Aurora Hills, Arlington VA 22202" + ) → True (same location, extra neighborhood name) + """ + if not addr1 or not addr2: + return False + + print(f"\n🔍 ADDRESS SIMILARITY CHECK:") + print(f" Address 1: {addr1}") + print(f" Address 2: {addr2}") + + # Normalize both addresses + norm1 = normalize_address(addr1) + norm2 = normalize_address(addr2) + + # Exact match after normalization + if norm1 == norm2: + print(f"✅ Addresses match exactly after normalization") + return True + + # STRATEGY 1: Extract and compare core street addresses + # This is the most reliable method for catching cases like: + # "3402 South Glebe Road Arlington VA 22202" vs + # "3402, South Glebe Road, Aurora Hills, Arlington VA 22202" + street1 = extract_street_address(norm1) + street2 = extract_street_address(norm2) + + print(f" Street Address 1: '{street1}'") + print(f" Street Address 2: '{street2}'") + + if street1 and street2: + street_similarity = SequenceMatcher(None, street1, street2).ratio() + print(f" Street similarity: {street_similarity:.2%}") + + # If street addresses are very similar (>92%), addresses are the same + if street_similarity >= 0.92: + print(f"✅ SIMILAR - Street addresses match ({street_similarity:.2%})") + return True + + # STRATEGY 2: Component-based comparison + comp1 = extract_address_components(addr1) + comp2 = extract_address_components(addr2) + + print(f" Components 1: {comp1}") + print(f" Components 2: {comp2}") + + # If street numbers match exactly and street names are similar + if comp1.get('street_number') and comp2.get('street_number'): + if comp1['street_number'] == comp2['street_number']: + # Same street number - check street name similarity + if comp1.get('street_name') and comp2.get('street_name'): + name_sim = SequenceMatcher(None, + comp1['street_name'], + comp2['street_name']).ratio() + print(f" Street name similarity: {name_sim:.2%}") + + if name_sim >= 0.85: + # Also check if zip codes match (if both have them) + if comp1.get('zip_code') and comp2.get('zip_code'): + if comp1['zip_code'] == comp2['zip_code']: + print(f"✅ SIMILAR - Same street number, similar name, same ZIP") + return True + else: + # No zip to compare, but street info matches + print(f"✅ SIMILAR - Same street number, similar street name") + return True + + # STRATEGY 3: Full normalized address fuzzy matching + similarity = SequenceMatcher(None, norm1, norm2).ratio() + is_similar = similarity >= threshold + + print(f"📊 Full address similarity:") + print(f" Address 1 (normalized): {norm1}") + print(f" Address 2 (normalized): {norm2}") + print(f" Similarity score: {similarity:.2%}") + print(f" Threshold: {threshold:.2%}") + print(f" Result: {'✅ SIMILAR (same location)' if is_similar else '❌ DIFFERENT (different locations)'}") + + # Log the address similarity check result + _log_activity( + 'address_similarity_check', + f"Compared addresses: similarity={similarity:.2%}, result={'SIMILAR' if is_similar else 'DIFFERENT'}" + ) + + return is_similar \ No newline at end of file diff --git a/advanced_security_middleware.py b/advanced_security_middleware.py new file mode 100644 index 0000000..6f15ee5 --- /dev/null +++ b/advanced_security_middleware.py @@ -0,0 +1,476 @@ +# File: advanced_security_middleware.py +# Enhanced security middleware for QR Attendance System + +from functools import wraps +from flask import request, session, jsonify, current_app, g +import hashlib +import secrets +import jwt +from datetime import datetime, timedelta +import re +from collections import defaultdict, deque +import time +import hmac +import base64 +import os + +# Try to import cryptography, fallback if not available +try: + from cryptography.fernet import Fernet + HAS_CRYPTOGRAPHY = True +except ImportError: + HAS_CRYPTOGRAPHY = False + +class SecurityManager: + """ + Advanced security manager for QR Attendance System + """ + + def __init__(self, app=None, db=None, logger_handler=None): + self.app = app + self.db = db + self.logger_handler = logger_handler + + # Security tracking + self.failed_attempts = defaultdict(lambda: deque(maxlen=10)) + self.suspicious_ips = defaultdict(int) + self.session_tokens = {} + + # Security configuration + self.max_failed_attempts = 5 + self.lockout_duration = 900 # 15 minutes + self.session_timeout = 3600 # 1 hour + + if app: + self.init_app(app, db, logger_handler) + + def init_app(self, app, db, logger_handler): + """Initialize security manager with Flask app""" + self.app = app + self.db = db + self.logger_handler = logger_handler + + # Generate encryption key for sensitive data + self.setup_encryption() + + # Register security middleware + app.before_request(self.security_check) + + # Register security routes + self.register_security_routes() + + def setup_encryption(self): + """Setup encryption for sensitive data. + + Derives a stable Fernet key from the app's SECRET_KEY so that all + gunicorn workers share the same key without needing a separate + ENCRYPTION_KEY env var. A random key is only generated as a last + resort (dev mode without SECRET_KEY set). + """ + if HAS_CRYPTOGRAPHY: + encryption_key = self.app.config.get('ENCRYPTION_KEY') + if not encryption_key: + # Derive a deterministic 32-byte key from SECRET_KEY so every + # worker produces the same value — no per-worker randomness. + secret = self.app.config.get('SECRET_KEY', '') + derived = hashlib.sha256(secret.encode()).digest() + encryption_key = base64.urlsafe_b64encode(derived) + self.cipher = Fernet(encryption_key) + else: + self.cipher = None + + def security_check(self): + """Comprehensive security check before each request""" + client_ip = self.get_client_ip() + + # Check for suspicious activity + if self.is_suspicious_request(): + self.log_security_event('suspicious_request', { + 'ip': client_ip, + 'user_agent': request.headers.get('User-Agent', ''), + 'endpoint': request.endpoint, + 'method': request.method + }) + return jsonify({'error': 'Request blocked for security reasons'}), 403 + + # NOTE: per-worker in-memory session token validation removed. + # Flask's cryptographically signed session cookie provides session + # integrity; CSRF tokens handle cross-site forgery. Keeping the + # validate_session_security() call here would log users out on every + # gunicorn worker boundary because session_tokens is not shared. + + # Check for SQL injection attempts + if self.detect_sql_injection(): + self.log_security_event('sql_injection_attempt', { + 'ip': client_ip, + 'query_params': dict(request.args), + 'form_data': dict(request.form) if request.form else {} + }) + return jsonify({'error': 'Malicious request detected'}), 403 + + # Rate limiting for authentication endpoints + if request.endpoint in ['login', 'register', 'reset_password']: + if self.is_auth_rate_limited(): + return jsonify({ + 'error': 'Too many attempts, please try again later' + }), 429 + + def get_client_ip(self): + """Get real client IP address""" + # Check for forwarded headers (in case behind proxy/CDN) + forwarded_ips = request.headers.getlist('X-Forwarded-For') + if forwarded_ips: + return forwarded_ips[0].split(',')[0].strip() + + return request.headers.get('X-Real-IP') or request.remote_addr + + def is_suspicious_request(self): + """Detect suspicious request patterns""" + client_ip = self.get_client_ip() + user_agent = request.headers.get('User-Agent', '').lower() + + # Check for common attack patterns + suspicious_patterns = [ + r'<script', r'javascript:', r'vbscript:', r'onload=', r'onerror=', + r'union\s+select', r'drop\s+table', r'insert\s+into', + r'\.\./\.\./.*etc/passwd', r'cmd\.exe', r'/bin/bash' + ] + + request_data = str(request.args) + str(request.form) + request.path + + for pattern in suspicious_patterns: + if re.search(pattern, request_data, re.IGNORECASE): + self.suspicious_ips[client_ip] += 1 + return True + + # Check for suspicious user agents + bot_patterns = ['bot', 'crawler', 'spider', 'scraper', 'scanner'] + if any(pattern in user_agent for pattern in bot_patterns): + if request.endpoint not in ['static', 'favicon']: + return True + + # Check request frequency (basic rate limiting) + current_time = time.time() + if not hasattr(g, 'request_history'): + g.request_history = deque(maxlen=50) + + g.request_history.append(current_time) + recent_requests = [t for t in g.request_history if current_time - t < 60] + + if len(recent_requests) > 30: # More than 30 requests per minute + return True + + return False + + def validate_session_security(self): + """Validate session security and integrity""" + try: + user_id = session.get('user_id') + session_token = session.get('security_token') + + if not user_id or not session_token: + return False + + # Check if session token matches stored token + stored_token = self.session_tokens.get(user_id) + if not stored_token or not hmac.compare_digest(session_token, stored_token['token']): + return False + + # Check session timeout - skip if "Remember Me" is enabled + if not session.get('remember_me', False): + if time.time() - stored_token['created'] > self.session_timeout: + del self.session_tokens[user_id] + return False + + # Check if session IP matches (optional security measure) + if self.app.config.get('STRICT_SESSION_IP', False): + if stored_token['ip'] != self.get_client_ip(): + self.log_security_event('session_ip_mismatch', { + 'user_id': user_id, + 'original_ip': stored_token['ip'], + 'current_ip': self.get_client_ip() + }) + return False + + return True + + except Exception as e: + if self.logger_handler: + self.logger_handler.logger.error(f"Session validation error: {e}") + return False + + def detect_sql_injection(self): + """Detect potential SQL injection attempts""" + sql_patterns = [ + r"union\s+select", r"drop\s+table", r"insert\s+into", + r"delete\s+from", r"update\s+set", r"exec\s*\(", + r"sp_executesql", r"xp_cmdshell", r";\s*--", + r"'\s*or\s*'", r'"\s*or\s*"', r"1\s*=\s*1" + ] + + # Check all request parameters + check_data = [] + check_data.extend(request.args.values()) + check_data.extend(request.form.values()) + + # Only attempt JSON parsing when the client declared application/json. + # Calling request.json without this guard raises a 415 Unsupported Media Type + # on every non-JSON request (GET pages, form POSTs, favicon, etc.). + if request.content_type and 'application/json' in request.content_type: + try: + json_body = request.get_json(silent=True, force=False) + if json_body and isinstance(json_body, dict): + check_data.extend( + str(v) for v in json_body.values() + if isinstance(v, (str, int, float)) + ) + except Exception: + pass + + for data in check_data: + data_str = str(data).lower() + for pattern in sql_patterns: + if re.search(pattern, data_str, re.IGNORECASE): + return True + + return False + + def is_auth_rate_limited(self): + """Check if authentication endpoint is rate limited""" + client_ip = self.get_client_ip() + current_time = time.time() + + # Clean old attempts + self.failed_attempts[client_ip] = deque([ + attempt for attempt in self.failed_attempts[client_ip] + if current_time - attempt < 900 # Keep attempts from last 15 minutes + ], maxlen=10) + + return len(self.failed_attempts[client_ip]) >= self.max_failed_attempts + + def record_failed_attempt(self, identifier): + """Record a failed authentication attempt""" + client_ip = self.get_client_ip() + current_time = time.time() + + self.failed_attempts[client_ip].append(current_time) + + self.log_security_event('authentication_failure', { + 'ip': client_ip, + 'identifier': identifier, + 'attempts': len(self.failed_attempts[client_ip]) + }) + + def create_secure_session(self, user_id): + """Create a secure session with additional security measures""" + # Generate secure session token + session_token = secrets.token_urlsafe(32) + + # Store session information + self.session_tokens[user_id] = { + 'token': session_token, + 'created': time.time(), + 'ip': self.get_client_ip(), + 'user_agent': request.headers.get('User-Agent', '')[:200] + } + + # Set session data + session['security_token'] = session_token + session['login_time'] = datetime.utcnow().isoformat() + + # Clear any failed attempts for this IP + client_ip = self.get_client_ip() + if client_ip in self.failed_attempts: + del self.failed_attempts[client_ip] + + self.log_security_event('secure_session_created', { + 'user_id': user_id, + 'ip': client_ip + }) + + def encrypt_sensitive_data(self, data): + """Encrypt sensitive data before storage""" + if not self.cipher: + return data # Return as-is if encryption not available + + try: + if isinstance(data, str): + data = data.encode('utf-8') + + encrypted_data = self.cipher.encrypt(data) + return base64.b64encode(encrypted_data).decode('utf-8') + + except Exception as e: + if self.logger_handler: + self.logger_handler.logger.error(f"Encryption error: {e}") + return data + + def decrypt_sensitive_data(self, encrypted_data): + """Decrypt sensitive data""" + if not self.cipher: + return encrypted_data # Return as-is if encryption not available + + try: + encrypted_bytes = base64.b64decode(encrypted_data.encode('utf-8')) + decrypted_data = self.cipher.decrypt(encrypted_bytes) + return decrypted_data.decode('utf-8') + + except Exception as e: + if self.logger_handler: + self.logger_handler.logger.error(f"Decryption error: {e}") + return encrypted_data + + def log_security_event(self, event_type, details): + """Log security events for monitoring""" + try: + security_log = { + 'event_type': event_type, + 'timestamp': datetime.utcnow().isoformat(), + 'ip': self.get_client_ip(), + 'user_agent': request.headers.get('User-Agent', ''), + 'endpoint': request.endpoint, + 'method': request.method, + 'details': details + } + + if self.logger_handler: + self.logger_handler.log_security_event( + event_type=event_type, + description=f"Security event: {event_type}", + additional_data=security_log + ) + + except Exception as e: + if self.logger_handler: + self.logger_handler.logger.error(f"Security logging error: {e}") + + def register_security_routes(self): + """Register security monitoring API endpoints""" + + @self.app.route('/api/security/status') + def security_status(): + """Get current security status""" + try: + # Admin only endpoint + if not session.get('user_id') or session.get('role') != 'admin': + return jsonify({'error': 'Access denied'}), 403 + + current_time = time.time() + + # Count active suspicious IPs + suspicious_count = len([ + ip for ip, count in self.suspicious_ips.items() + if count > 3 + ]) + + # Count recent failed attempts + recent_failures = sum( + len([ + attempt for attempt in attempts + if current_time - attempt < 300 # Last 5 minutes + ]) + for attempts in self.failed_attempts.values() + ) + + # Count active sessions + active_sessions = len([ + token for token in self.session_tokens.values() + if current_time - token['created'] < self.session_timeout + ]) + + return jsonify({ + 'suspicious_ips': suspicious_count, + 'recent_failed_attempts': recent_failures, + 'active_sessions': active_sessions, + 'rate_limited_ips': len(self.failed_attempts), + 'security_status': 'normal' if suspicious_count < 5 else 'elevated' + }) + + except Exception as e: + if self.logger_handler: + self.logger_handler.logger.error(f"Security status error: {e}") + return jsonify({'error': 'Failed to get security status'}), 500 + + @self.app.route('/api/security/clear-blocks', methods=['POST']) + def clear_security_blocks(): + """Clear security blocks (admin only)""" + try: + if not session.get('user_id') or session.get('role') != 'admin': + return jsonify({'error': 'Access denied'}), 403 + + # Clear failed attempts + cleared_ips = len(self.failed_attempts) + self.failed_attempts.clear() + + # Clear suspicious IPs + cleared_suspicious = len(self.suspicious_ips) + self.suspicious_ips.clear() + + self.log_security_event('security_blocks_cleared', { + 'admin_user': session.get('username'), + 'cleared_failed_attempts': cleared_ips, + 'cleared_suspicious_ips': cleared_suspicious + }) + + return jsonify({ + 'message': 'Security blocks cleared successfully', + 'cleared_failed_attempts': cleared_ips, + 'cleared_suspicious_ips': cleared_suspicious + }) + + except Exception as e: + if self.logger_handler: + self.logger_handler.logger.error(f"Clear blocks error: {e}") + return jsonify({'error': 'Failed to clear security blocks'}), 500 + +def enhanced_login_required(f): + """ + Enhanced login required decorator with security checks + """ + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user_id' not in session: + return jsonify({'error': 'Authentication required'}), 401 + + # Additional security validation + if not session.get('security_token'): + session.clear() + return jsonify({'error': 'Session security validation failed'}), 401 + + # Check session timeout - skip if "Remember Me" is enabled + if not session.get('remember_me', False): + login_time_str = session.get('login_time') + if login_time_str: + try: + login_time = datetime.fromisoformat(login_time_str) + if datetime.utcnow() - login_time > timedelta(hours=8): + session.clear() + return jsonify({'error': 'Session expired'}), 401 + except ValueError: + session.clear() + return jsonify({'error': 'Invalid session data'}), 401 + + return f(*args, **kwargs) + return decorated_function + +def csrf_protect(f): + """ + CSRF protection decorator + """ + @wraps(f) + def decorated_function(*args, **kwargs): + if request.method == 'POST': + token = request.form.get('csrf_token') or request.headers.get('X-CSRF-Token') + expected_token = session.get('csrf_token') + + if not token or not expected_token or not hmac.compare_digest(token, expected_token): + return jsonify({'error': 'CSRF token validation failed'}), 403 + + return f(*args, **kwargs) + return decorated_function + +def generate_csrf_token(): + """Generate CSRF token for forms""" + if 'csrf_token' not in session: + session['csrf_token'] = secrets.token_urlsafe(32) + return session['csrf_token'] \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..5611045 --- /dev/null +++ b/app.py @@ -0,0 +1,454 @@ +""" +app.py +====== +Application entry point and factory. + +This file is intentionally lean (~130 lines). All route logic lives in +the Blueprint modules under routes/. All shared utilities live in utils/. +The db and logger_handler singletons live in extensions.py. + +Blueprint registration order matches the original route-definition order +so that url_for() resolution is identical to the original monolithic app.py. +""" + +from flask import Flask, render_template, request, redirect, url_for, flash, session, g +from datetime import datetime, timedelta +from dotenv import load_dotenv +import os +import time as _time + +# Load .env BEFORE importing anything that reads env vars +load_dotenv() + +from extensions import db, init_logger +from config import get_config +from utils.template_helpers import register_template_helpers +from logger_handler import log_database_operations +from models import set_db +from turnstile_utils import turnstile_utils +from db_performance_optimization import initialize_performance_optimizations +from utils.helpers import has_admin_privileges + + +# --------------------------------------------------------------------------- +# Application factory +# --------------------------------------------------------------------------- +from location_logging import * # noqa: F401,F403 — registers location hooks at module level + + + + +def create_app() -> Flask: + app = Flask(__name__) + + # ------------------------------------------------------------------ + # Configuration + # ------------------------------------------------------------------ + # Load configuration from config.py (single source of truth for env vars) + cfg = get_config() + app.config.from_object(cfg) + + # Guard against deployment with the insecure default SECRET_KEY + import sys + if not app.debug and app.config.get('SECRET_KEY') == 'change-me-in-production': + print("FATAL: SECRET_KEY is set to the insecure default value. " + "Set SECRET_KEY in your .env file before deploying to production.") + sys.exit(1) + + # ------------------------------------------------------------------ + # Database initialization + # ------------------------------------------------------------------ + db.init_app(app) + + with app.app_context(): + # Unpack model classes and store on app for shared access + (User, QRCode, QRCodeStyle, QRCodeLocation, Project, AttendanceData, + Employee, TimeAttendance, UserProjectPermission, + UserLocationPermission) = set_db(db) # ADDED: QRCodeLocation + + + + # ------------------------------------------------------------------ + # Logger initialization + # ------------------------------------------------------------------ + init_logger(app, db) + + # ------------------------------------------------------------------ + # Blueprint registration (url_prefix='' preserves all original URLs) + # ------------------------------------------------------------------ + from routes.auth import bp as auth_bp + from routes.dashboard import bp as dashboard_bp + from routes.users import bp as users_bp + from routes.admin import bp as admin_bp + from routes.projects import bp as projects_bp + from routes.qr_codes import bp as qr_codes_bp + from routes.attendance import bp as attendance_bp + # Import sub-modules to register their routes on the shared attendance blueprint. + # These are side-effect imports — do not register their bp separately. + import routes.attendance_edit # noqa: F401 + import routes.verification # noqa: F401 + import routes.attendance_export # noqa: F401 + from routes.statistics import bp as statistics_bp + from routes.employees import bp as employees_bp + from routes.time_attendance import bp as time_attendance_bp + from routes.legacy_attendance import bp as legacy_attendance_bp + + for bp in (auth_bp, dashboard_bp, users_bp, admin_bp, projects_bp, + qr_codes_bp, attendance_bp, statistics_bp, + employees_bp, time_attendance_bp, legacy_attendance_bp): + app.register_blueprint(bp) + + # Register location-logging routes (from location_logging.py) + # Must be called after app is created; uses app, db, logger_handler directly. + from extensions import logger_handler as _lh + create_location_logging_routes(app, db, _lh) + + # ------------------------------------------------------------------ + # Security: CSRF protection + rate-limiting via SecurityManager + # ------------------------------------------------------------------ + from advanced_security_middleware import SecurityManager, generate_csrf_token + from extensions import logger_handler as _lh2 + security_manager = SecurityManager() + security_manager.init_app(app, db, _lh2) + + # Endpoints exempt from CSRF validation: + # - login / register (no session token exists yet) + # - qr_checkin (public, unauthenticated QR scan endpoint) + _CSRF_EXEMPT = {'auth.login', 'auth.register', 'qr_codes.qr_checkin', 'static'} + + @app.before_request + def csrf_protect(): + """Validate CSRF token on every state-mutating request.""" + if request.method not in ('POST', 'PUT', 'PATCH', 'DELETE'): + return + if request.endpoint in _CSRF_EXEMPT: + return + token = (request.form.get('csrf_token') + or request.headers.get('X-CSRF-Token')) + expected = session.get('csrf_token') + import hmac as _hmac + if not token or not expected or not _hmac.compare_digest(token, expected): + _lh2.logger.warning( + f"CSRF validation failed | endpoint={request.endpoint} " + f"| ip={request.remote_addr} | user={session.get('username','anon')}" + ) + from flask import abort + abort(403) + + # Make generate_csrf_token() available in every template as csrf_token() + @app.context_processor + def inject_csrf_token(): + return {'csrf_token': generate_csrf_token} + + # Expose security_manager to routes that need it (login rate-limiting) + app.security_manager = security_manager + + # ------------------------------------------------------------------ + # Template filters (global — must be on app, not blueprints) + # ------------------------------------------------------------------ + + @app.context_processor + def inject_company_name(): + """Make COMPANY_NAME and THEME_NAME available to all templates""" + return { + 'COMPANY_NAME': os.environ.get('COMPANY_NAME', 'QR Code Management System'), + 'THEME_NAME': os.environ.get('THEME_NAME', ''), + 'CURRENT_YEAR': datetime.utcnow().year, + } + + @app.context_processor + def inject_logging_status(): + """Inject logging status into all templates""" + return { + 'logging_enabled': True, + 'is_admin': has_admin_privileges(session.get('role', '')) + } + + @app.context_processor + def inject_turnstile(): + """Inject Turnstile settings into all templates""" + return { + 'turnstile_enabled': turnstile_utils.is_enabled(), + 'turnstile_site_key': turnstile_utils.get_site_key() + } + + # Register template helper context processors (from utils/template_helpers.py) + register_template_helpers(app) + + @app.template_filter('strftime') + def strftime_filter(value, format='%m/%d/%Y'): + """Format datetime/date/string as strftime""" + if isinstance(value, str): + if value.lower() == 'now': + return datetime.now().strftime(format) + try: + dt = datetime.fromisoformat(value) + return dt.strftime(format) + except (ValueError, TypeError): + return value + if hasattr(value, 'strftime'): + return value.strftime(format) + return str(value) + + @app.template_filter('days_since') + def days_since_filter(value): + """Calculate days since a given date""" + if not value: + return 0 + now = datetime.utcnow() + return (now - value).days + + @app.template_filter('time_ago') + def time_ago_filter(value): + """Human readable time ago""" + if not value: + return 'Never' + now = datetime.utcnow() + diff = now - value + if diff.days > 365: + years = diff.days // 365 + return f"{years} year{'s' if years != 1 else ''} ago" + elif diff.days > 30: + months = diff.days // 30 + return f"{months} month{'s' if months != 1 else ''} ago" + elif diff.days > 0: + return f"{diff.days} day{'s' if diff.days != 1 else ''} ago" + elif diff.seconds > 3600: + hours = diff.seconds // 3600 + return f"{hours} hour{'s' if hours != 1 else ''} ago" + elif diff.seconds > 60: + minutes = diff.seconds // 60 + return f"{minutes} minute{'s' if minutes != 1 else ''} ago" + else: + return "Just now" + + # ------------------------------------------------------------------ + # Request / response hooks + # ------------------------------------------------------------------ + + @app.before_request + def adjust_session_lifetime(): + """ + Dynamically set session lifetime based on the 'remember_me' flag stored + in the session. When the user chose 'Remember Me' at login, their + permanent session lives for 30 days; otherwise the default 10-hour + lifetime from Config.PERMANENT_SESSION_LIFETIME applies. + """ + if session.get('remember_me'): + app.permanent_session_lifetime = timedelta(days=30) + else: + app.permanent_session_lifetime = timedelta(hours=10) + + @app.before_request + def log_request_info(): + """Record request start time and scan for suspicious user agents""" + # Always record start time for slow-query detection in after_request + g.start_time = _time.time() + + if (request.endpoint and + (request.endpoint.startswith('static') or + request.path.startswith('/api/logs'))): + return + + from extensions import logger_handler as lh + user_agent = request.headers.get('User-Agent', '') + ip_address = request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr) + suspicious_patterns = [ + 'sqlmap', 'nikto', 'nmap', 'dirb', 'dirbuster', + 'wget', 'curl.*bot', 'scanner', 'exploit' + ] + if any(pattern in user_agent.lower() for pattern in suspicious_patterns): + lh.log_security_event( + event_type="suspicious_user_agent", + description=f"Suspicious user agent detected: {user_agent[:200]}", + severity="HIGH", + additional_data={'user_agent': user_agent, 'ip_address': ip_address} + ) + + @app.after_request + def log_response_info(response): + """Log slow requests and error responses for performance and health monitoring""" + from extensions import logger_handler as lh + if request.endpoint and request.endpoint.startswith('static'): + return response + if hasattr(g, 'start_time'): + duration = _time.time() - g.start_time + if duration > 2.0: + lh.log_system_event( + event_type="slow_query_detected", + description=f"Slow request: {request.endpoint} took {duration:.2f}s", + severity="WARNING", + additional_data={ + 'duration': duration, + 'endpoint': request.endpoint, + 'method': request.method, + 'user': session.get('username', 'anonymous') + } + ) + if response.status_code >= 400: + lh.logger.warning( + f"Error response: {response.status_code} for {request.path} " + f"by user {session.get('username', 'anonymous')}" + ) + return response + + # ------------------------------------------------------------------ + # Error handlers + # ------------------------------------------------------------------ + + @app.errorhandler(403) + def forbidden(error): + """Handle forbidden access errors""" + return render_template('errors/403.html'), 403 + + @app.errorhandler(404) + def not_found(error): + """Handle page not found errors""" + return render_template('errors/404.html'), 404 + + @app.errorhandler(500) + def internal_error(error): + """Handle internal server errors with user-friendly page""" + return render_template('errors/500.html'), 500 + + # ------------------------------------------------------------------ + # Startup initialization (runs under gunicorn and flask run alike) + # ------------------------------------------------------------------ + with app.app_context(): + try: + create_tables() + update_existing_qr_codes() + except Exception as e: + from extensions import logger_handler as _startup_lh + _startup_lh.logger.error(f"Startup initialization failed: {e}", exc_info=True) + raise + + return app + + +# --------------------------------------------------------------------------- +# Database initialization helpers (called at startup) +# --------------------------------------------------------------------------- + +@log_database_operations('database_initialization') +def create_tables(): + """Create database tables and default admin user with logging""" + from extensions import db as _db, logger_handler as lh + try: + _db.create_all() + from flask import current_app + from models.user import User + admin = User.query.filter_by(username='admin').first() + if not admin: + from config import Config as _Cfg + default_password = _Cfg.DEFAULT_ADMIN_PASSWORD + admin = User( + full_name='System Administrator', + email='admin@example.com', + username='admin', + role='admin' + ) + admin.set_password(default_password) + _db.session.add(admin) + _db.session.commit() + if default_password == 'admin123': + print("⚠️ WARNING: Default admin password 'admin123' is in use. " + "Set DEFAULT_ADMIN_PASSWORD in your .env file before going to production.") + lh.logger.warning( + "Default admin user created with insecure default password. " + "Set DEFAULT_ADMIN_PASSWORD environment variable." + ) + else: + lh.logger.info("Default admin user created during initialization") + lh._create_log_table() + except Exception as e: + lh.log_database_error('database_initialization', e) + raise + + +def update_existing_qr_codes(): + """Update existing QR codes with missing URLs or images at startup. + + Regenerates qr_url slugs without needing a request context. + For qr_code_image, constructs the base URL from FLASK_HOST/FLASK_PORT + config so this can run safely outside any HTTP request. + """ + from extensions import db as _db, logger_handler as lh + from utils.helpers import generate_qr_code, get_qr_styling, generate_qr_url + from config import Config as _Cfg + try: + from models.qrcode import QRCode + qr_codes = QRCode.query.filter_by(active_status=True).all() + if not qr_codes: + return + + # Build a base URL that does not require an active request context. + host = os.environ.get('FLASK_HOST', '0.0.0.0') + # 0.0.0.0 is a bind address, not a reachable hostname — default to localhost + if host in ('0.0.0.0', ''): + host = 'localhost' + port = os.environ.get('FLASK_PORT', '5000') + scheme = 'https' if _Cfg.SESSION_COOKIE_SECURE else 'http' + base_url = f"{scheme}://{host}:{port}/" + + updated_count = 0 + for qr_code in qr_codes: + if not qr_code.qr_url or not qr_code.qr_code_image: + try: + if not qr_code.qr_url: + qr_code.qr_url = generate_qr_url(qr_code.name, qr_code.id) + if not qr_code.qr_code_image: + qr_data = f"{base_url}qr/{qr_code.qr_url}" + styling = get_qr_styling(qr_code) + qr_code.qr_code_image = generate_qr_code( + data=qr_data, + fill_color=styling['fill_color'], + back_color=styling['back_color'], + box_size=styling['box_size'], + border=styling['border'], + error_correction=styling['error_correction'] + ) + updated_count += 1 + except Exception as e: + lh.log_flask_error('qr_code_update_error', f"Failed to update QR code {qr_code.id}: {str(e)}") + continue + if updated_count > 0: + _db.session.commit() + lh.logger.info(f"Startup: updated {updated_count} QR codes with missing URLs/images") + except Exception as e: + lh.log_database_error('update_existing_qr_codes', e) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +app = create_app() + +if __name__ == '__main__': + with app.app_context(): + try: + from extensions import logger_handler + logger_handler.logger.info("Initializing performance optimizations") + from app_performance_middleware import PerformanceMonitor # dev-mode only + cached_query = initialize_performance_optimizations(app, db, logger_handler) + performance_monitor = PerformanceMonitor(app, db, logger_handler) + + if cached_query: + logger_handler.logger.info("Performance optimizations completed successfully") + else: + logger_handler.logger.warning("Performance optimizations completed with warnings") + + logger_handler.logger.info("QR Attendance Management System started successfully") + + except Exception as e: + print(f"❌ Application startup failed: {e}") + raise + + from config import Config as _Cfg + app.run( + debug=_Cfg.DEBUG, + host=_Cfg.FLASK_HOST, + port=_Cfg.FLASK_PORT, + threaded=_Cfg.THREADED + ) \ No newline at end of file diff --git a/app_performance_middleware.py b/app_performance_middleware.py new file mode 100644 index 0000000..73c9823 --- /dev/null +++ b/app_performance_middleware.py @@ -0,0 +1,347 @@ +# File: app_performance_middleware.py +# Advanced performance middleware for QR Attendance System + +from functools import wraps +from flask import request, g, jsonify, current_app +import time +import threading +import queue +from datetime import datetime, timedelta +from collections import defaultdict, deque +import gc +import psutil +import os + +class PerformanceMonitor: + """ + Advanced performance monitoring and optimization middleware + """ + + def __init__(self, app=None, db=None, logger_handler=None): + self.app = app + self.db = db + self.logger_handler = logger_handler + + # Performance metrics storage + self.request_times = deque(maxlen=1000) # Keep last 1000 requests + self.slow_queries = deque(maxlen=100) + self.error_rates = defaultdict(int) + self.endpoint_stats = defaultdict(lambda: {'count': 0, 'total_time': 0, 'errors': 0}) + + # Rate limiting storage + self.rate_limit_storage = defaultdict(lambda: {'requests': deque(), 'blocked_until': None}) + + # Background task queue + self.task_queue = queue.Queue() + self.background_worker = None + + if app: + self.init_app(app, db, logger_handler) + + def init_app(self, app, db, logger_handler): + """Initialize performance monitoring with Flask app""" + self.app = app + self.db = db + self.logger_handler = logger_handler + + # Register before/after request handlers + app.before_request(self.before_request) + app.after_request(self.after_request) + + # Start background worker + self.start_background_worker() + + # Register performance monitoring routes + self.register_performance_routes() + + def before_request(self): + """Performance monitoring before each request""" + g.start_time = time.time() + g.request_id = f"{int(time.time())}-{threading.get_ident()}" + + # Rate limiting check + if self.is_rate_limited(): + return jsonify({ + 'error': 'Rate limit exceeded', + 'retry_after': 60 + }), 429 + + # Memory usage monitoring + self.monitor_memory_usage() + + def after_request(self, response): + """Performance monitoring after each request""" + if hasattr(g, 'start_time'): + request_time = time.time() - g.start_time + + # Record request metrics + self.record_request_metrics(request_time, response.status_code) + + # Log slow requests + if request_time > 2.0: # Requests taking more than 2 seconds + self.log_slow_request(request_time) + + # Add performance headers + response.headers['X-Response-Time'] = f"{request_time:.3f}s" + response.headers['X-Request-ID'] = getattr(g, 'request_id', 'unknown') + + return response + + def record_request_metrics(self, request_time, status_code): + """Record request performance metrics""" + endpoint = request.endpoint or 'unknown' + + # Store request time + self.request_times.append({ + 'endpoint': endpoint, + 'time': request_time, + 'status': status_code, + 'timestamp': datetime.utcnow() + }) + + # Update endpoint statistics + self.endpoint_stats[endpoint]['count'] += 1 + self.endpoint_stats[endpoint]['total_time'] += request_time + + if status_code >= 400: + self.endpoint_stats[endpoint]['errors'] += 1 + self.error_rates[status_code] += 1 + + def is_rate_limited(self): + """Check if current request should be rate limited""" + client_ip = request.environ.get('REMOTE_ADDR', 'unknown') + current_time = time.time() + + # Clean up old requests + client_data = self.rate_limit_storage[client_ip] + client_data['requests'] = deque([ + req_time for req_time in client_data['requests'] + if current_time - req_time < 60 # 1 minute window + ], maxlen=100) + + # Check if currently blocked + if client_data['blocked_until'] and current_time < client_data['blocked_until']: + return True + + # Add current request + client_data['requests'].append(current_time) + + # Check rate limit (100 requests per minute) + if len(client_data['requests']) > 100: + client_data['blocked_until'] = current_time + 300 # Block for 5 minutes + self.logger_handler.logger.warning(f"Rate limit exceeded for IP: {client_ip}") + return True + + return False + + def monitor_memory_usage(self): + """Monitor application memory usage""" + # Get memory usage every 10 requests (approximately) + import random + if random.randint(1, 10) == 1: + process = psutil.Process(os.getpid()) + memory_info = process.memory_info() + memory_mb = memory_info.rss / 1024 / 1024 + + if memory_mb > 1000: # More than 1GB + self.logger_handler.logger.warning(f"High memory usage: {memory_mb:.1f}MB") + + # Force garbage collection + gc.collect() + + # Queue background cleanup task + self.task_queue.put({ + 'type': 'memory_cleanup', + 'timestamp': datetime.utcnow() + }) + + def log_slow_request(self, request_time): + """Log slow requests for optimization""" + slow_request_data = { + 'endpoint': request.endpoint, + 'method': request.method, + 'time': request_time, + 'args': dict(request.args), + 'timestamp': datetime.utcnow() + } + + self.slow_queries.append(slow_request_data) + + self.logger_handler.logger.warning( + f"Slow request: {request.method} {request.endpoint} - {request_time:.3f}s" + ) + + def start_background_worker(self): + """Start background worker for performance tasks""" + def worker(): + while True: + try: + task = self.task_queue.get(timeout=30) + self.process_background_task(task) + self.task_queue.task_done() + except queue.Empty: + continue + except Exception as e: + if self.logger_handler: + self.logger_handler.logger.error(f"Background worker error: {e}") + + self.background_worker = threading.Thread(target=worker, daemon=True) + self.background_worker.start() + + def process_background_task(self, task): + """Process background performance tasks""" + task_type = task.get('type') + + if task_type == 'memory_cleanup': + self.perform_memory_cleanup() + elif task_type == 'performance_analysis': + self.perform_performance_analysis() + elif task_type == 'database_optimization': + self.optimize_database_connections() + + def perform_memory_cleanup(self): + """Perform memory cleanup operations""" + try: + # Clear old metrics + cutoff_time = datetime.utcnow() - timedelta(hours=1) + + # Clean request times + self.request_times = deque([ + req for req in self.request_times + if req['timestamp'] > cutoff_time + ], maxlen=1000) + + # Clean slow queries + self.slow_queries = deque([ + query for query in self.slow_queries + if query['timestamp'] > cutoff_time + ], maxlen=100) + + # Clean rate limit storage + current_time = time.time() + for ip, data in list(self.rate_limit_storage.items()): + if not data['requests'] and ( + not data['blocked_until'] or current_time > data['blocked_until'] + ): + del self.rate_limit_storage[ip] + + # Force garbage collection + gc.collect() + + self.logger_handler.logger.info("Memory cleanup completed") + + except Exception as e: + self.logger_handler.logger.error(f"Memory cleanup failed: {e}") + + def register_performance_routes(self): + """Register performance monitoring API endpoints""" + + @self.app.route('/api/performance/stats') + def performance_stats(): + """Get current performance statistics""" + try: + # Calculate average response times + recent_requests = [ + req for req in self.request_times + if req['timestamp'] > datetime.utcnow() - timedelta(minutes=5) + ] + + avg_response_time = ( + sum(req['time'] for req in recent_requests) / len(recent_requests) + if recent_requests else 0 + ) + + # Get endpoint statistics + endpoint_performance = {} + for endpoint, stats in self.endpoint_stats.items(): + endpoint_performance[endpoint] = { + 'avg_response_time': stats['total_time'] / stats['count'] if stats['count'] > 0 else 0, + 'total_requests': stats['count'], + 'error_rate': stats['errors'] / stats['count'] if stats['count'] > 0 else 0 + } + + # Get memory info + process = psutil.Process(os.getpid()) + memory_info = process.memory_info() + + return jsonify({ + 'avg_response_time': round(avg_response_time, 3), + 'total_requests': len(self.request_times), + 'slow_requests': len(self.slow_queries), + 'memory_usage_mb': round(memory_info.rss / 1024 / 1024, 1), + 'endpoint_performance': endpoint_performance, + 'error_rates': dict(self.error_rates) + }) + + except Exception as e: + self.logger_handler.logger.error(f"Performance stats error: {e}") + return jsonify({'error': 'Failed to get performance stats'}), 500 + + @self.app.route('/api/performance/slow-requests') + def slow_requests(): + """Get recent slow requests for analysis""" + try: + slow_request_list = [ + { + 'endpoint': req['endpoint'], + 'method': req.get('method', 'GET'), + 'time': round(req['time'], 3), + 'timestamp': req['timestamp'].isoformat() + } + for req in list(self.slow_queries)[-20:] # Last 20 slow requests + ] + + return jsonify({ + 'slow_requests': slow_request_list, + 'total_slow_requests': len(self.slow_queries) + }) + + except Exception as e: + self.logger_handler.logger.error(f"Slow requests API error: {e}") + return jsonify({'error': 'Failed to get slow requests'}), 500 + +def performance_optimization_decorator(threshold=1.0): + """ + Decorator to monitor and optimize specific function performance + """ + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + start_time = time.time() + + try: + result = func(*args, **kwargs) + execution_time = time.time() - start_time + + if execution_time > threshold: + print(f"⚠️ Slow function: {func.__name__} took {execution_time:.3f}s") + + return result + + except Exception as e: + execution_time = time.time() - start_time + print(f"❌ Function error: {func.__name__} failed after {execution_time:.3f}s - {e}") + raise + + return wrapper + return decorator + +def optimize_database_queries(): + """ + Database query optimization decorator + """ + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + # Enable query logging for this function + query_start = time.time() + + result = func(*args, **kwargs) + + query_time = time.time() - query_start + if query_time > 0.5: # Queries taking more than 500ms + print(f"🐌 Slow query in {func.__name__}: {query_time:.3f}s") + + return result + return wrapper + return decorator \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..42e04a6 --- /dev/null +++ b/config.py @@ -0,0 +1,140 @@ +""" +config.py +========= +Centralised application configuration. + +All environment variable reads happen here — once, at startup. +Blueprints and helpers that need a config value use: + + from flask import current_app + value = current_app.config['KEY'] + +Or for values needed at module import time (before app context): + + from config import Config + value = Config.COMPANY_NAME +""" + +import os +from datetime import timedelta + + +class Config: + # ------------------------------------------------------------------ # + # Core Flask + # ------------------------------------------------------------------ # + SECRET_KEY = os.environ.get('SECRET_KEY', 'change-me-in-production') + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', '') + SQLALCHEMY_TRACK_MODIFICATIONS = ( + os.environ.get('SQLALCHEMY_TRACK_MODIFICATIONS', 'False').lower() == 'true' + ) + TEMPLATES_AUTO_RELOAD = ( + os.environ.get('TEMPLATES_AUTO_RELOAD', 'True').lower() == 'true' + ) + DEBUG = os.environ.get('DEBUG', 'False').lower() == 'true' + + # ------------------------------------------------------------------ # + # SQLAlchemy connection pool (read from .env; safe defaults) + # ------------------------------------------------------------------ # + SQLALCHEMY_ENGINE_OPTIONS = { + 'pool_size': int(os.environ.get('SQLALCHEMY_ENGINE_OPTIONS_POOL_SIZE', '10')), + 'pool_timeout': int(os.environ.get('SQLALCHEMY_ENGINE_OPTIONS_POOL_TIMEOUT', '20')), + 'pool_recycle': int(os.environ.get('SQLALCHEMY_ENGINE_OPTIONS_POOL_RECYCLE', '3600')), + 'max_overflow': int(os.environ.get('SQLALCHEMY_ENGINE_OPTIONS_MAX_OVERFLOW', '20')), + } + + # ------------------------------------------------------------------ # + # Session / cookies + # ------------------------------------------------------------------ # + PERMANENT_SESSION_LIFETIME = timedelta(days=30) # Reduced from 30 days — payroll data sensitivity + SESSION_COOKIE_SECURE = ( + os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == 'true' + ) + SESSION_COOKIE_HTTPONLY = ( + os.environ.get('SESSION_COOKIE_HTTPONLY', 'true').lower() == 'true' + ) + SESSION_COOKIE_SAMESITE = os.environ.get('SESSION_COOKIE_SAMESITE', 'Lax') + + # ------------------------------------------------------------------ # + # Application identity + # ------------------------------------------------------------------ # + COMPANY_NAME = os.environ.get('COMPANY_NAME', 'QR Code Management System') + CONTRACT_NAME = os.environ.get('CONTRACT_NAME', 'Default Contract') + + # ------------------------------------------------------------------ # + # File uploads + # ------------------------------------------------------------------ # + UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/tmp') + + # ------------------------------------------------------------------ # + # Photo verification + # ------------------------------------------------------------------ # + PHOTO_VERIFICATION_ENABLED = ( + os.environ.get('ENABLE_PHOTO_VERIFICATION', 'true').lower() == 'true' + ) + DISTANCE_THRESHOLD_FOR_VERIFICATION = float( + os.environ.get('PHOTO_VERIFICATION_DISTANCE_THRESHOLD', '0.3') + ) + VERIFICATION_PHOTO_MAX_SIZE = int( + os.environ.get('VERIFICATION_PHOTO_MAX_SIZE', str(5 * 1024 * 1024)) + ) + + # ------------------------------------------------------------------ # + # Check-in interval + # ------------------------------------------------------------------ # + TIME_INTERVAL = int(os.environ.get('TIME_INTERVAL', '30')) + + # ------------------------------------------------------------------ # + # Export by Building — filtered sheet + weekly hours summary + # ------------------------------------------------------------------ # + # Comma-separated base employee IDs (project managers) removed from the + # "Filtered Report" and "Weekly Hours by Location" sheets. + BUILDING_SUMMARY_EXCLUDED_EMPLOYEE_IDS = [ + e.strip() for e in os.environ.get( + 'BUILDING_SUMMARY_EXCLUDED_EMPLOYEE_IDS', '4921,4944,4816,3979' + ).split(',') if e.strip() + ] + + # ------------------------------------------------------------------ # + # Server + # ------------------------------------------------------------------ # + FLASK_HOST = os.environ.get('FLASK_HOST', '0.0.0.0') + FLASK_PORT = int(os.environ.get('FLASK_PORT', '5000')) + THREADED = os.environ.get('THREADED', 'True').lower() == 'true' + + # ------------------------------------------------------------------ # + # Default admin (used only on first boot) + # ------------------------------------------------------------------ # + DEFAULT_ADMIN_PASSWORD = os.environ.get('DEFAULT_ADMIN_PASSWORD', 'admin123') + + # ------------------------------------------------------------------ # + # Remote (legacy) MySQL server — read-only source for Legacy Attendance + # ------------------------------------------------------------------ # + REMOTE_DB_HOST = os.environ.get('REMOTE_DB_HOST', '') + REMOTE_DB_PORT = int(os.environ.get('REMOTE_DB_PORT', '3306')) + REMOTE_DB_USERNAME = os.environ.get('REMOTE_DB_USERNAME', '') + REMOTE_DB_PASSWORD = os.environ.get('REMOTE_DB_PASSWORD', '') + REMOTE_DB_NAME = os.environ.get('REMOTE_DB_NAME', '') + + +class DevelopmentConfig(Config): + DEBUG = True + SESSION_COOKIE_SECURE = False + + +class ProductionConfig(Config): + DEBUG = False + TEMPLATES_AUTO_RELOAD = False + + +# Active config selected by environment variable +_config_map = { + 'development': DevelopmentConfig, + 'production': ProductionConfig, + 'default': Config, +} + +def get_config(): + """Return the active Config class based on FLASK_ENV.""" + env = os.environ.get('FLASK_ENV', 'default').lower() + return _config_map.get(env, Config) \ No newline at end of file diff --git a/db_audit_tables.py b/db_audit_tables.py new file mode 100644 index 0000000..cf41bcc --- /dev/null +++ b/db_audit_tables.py @@ -0,0 +1,97 @@ +# quick_fix_tables.py +""" +Quick Fix for Missing Maintenance Tables +====================================== + +Run this script to quickly create the missing tables that are causing the cleanup error. +""" + +import sys +import os +from sqlalchemy import text + +# Add current directory to path +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from app import app, db, logger_handler + +def quick_create_missing_tables(): + """Quickly create the missing tables for maintenance system""" + + print("🔧 Quick Fix: Creating Missing Maintenance Tables") + print("=" * 55) + + with app.app_context(): + try: + # Create attendance_audit table + print("📋 Creating attendance_audit table...") + + audit_sql = """ + CREATE TABLE IF NOT EXISTS attendance_audit ( + audit_id INT AUTO_INCREMENT PRIMARY KEY, + record_id INT NOT NULL, + action_type ENUM('INSERT', 'UPDATE', 'DELETE') NOT NULL, + old_values JSON, + new_values JSON, + changed_by VARCHAR(100), + change_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ip_address VARCHAR(45), + INDEX idx_audit_timestamp (change_timestamp DESC), + INDEX idx_audit_record (record_id) + ) ENGINE=InnoDB + """ + + db.session.execute(text(audit_sql)) + print(" ✅ attendance_audit table created") + + # Create attendance_statistics_cache table + print("📋 Creating attendance_statistics_cache table...") + + cache_sql = """ + CREATE TABLE IF NOT EXISTS attendance_statistics_cache ( + cache_key VARCHAR(255) PRIMARY KEY, + cache_data JSON NOT NULL, + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + expires_at TIMESTAMP, + INDEX idx_cache_expires (expires_at), + INDEX idx_cache_updated (last_updated DESC) + ) ENGINE=InnoDB + """ + + db.session.execute(text(cache_sql)) + print(" ✅ attendance_statistics_cache table created") + + # Commit the changes + db.session.commit() + + print("\n✅ SUCCESS: Missing tables created successfully!") + print("\n🧪 Testing the tables...") + + # Test the tables + test_queries = [ + "SELECT COUNT(*) FROM attendance_audit", + "SELECT COUNT(*) FROM attendance_statistics_cache" + ] + + for query in test_queries: + try: + result = db.session.execute(text(query)).fetchone() + table_name = query.split("FROM ")[1] + print(f" ✅ {table_name}: OK (rows: {result[0]})") + except Exception as e: + print(f" ❌ Test failed: {e}") + + print(f"\n🎉 QUICK FIX COMPLETED!") + print("\nNow you can run:") + print(" python maintenance_cli.py cleanup") + print(" python maintenance_cli.py health-check") + + return True + + except Exception as e: + print(f"❌ Error creating tables: {e}") + db.session.rollback() + return False + +if __name__ == "__main__": + quick_create_missing_tables() \ No newline at end of file diff --git a/db_health_check.py b/db_health_check.py new file mode 100644 index 0000000..8497b88 --- /dev/null +++ b/db_health_check.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +""" +Simple Health Check - Windows Compatible +======================================= +""" + +import sys +import os +from datetime import datetime +from sqlalchemy import text + +# Add current directory to path +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +def health_check(): + """Run database health check""" + try: + from app import app, db, logger_handler + + print("Database Health Check - Windows Compatible") + print("=" * 50) + + with app.app_context(): + health_status = { + 'overall': 'healthy', + 'issues': [], + 'warnings': [], + 'recommendations': [] + } + + # Check database connectivity + try: + result = db.session.execute(text("SELECT 1 as test")).fetchone() + if result and result.test == 1: + print("[OK] Database connectivity: WORKING") + else: + print("[ERROR] Database connectivity: FAILED - Unexpected result") + health_status['issues'].append("Database connectivity test failed") + except Exception as e: + print(f"[ERROR] Database connectivity: FAILED - {e}") + health_status['overall'] = 'critical' + health_status['issues'].append(f"Database connectivity failed: {e}") + + # Check critical tables exist + critical_tables = ['attendance_data', 'qr_codes', 'users', 'projects', 'log_events'] + existing_tables = [] + + print("\nTable Status:") + for table in critical_tables: + try: + # Use SHOW TABLES for better MySQL compatibility + table_check = db.session.execute(text(f"SHOW TABLES LIKE '{table}'")).fetchone() + if table_check: + # Get row count + count_result = db.session.execute(text(f"SELECT COUNT(*) FROM {table}")).fetchone() + row_count = count_result[0] if count_result else 0 + print(f"[OK] {table}: EXISTS ({row_count:,} rows)") + existing_tables.append(table) + else: + print(f"[MISSING] {table}: NOT FOUND") + health_status['issues'].append(f"Critical table {table} is missing") + except Exception as e: + print(f"[ERROR] {table}: ERROR - {e}") + health_status['issues'].append(f"Could not check table {table}: {e}") + + # Check maintenance tables + maintenance_tables = ['attendance_audit', 'attendance_statistics_cache'] + print("\nMaintenance Tables:") + for table in maintenance_tables: + try: + table_check = db.session.execute(text(f"SHOW TABLES LIKE '{table}'")).fetchone() + if table_check: + count_result = db.session.execute(text(f"SELECT COUNT(*) FROM {table}")).fetchone() + row_count = count_result[0] if count_result else 0 + print(f"[OK] {table}: EXISTS ({row_count:,} rows)") + else: + print(f"[MISSING] {table}: NOT FOUND") + health_status['warnings'].append(f"Maintenance table {table} is missing") + except Exception as e: + print(f"[ERROR] {table}: ERROR - {e}") + + # Check for indexes on existing tables + print("\nIndex Analysis:") + missing_indexes = 0 + for table in existing_tables: + try: + # Check if table has performance indexes + index_check = db.session.execute(text(f""" + SELECT COUNT(*) as index_count + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = '{table}' + AND INDEX_NAME != 'PRIMARY' + """)).fetchone() + + index_count = index_check.index_count if index_check else 0 + if index_count == 0: + print(f"[WARNING] {table}: No performance indexes") + missing_indexes += 1 + else: + print(f"[OK] {table}: {index_count} indexes") + + except Exception as e: + print(f"[ERROR] {table} indexes: {e}") + + if missing_indexes > 0: + health_status['warnings'].append(f"{missing_indexes} tables have no performance indexes") + health_status['recommendations'].append("Add performance indexes") + + # Check triggers + print("\nTrigger Status:") + try: + trigger_count_query = """ + SELECT COUNT(*) as trigger_count + FROM INFORMATION_SCHEMA.TRIGGERS + WHERE TRIGGER_SCHEMA = DATABASE() + """ + + trigger_result = db.session.execute(text(trigger_count_query)).fetchone() + trigger_count = trigger_result.trigger_count if trigger_result else 0 + + if trigger_count > 0: + print(f"[OK] Database triggers: {trigger_count} found") + else: + print("[WARNING] Database triggers: None found") + health_status['warnings'].append("No database triggers found") + health_status['recommendations'].append("Consider adding audit triggers") + + except Exception as e: + print(f"[ERROR] Trigger check failed: {e}") + health_status['warnings'].append("Trigger check failed") + + # Determine overall health + if len(health_status['issues']) > 0: + health_status['overall'] = 'critical' + elif len(health_status['warnings']) > 3: + health_status['overall'] = 'warning' + + # Print summary + print(f"\n{'='*50}") + print(f"OVERALL HEALTH: {health_status['overall'].upper()}") + print(f"{'='*50}") + + # Report issues + if health_status['issues']: + print("\nCRITICAL ISSUES:") + for i, issue in enumerate(health_status['issues'], 1): + print(f"{i}. {issue}") + + if health_status['warnings']: + print("\nWARNINGS:") + for i, warning in enumerate(health_status['warnings'], 1): + print(f"{i}. {warning}") + + if health_status['recommendations']: + print("\nRECOMMENDATIONS:") + for i, rec in enumerate(health_status['recommendations'], 1): + print(f"{i}. {rec}") + + # Provide actionable next steps + print("\nNEXT STEPS:") + if health_status['overall'] == 'critical': + print("1. Address critical issues above") + print("2. Verify database connection and table creation") + print("3. Check Flask app initialization") + elif health_status['overall'] == 'warning': + print("1. Add performance indexes: python db_maintenance.py add-indexes") + print("2. Set up audit triggers for data tracking") + print("3. Run regular maintenance") + else: + print("1. System is healthy!") + print("2. Run regular maintenance: python db_maintenance.py cleanup") + print("3. Monitor performance metrics") + + return health_status + + except ImportError as e: + print(f"[ERROR] Could not import Flask app: {e}") + print("Make sure you're running this from your Flask app directory") + return {'overall': 'critical', 'issues': [f"Import error: {e}"]} + except Exception as e: + print(f"[ERROR] Health check failed: {e}") + return {'overall': 'critical', 'issues': [f"Health check error: {e}"]} + +if __name__ == "__main__": + health_check() diff --git a/db_maintenance.py b/db_maintenance.py new file mode 100644 index 0000000..5c9e4a3 --- /dev/null +++ b/db_maintenance.py @@ -0,0 +1,526 @@ +# db_maintenance.py +""" +Fixed Simple Maintenance Script +============================== + +Fixes SQL parameter binding issues and datetime deprecation warnings. +""" + +import sys +import os +from datetime import datetime, timedelta, timezone +from sqlalchemy import text + +# Add current directory to path +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +def cleanup_data(dry_run=False): + """Clean up old data from maintenance tables - FIXED VERSION""" + try: + from app import app, db + + print("Data Cleanup - Fixed Version") + print("=" * 35) + + with app.app_context(): + # Define cleanup operations + # (table_name, date_column, retention_days, description) + tables_to_clean = [ + ('attendance_audit', 'change_timestamp', 90, 'Audit trail records'), + ('attendance_statistics_cache', 'expires_at', 1, 'Expired cache entries'), + ('log_events', 'created_timestamp', 180, 'Old system logs') + ] + + total_records_to_clean = 0 + total_records_cleaned = 0 + + print(f"Retention policy:") + for table, col, days, desc in tables_to_clean: + print(f" - {desc}: {days} days") + + print("\nAnalyzing tables...\n") + + for table_name, date_column, retention_days, description in tables_to_clean: + try: + # Check if table exists + table_check = db.session.execute(text(f"SHOW TABLES LIKE '{table_name}'")).fetchone() + + if not table_check: + print(f"[SKIP] {table_name}: Table doesn't exist") + continue + + # Calculate cutoff date (using timezone-aware datetime) + cutoff_date = datetime.now(timezone.utc) - timedelta(days=retention_days) + cutoff_str = cutoff_date.strftime('%Y-%m-%d %H:%M:%S') + + # Count total records in table + total_count_query = f"SELECT COUNT(*) as total FROM {table_name}" + total_result = db.session.execute(text(total_count_query)).fetchone() + total_records = total_result.total if total_result else 0 + + # Count records to clean - Fixed SQL syntax + count_query = f""" + SELECT COUNT(*) as count + FROM {table_name} + WHERE {date_column} < :cutoff_date + """ + count_result = db.session.execute( + text(count_query), + {'cutoff_date': cutoff_str} + ).fetchone() + + records_to_clean = count_result.count if count_result else 0 + records_to_keep = total_records - records_to_clean + + if records_to_clean > 0: + print(f"[{table_name}]") + print(f" Total records: {total_records:,}") + print(f" Records to clean: {records_to_clean:,}") + print(f" Records to keep: {records_to_keep:,}") + print(f" Cutoff date: {cutoff_str}") + + if dry_run: + print(f" [DRY RUN] Would delete {records_to_clean:,} records") + else: + # Perform cleanup - Fixed SQL syntax + delete_query = f""" + DELETE FROM {table_name} + WHERE {date_column} < :cutoff_date + """ + result = db.session.execute( + text(delete_query), + {'cutoff_date': cutoff_str} + ) + actual_deleted = result.rowcount + print(f" [CLEANED] Deleted {actual_deleted:,} records") + total_records_cleaned += actual_deleted + + total_records_to_clean += records_to_clean + print() # Empty line for readability + + else: + print(f"[{table_name}]") + print(f" Total records: {total_records:,}") + print(f" No old records to clean (all newer than {retention_days} days)") + print() + + except Exception as e: + print(f"[ERROR] Error processing {table_name}: {e}") + print(f" Error type: {type(e).__name__}") + print() + + # Summary + print("=" * 35) + if dry_run: + if total_records_to_clean > 0: + print(f"[DRY RUN SUMMARY]") + print(f"Would clean {total_records_to_clean:,} total records") + print(f"No changes made to database") + else: + print(f"[DRY RUN SUMMARY]") + print(f"No records need cleaning") + else: + if total_records_cleaned > 0: + # Commit changes + db.session.commit() + print(f"[CLEANUP COMPLETED]") + print(f"Successfully cleaned {total_records_cleaned:,} total records") + else: + print(f"[NO CLEANUP NEEDED]") + print(f"All records are within retention policy") + + except Exception as e: + print(f"[ERROR] Cleanup failed: {e}") + print(f"Error type: {type(e).__name__}") + try: + db.session.rollback() + print("Database changes rolled back") + except: + pass + +def add_performance_indexes(): + """Add basic performance indexes - FIXED VERSION""" + try: + from app import app, db + + print("Adding Performance Indexes - Fixed Version") + print("=" * 45) + + with app.app_context(): + # Define critical indexes with better error handling + indexes_to_create = [ + { + 'name': 'idx_attendance_date_employee', + 'table': 'attendance_data', + 'columns': 'check_in_date DESC, employee_id', + 'purpose': 'Optimize attendance queries by date and employee' + }, + { + 'name': 'idx_attendance_qr_date', + 'table': 'attendance_data', + 'columns': 'qr_code_id, check_in_date DESC', + 'purpose': 'Optimize queries by QR code and date' + }, + { + 'name': 'idx_attendance_location_date', + 'table': 'attendance_data', + 'columns': 'location_name, check_in_date DESC', + 'purpose': 'Optimize queries by location' + }, + { + 'name': 'idx_qrcode_project_active', + 'table': 'qr_codes', + 'columns': 'project_id, active_status', + 'purpose': 'Optimize project-based QR code queries' + }, + { + 'name': 'idx_users_username_active', + 'table': 'users', + 'columns': 'username, active_status', + 'purpose': 'Optimize user authentication' + }, + { + 'name': 'idx_log_events_timestamp_category', + 'table': 'log_events', + 'columns': 'created_timestamp DESC, event_category', + 'purpose': 'Optimize log queries and filtering' + } + ] + + indexes_created = 0 + indexes_skipped = 0 + indexes_failed = 0 + + for index_def in indexes_to_create: + print(f"\nProcessing {index_def['name']}...") + + try: + # Check if table exists first + table_check = db.session.execute( + text(f"SHOW TABLES LIKE :table_name"), + {'table_name': index_def['table']} + ).fetchone() + + if not table_check: + print(f" [SKIP] Table {index_def['table']} doesn't exist") + indexes_skipped += 1 + continue + + # Check if index already exists + index_check = db.session.execute(text(""" + SELECT COUNT(*) as count + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = :table_name + AND INDEX_NAME = :index_name + """), { + 'table_name': index_def['table'], + 'index_name': index_def['name'] + }).fetchone() + + if index_check and index_check.count > 0: + print(f" [EXISTS] Index already exists") + indexes_skipped += 1 + continue + + # Create the index + create_sql = f""" + CREATE INDEX {index_def['name']} + ON {index_def['table']} ({index_def['columns']}) + """ + + db.session.execute(text(create_sql)) + + print(f" [CREATED] Successfully created index") + print(f" Purpose: {index_def['purpose']}") + indexes_created += 1 + + except Exception as e: + error_msg = str(e).lower() + if 'duplicate' in error_msg or 'already exists' in error_msg: + print(f" [EXISTS] Index already exists (detected in error)") + indexes_skipped += 1 + else: + print(f" [ERROR] Failed to create index: {e}") + indexes_failed += 1 + + # Commit changes if any indexes were created + if indexes_created > 0: + db.session.commit() + + # Summary + print(f"\n{'=' * 45}") + print(f"INDEX CREATION SUMMARY") + print(f"{'=' * 45}") + print(f"Created: {indexes_created}") + print(f"Skipped (already exist): {indexes_skipped}") + print(f"Failed: {indexes_failed}") + print(f"Total processed: {len(indexes_to_create)}") + + if indexes_created > 0: + print(f"\n[SUCCESS] Created {indexes_created} new performance indexes") + print("These indexes will improve query performance for:") + print("- Attendance filtering by date/employee/location") + print("- User authentication") + print("- QR code project queries") + print("- Log event filtering") + elif indexes_skipped == len(indexes_to_create): + print(f"\n[INFO] All indexes already exist - no action needed") + else: + print(f"\n[WARNING] Some indexes could not be created") + + except Exception as e: + print(f"[ERROR] Index creation failed: {e}") + try: + db.session.rollback() + except: + pass + +def show_table_info(): + """Show detailed information about database tables""" + try: + from app import app, db + + print("Database Table Information - Detailed") + print("=" * 45) + + with app.app_context(): + # Get all tables + tables_result = db.session.execute(text("SHOW TABLES")).fetchall() + table_names = [row[0] for row in tables_result] + + print(f"Database: {db.engine.url.database}") + print(f"Total tables: {len(table_names)}\n") + + # Header for table info + print(f"{'Table Name':<25} {'Rows':<10} {'Size (MB)':<10} {'Indexes':<8} {'Type'}") + print("-" * 70) + + total_size = 0 + total_rows = 0 + + for table in sorted(table_names): + try: + # Get row count + count_result = db.session.execute(text(f"SELECT COUNT(*) FROM {table}")).fetchone() + row_count = count_result[0] if count_result else 0 + + # Get table size and type + info_query = f""" + SELECT + ROUND(((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024), 2) as size_mb, + ENGINE as engine_type + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{table}' + """ + info_result = db.session.execute(text(info_query)).fetchone() + size_mb = info_result.size_mb if info_result and info_result.size_mb else 0 + engine_type = info_result.engine_type if info_result else 'Unknown' + + # Get index count + index_query = f""" + SELECT COUNT(*) as index_count + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = '{table}' + AND INDEX_NAME != 'PRIMARY' + """ + index_result = db.session.execute(text(index_query)).fetchone() + index_count = index_result.index_count if index_result else 0 + + # Determine table category + table_type = "System" + if table in ['attendance_data', 'qr_codes', 'users', 'projects']: + table_type = "Core" + elif table in ['attendance_audit', 'attendance_statistics_cache', 'log_events']: + table_type = "Maintenance" + elif table in ['customers', 'tenant_configs']: + table_type = "Config" + + print(f"{table:<25} {row_count:<10,} {size_mb:<10.1f} {index_count:<8} {table_type}") + + total_size += size_mb + total_rows += row_count + + except Exception as e: + print(f"{table:<25} {'ERROR':<10} {'N/A':<10} {'N/A':<8} - {str(e)[:20]}") + + # Summary + print("-" * 70) + print(f"{'TOTALS':<25} {total_rows:<10,} {total_size:<10.1f}") + + # Additional insights + print(f"\nTable Categories:") + core_tables = [t for t in table_names if t in ['attendance_data', 'qr_codes', 'users', 'projects']] + maintenance_tables = [t for t in table_names if t in ['attendance_audit', 'attendance_statistics_cache', 'log_events']] + + print(f" Core tables: {len(core_tables)} ({', '.join(core_tables)})") + print(f" Maintenance tables: {len(maintenance_tables)} ({', '.join(maintenance_tables)})") + print(f" Other tables: {len(table_names) - len(core_tables) - len(maintenance_tables)}") + + except Exception as e: + print(f"[ERROR] Failed to get table info: {e}") + +def analyze_performance(): + """Analyze database performance and suggest optimizations""" + try: + from app import app, db + + print("Database Performance Analysis") + print("=" * 35) + + with app.app_context(): + # Check for tables without indexes + tables_to_check = ['attendance_data', 'qr_codes', 'users', 'log_events'] + + print("Index Coverage Analysis:") + print("-" * 25) + + tables_needing_indexes = [] + + for table in tables_to_check: + try: + # Check if table exists + table_check = db.session.execute( + text(f"SHOW TABLES LIKE :table_name"), + {'table_name': table} + ).fetchone() + + if not table_check: + continue + + # Count non-primary indexes + index_query = f""" + SELECT COUNT(*) as index_count + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = '{table}' + AND INDEX_NAME != 'PRIMARY' + """ + index_result = db.session.execute(text(index_query)).fetchone() + index_count = index_result.index_count if index_result else 0 + + # Get table size for impact assessment + size_query = f""" + SELECT + TABLE_ROWS, + ROUND(((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024), 2) as size_mb + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{table}' + """ + size_result = db.session.execute(text(size_query)).fetchone() + row_count = size_result.TABLE_ROWS if size_result else 0 + size_mb = size_result.size_mb if size_result and size_result.size_mb else 0 + + status = "GOOD" if index_count > 0 else "NEEDS INDEXES" + priority = "HIGH" if row_count > 1000 and index_count == 0 else "MEDIUM" + + print(f"{table:<20} {index_count:<3} indexes {row_count:<8,} rows {status}") + + if index_count == 0 and row_count > 100: + tables_needing_indexes.append({ + 'table': table, + 'rows': row_count, + 'size_mb': size_mb, + 'priority': priority + }) + + except Exception as e: + print(f"{table:<20} ERROR - {e}") + + # Recommendations + if tables_needing_indexes: + print(f"\nPerformance Recommendations:") + print("-" * 28) + + for table_info in sorted(tables_needing_indexes, key=lambda x: x['rows'], reverse=True): + print(f"• {table_info['table']}: Add performance indexes") + print(f" Rows: {table_info['rows']:,}, Priority: {table_info['priority']}") + + print(f"\nTo add indexes: python db_maintenance.py add-indexes") + else: + print(f"\n[GOOD] All major tables have performance indexes") + + # Check for large tables that might need optimization + print(f"\nLarge Table Analysis:") + print("-" * 22) + + large_tables_query = """ + SELECT + TABLE_NAME, + TABLE_ROWS, + ROUND(((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024), 2) as size_mb + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_ROWS > 1000 + ORDER BY TABLE_ROWS DESC + """ + + large_tables = db.session.execute(text(large_tables_query)).fetchall() + + if large_tables: + for table in large_tables: + print(f"{table.TABLE_NAME:<20} {table.TABLE_ROWS:<8,} rows {table.size_mb:<6.1f} MB") + + if any(table.TABLE_ROWS > 10000 for table in large_tables): + print(f"\nTip: Consider regular cleanup for tables with >10k rows") + else: + print("No large tables found (all have <1000 rows)") + + except Exception as e: + print(f"[ERROR] Performance analysis failed: {e}") + +def main(): + """Main function with enhanced commands""" + if len(sys.argv) < 2: + print("Fixed Simple Maintenance Script") + print("=" * 35) + print("Available commands:") + print(" cleanup [--dry-run] - Clean up old data") + print(" add-indexes - Add performance indexes") + print(" table-info - Show detailed table information") + print(" analyze-performance - Analyze database performance") + print(" health-check - Run health check") + print("") + print("Examples:") + print(" python db_maintenance.py cleanup --dry-run") + print(" python db_maintenance.py add-indexes") + print(" python db_maintenance.py table-info") + return + + command = sys.argv[1].lower() + + try: + if command == 'cleanup': + dry_run = '--dry-run' in sys.argv + cleanup_data(dry_run=dry_run) + + elif command == 'add-indexes': + add_performance_indexes() + + elif command == 'table-info': + show_table_info() + + elif command == 'analyze-performance': + analyze_performance() + + elif command == 'health-check': + # Run health check from the other script + try: + from db_health_check import health_check + health_check() + except ImportError: + print("[ERROR] simple_health_check.py not found") + print("Run: python windows_compatible_fix.py first") + + else: + print(f"Unknown command: {command}") + print("Use: cleanup, add-indexes, table-info, analyze-performance, or health-check") + + except KeyboardInterrupt: + print("\n[CANCELLED] Operation cancelled by user") + except Exception as e: + print(f"[ERROR] Command failed: {e}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/db_performance_optimization.py b/db_performance_optimization.py new file mode 100644 index 0000000..7f73910 --- /dev/null +++ b/db_performance_optimization.py @@ -0,0 +1,217 @@ +# File: db_performance_optimization_fixed.py +# Fixed version compatible with your existing AppLogger + +from sqlalchemy import text +from datetime import datetime, timedelta +import logging + +def create_advanced_performance_indexes(db, logger_handler): + """ + Create advanced performance indexes for optimal query performance + Compatible with existing AppLogger + """ + try: + # Critical indexes for attendance data + performance_indexes = [ + # Composite index for attendance queries by date range and employee + "CREATE INDEX IF NOT EXISTS idx_attendance_employee_date ON attendance_data(employee_id, check_in_date)", + + # Index for location-based queries + "CREATE INDEX IF NOT EXISTS idx_attendance_location_date ON attendance_data(location_name, check_in_date)", + + # Index for time-based analytics + "CREATE INDEX IF NOT EXISTS idx_attendance_datetime ON attendance_data(check_in_date, check_in_time)", + + # QR Code performance indexes + "CREATE INDEX IF NOT EXISTS idx_qrcode_project_active ON qr_codes(project_id, active_status)", + + # User authentication indexes + "CREATE INDEX IF NOT EXISTS idx_users_username_active ON users(username, active_status)", + "CREATE INDEX IF NOT EXISTS idx_users_role_active ON users(role, active_status)", + + # Project management indexes + "CREATE INDEX IF NOT EXISTS idx_projects_active_name ON projects(active_status, name)", + + # Employee search optimization + "CREATE INDEX IF NOT EXISTS idx_employee_search ON employee(firstName, lastName, id)", + ] + + indexes_created = 0 + for index_sql in performance_indexes: + try: + db.session.execute(text(index_sql)) + logger_handler.logger.info(f"Created index: {index_sql[:50]}...") + indexes_created += 1 + except Exception as e: + logger_handler.logger.warning(f"Index creation skipped: {str(e)[:100]}") + + db.session.commit() + + # Log success using compatible method + logger_handler.logger.info(f"Performance optimization complete: {indexes_created} indexes created") + + return True + + except Exception as e: + db.session.rollback() + # Use compatible logging method + logger_handler.log_database_error('performance_optimization', e) + return False + +def optimize_database_configuration(db, logger_handler): + """ + Optimize database configuration for better performance + """ + try: + optimization_queries = [ + # Query cache optimization (MySQL specific) + "SET SESSION query_cache_type = ON", + + # Connection optimization + "SET SESSION wait_timeout = 28800", + "SET SESSION interactive_timeout = 28800", + ] + + optimizations_applied = 0 + for query in optimization_queries: + try: + db.session.execute(text(query)) + optimizations_applied += 1 + except Exception as e: + # Some settings may require specific privileges + logger_handler.logger.debug(f"Configuration skip: {str(e)[:50]}") + + logger_handler.logger.info(f"Database configuration optimization completed: {optimizations_applied} optimizations applied") + + except Exception as e: + logger_handler.log_database_error('database_configuration', e) + +def create_database_maintenance_routine(app, db, logger_handler): + """ + Create automated database maintenance routine + """ + @app.cli.command() + def db_maintenance(): + """Run database maintenance tasks""" + try: + with app.app_context(): + logger_handler.logger.info("Starting database maintenance routine") + + # Optimize all tables + maintenance_queries = [ + "OPTIMIZE TABLE attendance_data", + "OPTIMIZE TABLE qr_codes", + "OPTIMIZE TABLE projects", + "OPTIMIZE TABLE users", + "OPTIMIZE TABLE employee", + ] + + successful_optimizations = 0 + for query in maintenance_queries: + try: + db.session.execute(text(query)) + logger_handler.logger.info(f"Executed: {query}") + successful_optimizations += 1 + except Exception as e: + logger_handler.logger.warning(f"Maintenance query failed: {query} - {str(e)}") + + db.session.commit() + + logger_handler.logger.info(f"Database maintenance completed: {successful_optimizations} tables optimized") + print("✅ Database maintenance completed successfully") + + except Exception as e: + logger_handler.log_database_error('database_maintenance', e) + print(f"❌ Database maintenance failed: {e}") + +def implement_caching_strategy(app, db, logger_handler): + """ + Implement intelligent caching strategy for improved performance + """ + from functools import wraps + import hashlib + + # Simple in-memory cache + cache_storage = {} + cache_ttl = {} + + def cached_query(ttl=300): # 5 minutes default TTL + """Decorator for caching database queries""" + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + # Create cache key + cache_key = f"{func.__name__}_{hashlib.md5(str(args + tuple(kwargs.items())).encode()).hexdigest()}" + current_time = datetime.utcnow().timestamp() + + # Check if cached result exists and is still valid + if cache_key in cache_storage: + if current_time - cache_ttl.get(cache_key, 0) < ttl: + logger_handler.logger.debug(f"Cache hit for {func.__name__}") + return cache_storage[cache_key] + + # Execute function and cache result + result = func(*args, **kwargs) + cache_storage[cache_key] = result + cache_ttl[cache_key] = current_time + + logger_handler.logger.debug(f"Cache miss for {func.__name__} - result cached") + return result + return wrapper + return decorator + + # Clean up expired cache entries periodically + def cleanup_cache(): + current_time = datetime.utcnow().timestamp() + expired_keys = [ + key for key, timestamp in cache_ttl.items() + if current_time - timestamp > 300 # 5 minutes + ] + + for key in expired_keys: + cache_storage.pop(key, None) + cache_ttl.pop(key, None) + + if expired_keys: + logger_handler.logger.debug(f"Cleaned up {len(expired_keys)} expired cache entries") + + # Schedule cache cleanup + @app.before_request + def before_request_cache_cleanup(): + # Cleanup cache every 100 requests (approximately) + import random + if random.randint(1, 100) == 1: + cleanup_cache() + + logger_handler.logger.info("Caching strategy implemented successfully") + return cached_query + +def initialize_performance_optimizations(app, db, logger_handler): + """ + Initialize all performance optimizations with compatibility + """ + try: + logger_handler.logger.info("Starting performance optimization...") + + # Create advanced indexes + index_success = create_advanced_performance_indexes(db, logger_handler) + + # Optimize database configuration + optimize_database_configuration(db, logger_handler) + + # Create maintenance routines + create_database_maintenance_routine(app, db, logger_handler) + + # Implement caching + cached_query = implement_caching_strategy(app, db, logger_handler) + + if index_success: + logger_handler.logger.info("✅ Performance optimization completed successfully") + else: + logger_handler.logger.warning("⚠️ Performance optimization completed with some issues") + + return cached_query + + except Exception as e: + logger_handler.log_database_error('performance_initialization', e) + return None \ No newline at end of file diff --git a/employee_data_merger.py b/employee_data_merger.py new file mode 100644 index 0000000..a9de40b --- /dev/null +++ b/employee_data_merger.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +""" +Employee Data Merger and Deduplication Script +============================================ + +This script combines employee data from multiple SQL files and removes duplicates +based on intelligent business rules while maintaining data integrity. + +Features: +- Parses SQL INSERT statements from multiple files +- Intelligent duplicate detection by employee ID +- Quality-based record selection for deduplication +- Comprehensive logging of merge operations +- Generates clean combined SQL output +""" + +import re +import os +from typing import List, Dict, Set, Tuple +from dataclasses import dataclass +from datetime import datetime + +@dataclass +class Employee: + """Employee record structure""" + index: int + id: int + firstName: str + lastName: str + title: str + contractId: int + source: str = "" + quality_score: float = 0.0 + +class EmployeeDataMerger: + """Handles merging and deduplication of employee data from multiple sources""" + + def __init__(self): + self.employees: List[Employee] = [] + self.duplicate_stats = { + 'total_duplicates': 0, + 'kept_gov': 0, + 'kept_lt': 0, + 'removed_dummy': 0, + 'quality_upgrades': 0 + } + + def parse_sql_file(self, filename: str, source_name: str) -> List[Employee]: + """Parse employee data from SQL INSERT file""" + print(f"📁 Parsing {filename} (Source: {source_name})") + + try: + with open(filename, 'r', encoding='utf-8') as f: + content = f.read() + except FileNotFoundError: + print(f"❌ File {filename} not found") + return [] + + # Extract VALUES section from INSERT statement + values_match = re.search(r'VALUES\s*(.*?)(?:;|$)', content, re.DOTALL) + if not values_match: + print(f"❌ No VALUES section found in {filename}") + return [] + + values_text = values_match.group(1) + employees = [] + + # Split records by ),( pattern + record_pattern = r'\((\d+),\s*(\d+),\s*\'([^\']*)\',\s*\'([^\']*)\',\s*(NULL|\'[^\']*\'),\s*(\d+)\)' + matches = re.findall(record_pattern, values_text) + + for match in matches: + index, emp_id, first_name, last_name, title, contract_id = match + + # Clean title field + title_clean = None if title == 'NULL' else title.strip("'") + + employee = Employee( + index=int(index), + id=int(emp_id), + firstName=first_name.strip(), + lastName=last_name.strip(), + title=title_clean, + contractId=int(contract_id), + source=source_name + ) + employees.append(employee) + + print(f"✅ Parsed {len(employees)} employees from {filename}") + return employees + + def is_dummy_record(self, emp: Employee) -> bool: + """Check if employee record appears to be dummy/test data""" + dummy_patterns = [ + r'^(no\s*(name|id)|pending|test|unknown|n\/?a|do\s*not|enter|dummy|xxx?|zzz?)', + r'^(incorrect|missing|wrong|employee)', + r'^[0-9]+$', # Only numbers + r'^[a-z]{1,2}$', # Single letters + r'^(a|b|x|z)\s*(a|b|x|z)$' # Single letter combinations + ] + + full_name = f"{emp.firstName} {emp.lastName}".lower().strip() + first_name = emp.firstName.lower().strip() + last_name = emp.lastName.lower().strip() + + for pattern in dummy_patterns: + if (re.search(pattern, full_name, re.IGNORECASE) or + re.search(pattern, first_name, re.IGNORECASE) or + re.search(pattern, last_name, re.IGNORECASE)): + return True + + return False + + def calculate_quality_score(self, emp: Employee) -> float: + """Calculate quality score for record selection""" + score = 0.0 + + # Major penalty for dummy records + if self.is_dummy_record(emp): + score -= 1000 + + # Reward complete data + if emp.title and emp.title.strip(): + score += 10 + + # Prefer higher contract IDs (usually more recent) + score += emp.contractId * 0.1 + + # Reward longer, more descriptive names + score += len(emp.firstName) + len(emp.lastName) + + # Slight preference for GOV source (appears more authoritative) + if emp.source == 'GOV': + score += 5 + + # Penalty for empty or very short names + if len(emp.firstName) <= 2 or len(emp.lastName) <= 2: + score -= 50 + + # Reward normal name patterns (letters, spaces, common punctuation) + name_pattern = re.compile(r'^[A-Za-z\s\.\-\']+$') + if name_pattern.match(emp.firstName) and name_pattern.match(emp.lastName): + score += 20 + + return score + + def deduplicate_employees(self, employees: List[Employee]) -> List[Employee]: + """Remove duplicates based on employee ID with intelligent selection""" + print("\n🔍 Starting deduplication process...") + + # Group employees by ID + id_groups: Dict[int, List[Employee]] = {} + for emp in employees: + if emp.id not in id_groups: + id_groups[emp.id] = [] + id_groups[emp.id].append(emp) + + # Calculate quality scores + for emp in employees: + emp.quality_score = self.calculate_quality_score(emp) + + deduplicated = [] + + for emp_id, group in id_groups.items(): + if len(group) == 1: + # No duplicates + deduplicated.append(group[0]) + else: + # Multiple records - select best one + self.duplicate_stats['total_duplicates'] += len(group) - 1 + + # Sort by quality score (highest first) + group.sort(key=lambda x: x.quality_score, reverse=True) + best_record = group[0] + + # Track statistics + if best_record.source == 'GOV': + self.duplicate_stats['kept_gov'] += 1 + else: + self.duplicate_stats['kept_lt'] += 1 + + # Count dummy records removed + dummy_removed = sum(1 for emp in group[1:] if self.is_dummy_record(emp)) + self.duplicate_stats['removed_dummy'] += dummy_removed + + # Check for quality upgrades + sources = [emp.source for emp in group] + if len(set(sources)) > 1: + self.duplicate_stats['quality_upgrades'] += 1 + + deduplicated.append(best_record) + + # Log decision for significant duplicates + if len(group) > 2 or any(not self.is_dummy_record(emp) for emp in group): + print(f"📋 Employee ID {emp_id}:") + print(f" ✅ KEPT: {best_record.source} - {best_record.firstName} {best_record.lastName} (Score: {best_record.quality_score:.1f})") + for removed in group[1:]: + status = "DUMMY" if self.is_dummy_record(removed) else "LOWER_QUALITY" + print(f" ❌ {status}: {removed.source} - {removed.firstName} {removed.lastName} (Score: {removed.quality_score:.1f})") + + return deduplicated + + def generate_combined_sql(self, employees: List[Employee], output_filename: str = 'combined_employees.sql'): + """Generate combined SQL file with deduplicated data""" + print(f"\n📝 Generating combined SQL file: {output_filename}") + + # Sort employees by index for consistency + employees.sort(key=lambda x: x.index) + + # Reassign consecutive indices + for i, emp in enumerate(employees, 1): + emp.index = i + + sql_content = """-- Combined and Deduplicated Employee Data +-- Generated on: {timestamp} +-- Total Records: {total} +-- Sources: Government employee data + LT employee data +-- Deduplication: Intelligent quality-based selection + +CREATE TABLE IF NOT EXISTS `employee` ( + `index` bigint NOT NULL AUTO_INCREMENT, + `id` bigint NOT NULL, + `firstName` varchar(50) NOT NULL, + `lastName` varchar(50) NOT NULL, + `title` varchar(20) DEFAULT NULL, + `contractId` bigint NOT NULL DEFAULT '1', + UNIQUE KEY `index_2` (`index`), + KEY `index` (`index`) +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- Clear existing data +TRUNCATE TABLE `employee`; + +-- Insert combined and deduplicated data +INSERT INTO `employee` (`index`, `id`, `firstName`, `lastName`, `title`, `contractId`) VALUES +""".format( + timestamp=datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + total=len(employees) +) + + # Generate VALUES entries + values_entries = [] + for emp in employees: + title_value = 'NULL' if emp.title is None else f"'{emp.title}'" + values_entries.append( + f"({emp.index}, {emp.id}, '{emp.firstName}', '{emp.lastName}', {title_value}, {emp.contractId})" + ) + + sql_content += ',\n'.join(values_entries) + ';\n' + + # Add statistics as comments + sql_content += f""" +-- DEDUPLICATION STATISTICS +-- ======================== +-- Original records processed: {self.duplicate_stats['total_duplicates'] + len(employees)} +-- Final unique records: {len(employees)} +-- Duplicates removed: {self.duplicate_stats['total_duplicates']} +-- Records kept from GOV source: {self.duplicate_stats['kept_gov']} +-- Records kept from LT source: {self.duplicate_stats['kept_lt']} +-- Dummy/test records removed: {self.duplicate_stats['removed_dummy']} +-- Quality upgrades performed: {self.duplicate_stats['quality_upgrades']} +""" + + # Write to file + with open(output_filename, 'w', encoding='utf-8') as f: + f.write(sql_content) + + print(f"✅ Combined SQL file generated successfully!") + return output_filename + + def merge_files(self, file_configs: List[Tuple[str, str]], output_filename: str = 'combined_employees.sql'): + """Main method to merge multiple employee data files""" + print("🚀 Starting Employee Data Merger") + print("=" * 50) + + all_employees = [] + + # Parse all source files + for filename, source_name in file_configs: + employees = self.parse_sql_file(filename, source_name) + all_employees.extend(employees) + + print(f"\n📊 SUMMARY BEFORE DEDUPLICATION") + print(f"Total records loaded: {len(all_employees)}") + + # Perform deduplication + deduplicated_employees = self.deduplicate_employees(all_employees) + + print(f"\n📊 DEDUPLICATION RESULTS") + print("=" * 30) + print(f"Original records: {len(all_employees)}") + print(f"Final records: {len(deduplicated_employees)}") + print(f"Duplicates removed: {self.duplicate_stats['total_duplicates']}") + print(f"Kept from GOV: {self.duplicate_stats['kept_gov']}") + print(f"Kept from LT: {self.duplicate_stats['kept_lt']}") + print(f"Dummy records removed: {self.duplicate_stats['removed_dummy']}") + print(f"Quality upgrades: {self.duplicate_stats['quality_upgrades']}") + + # Generate combined SQL + output_file = self.generate_combined_sql(deduplicated_employees, output_filename) + + print(f"\n✅ MERGE COMPLETED SUCCESSFULLY!") + print(f"📁 Output file: {output_file}") + print(f"📈 Final record count: {len(deduplicated_employees)}") + + return output_file, deduplicated_employees + +def main(): + """Main execution function""" + merger = EmployeeDataMerger() + + # Configure source files + file_configs = [ + ('gov_employee.sql', 'GOV'), # Government employee data + ('lt_employee.sql', 'LT') # LT employee data + ] + + # Verify files exist + missing_files = [f for f, _ in file_configs if not os.path.exists(f)] + if missing_files: + print(f"❌ Missing files: {missing_files}") + print("Please ensure all SQL files are in the current directory.") + return + + # Perform merge + try: + output_file, final_employees = merger.merge_files(file_configs, 'combined_employees.sql') + + # Additional validation + print(f"\n🔍 VALIDATION CHECKS") + print("=" * 20) + + # Check for remaining duplicates + ids = [emp.id for emp in final_employees] + duplicate_ids = set([id for id in ids if ids.count(id) > 1]) + + if duplicate_ids: + print(f"⚠️ Warning: {len(duplicate_ids)} employee IDs still have duplicates: {duplicate_ids}") + else: + print("✅ No duplicate employee IDs found") + + # Check data quality + dummy_count = sum(1 for emp in final_employees if merger.is_dummy_record(emp)) + print(f"📊 Data quality: {len(final_employees) - dummy_count}/{len(final_employees)} real records ({dummy_count} dummy records remaining)") + + # Contract ID distribution + contract_distribution = {} + for emp in final_employees: + contract_distribution[emp.contractId] = contract_distribution.get(emp.contractId, 0) + 1 + + print(f"📋 Contract ID distribution:") + for contract_id, count in sorted(contract_distribution.items()): + print(f" Contract {contract_id}: {count} employees") + + print(f"\n🎉 Employee data merge completed successfully!") + print(f"📁 Use {output_file} for your employee synchronization script.") + + except Exception as e: + print(f"❌ Error during merge: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/employee_duplicate_remove.py b/employee_duplicate_remove.py new file mode 100644 index 0000000..3bff164 --- /dev/null +++ b/employee_duplicate_remove.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +""" +Employee Duplicate Removal Script +================================== + +This script identifies and removes duplicate employee records based on their ID field, +keeping only the newest record (highest 'index' value) for each unique employee ID. + +IMPORTANT: +- Always backup your database before running this script +- This script performs PERMANENT deletions +- Run with --dry-run first to preview changes + +Usage: + python remove_duplicate_employees.py --dry-run # Preview changes only + python remove_duplicate_employees.py # Execute removal +""" + +import sys +import os +from datetime import datetime +from sqlalchemy import text, create_engine +from sqlalchemy.orm import sessionmaker +import argparse + +# Add parent directory to path for imports +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +class DuplicateEmployeeRemover: + """Handles identification and removal of duplicate employee records""" + + def __init__(self, database_url): + """Initialize with database connection""" + self.database_url = database_url + self.engine = create_engine(database_url) + Session = sessionmaker(bind=self.engine) + self.session = Session() + + self.stats = { + 'total_records': 0, + 'unique_employees': 0, + 'duplicate_records': 0, + 'records_to_remove': 0, + 'records_removed': 0 + } + + self.duplicate_groups = [] + + def analyze_duplicates(self): + """Identify duplicate employee records""" + print("\n" + "="*70) + print("EMPLOYEE DUPLICATE ANALYSIS") + print("="*70 + "\n") + + try: + # Get total employee count + count_query = text("SELECT COUNT(*) as total FROM employee") + result = self.session.execute(count_query).fetchone() + self.stats['total_records'] = result.total + + print(f"📊 Total employee records in database: {self.stats['total_records']}") + + # Find duplicate employee IDs + duplicate_query = text(""" + SELECT + id as employee_id, + COUNT(*) as occurrence_count, + GROUP_CONCAT(`index` ORDER BY `index` DESC) as all_indices, + MAX(`index`) as newest_index, + GROUP_CONCAT(CONCAT(firstName, ' ', lastName) ORDER BY `index` DESC SEPARATOR ' | ') as names + FROM employee + GROUP BY id + HAVING COUNT(*) > 1 + ORDER BY COUNT(*) DESC, id + """) + + duplicate_results = self.session.execute(duplicate_query).fetchall() + + if not duplicate_results: + print("\n✅ No duplicate employee records found!") + print(" All employee IDs are unique.\n") + return False + + # Process duplicate groups + print(f"\n⚠️ Found {len(duplicate_results)} employee IDs with duplicates:\n") + + for row in duplicate_results: + employee_id = row.employee_id + occurrence_count = row.occurrence_count + all_indices = [int(idx) for idx in row.all_indices.split(',')] + newest_index = row.newest_index + names = row.names + + # Records to remove (all except newest) + indices_to_remove = [idx for idx in all_indices if idx != newest_index] + + duplicate_group = { + 'employee_id': employee_id, + 'occurrence_count': occurrence_count, + 'newest_index': newest_index, + 'indices_to_remove': indices_to_remove, + 'names': names + } + + self.duplicate_groups.append(duplicate_group) + self.stats['records_to_remove'] += len(indices_to_remove) + + print(f" Employee ID: {employee_id}") + print(f" • Occurrences: {occurrence_count}") + print(f" • Names: {names}") + print(f" • Will keep: index {newest_index} (newest)") + print(f" • Will remove: indices {indices_to_remove}") + print() + + self.stats['unique_employees'] = self.stats['total_records'] - self.stats['records_to_remove'] + self.stats['duplicate_records'] = self.stats['records_to_remove'] + + # Print summary + print("─" * 70) + print(f"\n📈 SUMMARY:") + print(f" Total records currently: {self.stats['total_records']}") + print(f" Unique employee IDs: {len(duplicate_results) + (self.stats['total_records'] - len(duplicate_results) - self.stats['records_to_remove'])}") + print(f" Records after cleanup: {self.stats['unique_employees']}") + print(f" Records to be removed: {self.stats['records_to_remove']}") + print() + + return True + + except Exception as e: + print(f"\n❌ Error during analysis: {e}") + import traceback + traceback.print_exc() + return False + + def remove_duplicates(self, dry_run=True): + """Remove duplicate employee records (keeping newest)""" + + if not self.duplicate_groups: + print("No duplicates to remove.") + return False + + if dry_run: + print("\n" + "="*70) + print("DRY RUN MODE - NO CHANGES WILL BE MADE") + print("="*70 + "\n") + print("The following records WOULD BE removed:\n") + + for group in self.duplicate_groups: + print(f" Employee ID {group['employee_id']}:") + print(f" Removing indices: {group['indices_to_remove']}") + + print(f"\n Total records that would be removed: {self.stats['records_to_remove']}") + print("\n✓ Dry run complete. Run without --dry-run to execute removal.\n") + return True + + # Actual removal + print("\n" + "="*70) + print("REMOVING DUPLICATE RECORDS") + print("="*70 + "\n") + + try: + removed_count = 0 + + for group in self.duplicate_groups: + employee_id = group['employee_id'] + indices_to_remove = group['indices_to_remove'] + + print(f"Processing Employee ID {employee_id}...") + + for index_to_remove in indices_to_remove: + delete_query = text(""" + DELETE FROM employee + WHERE `index` = :index_val + """) + + self.session.execute(delete_query, {'index_val': index_to_remove}) + removed_count += 1 + print(f" ✓ Removed index {index_to_remove}") + + # Commit all changes + self.session.commit() + self.stats['records_removed'] = removed_count + + print(f"\n✅ Successfully removed {removed_count} duplicate records!") + + # Verify final count + verify_query = text("SELECT COUNT(*) as total FROM employee") + result = self.session.execute(verify_query).fetchone() + final_count = result.total + + print(f"\n📊 Final verification:") + print(f" Records before: {self.stats['total_records']}") + print(f" Records removed: {self.stats['records_removed']}") + print(f" Records now: {final_count}") + print(f" Expected: {self.stats['unique_employees']}") + + if final_count == self.stats['unique_employees']: + print("\n✅ Verification successful! Record counts match.\n") + else: + print("\n⚠️ Warning: Record count mismatch. Please verify manually.\n") + + return True + + except Exception as e: + self.session.rollback() + print(f"\n❌ Error during removal: {e}") + print("Changes have been rolled back.") + import traceback + traceback.print_exc() + return False + + def create_backup_log(self): + """Create a log file documenting what will be removed""" + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + log_filename = f"duplicate_removal_log_{timestamp}.txt" + + try: + with open(log_filename, 'w') as f: + f.write("="*70 + "\n") + f.write("EMPLOYEE DUPLICATE REMOVAL LOG\n") + f.write(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") + f.write("="*70 + "\n\n") + + f.write(f"Total records analyzed: {self.stats['total_records']}\n") + f.write(f"Duplicate records found: {self.stats['records_to_remove']}\n") + f.write(f"Unique employees after cleanup: {self.stats['unique_employees']}\n\n") + + f.write("DUPLICATE GROUPS:\n") + f.write("-"*70 + "\n\n") + + for group in self.duplicate_groups: + f.write(f"Employee ID: {group['employee_id']}\n") + f.write(f" Occurrences: {group['occurrence_count']}\n") + f.write(f" Names: {group['names']}\n") + f.write(f" Keeping: index {group['newest_index']} (newest)\n") + f.write(f" Removing: indices {group['indices_to_remove']}\n\n") + + print(f"📄 Log file created: {log_filename}\n") + return log_filename + + except Exception as e: + print(f"⚠️ Could not create log file: {e}") + return None + + def close(self): + """Close database connection""" + self.session.close() + self.engine.dispose() + +def main(): + """Main execution function""" + parser = argparse.ArgumentParser( + description='Remove duplicate employee records, keeping only the newest record for each employee ID', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python remove_duplicate_employees.py --dry-run # Preview changes + python remove_duplicate_employees.py # Execute removal + """ + ) + + parser.add_argument( + '--dry-run', + action='store_true', + help='Preview changes without actually removing records' + ) + + args = parser.parse_args() + + # Get database URL from environment + database_url = os.environ.get('DATABASE_URL') + + if not database_url: + print("\n❌ Error: DATABASE_URL not found in environment variables") + print(" Please ensure .env file exists with DATABASE_URL configured\n") + sys.exit(1) + + print("\n" + "="*70) + print("EMPLOYEE DUPLICATE REMOVAL TOOL") + print("="*70) + print("\nThis script will identify and remove duplicate employee records") + print("based on their ID field, keeping only the newest record (highest index).\n") + + if args.dry_run: + print("🔍 Running in DRY RUN mode - no changes will be made\n") + else: + print("⚠️ WARNING: This will permanently delete duplicate records!") + print(" Make sure you have backed up your database.\n") + + response = input("Continue with removal? (yes/no): ") + if response.lower() != 'yes': + print("\n❌ Operation cancelled.\n") + sys.exit(0) + + # Initialize remover + remover = DuplicateEmployeeRemover(database_url) + + try: + # Step 1: Analyze duplicates + has_duplicates = remover.analyze_duplicates() + + if not has_duplicates: + remover.close() + sys.exit(0) + + # Step 2: Create log file + remover.create_backup_log() + + # Step 3: Remove duplicates + success = remover.remove_duplicates(dry_run=args.dry_run) + + if success: + if args.dry_run: + print("✓ Dry run completed successfully") + print(" Run without --dry-run to execute the removal\n") + else: + print("✅ Duplicate removal completed successfully!\n") + sys.exit(0) + else: + print("❌ Operation failed. Please check the errors above.\n") + sys.exit(1) + + except KeyboardInterrupt: + print("\n\n⚠️ Operation interrupted by user\n") + sys.exit(1) + except Exception as e: + print(f"\n❌ Unexpected error: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + finally: + remover.close() + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/employee_sync_scheduler.py b/employee_sync_scheduler.py new file mode 100644 index 0000000..d2f4ebb --- /dev/null +++ b/employee_sync_scheduler.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +Employee Synchronization Scheduler +================================= + +This script provides automated scheduling for employee synchronization. +Can be run via cron or as a standalone scheduler with configurable intervals. +""" + +import os +import sys +import time +import schedule +import threading +from datetime import datetime, timedelta +from pathlib import Path + +# Add project root to Python path +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +# Import the main synchronizer +from employee_table_sync import EmployeeSynchronizer, load_configuration, EmployeeSyncLogger + +class EmployeeSyncScheduler: + """Automated employee synchronization scheduler""" + + def __init__(self): + self.logger = EmployeeSyncLogger('logs/employee_sync_scheduler.log') + self.is_running = False + self.last_sync_time = None + self.sync_interval_minutes = int(os.getenv('SYNC_INTERVAL_MINUTES', 60)) # Default: 1 hour + + def run_sync_job(self): + """Execute a single synchronization job""" + if self.is_running: + self.logger.warning("Synchronization already in progress, skipping this run") + return + + self.is_running = True + self.logger.info("Starting scheduled employee synchronization") + + try: + # Load configurations + remote_config, local_config = load_configuration() + + # Initialize and run synchronizer + synchronizer = EmployeeSynchronizer(remote_config, local_config) + stats = synchronizer.run_synchronization() + + # Log results (stats already contains serialized datetime objects) + if stats['errors_encountered'] == 0: + self.logger.info("Scheduled synchronization completed successfully", { + 'duration_seconds': (stats['end_time'] - stats['start_time']).total_seconds() if stats['end_time'] and stats['start_time'] else 0, + 'records_processed': stats['total_remote_records'], + 'records_inserted': stats['records_inserted'], + 'records_deleted': stats['records_deleted'] + }) + else: + self.logger.error("Scheduled synchronization completed with errors", None, { + 'error_count': stats['errors_encountered'], + 'records_processed': stats['total_remote_records'] + }) + + self.last_sync_time = datetime.now() + + except Exception as e: + self.logger.error("Scheduled synchronization failed", e) + + finally: + self.is_running = False + + def start_scheduler(self): + """Start the background scheduler""" + self.logger.info(f"Starting employee sync scheduler (interval: {self.sync_interval_minutes} minutes)") + + # Schedule the job + schedule.every(self.sync_interval_minutes).minutes.do(self.run_sync_job) + + # Run immediately on start + self.logger.info("Running initial synchronization") + self.run_sync_job() + + # Keep the scheduler running + while True: + schedule.run_pending() + time.sleep(60) # Check every minute + + def run_daily_sync(self): + """Run synchronization once daily (for cron usage)""" + self.logger.info("Running daily employee synchronization") + self.run_sync_job() + +def main(): + """Main execution function""" + import argparse + + parser = argparse.ArgumentParser(description='Employee Synchronization Scheduler') + parser.add_argument('--mode', choices=['once', 'daily', 'continuous'], + default='once', help='Synchronization mode') + parser.add_argument('--interval', type=int, default=60, + help='Sync interval in minutes (for continuous mode)') + + args = parser.parse_args() + + # Set environment variable for interval + os.environ['SYNC_INTERVAL_MINUTES'] = str(args.interval) + + scheduler = EmployeeSyncScheduler() + + if args.mode == 'once': + print("🔄 Running single employee synchronization...") + scheduler.run_sync_job() + elif args.mode == 'daily': + print("📅 Running daily employee synchronization...") + scheduler.run_daily_sync() + elif args.mode == 'continuous': + print(f"🔁 Starting continuous synchronization (every {args.interval} minutes)...") + scheduler.start_scheduler() + + print("✅ Synchronization completed") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/employee_table_sync.py b/employee_table_sync.py new file mode 100644 index 0000000..67a801c --- /dev/null +++ b/employee_table_sync.py @@ -0,0 +1,507 @@ +#!/usr/bin/env python3 +""" +Employee Data Synchronization Script +=================================== + +This script synchronizes the employee table with a remote MySQL server. +It replicates data from the remote server to the local application database. + +Features: +- Complete data synchronization from remote to local +- Comprehensive logging for all operations +- Error handling and rollback mechanisms +- Configurable connection parameters +- Maintains data integrity during sync operations +""" + +import os +import sys +import json +import time +from datetime import datetime +from typing import Dict, List, Optional, Tuple +from dotenv import load_dotenv +import pymysql +from sqlalchemy import create_engine, text, MetaData, Table +from sqlalchemy.exc import SQLAlchemyError +import logging + +# Load environment variables +load_dotenv() + +class EmployeeSyncLogger: + """Enhanced logging for employee synchronization operations""" + + def __init__(self, log_file: str = 'logs/employee_sync.log'): + """Initialize logger with file and console output""" + # Create logs directory if it doesn't exist + os.makedirs(os.path.dirname(log_file), exist_ok=True) + + # Configure logger + self.logger = logging.getLogger('employee_sync') + self.logger.setLevel(logging.INFO) + + # File handler + file_handler = logging.FileHandler(log_file) + file_handler.setLevel(logging.INFO) + + # Console handler + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + + # Formatter + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + file_handler.setFormatter(formatter) + console_handler.setFormatter(formatter) + + # Add handlers + if not self.logger.handlers: + self.logger.addHandler(file_handler) + self.logger.addHandler(console_handler) + + def _serialize_data(self, obj): + """Convert objects to JSON serializable format""" + if isinstance(obj, datetime): + return obj.isoformat() + elif isinstance(obj, dict): + return {k: self._serialize_data(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [self._serialize_data(item) for item in obj] + else: + return obj + + def info(self, message: str, data: Dict = None): + """Log info message with optional data""" + log_entry = {'message': message} + if data: + log_entry['data'] = self._serialize_data(data) + self.logger.info(json.dumps(log_entry)) + + def error(self, message: str, error: Exception = None, data: Dict = None): + """Log error message with optional exception and data""" + log_entry = { + 'message': message, + 'error_type': type(error).__name__ if error else None, + 'error_message': str(error) if error else None + } + if data: + log_entry['data'] = self._serialize_data(data) + self.logger.error(json.dumps(log_entry)) + + def warning(self, message: str, data: Dict = None): + """Log warning message with optional data""" + log_entry = {'message': message} + if data: + log_entry['data'] = self._serialize_data(data) + self.logger.warning(json.dumps(log_entry)) + +class EmployeeSynchronizer: + """ + Employee data synchronization service for replicating remote employee data + """ + + def __init__(self, remote_config: Dict, local_config: Dict): + """ + Initialize synchronizer with database configurations + + Args: + remote_config: Remote MySQL database configuration + local_config: Local MySQL database configuration + """ + self.remote_config = remote_config + self.local_config = local_config + self.logger = EmployeeSyncLogger() + self.remote_engine = None + self.local_engine = None + + self.sync_stats = { + 'start_time': None, + 'end_time': None, + 'total_remote_records': 0, + 'total_local_records_before': 0, + 'total_local_records_after': 0, + 'records_inserted': 0, + 'records_updated': 0, + 'records_deleted': 0, + 'errors_encountered': 0 + } + + def _create_connection_string(self, config: Dict) -> str: + """Create MySQL connection string from configuration with proper URL encoding""" + from urllib.parse import quote_plus + + # URL encode username and password to handle special characters + username = quote_plus(config['username']) + password = quote_plus(config['password']) + host = config['host'] + port = config.get('port', 3306) + database = config['database'] + + return ( + f"mysql+pymysql://" + f"{username}:{password}@" + f"{host}:{port}/" + f"{database}?charset=utf8mb4" + ) + + def connect_databases(self) -> bool: + """ + Establish connections to both remote and local databases + + Returns: + bool: True if both connections successful, False otherwise + """ + try: + # Connect to remote database + remote_connection_string = self._create_connection_string(self.remote_config) + self.remote_engine = create_engine( + remote_connection_string, + pool_pre_ping=True, + pool_recycle=3600, + echo=False + ) + + # Test remote connection + with self.remote_engine.connect() as conn: + result = conn.execute(text("SELECT 1")) + result.fetchone() + + self.logger.info("Successfully connected to remote database", { + 'host': self.remote_config['host'], + 'database': self.remote_config['database'] + }) + + # Connect to local database + local_connection_string = self._create_connection_string(self.local_config) + self.local_engine = create_engine( + local_connection_string, + pool_pre_ping=True, + pool_recycle=3600, + echo=False + ) + + # Test local connection + with self.local_engine.connect() as conn: + result = conn.execute(text("SELECT 1")) + result.fetchone() + + self.logger.info("Successfully connected to local database", { + 'host': self.local_config['host'], + 'database': self.local_config['database'] + }) + + return True + + except Exception as e: + self.logger.error("Failed to establish database connections", e) + return False + + def fetch_remote_employees(self) -> List[Dict]: + """ + Fetch all employee records from remote database + + Returns: + List[Dict]: List of employee records + """ + try: + with self.remote_engine.connect() as conn: + result = conn.execute(text(""" + SELECT `index`, id, firstName, lastName, title, contractId + FROM employee + ORDER BY `index` + """)) + + employees = [] + for row in result: + employee = { + 'index': row.index, + 'id': row.id, + 'firstName': row.firstName, + 'lastName': row.lastName, + 'title': row.title, + 'contractId': row.contractId + } + employees.append(employee) + + self.sync_stats['total_remote_records'] = len(employees) + self.logger.info(f"Fetched {len(employees)} employees from remote database") + + return employees + + except Exception as e: + self.logger.error("Failed to fetch remote employee data", e) + self.sync_stats['errors_encountered'] += 1 + return [] + + def get_local_employee_count(self) -> int: + """Get current count of local employee records""" + try: + with self.local_engine.connect() as conn: + result = conn.execute(text("SELECT COUNT(*) as count FROM employee")) + count = result.fetchone().count + return count + except Exception as e: + self.logger.error("Failed to get local employee count", e) + return 0 + + def create_employee_table_if_not_exists(self) -> bool: + """ + Create employee table in local database if it doesn't exist + + Returns: + bool: True if successful, False otherwise + """ + try: + with self.local_engine.connect() as conn: + # Check if table exists + result = conn.execute(text(""" + SELECT COUNT(*) as count + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'employee' + """)) + + table_exists = result.fetchone().count > 0 + + if not table_exists: + # Create table with same structure as provided SQL + conn.execute(text(""" + CREATE TABLE `employee` ( + `index` bigint NOT NULL AUTO_INCREMENT, + `id` bigint NOT NULL, + `firstName` varchar(50) NOT NULL, + `lastName` varchar(50) NOT NULL, + `title` varchar(20) DEFAULT NULL, + `contractId` bigint NOT NULL DEFAULT '1', + UNIQUE KEY `index_2` (`index`), + KEY `index` (`index`) + ) ENGINE=MyISAM DEFAULT CHARSET=latin1 + """)) + conn.commit() + + self.logger.info("Created employee table in local database") + else: + self.logger.info("Employee table already exists in local database") + + return True + + except Exception as e: + self.logger.error("Failed to create employee table", e) + return False + + def synchronize_employees(self, employees: List[Dict]) -> bool: + """ + Synchronize employee data to local database + + Args: + employees: List of employee records from remote database + + Returns: + bool: True if synchronization successful, False otherwise + """ + if not employees: + self.logger.warning("No employee data to synchronize") + return True + + try: + with self.local_engine.begin() as conn: # Use transaction + # Get current local employee count + self.sync_stats['total_local_records_before'] = self.get_local_employee_count() + + # Clear existing data (full replacement sync) + delete_result = conn.execute(text("DELETE FROM employee")) + deleted_count = delete_result.rowcount + self.sync_stats['records_deleted'] = deleted_count + + self.logger.info(f"Cleared {deleted_count} existing employee records") + + # Insert new data + insert_count = 0 + for employee in employees: + try: + conn.execute(text(""" + INSERT INTO employee (`index`, id, firstName, lastName, title, contractId) + VALUES (:index, :id, :firstName, :lastName, :title, :contractId) + """), { + 'index': employee['index'], + 'id': employee['id'], + 'firstName': employee['firstName'], + 'lastName': employee['lastName'], + 'title': employee['title'], + 'contractId': employee['contractId'] + }) + insert_count += 1 + + except Exception as e: + self.logger.error(f"Failed to insert employee {employee['id']}", e, employee) + self.sync_stats['errors_encountered'] += 1 + continue + + self.sync_stats['records_inserted'] = insert_count + + # Get final count + result = conn.execute(text("SELECT COUNT(*) as count FROM employee")) + self.sync_stats['total_local_records_after'] = result.fetchone().count + + self.logger.info(f"Successfully synchronized {insert_count} employee records") + + return True + + except Exception as e: + self.logger.error("Failed to synchronize employee data", e) + self.sync_stats['errors_encountered'] += 1 + return False + + def run_synchronization(self) -> Dict: + """ + Execute complete employee synchronization process + + Returns: + Dict: Synchronization statistics and results + """ + self.sync_stats['start_time'] = datetime.now() + + self.logger.info("Starting employee synchronization process") + + try: + # Step 1: Connect to databases + if not self.connect_databases(): + self.sync_stats['end_time'] = datetime.now() + return self.sync_stats + + # Step 2: Create table if needed + if not self.create_employee_table_if_not_exists(): + self.sync_stats['end_time'] = datetime.now() + return self.sync_stats + + # Step 3: Fetch remote data + employees = self.fetch_remote_employees() + if not employees and self.sync_stats['errors_encountered'] > 0: + self.sync_stats['end_time'] = datetime.now() + return self.sync_stats + + # Step 4: Synchronize data + success = self.synchronize_employees(employees) + + # Step 5: Log final results + self.sync_stats['end_time'] = datetime.now() + duration = (self.sync_stats['end_time'] - self.sync_stats['start_time']).total_seconds() + + if success: + self.logger.info("Employee synchronization completed successfully", { + 'duration_seconds': duration, + 'statistics': self.sync_stats + }) + else: + self.logger.error("Employee synchronization completed with errors", None, { + 'duration_seconds': duration, + 'statistics': self.sync_stats + }) + + return self.sync_stats + + except Exception as e: + self.sync_stats['end_time'] = datetime.now() + self.logger.error("Employee synchronization failed", e) + self.sync_stats['errors_encountered'] += 1 + return self.sync_stats + + finally: + # Close connections + if self.remote_engine: + self.remote_engine.dispose() + if self.local_engine: + self.local_engine.dispose() + +def load_configuration() -> Tuple[Dict, Dict]: + """ + Load database configurations from environment variables + + Returns: + Tuple[Dict, Dict]: Remote and local database configurations + """ + # Remote database configuration + remote_config = { + 'host': os.getenv('REMOTE_DB_HOST', 'localhost'), + 'port': int(os.getenv('REMOTE_DB_PORT', 3306)), + 'username': os.getenv('REMOTE_DB_USERNAME', 'root'), + 'password': os.getenv('REMOTE_DB_PASSWORD', ''), + 'database': os.getenv('REMOTE_DB_NAME', 'remote_database') + } + + # Local database configuration (from existing DATABASE_URL) + database_url = os.getenv('DATABASE_URL', '') + if database_url.startswith('mysql+pymysql://'): + # Parse existing DATABASE_URL with proper URL decoding + from urllib.parse import unquote_plus + import re + + # Handle URL-encoded credentials + match = re.match(r'mysql\+pymysql://([^:]+):([^@]+)@([^:]+):(\d+)/(.+)', database_url) + if match: + local_config = { + 'host': match.group(3), + 'port': int(match.group(4)), + 'username': unquote_plus(match.group(1)), + 'password': unquote_plus(match.group(2)), + 'database': match.group(5).split('?')[0] # Remove parameters + } + else: + raise ValueError("Invalid DATABASE_URL format") + else: + # Fallback configuration + local_config = { + 'host': os.getenv('LOCAL_DB_HOST', 'localhost'), + 'port': int(os.getenv('LOCAL_DB_PORT', 3306)), + 'username': os.getenv('LOCAL_DB_USERNAME', 'root'), + 'password': os.getenv('LOCAL_DB_PASSWORD', ''), + 'database': os.getenv('LOCAL_DB_NAME', 'local_database') + } + + return remote_config, local_config + +def main(): + """Main execution function""" + print("🔄 Employee Synchronization Script") + print("=" * 50) + + try: + # Load configurations + remote_config, local_config = load_configuration() + + print(f"📡 Remote Server: {remote_config['host']}:{remote_config['port']}") + print(f"💾 Local Server: {local_config['host']}:{local_config['port']}") + print() + + # Initialize synchronizer + synchronizer = EmployeeSynchronizer(remote_config, local_config) + + # Run synchronization + stats = synchronizer.run_synchronization() + + # Display results + print("\n📊 Synchronization Results:") + print("=" * 30) + print(f"⏱️ Duration: {(stats['end_time'] - stats['start_time']).total_seconds():.2f} seconds") + print(f"📡 Remote Records: {stats['total_remote_records']}") + print(f"💾 Local Records (Before): {stats['total_local_records_before']}") + print(f"💾 Local Records (After): {stats['total_local_records_after']}") + print(f"➕ Records Inserted: {stats['records_inserted']}") + print(f"🗑️ Records Deleted: {stats['records_deleted']}") + print(f"❌ Errors: {stats['errors_encountered']}") + + if stats['errors_encountered'] == 0: + print("\n✅ Synchronization completed successfully!") + return 0 + else: + print(f"\n⚠️ Synchronization completed with {stats['errors_encountered']} errors") + return 1 + + except Exception as e: + print(f"\n❌ Synchronization failed: {e}") + return 1 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/extensions.py b/extensions.py new file mode 100644 index 0000000..5ac542d --- /dev/null +++ b/extensions.py @@ -0,0 +1,39 @@ +""" +extensions.py +============= +Shared Flask extension instances (SQLAlchemy db + AppLogger). + +All Blueprints import from here to avoid circular imports. + +Initialization order (enforced in app.py): + 1. db = SQLAlchemy() -- created here at module level + 2. app.py configures Flask app + 3. db.init_app(app) -- called in app.py + 4. set_db(db) -- unpacks model classes + 5. init_logger(app, db) -- binds logger_handler here + 6. Blueprints are registered +""" + +from flask_sqlalchemy import SQLAlchemy +from logger_handler import AppLogger + +# --------------------------------------------------------------------------- +# Database — single shared instance +# --------------------------------------------------------------------------- +db = SQLAlchemy() + +# --------------------------------------------------------------------------- +# Application-level logger — initialized via init_logger() below +# --------------------------------------------------------------------------- +logger_handler: "AppLogger | None" = None + + +def init_logger(app, database) -> AppLogger: + """ + Instantiate AppLogger and bind it to the module-level ``logger_handler`` + variable so every Blueprint that does ``from extensions import logger_handler`` + receives the same fully-initialized instance. + """ + global logger_handler + logger_handler = AppLogger(app, database) + return logger_handler diff --git a/legacy_attendance_service.py b/legacy_attendance_service.py new file mode 100644 index 0000000..b3a4835 --- /dev/null +++ b/legacy_attendance_service.py @@ -0,0 +1,328 @@ +""" +legacy_attendance_service.py +============================= +Read-only data access for the "Legacy Attendance" feature. + +This talks directly to the OLD remote MySQL server (the one described by +QrCodeLtServices.sql: `contract`, `employee`, `locations`, `records` tables) +using pymysql — never SQLAlchemy ORM — because that schema is completely +different from the current app's models and is not, and should never be, +mapped as a SQLAlchemy model. + +Connection is opened per-call and closed immediately after use (no pooling, +no persistent connection kept on `g` or the app) because this data is only +ever displayed, never written to. Nothing here performs INSERT/UPDATE/DELETE +against the remote server. + +Env vars (already used by employee_table_sync.py): + REMOTE_DB_HOST + REMOTE_DB_PORT + REMOTE_DB_USERNAME + REMOTE_DB_PASSWORD + REMOTE_DB_NAME +""" + +import io +import math +from datetime import datetime, timedelta + +import pymysql +import pymysql.cursors + +from config import Config + +try: + import openpyxl + from openpyxl.styles import Font, PatternFill, Alignment + from openpyxl.utils import get_column_letter +except ImportError: # pragma: no cover — openpyxl is already a hard requirement elsewhere + openpyxl = None + + +class LegacyDbUnavailable(Exception): + """Raised when the remote legacy database cannot be reached or is not configured.""" + pass + + +def get_remote_connection(): + """ + Open a fresh, read-only connection to the legacy remote MySQL server. + Caller is responsible for closing it (use as a context manager). + """ + if not Config.REMOTE_DB_HOST or not Config.REMOTE_DB_NAME: + raise LegacyDbUnavailable( + "Legacy database is not configured. Set REMOTE_DB_HOST, REMOTE_DB_PORT, " + "REMOTE_DB_USERNAME, REMOTE_DB_PASSWORD, REMOTE_DB_NAME in .env" + ) + try: + return pymysql.connect( + host=Config.REMOTE_DB_HOST, + port=Config.REMOTE_DB_PORT, + user=Config.REMOTE_DB_USERNAME, + password=Config.REMOTE_DB_PASSWORD, + database=Config.REMOTE_DB_NAME, + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor, + connect_timeout=10, + read_timeout=30, + ) + except pymysql.MySQLError as e: + raise LegacyDbUnavailable(f"Could not connect to legacy database: {e}") + + +class LegacyPagination: + """ + Minimal stand-in for Flask-SQLAlchemy's Pagination object, so templates + can use the same `.items / .page / .pages / .has_prev / .iter_pages()` + pattern already used by time_attendance_records.html. + """ + + def __init__(self, items, page, per_page, total): + self.items = items + self.page = page + self.per_page = per_page + self.total = total + self.pages = max(1, math.ceil(total / per_page)) if per_page else 1 + + @property + def has_prev(self): + return self.page > 1 + + @property + def has_next(self): + return self.page < self.pages + + @property + def prev_num(self): + return self.page - 1 + + @property + def next_num(self): + return self.page + 1 + + def iter_pages(self, left_edge=1, right_edge=1, left_current=1, right_current=2): + last = 0 + for num in range(1, self.pages + 1): + if (num <= left_edge + or (num > self.page - left_current - 1 and num < self.page + right_current) + or num > self.pages - right_edge): + if last + 1 != num: + yield None + yield num + last = num + + +# ---------------------------------------------------------------------- # +# Shared filter -> WHERE clause builder +# ---------------------------------------------------------------------- # +def _build_where(filters): + """ + Build a WHERE clause + params list shared by count/list/export queries. + filters: dict with optional keys: employee_search, location, record_type, + start_date, end_date (all strings; dates as 'YYYY-MM-DD') + """ + clauses = [] + params = [] + + employee_search = (filters.get('employee_search') or '').strip() + if employee_search: + clauses.append( + "(r.employeeId LIKE %s OR CONCAT(e.firstName, ' ', e.lastName) LIKE %s)" + ) + like = f"%{employee_search}%" + params.extend([like, like]) + + location = (filters.get('location') or '').strip() + if location: + clauses.append("l.location = %s") + params.append(location) + + record_type = (filters.get('record_type') or '').strip() + if record_type: + clauses.append("r.type = %s") + params.append(record_type) + + start_date = (filters.get('start_date') or '').strip() + if start_date: + try: + start_dt = datetime.strptime(start_date, '%Y-%m-%d') + clauses.append("r.time >= %s") + params.append(start_dt) + except ValueError: + pass + + end_date = (filters.get('end_date') or '').strip() + if end_date: + try: + end_dt = datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1) + clauses.append("r.time < %s") + params.append(end_dt) + except ValueError: + pass + + where_sql = (" WHERE " + " AND ".join(clauses)) if clauses else "" + return where_sql, params + + +_BASE_FROM = """ + FROM records r + LEFT JOIN employee e ON e.id = CAST(r.employeeId AS UNSIGNED) + LEFT JOIN locations l ON l.`index` = CAST(r.locationId AS UNSIGNED) + LEFT JOIN contract c ON c.id = r.contractId +""" + +_SELECT_COLUMNS = """ + r.`index` AS record_index, + r.employeeId AS employee_id, + r.time AS record_time, + r.type AS record_type, + r.recordedAddress AS recorded_address, + r.locationId AS location_id_raw, + r.jobCode AS job_code, + r.isManual AS is_manual, + r.contractId AS contract_id, + e.firstName AS first_name, + e.lastName AS last_name, + l.location AS location_name, + l.building AS building, + l.address AS location_address, + c.name AS contract_name, + c.company AS contract_company +""" + + +def get_legacy_dashboard_stats(): + """Summary stats for the Legacy Attendance dashboard.""" + stats = { + 'total_records': 0, + 'unique_employees': 0, + 'unique_locations': 0, + 'earliest_record': None, + 'latest_record': None, + } + with get_remote_connection() as conn: + with conn.cursor() as cur: + cur.execute(""" + SELECT + COUNT(*) AS total_records, + COUNT(DISTINCT employeeId) AS unique_employees, + COUNT(DISTINCT locationId) AS unique_locations, + MIN(time) AS earliest_record, + MAX(time) AS latest_record + FROM records + """) + row = cur.fetchone() + if row: + stats.update(row) + return stats + + +def get_legacy_unique_locations(): + """Distinct location names for the records filter dropdown.""" + with get_remote_connection() as conn: + with conn.cursor() as cur: + cur.execute(""" + SELECT DISTINCT location FROM locations + WHERE location IS NOT NULL AND location != '' + ORDER BY location + """) + return [row['location'] for row in cur.fetchall()] + + +def get_legacy_records(filters, page=1, per_page=50): + """ + Fetch a filtered, paginated page of legacy attendance records, joined + against employee/locations/contract for display. + Returns a LegacyPagination instance. + """ + where_sql, params = _build_where(filters) + + with get_remote_connection() as conn: + with conn.cursor() as cur: + cur.execute(f"SELECT COUNT(*) AS total {_BASE_FROM}{where_sql}", params) + total = cur.fetchone()['total'] + + offset = max(0, (page - 1) * per_page) + cur.execute( + f"SELECT {_SELECT_COLUMNS} {_BASE_FROM}{where_sql} " + f"ORDER BY r.time DESC LIMIT %s OFFSET %s", + params + [per_page, offset] + ) + rows = cur.fetchall() + + for row in rows: + first = (row.get('first_name') or '').strip() + last = (row.get('last_name') or '').strip() + row['resolved_employee_name'] = f"{last}, {first}" if (first or last) else 'Unknown' + row['location_display'] = row.get('location_name') or row.get('location_id_raw') or 'Unknown' + + return LegacyPagination(rows, page, per_page, total) + + +def get_legacy_records_for_export(filters, max_rows=50000): + """Fetch ALL matching rows (no pagination) for Excel export.""" + where_sql, params = _build_where(filters) + with get_remote_connection() as conn: + with conn.cursor() as cur: + cur.execute( + f"SELECT {_SELECT_COLUMNS} {_BASE_FROM}{where_sql} " + f"ORDER BY r.time DESC LIMIT %s", + params + [max_rows] + ) + rows = cur.fetchall() + + for row in rows: + first = (row.get('first_name') or '').strip() + last = (row.get('last_name') or '').strip() + row['resolved_employee_name'] = f"{last}, {first}" if (first or last) else 'Unknown' + row['location_display'] = row.get('location_name') or row.get('location_id_raw') or 'Unknown' + + return rows + + +def build_legacy_export_workbook(rows): + """Build an openpyxl Workbook (in-memory) for the given legacy records.""" + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Legacy Attendance" + + headers = [ + 'Employee ID', 'Employee Name', 'Date', 'Time', 'Type', + 'Location', 'Building', 'Location Address', 'Recorded Address', + 'Contract', 'Company', 'Job Code', 'Manual Entry' + ] + header_fill = PatternFill(start_color='1F2937', end_color='1F2937', fill_type='solid') + header_font = Font(color='FFFFFF', bold=True) + for col_idx, header in enumerate(headers, start=1): + cell = ws.cell(row=1, column=col_idx, value=header) + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center', vertical='center') + + for row_idx, row in enumerate(rows, start=2): + record_time = row.get('record_time') + date_str = record_time.strftime('%Y-%m-%d') if record_time else '' + time_str = record_time.strftime('%H:%M:%S') if record_time else '' + ws.cell(row=row_idx, column=1, value=row.get('employee_id')) + ws.cell(row=row_idx, column=2, value=row.get('resolved_employee_name')) + ws.cell(row=row_idx, column=3, value=date_str) + ws.cell(row=row_idx, column=4, value=time_str) + ws.cell(row=row_idx, column=5, value=row.get('record_type')) + ws.cell(row=row_idx, column=6, value=row.get('location_display')) + ws.cell(row=row_idx, column=7, value=row.get('building')) + ws.cell(row=row_idx, column=8, value=row.get('location_address')) + ws.cell(row=row_idx, column=9, value=row.get('recorded_address')) + ws.cell(row=row_idx, column=10, value=row.get('contract_name')) + ws.cell(row=row_idx, column=11, value=row.get('contract_company')) + ws.cell(row=row_idx, column=12, value=row.get('job_code')) + ws.cell(row=row_idx, column=13, value='Yes' if row.get('is_manual') else 'No') + + for col_idx in range(1, len(headers) + 1): + ws.column_dimensions[get_column_letter(col_idx)].width = 20 + + ws.freeze_panes = 'A2' + + buffer = io.BytesIO() + wb.save(buffer) + buffer.seek(0) + return buffer diff --git a/location_logging.py b/location_logging.py new file mode 100644 index 0000000..a966008 --- /dev/null +++ b/location_logging.py @@ -0,0 +1,293 @@ +# File: location_logging.py +# Enhanced location action logging for Android debugging + +from flask import request, jsonify +from datetime import datetime +import json +import traceback + +def create_location_logging_routes(app, db, logger_handler): + """ + Create location logging routes for monitoring Android location issues + This should be included in your main app.py file + """ + + @app.route('/api/log-location-action', methods=['POST']) + def log_location_action(): + """ + Log location actions for debugging Android location issues + """ + try: + data = request.get_json() + + if not data: + return jsonify({'status': 'error', 'message': 'No data provided'}), 400 + + action = data.get('action', 'unknown') + action_data = data.get('data', {}) + timestamp = data.get('timestamp', datetime.now().isoformat()) + user_agent = data.get('userAgent', request.headers.get('User-Agent', '')) + + # Extract device information + device_info = extract_device_info(user_agent) + + # Create log entry + log_entry = { + 'action': action, + 'timestamp': timestamp, + 'device_info': device_info, + 'action_data': action_data, + 'ip_address': get_client_ip_enhanced(), + 'user_agent': user_agent[:500] # Limit length + } + + # Log to console for immediate debugging + print(f"📊 LOCATION ACTION LOG: {action}") + print(f" Device: {device_info.get('platform')} {device_info.get('browser')}") + print(f" Data: {json.dumps(action_data, indent=2)}") + + # Use existing logger if available + if logger_handler: + logger_handler.log_user_activity( + f'location_action_{action}', + f"Location action: {action} | Device: {device_info.get('platform')} | Data: {json.dumps(action_data)}" + ) + + # Store in database for analysis (optional) + try: + store_location_log_in_db(db, log_entry) + except Exception as db_error: + print(f"⚠️ Could not store location log in database: {db_error}") + + return jsonify({'status': 'success', 'message': 'Location action logged'}) + + except Exception as e: + print(f"❌ Error logging location action: {e}") + print(f"❌ Traceback: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': 'Logging failed'}), 500 + + @app.route('/api/location-debug-info', methods=['GET']) + def get_location_debug_info(): + """ + Get debugging information about location services + """ + try: + user_agent = request.headers.get('User-Agent', '') + device_info = extract_device_info(user_agent) + + debug_info = { + 'timestamp': datetime.now().isoformat(), + 'ip_address': get_client_ip_enhanced(), + 'device_info': device_info, + 'headers': dict(request.headers), + 'is_android': 'android' in user_agent.lower(), + 'is_chrome': 'chrome' in user_agent.lower() and 'edg' not in user_agent.lower(), + 'is_secure': request.is_secure, + 'protocol': request.scheme + } + + return jsonify(debug_info) + + except Exception as e: + print(f"❌ Error getting debug info: {e}") + return jsonify({'error': str(e)}), 500 + +def extract_device_info(user_agent_string): + """ + Extract detailed device information from user agent + """ + try: + # Use existing user agent parsing if available + if 'user_agents' in globals(): + from user_agents import parse + user_agent = parse(user_agent_string) + + return { + 'platform': user_agent.os.family, + 'platform_version': user_agent.os.version_string, + 'browser': user_agent.browser.family, + 'browser_version': user_agent.browser.version_string, + 'device': user_agent.device.family, + 'is_mobile': user_agent.is_mobile, + 'is_tablet': user_agent.is_tablet, + 'is_pc': user_agent.is_pc, + 'is_android': 'android' in user_agent_string.lower(), + 'is_chrome': 'chrome' in user_agent_string.lower() and 'edg' not in user_agent_string.lower() + } + else: + # Fallback manual parsing + ua_lower = user_agent_string.lower() + + return { + 'platform': 'Android' if 'android' in ua_lower else 'Unknown', + 'browser': 'Chrome' if 'chrome' in ua_lower else 'Unknown', + 'is_android': 'android' in ua_lower, + 'is_chrome': 'chrome' in ua_lower and 'edg' not in ua_lower, + 'user_agent_raw': user_agent_string[:200] + } + + except Exception as e: + print(f"⚠️ Error parsing user agent: {e}") + return { + 'error': str(e), + 'user_agent_raw': user_agent_string[:200] + } + +def get_client_ip_enhanced(): + """ + Enhanced client IP detection + """ + # Check various headers for real IP + for header in ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED', 'HTTP_FORWARDED']: + if header in request.environ: + ip = request.environ[header].split(',')[0].strip() + if ip: + return ip + + return request.environ.get('REMOTE_ADDR', 'unknown') + +def store_location_log_in_db(db, log_entry): + """ + Store location log in database for analysis (optional) + Create table if it doesn't exist + """ + try: + # Create table if it doesn't exist + db.session.execute(text(""" + CREATE TABLE IF NOT EXISTS location_action_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + action VARCHAR(100), + timestamp TIMESTAMP, + device_info JSON, + action_data JSON, + ip_address VARCHAR(45), + user_agent TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """)) + + # Insert log entry + db.session.execute(text(""" + INSERT INTO location_action_logs ( + action, timestamp, device_info, action_data, ip_address, user_agent + ) VALUES ( + :action, :timestamp, :device_info, :action_data, :ip_address, :user_agent + ) + """), { + 'action': log_entry['action'], + 'timestamp': log_entry['timestamp'], + 'device_info': json.dumps(log_entry['device_info']), + 'action_data': json.dumps(log_entry['action_data']), + 'ip_address': log_entry['ip_address'], + 'user_agent': log_entry['user_agent'] + }) + + db.session.commit() + + except Exception as e: + print(f"⚠️ Database logging error: {e}") + db.session.rollback() + +# Enhanced location processing for form submission +def process_location_data_enhanced(form_data): + """ + Enhanced location data processing with better Android handling + This replaces or enhances the existing process_location_data function + """ + print(f"\n📱 ENHANCED LOCATION PROCESSING:") + print(f" Raw location data received: {dict(form_data)}") + + processed = { + 'latitude': None, + 'longitude': None, + 'accuracy': None, + 'altitude': None, + 'source': 'manual', + 'address': None + } + + try: + # Process coordinates with enhanced validation + if form_data.get('latitude') and form_data.get('longitude'): + lat_str = str(form_data['latitude']).strip() + lng_str = str(form_data['longitude']).strip() + + # Handle various input formats + if lat_str not in ['null', '', 'undefined', 'NaN'] and lng_str not in ['null', '', 'undefined', 'NaN']: + try: + lat_val = float(lat_str) + lng_val = float(lng_str) + + # Validate coordinate ranges + if -90 <= lat_val <= 90 and -180 <= lng_val <= 180: + processed['latitude'] = lat_val + processed['longitude'] = lng_val + processed['source'] = form_data.get('location_source', 'gps') + print(f"✅ Valid coordinates processed: {lat_val:.10f}, {lng_val:.10f}") + else: + print(f"⚠️ Coordinates out of valid range: {lat_val}, {lng_val}") + except (ValueError, TypeError) as e: + print(f"⚠️ Could not convert coordinates to float: {e}") + + # Process accuracy + if form_data.get('accuracy'): + try: + acc_str = str(form_data['accuracy']).strip() + if acc_str not in ['null', '', 'undefined', 'NaN']: + acc_val = float(acc_str) + if acc_val > 0: # Accuracy should be positive + processed['accuracy'] = acc_val + print(f"✅ Accuracy processed: {acc_val}m") + except (ValueError, TypeError): + print(f"⚠️ Could not process accuracy value") + + # Process altitude + if form_data.get('altitude'): + try: + alt_str = str(form_data['altitude']).strip() + if alt_str not in ['null', '', 'undefined', 'NaN']: + processed['altitude'] = float(alt_str) + except (ValueError, TypeError): + print(f"⚠️ Could not process altitude value") + + # Process address with coordinate detection + if form_data.get('address'): + address = str(form_data['address']).strip() + if address and address not in ['null', '', 'undefined']: + # Check if address is actually coordinates + if re.match(r'^-?\d+\.\d+,?\s*-?\d+\.\d+$', address.replace(' ', '')): + print(f"🔍 Address appears to be coordinates: {address}") + processed['address'] = None # Will trigger reverse geocoding + else: + processed['address'] = address[:500] + print(f"✅ Address processed: {address[:50]}...") + + # Enhanced reverse geocoding trigger + if (processed['latitude'] is not None and processed['longitude'] is not None + and not processed['address']): + print(f"🌍 Triggering enhanced reverse geocoding...") + try: + # Use existing reverse geocoding function + reverse_geocoded = reverse_geocode_coordinates(processed['latitude'], processed['longitude']) + if reverse_geocoded: + processed['address'] = reverse_geocoded[:500] + print(f"✅ Reverse geocoding successful: {reverse_geocoded[:50]}...") + else: + processed['address'] = f"{processed['latitude']:.6f}, {processed['longitude']:.6f}" + print(f"⚠️ Reverse geocoding failed, using coordinates") + except Exception as geocoding_error: + print(f"❌ Reverse geocoding error: {geocoding_error}") + processed['address'] = f"{processed['latitude']:.6f}, {processed['longitude']:.6f}" + + print(f"📍 FINAL PROCESSED LOCATION:") + print(f" Coordinates: {processed['latitude']}, {processed['longitude']}") + print(f" Accuracy: {processed['accuracy']}m") + print(f" Source: {processed['source']}") + print(f" Address: {processed['address']}") + + return processed + + except Exception as e: + print(f"❌ Error in enhanced location processing: {e}") + print(f"❌ Traceback: {traceback.format_exc()}") + return processed \ No newline at end of file diff --git a/logger_handler.py b/logger_handler.py new file mode 100644 index 0000000..acf9859 --- /dev/null +++ b/logger_handler.py @@ -0,0 +1,984 @@ +#!/usr/bin/env python3 +""" +Enhanced Logging Handler for QR Attendance Management System +========================================================== + +This module provides comprehensive logging functionality for: +- User login/logout activities with session details +- QR code creation, modification, and deletion operations +- Database transaction errors and connection issues +- Flask application errors and exceptions +- Security events and unauthorized access attempts + +Features: +- Structured JSON logging for better analytics +- Rotating log files to prevent disk space issues +- Different log levels for various event types +- Database logging table for critical events +- Performance monitoring and error tracking +""" + +import logging +import logging.handlers +import json +import os +import traceback +from datetime import datetime, date, timedelta +from functools import wraps +from flask import request, session, g, render_template, has_request_context, current_app +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError +import uuid + +class AppLogger: + """ + Enhanced application logger with multiple output formats and destinations + """ + + def __init__(self, app=None, db=None): + """Initialize the logger with Flask app and database instances""" + self.app = app + self.db = db + self.logger = None + self.security_logger = None + + if app: + self.init_app(app, db) + + def init_app(self, app, db): + """Initialize logging with Flask application context""" + self.app = app + self.db = db + + # Create logs directory if it doesn't exist + log_dir = os.path.join(app.root_path, 'logs') + os.makedirs(log_dir, exist_ok=True) + + # Configure main application logger + self.logger = logging.getLogger('qr_attendance_app') + self.logger.setLevel(logging.INFO) + + # Configure security logger for sensitive events + self.security_logger = logging.getLogger('qr_attendance_security') + self.security_logger.setLevel(logging.WARNING) + + # Remove existing handlers to avoid duplication + self.logger.handlers.clear() + self.security_logger.handlers.clear() + + # Setup file handlers with rotation + self._setup_file_handlers(log_dir) + + # Setup console handler for development + self._setup_console_handler() + + # Create database logging table + self._create_log_table() + + # Register error handlers with Flask + self._register_error_handlers() + + app.logger_handler = self + + def _setup_file_handlers(self, log_dir): + """Setup rotating file handlers for different log types""" + + # Main application log (rotates when 10MB, keeps 5 files) + app_handler = logging.handlers.RotatingFileHandler( + os.path.join(log_dir, 'application.log'), + maxBytes=10*1024*1024, # 10MB + backupCount=5 + ) + app_handler.setLevel(logging.INFO) + + # Error log (rotates when 5MB, keeps 10 files) + error_handler = logging.handlers.RotatingFileHandler( + os.path.join(log_dir, 'errors.log'), + maxBytes=5*1024*1024, # 5MB + backupCount=10 + ) + error_handler.setLevel(logging.ERROR) + + # Security log (rotates when 2MB, keeps 20 files for compliance) + security_handler = logging.handlers.RotatingFileHandler( + os.path.join(log_dir, 'security.log'), + maxBytes=2*1024*1024, # 2MB + backupCount=20 + ) + security_handler.setLevel(logging.WARNING) + + # Create custom formatter with JSON structure + formatter = logging.Formatter( + '%(asctime)s | %(levelname)s | %(name)s | %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + + app_handler.setFormatter(formatter) + error_handler.setFormatter(formatter) + security_handler.setFormatter(formatter) + + # Add handlers to loggers + self.logger.addHandler(app_handler) + self.logger.addHandler(error_handler) + self.security_logger.addHandler(security_handler) + + def _setup_console_handler(self): + """Setup console handler for development environment""" + if self.app.debug: + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.DEBUG) + + console_formatter = logging.Formatter( + '%(asctime)s [%(levelname)s] %(message)s', + datefmt='%H:%M:%S' + ) + console_handler.setFormatter(console_formatter) + + self.logger.addHandler(console_handler) + + def _create_log_table(self): + """Create database table for storing critical log events""" + try: + with self.app.app_context(): + # Create log_events table if it doesn't exist + create_table_sql = """ + CREATE TABLE IF NOT EXISTS log_events ( + id INT AUTO_INCREMENT PRIMARY KEY, + event_id VARCHAR(36) UNIQUE NOT NULL, + event_type VARCHAR(50) NOT NULL, + event_category VARCHAR(30) NOT NULL, + user_id INT NULL, + username VARCHAR(80) NULL, + event_description TEXT NOT NULL, + event_data JSON NULL, + ip_address VARCHAR(45) NULL, + user_agent TEXT NULL, + request_path VARCHAR(500) NULL, + session_id VARCHAR(100) NULL, + severity_level VARCHAR(20) DEFAULT 'INFO', + created_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_event_type (event_type), + INDEX idx_event_category (event_category), + INDEX idx_user_id (user_id), + INDEX idx_created_timestamp (created_timestamp), + INDEX idx_severity_level (severity_level) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """ + + self.db.session.execute(text(create_table_sql)) + self.db.session.commit() + + except Exception as e: + logging.getLogger('qr_attendance_app').warning(f"Could not create log_events table: {e}") + + def _register_error_handlers(self): + """Register Flask error handlers for automatic logging""" + + @self.app.errorhandler(500) + def handle_internal_error(error): + """Log internal server errors automatically""" + self.log_flask_error( + error_type="InternalServerError", + error_message=str(error), + stack_trace=traceback.format_exc() + ) + + # Return user-friendly error page + if self.app.debug: + return None # Let Flask handle debug errors + + return render_template('errors/500.html'), 500 + + @self.app.errorhandler(404) + def handle_not_found(error): + """Log 404 errors for security monitoring""" + self.log_security_event( + event_type="page_not_found", + description=f"404 error: {request.path}", + severity="LOW" + ) + + return render_template('errors/404.html'), 404 + + def _get_request_context(self): + """Get current request context information. + Safe to call from background threads — returns empty dict when no + request context is active (e.g. during background import jobs). + """ + try: + if not has_request_context(): + return {} + except Exception: + return {} + + return { + 'ip_address': request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr), + 'user_agent': request.headers.get('User-Agent', ''), + 'request_path': request.path, + 'request_method': request.method, + 'session_id': session.get('_id', 'anonymous'), + 'user_id': session.get('user_id'), + 'username': session.get('username') + } + + def _log_to_database(self, event_type, event_category, description, event_data=None, severity='INFO'): + """Log critical events to database table""" + try: + context = self._get_request_context() + event_id = str(uuid.uuid4()) + + insert_sql = """ + INSERT INTO log_events ( + event_id, event_type, event_category, user_id, username, + event_description, event_data, ip_address, user_agent, + request_path, session_id, severity_level + ) VALUES ( + :event_id, :event_type, :event_category, :user_id, :username, + :description, :event_data, :ip_address, :user_agent, + :request_path, :session_id, :severity + ) + """ + + self.db.session.execute(text(insert_sql), { + 'event_id': event_id, + 'event_type': event_type, + 'event_category': event_category, + 'user_id': context.get('user_id'), + 'username': context.get('username'), + 'description': description, + 'event_data': json.dumps(event_data) if event_data else None, + 'ip_address': context.get('ip_address'), + 'user_agent': context.get('user_agent'), + 'request_path': context.get('request_path'), + 'session_id': context.get('session_id'), + 'severity': severity + }) + + self.db.session.commit() + + except Exception as e: + # Don't let logging errors break the application + logging.getLogger('qr_attendance_app').warning(f"Database logging error (non-fatal): {e}") + try: + self.db.session.rollback() + except: + pass + + # USER LOGIN/LOGOUT LOGGING METHODS + + def log_user_login(self, user_id, username, success=True, failure_reason=None): + """Log user login attempts with detailed session information""" + context = self._get_request_context() + + event_data = { + 'user_id': user_id, + 'username': username, + 'success': success, + 'login_timestamp': datetime.now().isoformat(), + 'session_info': { + 'session_id': context.get('session_id'), + 'ip_address': context.get('ip_address'), + 'user_agent': context.get('user_agent') + } + } + + if failure_reason: + event_data['failure_reason'] = failure_reason + + if success: + message = f"User login successful: {username} (ID: {user_id})" + self.logger.info(json.dumps({ + 'event': 'user_login_success', + 'data': event_data + })) + + # Log to database for security monitoring + self._log_to_database( + event_type='user_login_success', + event_category='authentication', + description=message, + event_data=event_data, + severity='INFO' + ) + else: + message = f"User login failed: {username} - {failure_reason}" + self.security_logger.warning(json.dumps({ + 'event': 'user_login_failure', + 'data': event_data + })) + + # Log failed logins to database for security analysis + self._log_to_database( + event_type='user_login_failure', + event_category='security', + description=message, + event_data=event_data, + severity='WARNING' + ) + + def log_user_logout(self, user_id, username, session_duration=None): + """Log user logout events with session duration""" + context = self._get_request_context() + + event_data = { + 'user_id': user_id, + 'username': username, + 'logout_timestamp': datetime.now().isoformat(), + 'session_duration_minutes': session_duration, + 'session_info': { + 'session_id': context.get('session_id'), + 'ip_address': context.get('ip_address') + } + } + + message = f"User logout: {username} (ID: {user_id})" + if session_duration: + message += f" - Session duration: {session_duration} minutes" + + self.logger.info(json.dumps({ + 'event': 'user_logout', + 'data': event_data + })) + + # Log to database + self._log_to_database( + event_type='user_logout', + event_category='authentication', + description=message, + event_data=event_data + ) + + # QR CODE LOGGING METHODS + + def log_qr_code_created(self, qr_code_id, qr_code_name, created_by_user_id, qr_data): + """Log QR code creation events""" + event_data = { + 'qr_code_id': qr_code_id, + 'qr_code_name': qr_code_name, + 'created_by_user_id': created_by_user_id, + 'created_timestamp': datetime.now().isoformat(), + 'qr_code_details': { + 'location': qr_data.get('location'), + 'location_address': qr_data.get('location_address'), + 'location_event': qr_data.get('location_event'), + 'has_coordinates': qr_data.get('has_coordinates', False) + } + } + + if qr_data.get('has_coordinates'): + event_data['qr_code_details']['coordinates'] = { + 'latitude': qr_data.get('latitude'), + 'longitude': qr_data.get('longitude'), + 'accuracy': qr_data.get('coordinate_accuracy') + } + + message = f"QR code created: {qr_code_name} (ID: {qr_code_id}) by user {created_by_user_id}" + + self.logger.info(json.dumps({ + 'event': 'qr_code_created', + 'data': event_data + })) + + # Log to database + self._log_to_database( + event_type='qr_code_created', + event_category='qr_management', + description=message, + event_data=event_data + ) + + def log_qr_code_updated(self, qr_code_id, qr_code_name, updated_by_user_id, changes): + """Log QR code modification events""" + event_data = { + 'qr_code_id': qr_code_id, + 'qr_code_name': qr_code_name, + 'updated_by_user_id': updated_by_user_id, + 'updated_timestamp': datetime.now().isoformat(), + 'changes': changes + } + + message = f"QR code updated: {qr_code_name} (ID: {qr_code_id}) by user {updated_by_user_id}" + + self.logger.info(json.dumps({ + 'event': 'qr_code_updated', + 'data': event_data + })) + + # Log to database + self._log_to_database( + event_type='qr_code_updated', + event_category='qr_management', + description=message, + event_data=event_data + ) + + def log_qr_code_deleted(self, qr_code_id, qr_code_name, deleted_by_user_id): + """Log QR code deletion events""" + event_data = { + 'qr_code_id': qr_code_id, + 'qr_code_name': qr_code_name, + 'deleted_by_user_id': deleted_by_user_id, + 'deleted_timestamp': datetime.now().isoformat() + } + + message = f"QR code deleted: {qr_code_name} (ID: {qr_code_id}) by user {deleted_by_user_id}" + + self.logger.warning(json.dumps({ + 'event': 'qr_code_deleted', + 'data': event_data + })) + + # Log to database with higher severity + self._log_to_database( + event_type='qr_code_deleted', + event_category='qr_management', + description=message, + event_data=event_data, + severity='WARNING' + ) + + def log_qr_code_accessed(self, qr_code_id, qr_code_name, access_method='scan'): + """Log QR code access/scan events""" + context = self._get_request_context() + + event_data = { + 'qr_code_id': qr_code_id, + 'qr_code_name': qr_code_name, + 'access_method': access_method, + 'access_timestamp': datetime.now().isoformat(), + 'access_info': { + 'ip_address': context.get('ip_address'), + 'user_agent': context.get('user_agent') + } + } + + message = f"QR code accessed: {qr_code_name} (ID: {qr_code_id}) via {access_method}" + + self.logger.info(json.dumps({ + 'event': 'qr_code_accessed', + 'data': event_data + })) + + def log_photo_verification(self, employee_id, qr_code_id, distance, status='pending'): + """Log photo verification event""" + self.logger.info( + f"Photo Verification - Employee: {employee_id}, QR: {qr_code_id}, Distance: {distance:.3f} mi, Status: {status}" + ) + + def log_qr_code_generated(self, data_length, fill_color, back_color, box_size, border, error_correction): + """Log QR code generation with customization details""" + try: + self.logger.info(f"QR code generated with customization - " + f"Data length: {data_length}, " + f"Fill: {fill_color}, Background: {back_color}, " + f"Box size: {box_size}, Border: {border}, " + f"Error correction: {error_correction}") + except Exception as e: + self.logger.error(f"Failed to log QR code generation: {e}") + + # DATABASE ERROR LOGGING METHODS + + def log_database_error(self, operation, error, query=None, parameters=None): + """Log database operation errors""" + event_data = { + 'operation': operation, + 'error_type': type(error).__name__, + 'error_message': str(error), + 'error_timestamp': datetime.now().isoformat(), + 'database_info': { + 'query': query[:500] if query else None, # Truncate long queries + 'parameters': str(parameters)[:200] if parameters else None + } + } + + if isinstance(error, SQLAlchemyError): + event_data['sqlalchemy_error'] = True + if hasattr(error, 'orig'): + event_data['original_error'] = str(error.orig) + + message = f"Database error in {operation}: {type(error).__name__} - {str(error)}" + + self.logger.error(json.dumps({ + 'event': 'database_error', + 'data': event_data + })) + + # Log to database if possible (try/catch to avoid recursive errors) + try: + self._log_to_database( + event_type='database_error', + event_category='database', + description=message, + event_data=event_data, + severity='ERROR' + ) + except: + # If database logging fails, just continue + pass + + def log_database_connection_error(self, error): + """Log database connection failures""" + event_data = { + 'error_type': 'database_connection_failure', + 'error_message': str(error), + 'error_timestamp': datetime.now().isoformat() + } + + message = f"Database connection error: {str(error)}" + + self.logger.critical(json.dumps({ + 'event': 'database_connection_error', + 'data': event_data + })) + + # FLASK ERROR LOGGING METHODS + + def log_flask_error(self, error_type, error_message, stack_trace=None, request_data=None): + """Log Flask application errors""" + context = self._get_request_context() + + event_data = { + 'error_type': error_type, + 'error_message': error_message, + 'error_timestamp': datetime.now().isoformat(), + 'request_context': context, + 'stack_trace': stack_trace[:2000] if stack_trace else None # Truncate long traces + } + + if request_data: + event_data['request_data'] = request_data + + message = f"Flask error: {error_type} - {error_message}" + + self.logger.error(json.dumps({ + 'event': 'flask_error', + 'data': event_data + })) + + # Log to database + self._log_to_database( + event_type='flask_error', + event_category='application', + description=message, + event_data=event_data, + severity='ERROR' + ) + + # SECURITY EVENT LOGGING METHODS + + def log_security_event(self, event_type, description, severity='MEDIUM', additional_data=None): + """Log security-related events""" + context = self._get_request_context() + + event_data = { + 'security_event_type': event_type, + 'severity': severity, + 'event_timestamp': datetime.now().isoformat(), + 'request_context': context + } + + if additional_data: + event_data['additional_data'] = additional_data + + message = f"Security event: {event_type} - {description}" + + self.security_logger.warning(json.dumps({ + 'event': 'security_event', + 'data': event_data + })) + + # Log to database with high priority + self._log_to_database( + event_type='security_event', + event_category='security', + description=message, + event_data=event_data, + severity='WARNING' + ) + + # UTILITY METHODS + def get_log_statistics(self, days=7): + """Get logging statistics for the specified number of days""" + try: + from datetime import datetime, timedelta + cutoff_date = datetime.now() - timedelta(days=days) + + # Initialize default stats with all categories + stats = { + 'total_events': 0, + 'security_events': 0, + 'authentication_events': 0, + 'qr_management_events': 0, + 'database_errors': 0, + 'application_events': 0, + 'system_events': 0 + } + + # Check if table exists first + try: + table_check = self.db.session.execute(text("SHOW TABLES LIKE 'log_events'")).fetchone() + if not table_check: + self.logger.warning("get_log_statistics: log_events table does not exist") + return stats + except Exception as table_error: + self.logger.warning(f"get_log_statistics: cannot check table existence: {table_error}") + return stats + + # Get total events count + try: + total_sql = """ + SELECT COUNT(*) as total_events + FROM log_events + WHERE created_timestamp >= :cutoff_date + """ + + total_result = self.db.session.execute(text(total_sql), {'cutoff_date': cutoff_date}).fetchone() + if total_result: + stats['total_events'] = total_result.total_events + self.logger.debug(f"get_log_statistics: {stats['total_events']} total events in last {days} days") + except Exception as total_error: + self.logger.warning(f"get_log_statistics: error getting total events: {total_error}") + + # Get events by category + try: + category_sql = """ + SELECT + event_category, + COUNT(*) as event_count + FROM log_events + WHERE created_timestamp >= :cutoff_date + GROUP BY event_category + """ + + category_result = self.db.session.execute(text(category_sql), {'cutoff_date': cutoff_date}).fetchall() + + for row in category_result: + category = row.event_category + count = row.event_count + self.logger.debug(f"get_log_statistics: {count} events in category: {category}") + + # Map categories to stats keys + if category == 'security': + stats['security_events'] = count + elif category == 'authentication': + stats['authentication_events'] = count + elif category == 'qr_management': + stats['qr_management_events'] = count + elif category == 'database': + stats['database_errors'] = count + elif category == 'application': + stats['application_events'] = count + elif category == 'system': + stats['system_events'] = count + + except Exception as category_error: + self.logger.warning(f"get_log_statistics: error getting category stats: {category_error}") + + self.logger.debug(f"get_log_statistics result: {stats}") + return stats + + except Exception as e: + self.logger.error(f"Error in get_log_statistics: {e}", exc_info=True) + self.log_database_error('get_log_statistics', e) + return { + 'total_events': 0, + 'security_events': 0, + 'authentication_events': 0, + 'qr_management_events': 0, + 'database_errors': 0, + 'application_events': 0, + 'system_events': 0 + } + + def cleanup_old_logs(self, days_to_keep=90): + """Clean up old log entries from database""" + try: + from datetime import datetime, timedelta # Import here as backup + cutoff_date = datetime.now() - timedelta(days=days_to_keep) + self.logger.info(f"Starting log cleanup: removing entries older than {cutoff_date}") + + # Check if table exists first + try: + table_check = self.db.session.execute(text("SHOW TABLES LIKE 'log_events'")).fetchone() + if not table_check: + self.logger.warning("cleanup_old_logs: log_events table does not exist") + return 0 + except Exception as table_error: + self.logger.warning(f"cleanup_old_logs: cannot check table existence: {table_error}") + return 0 + + # First, count how many records will be deleted + try: + count_sql = """ + SELECT COUNT(*) as count_to_delete + FROM log_events + WHERE created_timestamp < :cutoff_date + AND severity_level NOT IN ('ERROR', 'CRITICAL', 'HIGH') + """ + + count_result = self.db.session.execute(text(count_sql), {'cutoff_date': cutoff_date}).fetchone() + count_to_delete = count_result.count_to_delete if count_result else 0 + + self.logger.debug(f"cleanup_old_logs: {count_to_delete} records to delete") + + if count_to_delete == 0: + self.logger.info("cleanup_old_logs: no old records found to cleanup") + return 0 + + except Exception as count_error: + self.logger.warning(f"cleanup_old_logs: error counting records: {count_error}") + return 0 + + # Perform the cleanup - exclude critical logs + try: + cleanup_sql = """ + DELETE FROM log_events + WHERE created_timestamp < :cutoff_date + AND severity_level NOT IN ('ERROR', 'CRITICAL', 'HIGH') + """ + + result = self.db.session.execute(text(cleanup_sql), {'cutoff_date': cutoff_date}) + deleted_count = result.rowcount + self.db.session.commit() + + self.logger.info(f"cleanup_old_logs: deleted {deleted_count} old log entries") + + # Log the cleanup operation + self.logger.info(f"Log cleanup completed: {deleted_count} entries removed (keeping entries newer than {days_to_keep} days)") + + return deleted_count + + except Exception as delete_error: + self.logger.error(f"cleanup_old_logs: error during deletion: {delete_error}", exc_info=True) + self.db.session.rollback() + return 0 + + except Exception as e: + self.logger.error(f"Error in cleanup_old_logs: {e}", exc_info=True) + self.db.session.rollback() + self.log_database_error('cleanup_old_logs', e) + return 0 + + def get_recent_logs(self, days=7, limit=100, category_filter=None, severity_filter=None, search_term=None): + """Enhanced method to get recent logs with filtering options""" + try: + cutoff_date = datetime.now() - timedelta(days=days) + + # Build the base query + base_sql = """ + SELECT + event_id, + event_type, + event_category, + event_description, + severity_level, + created_timestamp, + username, + ip_address, + user_id + FROM log_events + WHERE created_timestamp >= :cutoff_date + """ + + # Add filters + params = {'cutoff_date': cutoff_date} + + if category_filter: + base_sql += " AND event_category = :category_filter" + params['category_filter'] = category_filter + + if severity_filter: + base_sql += " AND severity_level = :severity_filter" + params['severity_filter'] = severity_filter + + if search_term: + base_sql += " AND (event_description LIKE :search_term OR event_type LIKE :search_term OR username LIKE :search_term)" + params['search_term'] = f"%{search_term}%" + + # Add ordering and limit + base_sql += " ORDER BY created_timestamp DESC LIMIT :limit" + params['limit'] = limit + + result = self.db.session.execute(text(base_sql), params).fetchall() + + logs = [] + for row in result: + logs.append({ + 'event_id': row.event_id, + 'event_type': row.event_type, + 'event_category': row.event_category, + 'description': row.event_description, + 'severity': row.severity_level, + 'timestamp': row.created_timestamp.isoformat(), + 'username': row.username or 'System', + 'ip_address': row.ip_address or '-', + 'user_id': row.user_id + }) + + return logs + + except Exception as e: + self.log_database_error('get_recent_logs', e) + self.logger.error(f"Error in get_recent_logs: {e}", exc_info=True) + return [] + + def log_system_event(self, event_type, description, severity='INFO', additional_data=None): + """Log system-level events such as startup, optimization, and slow queries""" + event_data = { + 'system_event_type': event_type, + 'severity': severity, + 'event_timestamp': datetime.now().isoformat() + } + if additional_data: + event_data['additional_data'] = additional_data + message = f"System event: {event_type} - {description}" + self.logger.info(json.dumps({'event': 'system_event', 'data': event_data})) + self._log_to_database( + event_type=event_type, + event_category='system', + description=message, + event_data=event_data, + severity=severity + ) + + def log_user_activity(self, activity_type, description='', additional_data=None): + """Log user activity events called directly from route handlers""" + context = self._get_request_context() + event_data = { + 'activity_type': activity_type, + 'event_timestamp': datetime.now().isoformat(), + 'user_id': context.get('user_id'), + 'username': context.get('username') + } + if additional_data: + event_data['additional_data'] = additional_data + message = f"User activity: {activity_type} - {description}" if description else f"User activity: {activity_type}" + self.logger.info(json.dumps({'event': 'user_activity', 'data': event_data})) + self._log_to_database( + event_type=f'user_activity_{activity_type}', + event_category='activity', + description=message, + event_data=event_data, + severity='INFO' + ) + + def verify_log_table_exists(self): + """Verify that the log_events table exists and has the correct structure""" + try: + check_table_sql = """ + SELECT COUNT(*) as table_exists + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'log_events' + """ + result = self.db.session.execute(text(check_table_sql)).fetchone() + if result.table_exists == 0: + self.logger.warning("log_events table does not exist — creating it now") + self._create_log_table() + return True + count_sql = "SELECT COUNT(*) as record_count FROM log_events" + count_result = self.db.session.execute(text(count_sql)).fetchone() + self.logger.debug(f"log_events table exists with {count_result.record_count} records") + return True + except Exception as e: + self.logger.error(f"Error verifying log table: {e}", exc_info=True) + return False + + def log_modal_interaction(self, event_type, description, additional_data=None): + """Log modal interactions for debugging""" + try: + context = self._get_request_context() + event_data = { + 'interaction_type': event_type, + 'event_timestamp': datetime.now().isoformat(), + 'request_context': context + } + if additional_data: + event_data['additional_data'] = additional_data + message = f"Modal interaction: {event_type} - {description}" + self._log_to_database( + event_type='modal_interaction', + event_category='ui', + description=message, + event_data=event_data, + severity='INFO' + ) + except Exception as e: + self.logger.warning(f"Error logging modal interaction: {e}") + +# DECORATOR FUNCTIONS FOR AUTOMATIC LOGGING + +def log_user_activity(activity_type): + """Decorator to automatically log user activities to file and database.""" + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + try: + result = f(*args, **kwargs) + + # Log successful activity via the AppLogger instance on current_app + try: + lh = current_app.logger_handler + lh.log_user_activity( + activity_type=activity_type, + description=( + f"User '{session.get('username', 'anonymous')}' " + f"completed activity: {activity_type}" + ) + ) + except Exception as log_error: + # Logging must never break the decorated route + logging.getLogger('qr_attendance_app').warning( + f"log_user_activity decorator failed for '{activity_type}': {log_error}" + ) + + return result + + except Exception as e: + # Log the error, then re-raise so Flask handles it normally + try: + lh = current_app.logger_handler + lh.log_flask_error( + error_type=f"activity_error_{activity_type}", + error_message=str(e), + stack_trace=traceback.format_exc() + ) + except Exception as log_error: + logging.getLogger('qr_attendance_app').warning( + f"log_user_activity error-branch failed for '{activity_type}': {log_error}" + ) + raise + + return decorated_function + return decorator + +def log_database_operations(operation_name): + """Decorator to automatically log database operation errors.""" + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + try: + return f(*args, **kwargs) + + except Exception as e: + # Log the database error via the AppLogger instance on current_app + try: + lh = current_app.logger_handler + lh.log_database_error( + operation=operation_name, + error=e + ) + except Exception as log_error: + logging.getLogger('qr_attendance_app').warning( + f"log_database_operations decorator failed for '{operation_name}': {log_error}" + ) + raise + + return decorated_function + return decorator + +# INITIALIZATION FUNCTION +def init_logging(app, db): + """Initialize the logging system with the Flask app""" + logger_handler = AppLogger(app, db) + return logger_handler \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..b4021ac --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,24 @@ +""" +Models package for QR Attendance Management System +================================================== + +This package contains all SQLAlchemy models split from app.py for better organization. +All models maintain backward compatibility and existing functionality. +""" + +from . import base + +def set_db(database): + """Set the database instance for all models""" + base.db = database + + # Now import all models (they will use base.db) + from .user import User + from .qrcode import QRCode, QRCodeStyle, QRCodeLocation # ADDED: QRCodeLocation + from .project import Project + from .attendance import AttendanceData + from .employee import Employee + from .time_attendance import TimeAttendance + from .permissions import UserProjectPermission, UserLocationPermission + + return User, QRCode, QRCodeStyle, QRCodeLocation, Project, AttendanceData, Employee, TimeAttendance, UserProjectPermission, UserLocationPermission diff --git a/models/attendance.py b/models/attendance.py new file mode 100644 index 0000000..420303a --- /dev/null +++ b/models/attendance.py @@ -0,0 +1,81 @@ +""" +Attendance Model for QR Attendance Management System +=================================================== + +AttendanceData model for tracking attendance records with location support. +Extracted from app.py for better code organization. +""" + +from datetime import datetime +from . import base + +class AttendanceData(base.db.Model): + """Enhanced attendance tracking model with location support""" + __tablename__ = 'attendance_data' + + # Existing fields + id = base.db.Column(base.db.Integer, primary_key=True) + qr_code_id = base.db.Column(base.db.Integer, base.db.ForeignKey('qr_codes.id', ondelete='CASCADE'), nullable=False) + employee_id = base.db.Column(base.db.String(50), nullable=False) + check_in_date = base.db.Column(base.db.Date, nullable=False, default=datetime.today) + check_in_time = base.db.Column(base.db.Time, nullable=False, default=lambda: datetime.now().time()) + device_info = base.db.Column(base.db.String(200)) + user_agent = base.db.Column(base.db.Text) + ip_address = base.db.Column(base.db.String(45)) + location_name = base.db.Column(base.db.String(100), nullable=False) + status = base.db.Column(base.db.String(20), default='present') + created_timestamp = base.db.Column(base.db.DateTime, default=datetime.utcnow) + updated_timestamp = base.db.Column(base.db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + latitude = base.db.Column(base.db.Float, nullable=True) + longitude = base.db.Column(base.db.Float, nullable=True) + accuracy = base.db.Column(base.db.Float, nullable=True) + location_accuracy = base.db.Column(base.db.Float, nullable=True) + altitude = base.db.Column(base.db.Float, nullable=True) + location_source = base.db.Column(base.db.String(50), default='manual') + address = base.db.Column(base.db.String(500), nullable=True) + # Stores the QR-side address for dynamic QR check-ins (overrides qr_codes.location_address join) + qr_address = base.db.Column(base.db.Text, nullable=True) + # True when this record was created via a Dynamic QR code scan + is_dynamic_qr = base.db.Column(base.db.Boolean, default=False, nullable=False) + verification_photo = base.db.Column(base.db.Text, nullable=True) # Base64 encoded image + verification_required = base.db.Column(base.db.Boolean, default=False) + verification_status = base.db.Column(base.db.String(20), nullable=True) # 'pending', 'approved', 'rejected' + verification_timestamp = base.db.Column(base.db.DateTime, nullable=True) + edit_note = base.db.Column(base.db.Text, nullable=True) + # Relationships + qr_code = base.db.relationship('QRCode', backref=base.db.backref('attendance_records', lazy='dynamic')) + + def __repr__(self): + return f'<AttendanceData {self.employee_id} at {self.location_name} on {self.check_in_date}>' + + @property + def has_location_data(self): + """Check if this record has GPS coordinates""" + return self.latitude is not None and self.longitude is not None + + @property + def location_accuracy_level(self): + """Get human-readable accuracy level""" + if not self.accuracy: + return 'unknown' + elif self.accuracy <= 5: + return 'high' + elif self.accuracy <= 20: + return 'medium' + else: + return 'low' + + @property + def needs_photo_verification(self): + """Check if this check-in requires photo verification""" + return self.verification_required == True + + @property + def is_verification_pending(self): + """Check if photo verification is pending approval""" + return self.verification_status == 'pending' + + @property + def is_verification_approved(self): + """Check if photo verification was approved""" + return self.verification_status == 'approved' \ No newline at end of file diff --git a/models/base.py b/models/base.py new file mode 100644 index 0000000..05c42d3 --- /dev/null +++ b/models/base.py @@ -0,0 +1,7 @@ +# models/base.py +""" +Base module to hold the database instance for all models +""" + +# This will be set by app.py +db = None \ No newline at end of file diff --git a/models/employee.py b/models/employee.py new file mode 100644 index 0000000..4bcb99b --- /dev/null +++ b/models/employee.py @@ -0,0 +1,82 @@ +""" +Employee Model for QR Attendance Management System +================================================= + +Employee model to manage employee data from the external employee table. +This model interfaces with the existing employee table structure. +""" + +from datetime import datetime +from sqlalchemy import cast, String +from . import base + +class Employee(base.db.Model): + """ + Employee model to manage employee records + Maps to existing employee table structure + """ + __tablename__ = 'employee' + + # Map to existing table structure from employee.sql + index = base.db.Column('index', base.db.BigInteger, primary_key=True, autoincrement=True) + id = base.db.Column('id', base.db.BigInteger, nullable=False, unique=True) + firstName = base.db.Column('firstName', base.db.String(50), nullable=False) + lastName = base.db.Column('lastName', base.db.String(50), nullable=False) + title = base.db.Column('title', base.db.String(20), nullable=True) + contractId = base.db.Column('contractId', base.db.BigInteger, nullable=False, default=1) + + def __repr__(self): + return f'<Employee {self.firstName} {self.lastName} (ID: {self.id})>' + + @property + def full_name(self): + """Get employee's full name""" + return f"{self.firstName} {self.lastName}" + + @property + def display_title(self): + """Get formatted title for display""" + return self.title if self.title else "No Title" + + @classmethod + def get_by_employee_id(cls, employee_id): + """Get employee by their ID (not primary key index)""" + return cls.query.filter_by(id=employee_id).first() + + @classmethod + def search_employees(cls, search_term): + """Search employees by name, ID, or title""" + if not search_term: + return cls.query.all() + + search_pattern = f"%{search_term}%" + return cls.query.filter( + base.db.or_( + cls.firstName.like(search_pattern), + cls.lastName.like(search_pattern), + cls.title.like(search_pattern), + cast(cls.id, String).like(search_pattern) + ) + ).all() + + def to_dict(self): + """Convert employee to dictionary for JSON serialization""" + return { + 'index': self.index, + 'id': self.id, + 'firstName': self.firstName, + 'lastName': self.lastName, + 'full_name': self.full_name, + 'title': self.title, + 'display_title': self.display_title, + 'contractId': self.contractId + } + + project = base.db.relationship('Project', foreign_keys=[contractId], + primaryjoin="Employee.contractId == Project.id", + backref='employees') + + @property + def contract_name(self): + """Get project name from contractId""" + return self.project.name if self.project else f"Contract {self.contractId}" \ No newline at end of file diff --git a/models/permissions.py b/models/permissions.py new file mode 100644 index 0000000..68cee1a --- /dev/null +++ b/models/permissions.py @@ -0,0 +1,48 @@ +""" +Permission Models for QR Attendance Management System +==================================================== + +Permission models to manage Project Manager access control. +These models define which projects and locations a Project Manager can access. +""" + +from datetime import datetime +from . import base + +class UserProjectPermission(base.db.Model): + """ + UserProjectPermission model to manage project access for Project Managers + Links users to specific projects they are allowed to view + """ + __tablename__ = 'user_project_permissions' + + id = base.db.Column(base.db.Integer, primary_key=True) + user_id = base.db.Column(base.db.Integer, base.db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + project_id = base.db.Column(base.db.Integer, base.db.ForeignKey('projects.id', ondelete='CASCADE'), nullable=False) + created_date = base.db.Column(base.db.DateTime, default=datetime.utcnow) + + # Relationships + user = base.db.relationship('User', backref=base.db.backref('project_permissions', lazy='dynamic', cascade='all, delete-orphan')) + project = base.db.relationship('Project', backref=base.db.backref('user_permissions', lazy='dynamic')) + + def __repr__(self): + return f'<UserProjectPermission user_id={self.user_id} project_id={self.project_id}>' + + +class UserLocationPermission(base.db.Model): + """ + UserLocationPermission model to manage location access for Project Managers + Links users to specific locations they are allowed to view + """ + __tablename__ = 'user_location_permissions' + + id = base.db.Column(base.db.Integer, primary_key=True) + user_id = base.db.Column(base.db.Integer, base.db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + location_name = base.db.Column(base.db.String(200), nullable=False) + created_date = base.db.Column(base.db.DateTime, default=datetime.utcnow) + + # Relationships + user = base.db.relationship('User', backref=base.db.backref('location_permissions', lazy='dynamic', cascade='all, delete-orphan')) + + def __repr__(self): + return f'<UserLocationPermission user_id={self.user_id} location={self.location_name}>' \ No newline at end of file diff --git a/models/project.py b/models/project.py new file mode 100644 index 0000000..9975993 --- /dev/null +++ b/models/project.py @@ -0,0 +1,40 @@ +""" +Project Model for QR Attendance Management System +================================================ + +Project model to organize QR codes by projects. +Extracted from app.py for better code organization. +""" + +from datetime import datetime +from . import base + +class Project(base.db.Model): + """ + Project model to organize QR codes by projects + """ + __tablename__ = 'projects' + + id = base.db.Column(base.db.Integer, primary_key=True) + name = base.db.Column(base.db.String(100), nullable=False) + description = base.db.Column(base.db.Text, nullable=True) + created_by = base.db.Column(base.db.Integer, base.db.ForeignKey('users.id'), nullable=True) + created_date = base.db.Column(base.db.DateTime, default=datetime.utcnow) + active_status = base.db.Column(base.db.Boolean, default=True) + + # Relationships + qr_codes = base.db.relationship('QRCode', backref='project', lazy='dynamic') + creator = base.db.relationship('User', backref='created_projects') + + def __repr__(self): + return f'<Project {self.name}>' + + @property + def qr_count(self): + """Get count of QR codes in this project""" + return self.qr_codes.filter_by(active_status=True).count() + + @property + def total_qr_count(self): + """Get total count of QR codes (including inactive) in this project""" + return self.qr_codes.count() \ No newline at end of file diff --git a/models/qrcode.py b/models/qrcode.py new file mode 100644 index 0000000..62fa794 --- /dev/null +++ b/models/qrcode.py @@ -0,0 +1,118 @@ +""" +QRCode, QRCodeStyle, and QRCodeLocation Models for QR Attendance Management System +=================================================================================== + +QRCode models to manage QR code records and metadata with customization options. +Extracted from app.py for better code organization. +""" + +from datetime import datetime +from . import base + +class QRCode(base.db.Model): + """ + Enhanced QR Code model to manage QR code records and metadata with address coordinates + """ + __tablename__ = 'qr_codes' + + id = base.db.Column(base.db.Integer, primary_key=True) + name = base.db.Column(base.db.String(100), nullable=False) + # nullable=True for dynamic QR codes which have no single fixed location/address + location = base.db.Column(base.db.String(100), nullable=True) + location_address = base.db.Column(base.db.Text, nullable=True) + location_event = base.db.Column(base.db.String(200), nullable=False) + qr_code_image = base.db.Column(base.db.Text, nullable=False) # Base64 encoded image + created_by = base.db.Column(base.db.Integer, base.db.ForeignKey('users.id'), nullable=True) + created_date = base.db.Column(base.db.DateTime, default=datetime.utcnow) + active_status = base.db.Column(base.db.Boolean, default=True) + qr_url = base.db.Column(base.db.String(255), unique=True, nullable=True) + # Address Coordinates Fields + address_latitude = base.db.Column(base.db.Float, nullable=True) + address_longitude = base.db.Column(base.db.Float, nullable=True) + coordinate_accuracy = base.db.Column(base.db.String(50), nullable=True, default='geocoded') + coordinates_updated_date = base.db.Column(base.db.DateTime, nullable=True) + project_id = base.db.Column(base.db.Integer, base.db.ForeignKey('projects.id'), nullable=True) + # QR Code Customization fields + fill_color = base.db.Column(base.db.String(7), default="#000000") # Hex color + back_color = base.db.Column(base.db.String(7), default="#FFFFFF") # Background color + box_size = base.db.Column(base.db.Integer, default=10) + border = base.db.Column(base.db.Integer, default=4) + error_correction = base.db.Column(base.db.String(1), default='L') + style_id = base.db.Column(base.db.Integer, base.db.ForeignKey('qr_code_styles.id'), nullable=True) + # --- ADDED: QR Code Type --- + # 'standard' = fixed single location (existing behavior, default) + # 'dynamic' = employee selects location from a list at scan time + qr_type = base.db.Column(base.db.String(20), nullable=False, default='standard') + # Per-QR photo verification toggle (default: enabled) + photo_verification_enabled = base.db.Column(base.db.Boolean, nullable=False, default=True) + + # Relationship to style + style = base.db.relationship('QRCodeStyle', backref='qr_codes') + + @property + def has_coordinates(self): + """Check if this QR code has address coordinates""" + return self.address_latitude is not None and self.address_longitude is not None + + @property + def coordinates_display(self): + """Get formatted coordinates for display""" + if self.has_coordinates: + return f"{self.address_latitude:.10f}, {self.address_longitude:.10f}" + return "Coordinates not available" + + def update_coordinates(self, latitude, longitude, accuracy='geocoded'): + """Update the address coordinates for this QR code""" + self.address_latitude = latitude + self.address_longitude = longitude + self.coordinate_accuracy = accuracy + self.coordinates_updated_date = datetime.utcnow() + + +class QRCodeStyle(base.db.Model): + """QR Code customization styles""" + __tablename__ = 'qr_code_styles' + + id = base.db.Column(base.db.Integer, primary_key=True) + name = base.db.Column(base.db.String(100), nullable=False) # Style name + fill_color = base.db.Column(base.db.String(7), default="#000000") # Hex color for QR modules + back_color = base.db.Column(base.db.String(7), default="#FFFFFF") # Hex color for background + box_size = base.db.Column(base.db.Integer, default=10) # Size of each QR module + border = base.db.Column(base.db.Integer, default=4) # Border size + error_correction = base.db.Column(base.db.String(1), default='L') # L, M, Q, H + is_default = base.db.Column(base.db.Boolean, default=False) + created_at = base.db.Column(base.db.DateTime, default=datetime.utcnow) + created_by = base.db.Column(base.db.Integer, base.db.ForeignKey('users.id')) + + def __repr__(self): + return f'<QRCodeStyle {self.name}>' + + +# --- ADDED: QRCodeLocation model --- +class QRCodeLocation(base.db.Model): + """ + Selectable locations for dynamic QR codes. + Each record represents one location option displayed to the employee at scan time. + Only relevant when the parent QRCode.qr_type == 'dynamic'. + """ + __tablename__ = 'qr_code_locations' + + id = base.db.Column(base.db.Integer, primary_key=True) + qr_code_id = base.db.Column( + base.db.Integer, + base.db.ForeignKey('qr_codes.id', ondelete='CASCADE'), + nullable=False + ) + location_name = base.db.Column(base.db.String(100), nullable=False) + location_address = base.db.Column(base.db.Text, nullable=True) + address_latitude = base.db.Column(base.db.Float, nullable=True) + address_longitude = base.db.Column(base.db.Float, nullable=True) + sort_order = base.db.Column(base.db.Integer, default=0, nullable=False) + active_status = base.db.Column(base.db.Boolean, default=True, nullable=False) + created_date = base.db.Column(base.db.DateTime, default=datetime.utcnow) + + # Back-reference: qr_code_instance.locations → list of QRCodeLocation rows + qr_code = base.db.relationship('QRCode', backref='locations') + + def __repr__(self): + return f'<QRCodeLocation "{self.location_name}" (QR #{self.qr_code_id})>' \ No newline at end of file diff --git a/models/time_attendance.py b/models/time_attendance.py new file mode 100644 index 0000000..5ae9f6d --- /dev/null +++ b/models/time_attendance.py @@ -0,0 +1,132 @@ +""" +Time Attendance Model for QR Attendance Management System +========================================================= + +TimeAttendance model to manage imported time attendance data from Excel files. +This model is designed to store attendance data imported from external sources. +""" + +from datetime import datetime +from . import base + +class TimeAttendance(base.db.Model): + """ + Time Attendance model to manage imported attendance records from Excel files + """ + __tablename__ = 'time_attendance' + + # Primary key + id = base.db.Column(base.db.Integer, primary_key=True, autoincrement=True) + + # Employee identification + employee_id = base.db.Column(base.db.String(50), nullable=False, index=True) + employee_name = base.db.Column(base.db.String(200), nullable=False) + + # Platform and device information + platform = base.db.Column(base.db.String(200), nullable=True) + + # Date and time information + attendance_date = base.db.Column(base.db.Date, nullable=False, index=True) + attendance_time = base.db.Column(base.db.Time, nullable=False) + + # Location information + location_name = base.db.Column(base.db.String(200), nullable=False) + + # Action and event details + action_description = base.db.Column(base.db.String(100), nullable=False) + event_description = base.db.Column(base.db.Text, nullable=True) + recorded_address = base.db.Column(base.db.Text, nullable=True) + # Distance/Location accuracy field (in miles) + distance = base.db.Column(base.db.Float, nullable=True) + + # Import tracking + import_batch_id = base.db.Column(base.db.String(36), nullable=True, index=True) + import_date = base.db.Column(base.db.DateTime, default=datetime.utcnow) + import_source = base.db.Column(base.db.String(100), nullable=True) + project_id = base.db.Column(base.db.Integer, base.db.ForeignKey('projects.id'), nullable=True, index=True) + + # Audit fields + created_by = base.db.Column(base.db.Integer, base.db.ForeignKey('users.id'), nullable=True) + created_date = base.db.Column(base.db.DateTime, default=datetime.utcnow) + updated_date = base.db.Column(base.db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationship + project = base.db.relationship('Project', backref='time_attendance_records') + + def __repr__(self): + return f'<TimeAttendance {self.employee_id} - {self.employee_name} at {self.location_name} on {self.attendance_date}>' + + @property + def full_datetime(self): + """Get combined datetime from date and time""" + return datetime.combine(self.attendance_date, self.attendance_time) + + @property + def formatted_datetime(self): + """Get formatted datetime string for display""" + return self.full_datetime.strftime('%Y-%m-%d %H:%M:%S') + + @classmethod + def get_by_employee_id(cls, employee_id, start_date=None, end_date=None): + """Get attendance records by employee ID with optional date range""" + query = cls.query.filter_by(employee_id=employee_id) + + if start_date: + query = query.filter(cls.attendance_date >= start_date) + if end_date: + query = query.filter(cls.attendance_date <= end_date) + + return query.order_by(cls.attendance_date.desc(), cls.attendance_time.desc()).all() + + @classmethod + def get_by_location(cls, location_name, start_date=None, end_date=None): + """Get attendance records by location with optional date range""" + query = cls.query.filter_by(location_name=location_name) + + if start_date: + query = query.filter(cls.attendance_date >= start_date) + if end_date: + query = query.filter(cls.attendance_date <= end_date) + + return query.order_by(cls.attendance_date.desc(), cls.attendance_time.desc()).all() + + @classmethod + def get_by_import_batch(cls, batch_id): + """Get all records from a specific import batch""" + return cls.query.filter_by(import_batch_id=batch_id).order_by( + cls.attendance_date.desc(), cls.attendance_time.desc() + ).all() + + @classmethod + def get_unique_employees(cls): + """Get list of unique employees from time attendance records""" + return base.db.session.query( + cls.employee_id, + cls.employee_name + ).distinct().order_by(cls.employee_name).all() + + @classmethod + def get_unique_locations(cls): + """Get list of unique locations from time attendance records""" + return base.db.session.query(cls.location_name).distinct().order_by(cls.location_name).all() + + def to_dict(self): + """Convert record to dictionary for JSON serialization""" + return { + 'id': self.id, + 'employee_id': self.employee_id, + 'employee_name': self.employee_name, + 'platform': self.platform, + 'attendance_date': self.attendance_date.isoformat() if self.attendance_date else None, + 'attendance_time': self.attendance_time.isoformat() if self.attendance_time else None, + 'formatted_datetime': self.formatted_datetime, + 'location_name': self.location_name, + 'action_description': self.action_description, + 'event_description': self.event_description, + 'recorded_address': self.recorded_address, + 'import_batch_id': self.import_batch_id, + 'import_date': self.import_date.isoformat() if self.import_date else None, + 'import_source': self.import_source, + 'created_date': self.created_date.isoformat() if self.created_date else None, + 'updated_date': self.updated_date.isoformat() if self.updated_date else None + } \ No newline at end of file diff --git a/models/user.py b/models/user.py new file mode 100644 index 0000000..e56e09c --- /dev/null +++ b/models/user.py @@ -0,0 +1,70 @@ +""" +User Model for QR Attendance Management System +============================================== + +User model to manage system users with role-based access control. +Extracted from app.py for better code organization. +""" + +from werkzeug.security import generate_password_hash, check_password_hash +from datetime import datetime + +# Valid user roles (kept in sync with app.py) +STAFF_LEVEL_ROLES = ['staff', 'payroll', 'project_manager', 'accounting'] + +# Import db from app - this works because app.py imports this file after db is created +import sys +from . import base + +class User(base.db.Model): + """ + User model to manage system users with role-based access control + """ + __tablename__ = 'users' + + id = base.db.Column(base.db.Integer, primary_key=True) + full_name = base.db.Column(base.db.String(100), nullable=False) + email = base.db.Column(base.db.String(120), unique=True, nullable=False) + username = base.db.Column(base.db.String(80), unique=True, nullable=False) + password_hash = base.db.Column(base.db.String(255), nullable=False) + role = base.db.Column(base.db.String(20), nullable=False, default='staff') # admin or staff + created_by = base.db.Column(base.db.Integer, base.db.ForeignKey('users.id'), nullable=True) + created_date = base.db.Column(base.db.DateTime, default=datetime.utcnow) + active_status = base.db.Column(base.db.Boolean, default=True) + last_login_date = base.db.Column(base.db.DateTime, nullable=True) + + # Relationships + created_users = base.db.relationship('User', backref=base.db.backref('creator', remote_side=[id])) + created_qr_codes = base.db.relationship('QRCode', backref='creator', lazy='dynamic') + + def set_password(self, password): + """Hash and set user password""" + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + """Verify user password""" + return check_password_hash(self.password_hash, password) + + def is_admin(self): + """Check if user has admin privileges""" + return self.role == 'admin' + + def has_staff_permissions(self): + """Check if user has staff-level permissions (includes new roles)""" + return self.role in STAFF_LEVEL_ROLES + + @staticmethod + 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 = { + 'admin': 'Administrator', + 'staff': 'Staff User', + 'payroll': 'Payroll Specialist', + 'project_manager': 'Project Manager', + 'accounting': 'Accounting Specialist' + } + return role_names.get(self.role, self.role.title()) \ No newline at end of file diff --git a/qr_code_import_service.py b/qr_code_import_service.py new file mode 100644 index 0000000..c9bceb3 --- /dev/null +++ b/qr_code_import_service.py @@ -0,0 +1,392 @@ +""" +QR Code Import Service +===================== + +Service for handling bulk QR code imports from Excel files. +Follows the same patterns as TimeAttendanceImportService. + +IMPORTANT: This service does NOT import any models. +All model classes must be passed as parameters from app.py. +""" + +import pandas as pd +import uuid +from datetime import datetime +from sqlalchemy.exc import SQLAlchemyError +from typing import Dict, List, Any, Optional, Tuple +import traceback + + +class QRCodeImportService: + """Service for bulk QR code import from Excel files""" + + def __init__(self, db, logger_handler=None): + """Initialize the import service with database and logger""" + self.db = db + self.logger = logger_handler + + def validate_excel_file(self, file_path: str) -> Dict[str, Any]: + """ + Validate Excel file structure and data + + Args: + file_path: Path to the Excel file + + Returns: + Dictionary with validation results + """ + try: + # Read Excel file + df = pd.read_excel(file_path) + + # Required columns + required_columns = [ + 'QR Code Name', + 'QR Code Location', + 'Project', + 'Location Address', + 'Event' + ] + + # Optional columns + optional_columns = [ + 'Latitude', + 'Longitude' + ] + + # Check for required columns + missing_columns = [] + for col in required_columns: + if col not in df.columns: + missing_columns.append(col) + + if missing_columns: + return { + 'success': False, + 'error': f"Missing required columns: {', '.join(missing_columns)}", + 'missing_columns': missing_columns + } + + # Validate data + errors = [] + warnings = [] + valid_rows = [] + invalid_rows = [] + + for index, row in df.iterrows(): + row_num = index + 2 # Excel row number (header is row 1) + row_errors = [] + + # Validate QR Code Name + if pd.isna(row['QR Code Name']) or str(row['QR Code Name']).strip() == '': + row_errors.append(f"Row {row_num}: QR Code Name is required") + + # Validate Location + if pd.isna(row['QR Code Location']) or str(row['QR Code Location']).strip() == '': + row_errors.append(f"Row {row_num}: QR Code Location is required") + + # Validate Location Address + if pd.isna(row['Location Address']) or str(row['Location Address']).strip() == '': + row_errors.append(f"Row {row_num}: Location Address is required") + + # Validate Event + if pd.isna(row['Event']) or str(row['Event']).strip() == '': + row_errors.append(f"Row {row_num}: Event is required") + + # Validate Project (must be a string) + if pd.isna(row['Project']) or str(row['Project']).strip() == '': + row_errors.append(f"Row {row_num}: Project is required") + + # Validate GPS coordinates if provided + has_latitude = 'Latitude' in df.columns and not pd.isna(row.get('Latitude')) + has_longitude = 'Longitude' in df.columns and not pd.isna(row.get('Longitude')) + + if has_latitude and has_longitude: + try: + lat = float(row['Latitude']) + lon = float(row['Longitude']) + + # Validate latitude range + if not (-90 <= lat <= 90): + row_errors.append(f"Row {row_num}: Latitude must be between -90 and 90") + + # Validate longitude range + if not (-180 <= lon <= 180): + row_errors.append(f"Row {row_num}: Longitude must be between -180 and 180") + except (ValueError, TypeError): + row_errors.append(f"Row {row_num}: Invalid GPS coordinates format") + elif has_latitude or has_longitude: + warnings.append(f"Row {row_num}: Both Latitude and Longitude must be provided together") + + if row_errors: + errors.extend(row_errors) + invalid_rows.append({ + 'row_number': row_num, + 'data': row.to_dict(), + 'errors': row_errors + }) + else: + valid_rows.append({ + 'row_number': row_num, + 'data': row.to_dict() + }) + + return { + 'success': len(errors) == 0, + 'total_rows': len(df), + 'valid_rows': len(valid_rows), + 'invalid_rows': len(invalid_rows), + 'errors': errors, + 'warnings': warnings, + 'valid_data': valid_rows, + 'invalid_data': invalid_rows + } + + except Exception as e: + if self.logger: + self.logger.logger.error(f"Error validating Excel file: {e}") + return { + 'success': False, + 'error': f"Error reading Excel file: {str(e)}", + 'total_rows': 0, + 'valid_rows': 0, + 'invalid_rows': 0, + 'errors': [str(e)], + 'warnings': [] + } + + def import_from_excel( + self, + file_path: str, + created_by: int, + generate_qr_code_func, + generate_qr_url_func, + request_url_root: str, + project_lookup: Dict[str, int] = None, + QRCode=None, + Project=None, + geocode_func=None + ) -> Dict[str, Any]: + """ + Import QR codes from Excel file + + Args: + file_path: Path to the Excel file + created_by: User ID who initiated the import + generate_qr_code_func: Function to generate QR code image + generate_qr_url_func: Function to generate QR URL + request_url_root: Base URL for QR code destination + project_lookup: Dictionary mapping project names to IDs + QRCode: QRCode model class (passed from app.py) + Project: Project model class (passed from app.py) + geocode_func: Function to geocode addresses (optional, for auto-geocoding) + + Returns: + Dictionary with import results + """ + # Models are now passed as parameters to avoid import issues + + # Validate that model classes were passed + if QRCode is None or Project is None: + return { + 'success': False, + 'error': 'Model classes not provided. Please update your route to pass QRCode and Project models.', + 'imported_records': 0, + 'failed_records': 0, + 'errors': ['Model classes missing'] + } + + try: + # First validate the file + validation_result = self.validate_excel_file(file_path) + + if not validation_result['success']: + return { + 'success': False, + 'error': validation_result.get('error', 'Validation failed'), + 'imported_records': 0, + 'failed_records': validation_result['total_rows'], + 'errors': validation_result['errors'] + } + + # Read Excel file + df = pd.read_excel(file_path) + + # Track import statistics + imported_count = 0 + failed_count = 0 + geocoded_count = 0 # Track how many addresses were auto-geocoded + errors = [] + imported_qr_codes = [] + + # If no project lookup provided, create one + if project_lookup is None: + projects = Project.query.filter_by(active_status=True).all() + project_lookup = {p.name: p.id for p in projects} + + for index, row in df.iterrows(): + row_num = index + 2 + + try: + # Extract data + name = str(row['QR Code Name']).strip() + location = str(row['QR Code Location']).strip() + location_address = str(row['Location Address']).strip() + location_event = str(row['Event']).strip() + project_name = str(row['Project']).strip() + + # Get project ID + project_id = project_lookup.get(project_name) + if not project_id: + errors.append(f"Row {row_num}: Project '{project_name}' not found") + failed_count += 1 + continue + + # Extract GPS coordinates if provided + address_latitude = None + address_longitude = None + has_coordinates = False + coordinate_accuracy = None + + if 'Latitude' in df.columns and 'Longitude' in df.columns: + if not pd.isna(row.get('Latitude')) and not pd.isna(row.get('Longitude')): + try: + address_latitude = float(row['Latitude']) + address_longitude = float(row['Longitude']) + has_coordinates = True + coordinate_accuracy = 'manual' + + if self.logger: + self.logger.logger.info(f"Row {row_num}: Using provided coordinates ({address_latitude}, {address_longitude})") + except (ValueError, TypeError): + if self.logger: + self.logger.logger.warning(f"Row {row_num}: Invalid coordinate format, will attempt geocoding") + + # Auto-geocode if coordinates not provided and geocode function available + if not has_coordinates and geocode_func and location_address: + try: + if self.logger: + self.logger.logger.info(f"Row {row_num}: Attempting to geocode address: {location_address[:50]}...") + + # Call the geocoding function + geocoded_lat, geocoded_lng, geocoded_accuracy = geocode_func(location_address) + + if geocoded_lat and geocoded_lng: + address_latitude = geocoded_lat + address_longitude = geocoded_lng + has_coordinates = True + coordinate_accuracy = geocoded_accuracy if geocoded_accuracy else 'geocoded' + geocoded_count += 1 # Increment geocoded counter + + if self.logger: + self.logger.logger.info( + f"Row {row_num}: Successfully geocoded to ({address_latitude}, {address_longitude}) " + f"with accuracy: {coordinate_accuracy}" + ) + else: + if self.logger: + self.logger.logger.warning(f"Row {row_num}: Geocoding returned no results for address") + except Exception as geocode_error: + if self.logger: + self.logger.logger.error(f"Row {row_num}: Geocoding error: {geocode_error}") + # Continue without coordinates - they're optional + + # Check for duplicate QR code name + existing_qr = QRCode.query.filter_by(name=name, active_status=True).first() + if existing_qr: + errors.append(f"Row {row_num}: QR code with name '{name}' already exists") + failed_count += 1 + continue + + # Create new QR code record (without URL and image first) + new_qr_code = QRCode( + name=name, + location=location, + location_address=location_address, + location_event=location_event, + qr_code_image="", + qr_url="", + created_by=created_by, + project_id=project_id, + address_latitude=address_latitude, + address_longitude=address_longitude, + coordinate_accuracy=coordinate_accuracy, + coordinates_updated_date=datetime.utcnow() if has_coordinates else None, + fill_color='#000000', + back_color='#FFFFFF', + box_size=10, + border=4, + error_correction='H' # Highest error correction level (30% recovery) + ) + + # Add to session and flush to get ID + self.db.session.add(new_qr_code) + self.db.session.flush() + + # Generate URL and QR code image + qr_url = generate_qr_url_func(name, new_qr_code.id) + qr_data = f"{request_url_root}qr/{qr_url}" + qr_image = generate_qr_code_func( + data=qr_data, + fill_color='#000000', + back_color='#FFFFFF', + box_size=10, + border=4, + error_correction='H' # Highest error correction level (30% recovery) + ) + + # Update QR code with URL and image + new_qr_code.qr_url = qr_url + new_qr_code.qr_code_image = qr_image + + imported_count += 1 + imported_qr_codes.append({ + 'name': name, + 'location': location, + 'project': project_name, + 'id': new_qr_code.id + }) + + except Exception as row_error: + self.db.session.rollback() + error_msg = f"Row {row_num}: {str(row_error)}" + errors.append(error_msg) + failed_count += 1 + + if self.logger: + self.logger.logger.error(f"Error importing row {row_num}: {row_error}") + + # Commit all successful imports + if imported_count > 0: + self.db.session.commit() + + if self.logger: + self.logger.logger.info( + f"Bulk QR code import completed: {imported_count} imported, {failed_count} failed, " + f"{geocoded_count} addresses auto-geocoded" + ) + + return { + 'success': True, + 'imported_records': imported_count, + 'failed_records': failed_count, + 'geocoded_records': geocoded_count, + 'total_rows': len(df), + 'errors': errors, + 'imported_qr_codes': imported_qr_codes + } + + except Exception as e: + self.db.session.rollback() + + if self.logger: + self.logger.logger.error(f"Error during QR code import: {e}") + self.logger.logger.error(traceback.format_exc()) + + return { + 'success': False, + 'error': str(e), + 'imported_records': 0, + 'failed_records': 0, + 'errors': [str(e)] + } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b0f11db --- /dev/null +++ b/requirements.txt @@ -0,0 +1,67 @@ +# QR Code Management System - Python Dependencies +# Production-ready Flask application with MySQL support +# Versions pinned as of March 2026 — run `pip install -r requirements.txt` on fresh deploy + +# Core Flask Framework +Flask==3.1.0 +Flask-SQLAlchemy==3.1.1 + +# Database Support - MySQL +PyMySQL==1.1.1 # Pure Python MySQL client (sole driver — mysql-connector-python removed) +SQLAlchemy==2.0.36 + +# Security and Authentication +Werkzeug==3.1.3 # Security utilities and password hashing + +# QR Code Generation +qrcode==8.0 # QR code generation library +Pillow==11.1.0 # Image processing for QR codes + +# User Agent Detection +user-agents==2.2.0 # Device and browser detection from user agent strings + +# URL and Regex Processing +regex==2024.11.6 # Enhanced regex support for URL generation + +# Environment and Configuration +python-dotenv==1.0.1 # Environment variable management + +# Date and Time Processing +python-dateutil==2.9.0.post0 # Extended date/time processing + +# Development and Testing (optional) +pytest==8.3.4 # Testing framework +pytest-flask==1.3.0 # Flask testing utilities +Flask-Testing==0.8.1 # Additional Flask testing tools + +# Production Server (optional) +gunicorn==23.0.0 # WSGI HTTP Server for production +gevent==24.11.1 # Async worker support + +# Utilities +click==8.1.8 # Command line interface creation +itsdangerous==2.2.0 # Secure data serialization +Jinja2==3.1.5 # Template engine +MarkupSafe==3.0.2 # Safe string handling + +# Data Export and Processing +openpyxl==3.1.5 # Excel file generation for attendance reports +pandas==2.2.3 # Data manipulation for reports (optional) + +# HTTP Requests (for potential integrations) +requests==2.32.3 # HTTP library for external API calls + +# Google Maps Integration +googlemaps==4.10.0 # Google Maps API client + +# Caching (optional for performance) +Flask-Caching==2.3.0 # Caching support for Flask + +# Logging and Monitoring (optional) +python-json-logger==3.2.1 # Structured logging support + +# Cryptography dependencies (required for some MySQL features) +cryptography==44.0.0 # Required for MySQL SSL connections + +# Employee Synchronization Dependencies +schedule==1.2.2 # For automated scheduling \ No newline at end of file diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routes/admin.py b/routes/admin.py new file mode 100644 index 0000000..3298feb --- /dev/null +++ b/routes/admin.py @@ -0,0 +1,580 @@ +""" +routes/admin.py +=============== +Admin panel and log management routes. + +Routes: /admin/logs, /admin/health/google-maps, /api/logs/* +""" +from flask import Blueprint, render_template, request, redirect, flash, session, jsonify, url_for +from datetime import datetime, timedelta +import json, math + +from extensions import db, logger_handler +from sqlalchemy import text +from utils.geocoding import gmaps_client +from logger_handler import log_user_activity, log_database_operations +from utils.helpers import admin_required, login_required + +bp = Blueprint('admin', __name__) + + + +@bp.route('/admin/logs', endpoint='admin_logs') +@admin_required +def admin_logs(): + """Admin logging dashboard""" + try: + # Get log statistics for the last 7 days + stats = logger_handler.get_log_statistics(days=7) + return render_template('admin_logs.html', log_stats=stats) + except Exception as e: + logger_handler.log_database_error('admin_logs_load', e) + flash('Error loading log statistics.', 'error') + return redirect(url_for('dashboard.dashboard')) + +def check_google_maps_health(): + """Check if Google Maps services are working properly""" + try: + if not gmaps_client: + return False, "Google Maps client not initialized" + + # Test with a known address + test_result = gmaps_client.geocode("1600 Amphitheatre Parkway, Mountain View, CA") + + if test_result: + return True, "Google Maps services are operational" + else: + return False, "Google Maps API not returning results" + + except Exception as e: + return False, f"Google Maps health check failed: {str(e)}" + +# Optional: Add health check route +@bp.route('/admin/health/google-maps', endpoint='google_maps_health') +@admin_required +def google_maps_health(): + """Admin route to check Google Maps service health""" + is_healthy, message = check_google_maps_health() + + return jsonify({ + 'healthy': is_healthy, + 'message': message, + 'service': 'Google Maps', + 'fallback_available': True, + 'timestamp': datetime.now().isoformat() + }) + +# API endpoints for logging data (admin only) +@bp.route('/api/logs/recent', endpoint='api_recent_logs') +@admin_required +def api_recent_logs(): + """API endpoint to get recent log entries with full details and pagination support""" + try: + days = request.args.get('days', 1, type=int) + limit = request.args.get('limit', 50, type=int) + page = request.args.get('page', 1, type=int) + category = request.args.get('category', '') + severity = request.args.get('severity', '') + search = request.args.get('search', '') + + logger_handler.logger.debug( + f"api_recent_logs: days={days}, limit={limit}, page={page}, " + f"category={category!r}, severity={severity!r}, search={search!r}" + ) + + cutoff_date = datetime.now() - timedelta(days=days) + + # Calculate offset for pagination + offset = (page - 1) * limit + + # Build the base SQL query with filters + base_sql = """ + SELECT + event_id, + event_type, + event_category, + event_description, + event_data, + severity_level, + created_timestamp, + username, + user_id, + ip_address + FROM log_events + WHERE created_timestamp >= :cutoff_date + """ + + count_sql = """ + SELECT COUNT(*) as total_count + FROM log_events + WHERE created_timestamp >= :cutoff_date + """ + + params = {'cutoff_date': cutoff_date} + + # Add category filter + if category: + base_sql += " AND event_category = :category" + count_sql += " AND event_category = :category" + params['category'] = category + + # Add severity filter + if severity: + base_sql += " AND severity_level = :severity" + count_sql += " AND severity_level = :severity" + params['severity'] = severity + + # Add search filter + if search: + search_condition = " AND (event_type LIKE :search OR event_description LIKE :search OR username LIKE :search)" + base_sql += search_condition + count_sql += search_condition + params['search'] = f'%{search}%' + + # Get total count first + count_result = db.session.execute(text(count_sql), params).fetchone() + total_count = count_result.total_count if count_result else 0 + + # Add ordering, limit and offset to main query + base_sql += " ORDER BY created_timestamp DESC LIMIT :limit OFFSET :offset" + params['limit'] = limit + params['offset'] = offset + + # Execute main query + result = db.session.execute(text(base_sql), params).fetchall() + + logs = [] + for row in result: + # Parse event_data if it's JSON + event_data = None + if row.event_data: + try: + event_data = json.loads(row.event_data) if isinstance(row.event_data, str) else row.event_data + except (json.JSONDecodeError, TypeError): + event_data = row.event_data + + logs.append({ + 'event_id': row.event_id, + 'event_type': row.event_type, + 'event_category': row.event_category, + 'description': row.event_description, + 'event_data': event_data, + 'severity': row.severity_level, + 'timestamp': row.created_timestamp.isoformat(), + 'username': row.username or 'System', + 'user_id': row.user_id, + 'ip_address': row.ip_address or '-' + }) + + logger_handler.logger.debug(f"api_recent_logs: returning {len(logs)} of {total_count} total records") + + return jsonify({ + 'success': True, + 'logs': logs, + 'total': total_count, + 'page': page, + 'limit': limit, + 'total_pages': math.ceil(total_count / limit) if total_count > 0 else 0, + 'has_next': offset + limit < total_count, + 'has_prev': page > 1 + }) + + except Exception as e: + logger_handler.log_database_error('api_recent_logs', e) + return jsonify({ + 'success': False, + 'error': f'Failed to fetch recent logs: {str(e)}' + }), 500 + +@bp.route('/api/logs/stats', endpoint='api_log_stats') +@admin_required +def api_log_stats(): + """API endpoint to get logging statistics""" + try: + days = request.args.get('days', 7, type=int) + logger_handler.logger.debug(f"api_log_stats: fetching statistics for last {days} days") + + # Get statistics from logger handler + stats = logger_handler.get_log_statistics(days=days) + + + # Ensure all expected keys exist with updated categories + expected_stats = { + 'total_events': stats.get('total_events', 0), + 'security_events': stats.get('security_events', 0), + 'authentication_events': stats.get('authentication_events', 0), + 'qr_management_events': stats.get('qr_management_events', 0), + 'database_errors': stats.get('database_errors', 0), + 'application_events': stats.get('application_events', 0), + 'system_events': stats.get('system_events', 0) + } + + return jsonify({ + 'success': True, + 'stats': expected_stats, + 'days': days, + 'timestamp': datetime.now().isoformat() + }) + + except Exception as e: + logger_handler.log_database_error('api_log_stats', e) + return jsonify({ + 'success': False, + 'error': f'Failed to fetch log statistics: {str(e)}', + 'stats': { + 'total_events': 0, + 'security_events': 0, + 'authentication_events': 0, + 'qr_management_events': 0, + 'database_errors': 0, + 'application_events': 0, + 'system_events': 0 + } + }), 500 + +@bp.route('/api/logs/cleanup', methods=['POST'], endpoint='api_cleanup_logs') +@admin_required +def api_cleanup_logs(): + """API endpoint to cleanup old log entries""" + try: + # Get JSON data + data = request.get_json() + if not data: + return jsonify({ + 'success': False, + 'error': 'No JSON data provided' + }), 400 + + days_to_keep = data.get('days_to_keep', 90) + + # Validate input + if not isinstance(days_to_keep, int) or days_to_keep < 7: + return jsonify({ + 'success': False, + 'error': 'days_to_keep must be an integer >= 7' + }), 400 + + if days_to_keep > 365: + return jsonify({ + 'success': False, + 'error': 'days_to_keep cannot exceed 365 days' + }), 400 + + # Perform cleanup using logger handler + deleted_count = logger_handler.cleanup_old_logs(days_to_keep=days_to_keep) + + admin_username = session.get('username', 'unknown') + logger_handler.logger.info( + f"Admin {admin_username} performed log cleanup: {deleted_count} records deleted " + f"(keeping last {days_to_keep} days)" + ) + + # Log the admin action + logger_handler.log_security_event( + event_type="admin_log_cleanup", + description=f"Admin {admin_username} performed log cleanup: {deleted_count} entries removed (keeping last {days_to_keep} days)", + severity="HIGH", + additional_data={ + 'admin_user': admin_username, + 'days_to_keep': days_to_keep, + 'deleted_count': deleted_count, + 'ip_address': request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr) + } + ) + + return jsonify({ + 'success': True, + 'deleted_count': deleted_count, + 'days_to_keep': days_to_keep, + 'message': f'Successfully cleaned up {deleted_count} old log entries (keeping last {days_to_keep} days)', + 'performed_by': admin_username, + 'performed_at': datetime.now().isoformat() + }) + + except Exception as e: + logger_handler.log_database_error('api_cleanup_logs', e) + return jsonify({ + 'success': False, + 'error': f'Failed to cleanup old logs: {str(e)}' + }), 500 + +@bp.route('/api/logs/clear', methods=['POST'], endpoint='api_clear_logs') +@admin_required +def api_clear_logs(): + """API endpoint to clear ALL log entries""" + try: + admin_username = session.get('username', 'unknown') + logger_handler.logger.info(f"Admin {admin_username} initiated full log clear") + + # Count existing logs before deletion + try: + count_sql = "SELECT COUNT(*) as total_logs FROM log_events" + count_result = db.session.execute(text(count_sql)).fetchone() + total_logs = count_result.total_logs if count_result else 0 + + if total_logs == 0: + return jsonify({ + 'success': True, + 'deleted_count': 0, + 'message': 'No logs found to clear' + }) + + except Exception as count_error: + logger_handler.logger.warning(f"Error counting logs before clear: {count_error}") + total_logs = 0 + + # Perform the clear operation + try: + clear_sql = "DELETE FROM log_events" + result = db.session.execute(text(clear_sql)) + deleted_count = result.rowcount + db.session.commit() + + logger_handler.logger.info( + f"Admin {admin_username} cleared all log entries: {deleted_count} records deleted" + ) + + # Log the clear operation (this will be the first entry in the new log) + logger_handler.log_security_event( + event_type="admin_log_clear", + description=f"Admin {admin_username} cleared all log entries: {deleted_count} records deleted", + severity="HIGH", + additional_data={ + 'admin_user': admin_username, + 'deleted_count': deleted_count, + 'ip_address': request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr) + } + ) + + return jsonify({ + 'success': True, + 'deleted_count': deleted_count, + 'message': f'Successfully cleared {deleted_count} log entries', + 'performed_by': admin_username, + 'performed_at': datetime.now().isoformat() + }) + + except Exception as delete_error: + logger_handler.log_database_error('api_clear_logs_delete', delete_error) + db.session.rollback() + return jsonify({ + 'success': False, + 'error': f'Failed to clear logs: {str(delete_error)}' + }), 500 + + except Exception as e: + logger_handler.log_database_error('api_clear_logs', e) + return jsonify({ + 'success': False, + 'error': f'Failed to clear logs: {str(e)}' + }), 500 + +@bp.route('/api/logs/clear-old', methods=['POST'], endpoint='api_clear_old_logs') +@admin_required +def api_clear_old_logs(): + """API endpoint to clear log entries older than specified days""" + try: + # Get JSON data + data = request.get_json() + if not data: + return jsonify({ + 'success': False, + 'error': 'No JSON data provided' + }), 400 + + days_threshold = data.get('days_threshold', 90) + admin_username = session.get('username', 'unknown') + + # Validate input + if not isinstance(days_threshold, int) or days_threshold not in [30, 60, 90]: + return jsonify({ + 'success': False, + 'error': 'days_threshold must be 30, 60, or 90' + }), 400 + + # Calculate cutoff date + cutoff_date = datetime.now() - timedelta(days=days_threshold) + + # Count existing logs before deletion + try: + count_sql = "SELECT COUNT(*) as total_logs FROM log_events WHERE created_timestamp < :cutoff_date" + count_result = db.session.execute(text(count_sql), {'cutoff_date': cutoff_date}).fetchone() + total_logs = count_result.total_logs if count_result else 0 + + if total_logs == 0: + return jsonify({ + 'success': True, + 'deleted_count': 0, + 'message': f'No logs older than {days_threshold} days found to clear' + }) + + except Exception as count_error: + logger_handler.logger.warning(f"Error counting old logs before clear: {count_error}") + total_logs = 0 + + # Perform the clear operation + try: + clear_sql = "DELETE FROM log_events WHERE created_timestamp < :cutoff_date" + result = db.session.execute(text(clear_sql), {'cutoff_date': cutoff_date}) + deleted_count = result.rowcount + db.session.commit() + + logger_handler.logger.info( + f"Admin {admin_username} cleared {deleted_count} log entries older than {days_threshold} days" + ) + + # Log the clear operation + logger_handler.log_security_event( + event_type="admin_clear_old_logs", + description=f"Admin {admin_username} cleared {deleted_count} log entries older than {days_threshold} days", + severity="HIGH", + additional_data={ + 'admin_user': admin_username, + 'days_threshold': days_threshold, + 'deleted_count': deleted_count, + 'cutoff_date': cutoff_date.isoformat(), + 'ip_address': request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr) + } + ) + + return jsonify({ + 'success': True, + 'deleted_count': deleted_count, + 'days_threshold': days_threshold, + 'message': f'Successfully cleared {deleted_count} log entries older than {days_threshold} days', + 'performed_by': admin_username, + 'performed_at': datetime.now().isoformat() + }) + + except Exception as delete_error: + logger_handler.log_database_error('api_clear_old_logs_delete', delete_error) + db.session.rollback() + return jsonify({ + 'success': False, + 'error': f'Failed to clear old logs: {str(delete_error)}' + }), 500 + + except Exception as e: + logger_handler.log_database_error('api_clear_old_logs', e) + return jsonify({ + 'success': False, + 'error': f'Failed to clear old logs: {str(e)}' + }), 500 + +@bp.route('/api/logs/export', endpoint='api_export_logs') +@admin_required +def api_export_logs(): + """API endpoint to export log entries""" + try: + days = request.args.get('days', 7, type=int) + category = request.args.get('category', '') + severity = request.args.get('severity', '') + search = request.args.get('search', '') + + admin_username = session.get('username', 'unknown') + logger_handler.logger.info( + f"Admin {admin_username} initiated log export: last {days} days, " + f"category={category!r}, severity={severity!r}" + ) + + cutoff_date = datetime.now() - timedelta(days=days) + + # Build the SQL query with filters + base_sql = """ + SELECT + event_id, + event_type, + event_category, + event_description, + event_data, + severity_level, + created_timestamp, + username, + user_id, + ip_address + FROM log_events + WHERE created_timestamp >= :cutoff_date + """ + + params = {'cutoff_date': cutoff_date} + + # Add category filter + if category: + base_sql += " AND event_category = :category" + params['category'] = category + + # Add severity filter + if severity: + base_sql += " AND severity_level = :severity" + params['severity'] = severity + + # Add search filter + if search: + base_sql += " AND (event_type LIKE :search OR event_description LIKE :search OR username LIKE :search)" + params['search'] = f'%{search}%' + + base_sql += " ORDER BY created_timestamp DESC" + + result = db.session.execute(text(base_sql), params).fetchall() + + logs = [] + for row in result: + # Parse event_data if it's JSON + event_data = None + if row.event_data: + try: + event_data = json.loads(row.event_data) if isinstance(row.event_data, str) else row.event_data + except (json.JSONDecodeError, TypeError): + event_data = row.event_data + + logs.append({ + 'event_id': row.event_id, + 'event_type': row.event_type, + 'event_category': row.event_category, + 'description': row.event_description, + 'event_data': event_data, + 'severity': row.severity_level, + 'timestamp': row.created_timestamp.isoformat(), + 'username': row.username or 'System', + 'user_id': row.user_id, + 'ip_address': row.ip_address or '-' + }) + + # Log the export operation + logger_handler.log_security_event( + event_type="admin_log_export", + description=f"Admin {admin_username} exported {len(logs)} log entries (last {days} days)", + severity="MEDIUM", + additional_data={ + 'admin_user': admin_username, + 'exported_count': len(logs), + 'days_exported': days, + 'filters': { + 'category': category, + 'severity': severity, + 'search': search + }, + 'ip_address': request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr) + } + ) + + return jsonify({ + 'success': True, + 'logs': logs, + 'total': len(logs), + 'filters_applied': { + 'days': days, + 'category': category, + 'severity': severity, + 'search': search + } + }) + + except Exception as e: + logger_handler.log_database_error('api_export_logs', e) + return jsonify({ + 'success': False, + 'error': f'Failed to export logs: {str(e)}' + }), 500 + +# PROJECT MANAGEMENT ROUTES \ No newline at end of file diff --git a/routes/attendance.py b/routes/attendance.py new file mode 100644 index 0000000..899241f --- /dev/null +++ b/routes/attendance.py @@ -0,0 +1,866 @@ +""" +routes/attendance.py +==================== +Attendance check-in records, manual entry, verification review, +export configuration, and Excel export routes. + +Routes: /attendance, /attendance/<id>/edit, /attendance/add, + /attendance/save_manual, /api/attendance/*, /api/search_employees, + /api/get_project_locations, /verification-review/*, + /export-configuration, /generate-excel-export +""" +from flask import abort, Blueprint, render_template, request, redirect, flash, session, jsonify, send_file, url_for +from datetime import datetime, date, timedelta, time +import io, os, json, re, traceback + +from extensions import db, logger_handler +from models.attendance import AttendanceData +from models.employee import Employee +from models.permissions import UserLocationPermission, UserProjectPermission +from models.project import Project +from models.qrcode import QRCode +from models.user import User +from sqlalchemy import text, or_, and_ +from logger_handler import log_user_activity, log_database_operations +from utils.helpers import ( + admin_required, + expand_employee_id_filter, + get_base_employee_id, + get_client_ip, + has_admin_privileges, + has_staff_level_access, + login_required, + staff_or_admin_required) +from utils.geocoding import (calculate_location_accuracy_enhanced, process_location_data_enhanced, + check_location_accuracy_column_exists) +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter + +bp = Blueprint('attendance', __name__) + +# Case-folded employee_id, matched against the REGEXP patterns from +# build_employee_id_regex() so any separator style ("1234 SP", "1234.PW", +# "1234-PT", "SP1234") is found regardless of the column's collation. +UPPER_EMPLOYEE_ID_SQL = "UPPER(ad.employee_id)" + + + +@bp.route('/attendance', endpoint='attendance_report') +@login_required +def attendance_report(): + """Safe attendance report with backward compatibility for location_accuracy and fixed datetime handling""" + try: + logger_handler.logger.debug("Loading 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: + pass + + # Check if location_accuracy column exists + has_location_accuracy = check_location_accuracy_column_exists() + logger_handler.logger.debug(f"Location accuracy column exists: {has_location_accuracy}") + + # Get filter parameters + date_from = request.args.get('date_from', '') + date_to = request.args.get('date_to', '') + location_filter = request.args.get('location', '') + # employee param is now a comma-separated list of IDs (multi-employee filter) + employee_filter = request.args.get('employee', '') + project_filter = request.args.get('project', '') + + # Build the list of selected employee IDs (strip blanks) + employee_ids = [e.strip() for e in employee_filter.split(',') if e.strip()] if employee_filter else [] + + # Build display names for each selected employee + employee_display_names = [] + for eid in employee_ids: + try: + # Use the base ID so work-type IDs ("1234SP") still resolve a name + emp = Employee.query.filter_by(id=int(get_base_employee_id(eid))).first() + if emp: + employee_display_names.append({ + 'id': eid, + 'name': f"{emp.lastName}, {emp.firstName}" + }) + else: + employee_display_names.append({'id': eid, 'name': f"ID: {eid}"}) + except (ValueError, TypeError): + employee_display_names.append({'id': eid, 'name': eid}) + + # Legacy single-value display name (kept for backward compat in template) + employee_display_name = ', '.join([e['name'] for e in employee_display_names]) + + # ============================================================ + # PROJECT MANAGER ACCESS CONTROL + # ============================================================ + user_role = session.get('role') + user_id = session.get('user_id') + + # Initialize permission filters + allowed_project_ids = [] + allowed_location_names = [] + + # Check if user is Project Manager and get their permissions + if user_role == 'project_manager': + logger_handler.logger.debug(f"Project Manager access control enabled for user {session.get('username')}") + + try: + # Get assigned projects + assigned_projects = UserProjectPermission.query.filter_by(user_id=user_id).all() + allowed_project_ids = [p.project_id for p in assigned_projects] + + # Get assigned locations + assigned_locations = UserLocationPermission.query.filter_by(user_id=user_id).all() + allowed_location_names = [l.location_name for l in assigned_locations] + + # Log the permissions + logger_handler.logger.info( + f"🔒 Project Manager {session.get('username')} restricted to: " + f"Projects: {allowed_project_ids}, Locations: {allowed_location_names}" + ) + + logger_handler.logger.debug(f"PM allowed projects: {allowed_project_ids}, locations: {allowed_location_names}") + except Exception as perm_error: + logger_handler.logger.warning(f"Error loading PM permissions: {perm_error}") + logger_handler.logger.error(f"Error loading Project Manager permissions: {perm_error}") + + # If no permissions assigned, user cannot view anything + if not allowed_project_ids and not allowed_location_names: + logger_handler.logger.warning( + f"Project Manager {session.get('username')} has no assigned projects or locations" + ) + flash('You do not have access to any projects or locations. Please contact an administrator.', 'warning') + + # Create empty stats object using named tuple style + from collections import namedtuple + Stats = namedtuple('Stats', ['total_checkins', 'unique_employees', 'active_locations', + 'today_checkins', 'records_with_gps', 'records_with_accuracy', + 'avg_location_accuracy']) + empty_stats = Stats(0, 0, 0, 0, 0, 0, 0) + + # Return empty template + return render_template('attendance_report.html', + attendance_records=[], + locations=[], + projects=[], + stats=empty_stats, + date_from=date_from, + date_to=date_to, + location_filter=location_filter, + employee_filter=employee_filter, + employee_ids=employee_ids, + employee_display_names=employee_display_names, + employee_display_name=employee_display_name, + project_filter=project_filter, + today_date=datetime.now().strftime('%Y-%m-%d'), + current_date_formatted=datetime.now().strftime('%B %d'), + has_location_accuracy_feature=has_location_accuracy, + user_role=user_role) + + # ============================================================ + # END: PROJECT MANAGER ACCESS CONTROL + # ============================================================ + + # Build base query - conditional based on column existence + if has_location_accuracy: + # New query with location accuracy + base_query = """ + SELECT + ad.id, + ad.employee_id, + ad.check_in_date, + ad.check_in_time, + ad.location_name, + qc.location_event, + COALESCE(ad.qr_address, qc.location_address) as qr_address, + ad.address as checked_in_address, + ad.latitude, + ad.longitude, + ad.location_accuracy, + ad.accuracy as gps_accuracy, + ad.device_info, + ad.created_timestamp, + ad.updated_timestamp, + CONCAT(e.firstName, ' ', e.lastName) as employee_name, + ad.verification_required, + ad.verification_status, + ad.verification_photo, + COALESCE(ad.is_dynamic_qr, 0) as is_dynamic_qr + FROM attendance_data ad + LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id + LEFT JOIN employee e ON CAST(ad.employee_id AS UNSIGNED) = e.id + WHERE 1=1 + """ + else: + # Fallback query without location accuracy + base_query = """ + SELECT + ad.id, + ad.employee_id, + ad.check_in_date, + ad.check_in_time, + ad.location_name, + qc.location_event, + COALESCE(ad.qr_address, qc.location_address) as qr_address, + ad.address as checked_in_address, + ad.latitude, + ad.longitude, + NULL as location_accuracy, + ad.accuracy as gps_accuracy, + ad.device_info, + ad.created_timestamp, + ad.updated_timestamp, + CONCAT(e.firstName, ' ', e.lastName) as employee_name, + ad.verification_required, + ad.verification_status, + ad.verification_photo, + COALESCE(ad.is_dynamic_qr, 0) as is_dynamic_qr + FROM attendance_data ad + LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id + LEFT JOIN employee e ON CAST(ad.employee_id AS UNSIGNED) = e.id + WHERE 1=1 + """ + + # Prepare filter conditions and parameters + filter_conditions = [] + query_params = {} + + # ============================================================ + # APPLY PROJECT MANAGER FILTERS TO SQL QUERY + # ============================================================ + if user_role == 'project_manager': + # Filter by allowed projects + if allowed_project_ids: + project_placeholders = ','.join([f':project_{i}' for i in range(len(allowed_project_ids))]) + filter_conditions.append(f"qc.project_id IN ({project_placeholders})") + for i, pid in enumerate(allowed_project_ids): + query_params[f'project_{i}'] = pid + + # Filter by allowed locations + if allowed_location_names: + location_placeholders = ','.join([f':location_{i}' for i in range(len(allowed_location_names))]) + filter_conditions.append(f"ad.location_name IN ({location_placeholders})") + for i, loc in enumerate(allowed_location_names): + query_params[f'location_{i}'] = loc + # ============================================================ + # END: APPLY PROJECT MANAGER FILTERS + # ============================================================ + + # Apply user-selected filters + if date_from: + filter_conditions.append("ad.check_in_date >= :date_from") + query_params['date_from'] = date_from + + if date_to: + filter_conditions.append("ad.check_in_date <= :date_to") + query_params['date_to'] = date_to + + if location_filter: + # Exact match — dropdown value IS the exact location_name string + filter_conditions.append("ad.location_name = :location") + query_params['location'] = location_filter + + if employee_ids: + # Expand each selected ID into every stored spelling so extra-work + # check-ins (SP / PW / PT) are included alongside regular records. + # Two branches: exact match on the raw column (uses the index), plus a + # REGEXP match that catches any separator style ("1234.PW", "1234-SP"). + exact_variants, regex_patterns = expand_employee_id_filter(employee_ids) + # Never emit an empty IN () — fall back to the raw selection if expansion + # somehow produced nothing, so the filter can't degrade into "match all". + if not exact_variants: + exact_variants = list(employee_ids) + + exact_placeholders = ', '.join([f':employee_{i}' for i in range(len(exact_variants))]) + exact_condition = f"ad.employee_id IN ({exact_placeholders})" + for i, variant in enumerate(exact_variants): + query_params[f'employee_{i}'] = variant + + if regex_patterns: + regex_clauses = ' OR '.join([ + f"{UPPER_EMPLOYEE_ID_SQL} REGEXP :employee_re_{i}" + for i in range(len(regex_patterns)) + ]) + filter_conditions.append(f"({exact_condition} OR {regex_clauses})") + for i, pattern in enumerate(regex_patterns): + query_params[f'employee_re_{i}'] = pattern + else: + filter_conditions.append(exact_condition) + logger_handler.logger.info( + f"Attendance report filtered by employee IDs: {employee_ids} " + f"(matching {len(exact_variants)} ID variants incl. SP/PW/PT) " + f"by user {session.get('username', 'unknown')}" + ) + + if project_filter: + # For standard QR records: match by the QR code's project_id directly. + # For dynamic QR records: the dynamic QR itself may not be in any project, + # but the employee-selected location corresponds to a standard QR in that + # project. Match those by checking if attendance_data.location_name + # appears in the locations of QR codes belonging to the selected project. + filter_conditions.append( + "(qc.project_id = :project OR " + "(ad.is_dynamic_qr = 1 AND ad.location_name IN (" + " SELECT DISTINCT qc2.location FROM qr_codes qc2 " + " WHERE qc2.project_id = :project AND qc2.qr_type = 'standard' " + " AND qc2.location IS NOT NULL AND qc2.location != ''" + ")))" + ) + query_params['project'] = project_filter + + # Combine query with filters + if filter_conditions: + base_query += " AND " + " AND ".join(filter_conditions) + + # Fetch one extra record to detect truncation without a separate COUNT query + ATTENDANCE_PAGE_LIMIT = 1000 + base_query += f" ORDER BY ad.check_in_date DESC, ad.check_in_time DESC LIMIT {ATTENDANCE_PAGE_LIMIT + 1}" + + logger_handler.logger.debug(f"Executing attendance query with filters: {list(query_params.keys())}") + + # Execute query + result = db.session.execute(text(base_query), query_params) + records = result.fetchall() + # If we got more than the limit, the result set is truncated + records_truncated = len(records) > ATTENDANCE_PAGE_LIMIT + if records_truncated: + records = records[:ATTENDANCE_PAGE_LIMIT] + logger_handler.logger.debug(f"Loaded {len(records)} attendance records (truncated={records_truncated})") + + # Process records + processed_records = [] + for record in records: + try: + record_dict = { + 'id': record[0], + 'employee_id': record[1], + 'check_in_date': record[2], + 'check_in_time': record[3], + 'location_name': record[4], + 'location_event': record[5], + 'qr_address': record[6], + 'checked_in_address': record[7], + 'latitude': record[8], + 'longitude': record[9], + 'location_accuracy': record[10] if has_location_accuracy else None, + 'gps_accuracy': record[11], + 'device_info': record[12], + 'created_timestamp': record[13], + 'updated_timestamp': record[14], + 'employee_name': record[15] or 'Unknown Employee', + 'verification_required': record[16] if len(record) > 16 else False, + 'verification_status': record[17] if len(record) > 17 else None, + 'verification_photo': record[18] if len(record) > 18 else None, + 'is_dynamic_qr': bool(record[19]) if len(record) > 19 else False + } + + # Calculate accuracy_level for template display + if record_dict['location_accuracy'] is not None: + accuracy_value = float(record_dict['location_accuracy']) + if accuracy_value <= 0.3: + record_dict['accuracy_level'] = 'accurate' + else: + record_dict['accuracy_level'] = 'inaccurate' + else: + record_dict['accuracy_level'] = 'unknown' + processed_records.append(record_dict) + except Exception as rec_error: + logger_handler.logger.warning(f"Error processing attendance record: {rec_error}") + continue + + # Get unique locations for filter dropdown + try: + # ============================================================ + # FILTER LOCATIONS FOR PROJECT MANAGER + # ============================================================ + if user_role == 'project_manager' and allowed_location_names: + # Only show locations the PM has access to + locations = sorted(allowed_location_names) + logger_handler.logger.debug(f"Filtered to {len(locations)} locations for Project Manager") + else: + # Show all locations for Admin/Staff/Payroll + locations_query = db.session.execute(text(""" + SELECT DISTINCT location_name + FROM attendance_data + WHERE location_name IS NOT NULL + AND location_name != 'Dynamic' + AND location_name != '' + ORDER BY location_name + """)) + locations = [row[0] for row in locations_query.fetchall()] + logger_handler.logger.debug(f"Found {len(locations)} unique locations") + # ============================================================ + # END: FILTER LOCATIONS FOR PROJECT MANAGER + # ============================================================ + except Exception as e: + logger_handler.logger.warning(f"Error loading locations filter: {e}") + locations = [] + + # Get projects for filter dropdown + try: + # ============================================================ + # FILTER PROJECTS FOR PROJECT MANAGER + # ============================================================ + if user_role == 'project_manager' and allowed_project_ids: + # Only show projects the PM has access to + project_placeholders = ','.join([str(pid) for pid in allowed_project_ids]) + projects_query = db.session.execute(text(f""" + SELECT p.id, p.name, COUNT(DISTINCT ad.id) as attendance_count + FROM projects p + LEFT JOIN qr_codes qc ON qc.project_id = p.id + LEFT JOIN attendance_data ad ON ad.qr_code_id = qc.id + WHERE p.active_status = true AND p.id IN ({project_placeholders}) + GROUP BY p.id, p.name + ORDER BY p.name + """)) + projects = projects_query.fetchall() + logger_handler.logger.debug(f"Filtered to {len(projects)} projects for Project Manager") + else: + # Show all projects for Admin/Staff/Payroll + projects = db.session.execute(text(""" + SELECT p.id, p.name, COUNT(DISTINCT ad.id) as attendance_count + FROM projects p + LEFT JOIN qr_codes qc ON qc.project_id = p.id + LEFT JOIN attendance_data ad ON ad.qr_code_id = qc.id + WHERE p.active_status = true + GROUP BY p.id, p.name + HAVING COUNT(DISTINCT ad.id) > 0 + ORDER BY p.name + """)).fetchall() + logger_handler.logger.debug(f"Loaded {len(projects)} projects with attendance data") + # ============================================================ + # END: FILTER PROJECTS FOR PROJECT MANAGER + # ============================================================ + except Exception as e: + logger_handler.logger.warning(f"Error loading projects filter: {e}") + projects = [] + + # ============================================================ + # STATISTICS - COMPLETELY REWRITTEN FOR SAFETY + # ============================================================ + logger_handler.logger.debug("Loading attendance statistics") + + # Create simple dict for stats (most compatible approach) + stats_dict = { + 'total_checkins': 0, + 'unique_employees': 0, + 'active_locations': 0, + 'today_checkins': 0, + 'records_with_gps': 0, + 'records_with_accuracy': 0, + 'avg_location_accuracy': 0.0 + } + + try: + # Build stats query + if has_location_accuracy: + stats_select = """ + SELECT + COALESCE(COUNT(*), 0) as total_checkins, + COALESCE(COUNT(DISTINCT employee_id), 0) as unique_employees, + COALESCE(COUNT(DISTINCT qr_code_id), 0) as active_locations, + COALESCE(COUNT(CASE WHEN check_in_date = CURRENT_DATE THEN 1 END), 0) as today_checkins, + COALESCE(COUNT(CASE WHEN latitude IS NOT NULL AND longitude IS NOT NULL THEN 1 END), 0) as records_with_gps, + COALESCE(COUNT(CASE WHEN location_accuracy IS NOT NULL THEN 1 END), 0) as records_with_accuracy, + COALESCE(AVG(location_accuracy), 0) as avg_location_accuracy + """ + else: + stats_select = """ + SELECT + COALESCE(COUNT(*), 0) as total_checkins, + COALESCE(COUNT(DISTINCT employee_id), 0) as unique_employees, + COALESCE(COUNT(DISTINCT qr_code_id), 0) as active_locations, + COALESCE(COUNT(CASE WHEN check_in_date = CURRENT_DATE THEN 1 END), 0) as today_checkins, + COALESCE(COUNT(CASE WHEN latitude IS NOT NULL AND longitude IS NOT NULL THEN 1 END), 0) as records_with_gps, + 0 as records_with_accuracy, + 0 as avg_location_accuracy + """ + + stats_query_text = stats_select + " FROM attendance_data ad" + stats_params = {} + + # Add filters for Project Manager + if user_role == 'project_manager': + stats_query_text += " LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id WHERE 1=1" + + stats_conditions = [] + + if allowed_project_ids: + project_placeholders = ','.join([f':stat_project_{i}' for i in range(len(allowed_project_ids))]) + stats_conditions.append(f"qc.project_id IN ({project_placeholders})") + for i, pid in enumerate(allowed_project_ids): + stats_params[f'stat_project_{i}'] = pid + + if allowed_location_names: + location_placeholders = ','.join([f':stat_location_{i}' for i in range(len(allowed_location_names))]) + stats_conditions.append(f"ad.location_name IN ({location_placeholders})") + for i, loc in enumerate(allowed_location_names): + stats_params[f'stat_location_{i}'] = loc + + if stats_conditions: + stats_query_text += " AND " + " AND ".join(stats_conditions) + + logger_handler.logger.debug(f"Executing stats query with params: {list(stats_params.keys())}") + + # Execute stats query + stats_result = db.session.execute(text(stats_query_text), stats_params) + stats_row = stats_result.fetchone() + + logger_handler.logger.debug(f"Stats row type: {type(stats_row).__name__}") + + # Safely extract stats from row + if stats_row is not None and len(stats_row) >= 7: + try: + stats_dict['total_checkins'] = int(stats_row[0]) if stats_row[0] is not None else 0 + stats_dict['unique_employees'] = int(stats_row[1]) if stats_row[1] is not None else 0 + stats_dict['active_locations'] = int(stats_row[2]) if stats_row[2] is not None else 0 + stats_dict['today_checkins'] = int(stats_row[3]) if stats_row[3] is not None else 0 + stats_dict['records_with_gps'] = int(stats_row[4]) if stats_row[4] is not None else 0 + stats_dict['records_with_accuracy'] = int(stats_row[5]) if stats_row[5] is not None else 0 + stats_dict['avg_location_accuracy'] = float(stats_row[6]) if stats_row[6] is not None else 0.0 + logger_handler.logger.debug(f"Loaded statistics: {stats_dict['total_checkins']} total check-ins") + except (IndexError, TypeError, ValueError) as extract_error: + logger_handler.logger.warning(f"Error extracting stats values: {extract_error}") + # stats_dict already has default values + else: + logger_handler.logger.warning("Stats query returned None or insufficient columns, using default stats") + + except Exception as stats_error: + logger_handler.logger.error(f"Error loading statistics: {stats_error}", exc_info=True) + # stats_dict already has default values + + # Convert dict to object-like for template compatibility + class StatsObject: + def __init__(self, stats_dict): + for key, value in stats_dict.items(): + setattr(self, key, value) + + stats = StatsObject(stats_dict) + logger_handler.logger.debug(f"Stats object created: total_checkins={stats.total_checkins}") + + # ============================================================ + # END: STATISTICS + # ============================================================ + + # Add today's date for template + today_date = datetime.now().strftime('%Y-%m-%d') + current_date_formatted = datetime.now().strftime('%B %d') + + logger_handler.logger.debug("Rendering attendance report template") + + return render_template('attendance_report.html', + attendance_records=processed_records, + records_truncated=records_truncated, + records_limit=ATTENDANCE_PAGE_LIMIT, + locations=locations, + projects=projects, + stats=stats, + date_from=date_from, + date_to=date_to, + location_filter=location_filter, + employee_filter=employee_filter, + employee_ids=employee_ids, + employee_display_names=employee_display_names, + employee_display_name=employee_display_name, + project_filter=project_filter, + today_date=datetime.now().strftime('%Y-%m-%d'), + current_date_formatted=datetime.now().strftime('%B %d'), + has_location_accuracy_feature=has_location_accuracy, + user_role=user_role) + + except Exception as e: + logger_handler.logger.error(f"Error loading attendance report: {e}", exc_info=True) + + error_traceback = traceback.format_exc() + + + # Log the error + try: + logger_handler.log_database_error('attendance_report', e) + except Exception as log_error: + logger_handler.logger.warning(f"Additional logging error: {log_error}") + + flash('Error loading attendance report. Please check the server logs for details.', 'error') + return redirect(url_for('dashboard.dashboard')) + +@bp.route('/api/time-attendance/locations', endpoint='time_attendance_locations_api') +@login_required +def time_attendance_locations_api(): + """Return distinct location_name values from time_attendance, optionally filtered by project_id. + Used by the time attendance records page to dynamically scope the location dropdown.""" + try: + project_id = request.args.get('project_id', '').strip() + + if project_id: + try: + project_id_int = int(project_id) + except (ValueError, TypeError): + return jsonify({'success': False, 'error': 'Invalid project_id'}), 400 + + result = db.session.execute(text(""" + SELECT DISTINCT location_name + FROM time_attendance + WHERE project_id = :project_id + AND location_name IS NOT NULL + ORDER BY location_name + """), {'project_id': project_id_int}) + else: + result = db.session.execute(text(""" + SELECT DISTINCT location_name + FROM time_attendance + WHERE location_name IS NOT NULL + ORDER BY location_name + """)) + + locations = [row[0] for row in result.fetchall()] + logger_handler.logger.info( + f"User {session.get('username', 'unknown')} fetched time attendance locations" + + (f" for project_id={project_id}" if project_id else " (all projects)") + ) + return jsonify({'success': True, 'locations': locations}) + + except Exception as e: + logger_handler.logger.error(f"Error in time_attendance_locations_api: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@bp.route('/api/attendance/locations', endpoint='attendance_locations_api') +@login_required +def attendance_locations_api(): + """Return distinct location_name values from attendance_data, optionally filtered by project_id. + Used by the attendance report page to dynamically scope the location dropdown when a project is selected.""" + try: + project_id = request.args.get('project_id', '').strip() + + if project_id: + try: + project_id_int = int(project_id) + except (ValueError, TypeError): + return jsonify({'success': False, 'error': 'Invalid project_id'}), 400 + + result = db.session.execute(text(""" + SELECT DISTINCT ad.location_name + FROM attendance_data ad + INNER JOIN qr_codes qc ON ad.qr_code_id = qc.id + WHERE qc.project_id = :project_id + AND ad.location_name IS NOT NULL + ORDER BY ad.location_name + """), {'project_id': project_id_int}) + else: + result = db.session.execute(text(""" + SELECT DISTINCT location_name + FROM attendance_data + WHERE location_name IS NOT NULL + ORDER BY location_name + """)) + + locations = [row[0] for row in result.fetchall()] + logger_handler.logger.info( + f"User {session.get('username', 'unknown')} fetched attendance locations" + + (f" for project_id={project_id}" if project_id else " (all projects)") + ) + return jsonify({'success': True, 'locations': locations}) + + except Exception as e: + logger_handler.logger.error(f"Error in attendance_locations_api: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@bp.route('/api/search_employees', endpoint='search_employees_api') +@login_required +def search_employees_api(): + """ + API endpoint to search employees by name or ID. + Returns matches from the Employee table first, then appends any IDs found + in attendance_data that have no Employee record — so unregistered IDs + that have attendance records can still be filtered on the attendance page. + """ + try: + search_query = request.args.get('q', '').strip() + + if not search_query or len(search_query) < 2: + return jsonify({'employees': []}) + + search_pattern = f"%{search_query}%" + + # 1. Registered employees — search by ID or name + employees = Employee.query.filter( + db.or_( + Employee.id.like(search_pattern), + Employee.firstName.like(search_pattern), + Employee.lastName.like(search_pattern), + db.func.concat(Employee.firstName, ' ', Employee.lastName).like(search_pattern) + ) + ).limit(10).all() + + employee_list = [{ + 'id': emp.id, + 'firstName': emp.firstName, + 'lastName': emp.lastName, + 'full_name': f"{emp.firstName} {emp.lastName}" + } for emp in employees] + + registered_ids = {str(emp.id) for emp in employees} + + # 2. Unregistered IDs — present in attendance_data but not in Employee table. + # Only add when the search term looks like (part of) a numeric ID and we + # still have room in the result list. + if len(employee_list) < 10: + remaining_slots = 10 - len(employee_list) + try: + unregistered_rows = db.session.execute( + text(""" + SELECT DISTINCT ad.employee_id + FROM attendance_data ad + LEFT JOIN employee e ON CAST(ad.employee_id AS UNSIGNED) = e.id + WHERE e.id IS NULL + AND ad.employee_id LIKE :pattern + ORDER BY ad.employee_id + LIMIT :lim + """), + {'pattern': search_pattern, 'lim': remaining_slots} + ).fetchall() + + for row in unregistered_rows: + emp_id = str(row[0]) + if emp_id not in registered_ids: + employee_list.append({ + 'id': emp_id, + 'firstName': f'ID: {emp_id}', + 'lastName': '(no record)', + 'full_name': f'ID: {emp_id} (no record)' + }) + except Exception as unreg_err: + logger_handler.logger.warning(f"Could not search unregistered employee IDs: {unreg_err}") + + return jsonify({'employees': employee_list}) + + except Exception as e: + logger_handler.logger.error(f"Error searching employees: {e}") + return jsonify({'employees': [], 'error': str(e)}), 500 + + +@bp.route('/api/get_project_locations', endpoint='get_project_locations_api') +@login_required +def get_project_locations_api(): + """ + API endpoint to get locations for a specific project + Returns JSON with location list + """ + try: + project_id = request.args.get('project_id', '').strip() + + if not project_id: + return jsonify({'success': False, 'locations': [], 'error': 'Project ID required'}) + + # Get active QR codes for this project + qr_codes = QRCode.query.filter_by( + project_id=int(project_id), + active_status=True + ).order_by(QRCode.location).all() + + # Group QR codes by location to get unique locations + locations_dict = {} + for qr in qr_codes: + location_key = f"{qr.location}||{qr.location_address}" + + if location_key not in locations_dict: + locations_dict[location_key] = { + 'location': qr.location, + 'location_address': qr.location_address, + 'qr_codes': {} + } + + # Store QR code ID for each event type + locations_dict[location_key]['qr_codes'][qr.location_event] = qr.id + + # Convert to list format + location_list = [{ + 'location': loc_data['location'], + 'location_address': loc_data['location_address'], + 'qr_codes': loc_data['qr_codes'] + } for loc_data in locations_dict.values()] + + return jsonify({'success': True, 'locations': location_list}) + + except Exception as e: + logger_handler.logger.error(f"Error getting project locations: {e}") + return jsonify({'success': False, 'locations': [], 'error': str(e)}), 500 + +@bp.route('/attendance/<int:record_id>/delete', methods=['POST'], endpoint='delete_attendance') +@login_required +@log_database_operations('attendance_delete') +def delete_attendance(record_id): + """Delete attendance record (Admin and Payroll only)""" + # Check if user has permission to delete attendance records + if session.get('role') not in ['admin', 'payroll', 'accounting']: + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({ + 'success': False, + 'message': 'Access denied. Only administrators and payroll staff can delete attendance records.' + }), 403 + else: + flash('Access denied. Only administrators and payroll staff can delete attendance records.', 'error') + return redirect(url_for('attendance.attendance_report')) + + try: + attendance_record = db.session.get(AttendanceData, record_id) + if attendance_record is None: + abort(404) + + # Store record info for logging before deletion + employee_id = attendance_record.employee_id + location_name = attendance_record.location_name + check_in_date = attendance_record.check_in_date + + # Log the deletion + logger_handler.log_security_event( + event_type="attendance_record_deletion", + description=f"{session.get('role', 'unknown').title()} {session.get('username')} deleted attendance record {record_id}", + severity="HIGH", + additional_data={ + 'record_id': record_id, + 'employee_id': employee_id, + 'location_name': location_name, + 'check_in_date': str(check_in_date), + 'user_role': session.get('role') + } + ) + + # Delete the record + db.session.delete(attendance_record) + db.session.commit() + + logger_handler.logger.info( + f"User {session.get('username')} ({session.get('role', 'unknown')}) " + f"deleted attendance record {record_id} for employee {employee_id}" + ) + + # Return JSON response for AJAX requests + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({ + 'success': True, + 'message': f'Attendance record for {employee_id} deleted successfully!' + }) + else: + flash(f'Attendance record for {employee_id} deleted successfully!', 'success') + return redirect(url_for('attendance.attendance_report')) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('attendance_delete', e) + logger_handler.logger.error(f"Error deleting attendance record {record_id}: {e}", exc_info=True) + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({ + 'success': False, + 'message': 'Error deleting attendance record. Please try again.' + }), 500 + else: + flash('Error deleting attendance record. Please try again.', 'error') + return redirect(url_for('attendance.attendance_report')) + diff --git a/routes/attendance_edit.py b/routes/attendance_edit.py new file mode 100644 index 0000000..48b174e --- /dev/null +++ b/routes/attendance_edit.py @@ -0,0 +1,348 @@ +""" +routes/attendance_edit.py +========================= +Attendance record edit, manual entry, and delete routes. + +Routes: /attendance/<id>/edit, /attendance/add, + /attendance/save_manual, /attendance/<id>/delete + +""" +from flask import abort, Blueprint, render_template, request, redirect, flash, session, jsonify, send_file, url_for +from datetime import datetime, date, timedelta, time +import io, os, json, re, traceback + +from extensions import db, logger_handler +from models.attendance import AttendanceData +from models.employee import Employee +from models.permissions import UserLocationPermission, UserProjectPermission +from models.project import Project +from models.qrcode import QRCode +from models.user import User +from sqlalchemy import text, or_, and_ +from logger_handler import log_user_activity, log_database_operations +from utils.helpers import ( + admin_required, + get_client_ip, + has_admin_privileges, + has_staff_level_access, + login_required, + staff_or_admin_required) +from utils.geocoding import (calculate_location_accuracy_enhanced, process_location_data_enhanced, + check_location_accuracy_column_exists) +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter + +from routes.attendance import bp # shared blueprint — do not redefine + + +@bp.route('/attendance/<int:record_id>/edit', methods=['GET', 'POST'], endpoint='edit_attendance') +@login_required +@log_database_operations('attendance_update') +def edit_attendance(record_id): + """Edit attendance record (Admin and Payroll only)""" + # Check if user has permission to edit attendance records + if session.get('role') not in ['admin', 'payroll', 'accounting']: + flash('Access denied. Only administrators and accounting staff can edit attendance records.', 'error') + return redirect(url_for('attendance.attendance_report')) + + try: + attendance_record = db.session.get(AttendanceData, record_id) + if attendance_record is None: + abort(404) + + if request.method == 'POST': + # Get the audit note from form - REQUIRED + edit_note = request.form.get('edit_note', '').strip() + if not edit_note: + flash('Edit reason is required for audit purposes.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + return render_template('edit_attendance.html', + attendance_record=attendance_record, + projects=projects, + qr_codes=QRCode.query.filter_by(active_status=True).all()) + + # Track changes for logging + changes = {} + old_values = { + 'employee_id': attendance_record.employee_id, + 'check_in_date': attendance_record.check_in_date, + 'check_in_time': attendance_record.check_in_time, + 'location_name': attendance_record.location_name, + 'qr_code_id': attendance_record.qr_code_id, + 'location_event': attendance_record.qr_code.location_event if attendance_record.qr_code else None + } + + # Update attendance record fields + new_employee_id = request.form['employee_id'].strip().upper() + new_check_in_date = datetime.strptime(request.form['check_in_date'], '%Y-%m-%d').date() + new_check_in_time = datetime.strptime(request.form['check_in_time'], '%H:%M').time() + new_location_name = request.form['location_name'].strip() + + # Get the new QR code ID from the form (this determines the location event) + new_qr_code_id = request.form.get('qr_code_id', '').strip() + if not new_qr_code_id: + flash('Location event selection is required.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + return render_template('edit_attendance.html', + attendance_record=attendance_record, + projects=projects, + qr_codes=QRCode.query.filter_by(active_status=True).all()) + + # Validate the QR code exists + new_qr_code = db.session.get(QRCode, int(new_qr_code_id)) + if not new_qr_code: + flash('Selected location event not found.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + return render_template('edit_attendance.html', + attendance_record=attendance_record, + projects=projects, + qr_codes=QRCode.query.filter_by(active_status=True).all()) + + # Track what changed + if attendance_record.employee_id != new_employee_id: + changes['employee_id'] = f"{attendance_record.employee_id} → {new_employee_id}" + if attendance_record.check_in_date != new_check_in_date: + changes['check_in_date'] = f"{attendance_record.check_in_date} → {new_check_in_date}" + if attendance_record.check_in_time != new_check_in_time: + changes['check_in_time'] = f"{attendance_record.check_in_time} → {new_check_in_time}" + if attendance_record.location_name != new_location_name: + changes['location_name'] = f"{attendance_record.location_name} → {new_location_name}" + if attendance_record.qr_code_id != int(new_qr_code_id): + old_event = attendance_record.qr_code.location_event if attendance_record.qr_code else 'Unknown' + new_event = new_qr_code.location_event + changes['location_event'] = f"{old_event} → {new_event}" + changes['qr_code_id'] = f"{attendance_record.qr_code_id} → {new_qr_code_id}" + + # Apply changes + attendance_record.employee_id = new_employee_id + attendance_record.check_in_date = new_check_in_date + attendance_record.check_in_time = new_check_in_time + attendance_record.location_name = new_location_name + attendance_record.qr_code_id = int(new_qr_code_id) + attendance_record.updated_timestamp = datetime.utcnow() + + # Store the audit note with timestamp and user info + timestamp = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC') + username = session.get('username', 'Unknown') + role = session.get('role', 'unknown') + + new_note_entry = f"[{timestamp}] {role.title()} '{username}': {edit_note}" + + if attendance_record.edit_note: + # Append to existing notes + attendance_record.edit_note = f"{attendance_record.edit_note}\n\n{new_note_entry}" + else: + # First edit note + attendance_record.edit_note = new_note_entry + + db.session.commit() + + # Enhanced logging with audit note + if changes: + logger_handler.log_security_event( + event_type="attendance_record_update", + description=f"{session.get('role', 'unknown').title()} {session.get('username')} updated attendance record {record_id}", + severity="MEDIUM", + additional_data={ + 'record_id': record_id, + 'changes': changes, + 'user_role': session.get('role'), + 'edit_reason': edit_note, + 'editor_username': session.get('username') + } + ) + logger_handler.logger.info( + f"User {session.get('username')} ({session.get('role', 'unknown')}) " + f"updated attendance record {record_id}: {changes}, reason: {edit_note}" + ) + else: + # Log even if no changes were made (for audit purposes) + logger_handler.log_security_event( + event_type="attendance_record_edit_no_changes", + description=f"{session.get('role', 'unknown').title()} {session.get('username')} accessed edit form for record {record_id} but made no changes", + severity="LOW", + additional_data={ + 'record_id': record_id, + 'user_role': session.get('role'), + 'edit_reason': edit_note, + 'editor_username': session.get('username') + } + ) + logger_handler.logger.info( + f"User {session.get('username')} ({session.get('role', 'unknown')}) " + f"edited attendance record {record_id} with no changes, reason: {edit_note}" + ) + + flash(f'Attendance record for {new_employee_id} updated successfully! Edit reason logged for audit.', 'success') + return redirect(url_for('attendance.attendance_report')) + + # GET request - show edit form + # Get available projects for the dropdown + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + + # Get available QR codes for location dropdown (for backward compatibility) + qr_codes = QRCode.query.filter_by(active_status=True).all() + + return render_template('edit_attendance.html', + attendance_record=attendance_record, + projects=projects, + qr_codes=qr_codes) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('attendance_update', e) + logger_handler.logger.error(f"Error updating attendance record {record_id}: {e}", exc_info=True) + flash('Error updating attendance record. Please try again.', 'error') + return redirect(url_for('attendance.attendance_report')) + +@bp.route('/attendance/add', methods=['GET'], endpoint='add_manual_attendance') +@login_required +@log_user_activity('manual_attendance_access') +def add_manual_attendance(): + """ + Display form to manually add attendance record + Only accessible by admin and accounting roles + """ + try: + user_role = session.get('role') + + # Check authorization + if user_role not in ['admin', 'accounting']: + flash('You do not have permission to manually add attendance records.', 'error') + return redirect(url_for('attendance.attendance_report')) + + # Get all active projects + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + + # Get today's date for form + today_date = datetime.now().strftime('%Y-%m-%d') + + logger_handler.logger.info( + f"User {session.get('username')} ({user_role}) accessed manual attendance entry form" + ) + + return render_template('add_manual_attendance.html', + projects=projects, + today_date=today_date) + + except Exception as e: + logger_handler.logger.error(f"Error loading manual attendance form: {e}") + flash('Error loading form. Please try again.', 'error') + return redirect(url_for('attendance.attendance_report')) + + +@bp.route('/attendance/save_manual', methods=['POST'], endpoint='save_manual_attendance') +@login_required +@log_user_activity('manual_attendance_creation') +@log_database_operations('manual_attendance_insert') +def save_manual_attendance(): + """ + Save manually created attendance record + Only accessible by admin and accounting roles + """ + try: + user_role = session.get('role') + + # Check authorization + if user_role not in ['admin', 'accounting']: + return jsonify({ + 'success': False, + 'message': 'You do not have permission to manually add attendance records.' + }), 403 + + # Get form data + employee_id = request.form.get('employee_id', '').strip() + location_id = request.form.get('location_id', '').strip() + check_date = request.form.get('check_date', '').strip() + check_time = request.form.get('check_time', '').strip() + + # Validate required fields + if not all([employee_id, location_id, check_date, check_time]): + flash('All fields are required.', 'error') + return redirect(url_for('attendance.add_manual_attendance')) + + # Validate employee exists + employee = Employee.query.filter_by(id=int(employee_id)).first() + if not employee: + flash(f'Employee with ID {employee_id} not found.', 'error') + return redirect(url_for('attendance.add_manual_attendance')) + + # Get QR code (location) + qr_code = db.session.get(QRCode, int(location_id)) + if not qr_code: + flash('Selected location not found.', 'error') + return redirect(url_for('attendance.add_manual_attendance')) + + # Parse date and time + try: + check_date_obj = datetime.strptime(check_date, '%Y-%m-%d').date() + check_time_obj = datetime.strptime(check_time, '%H:%M').time() + except ValueError as e: + flash('Invalid date or time format.', 'error') + logger_handler.logger.error(f"Date/time parsing error: {e}") + return redirect(url_for('attendance.add_manual_attendance')) + + # Check if record already exists for this employee, location, date, and time + existing_record = AttendanceData.query.filter_by( + employee_id=str(employee_id), + qr_code_id=qr_code.id, + check_in_date=check_date_obj, + check_in_time=check_time_obj + ).first() + + if existing_record: + flash('An attendance record already exists for this employee at this location, date, and time.', 'warning') + return redirect(url_for('attendance.add_manual_attendance')) + + # Create new attendance record + # Use QR code's location address for both QR address and check-in address + # Set fixed distance of 0.010 miles + new_attendance = AttendanceData( + qr_code_id=qr_code.id, + employee_id=str(employee_id), + check_in_date=check_date_obj, + check_in_time=check_time_obj, + location_name=qr_code.location, + # Use QR code's coordinates + latitude=qr_code.address_latitude, + longitude=qr_code.address_longitude, + # Use QR code's address for both + address=qr_code.location_address, + # Set fixed distance + location_accuracy=0.010, + accuracy=0.010, + # Mark as manual entry + location_source='manual_entry', + device_info='Manual Entry by Admin/Accounting', + user_agent=f'Manual Entry - User: {session.get("username")}', + ip_address=get_client_ip(), + status='present', + verification_required=False, + verification_status='approved', + created_timestamp=datetime.utcnow(), + updated_timestamp=datetime.utcnow() + ) + + db.session.add(new_attendance) + db.session.commit() + + # Log the manual entry + logger_handler.logger.info( + f"Manual attendance record created by {session.get('username')} ({user_role}): " + f"Employee {employee.firstName} {employee.lastName} (ID: {employee_id}), " + f"Location: {qr_code.location}, Event: {qr_code.location_event}, " + f"Date: {check_date}, Time: {check_time}" + ) + + flash(f'Attendance record successfully created for {employee.firstName} {employee.lastName}.', 'success') + return redirect(url_for('attendance.attendance_report')) + + except Exception as e: + db.session.rollback() + logger_handler.logger.error(f"Error saving manual attendance record: {e}") + logger_handler.logger.error(f"Error saving manual attendance record: {e}", exc_info=True) + flash('Error saving attendance record. Please try again.', 'error') + return redirect(url_for('attendance.add_manual_attendance')) + + diff --git a/routes/attendance_export.py b/routes/attendance_export.py new file mode 100644 index 0000000..4f10d5c --- /dev/null +++ b/routes/attendance_export.py @@ -0,0 +1,962 @@ +""" +routes/attendance_export.py +=========================== +Export configuration and Excel export generation routes. + +Routes: /export-configuration, /generate-excel-export +Helper functions: create_excel_export, create_excel_export_ordered, + format_employee_id_for_excel + +""" +from flask import abort, Blueprint, render_template, request, redirect, flash, session, jsonify, send_file, url_for +from datetime import datetime, date, timedelta, time +import io, os, json, re, traceback + +from extensions import db, logger_handler +from models.attendance import AttendanceData +from models.employee import Employee +from models.permissions import UserLocationPermission, UserProjectPermission +from models.project import Project +from models.qrcode import QRCode +from models.user import User +from sqlalchemy import text, or_, and_ +from logger_handler import log_user_activity, log_database_operations +from utils.helpers import ( + admin_required, + employee_id_regex_condition, + expand_employee_id_filter, + get_client_ip, + has_admin_privileges, + has_staff_level_access, + login_required, + staff_or_admin_required) +from utils.geocoding import (calculate_location_accuracy_enhanced, process_location_data_enhanced, + check_location_accuracy_column_exists) +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter + +from routes.attendance import bp # shared blueprint — do not redefine + + + +@bp.route('/export-configuration', endpoint='export_configuration') +@login_required +def export_configuration(): + """Display export configuration page for customizing Excel exports""" + try: + user_role = session.get('role') + if user_role not in ['admin', 'payroll', 'accounting']: + logger_handler.logger.warning(f"User {session.get('username', 'unknown')} (role: {user_role}) attempted unauthorized access to export configuration") + flash('Access denied. Only administrators and payroll staff can access export configuration.', 'error') + return redirect(url_for('attendance.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: + pass + + # Get current filters from session or request args + filters = { + 'date_from': request.args.get('date_from', ''), + 'date_to': request.args.get('date_to', ''), + 'location_filter': request.args.get('location', ''), + 'employee_filter': request.args.get('employee', ''), + 'project_filter': request.args.get('project', '') + } + + logger_handler.logger.debug(f"Export config filters: {filters}") + + # Get project name if project filter is applied + project_name = None + if filters.get('project_filter'): + try: + project = db.session.get(Project, int(filters['project_filter'])) + if project: + project_name = project.name + logger_handler.logger.debug(f"Project filter: ID={filters['project_filter']}, Name={project_name}") + except Exception as e: + logger_handler.logger.warning(f"Error fetching project name for filter: {e}") + + # Check if location accuracy feature exists + try: + has_location_accuracy = check_location_accuracy_column_exists() + except Exception as e: + logger_handler.logger.warning(f"Error checking location accuracy column: {e}") + has_location_accuracy = False + + # Define all available columns with their default settings + available_columns = [ + {'key': 'employee_id', 'label': 'Employee ID', 'default_name': 'ID', 'enabled': True}, + {'key': 'employee_name', 'label': 'Employee Name', 'default_name': 'Employee Name', 'enabled': False}, + {'key': 'location_name', 'label': 'Location', 'default_name': 'Location Name', 'enabled': True}, + {'key': 'status', 'label': 'Event', 'default_name': 'Action Description', 'enabled': True}, + {'key': 'check_in_date', 'label': 'Date', 'default_name': 'Date', 'enabled': True}, + {'key': 'check_in_time', 'label': 'Time', 'default_name': 'Time', 'enabled': True}, + {'key': 'qr_address', 'label': 'QR Address', 'default_name': 'Event Description', 'enabled': True}, + {'key': 'address', 'label': 'Check-in Address', 'default_name': 'Recorded Address', 'enabled': True}, + {'key': 'device_info', 'label': 'Device', 'default_name': 'Platform', 'enabled': True}, + {'key': 'ip_address', 'label': 'IP Address', 'default_name': 'IP Address', 'enabled': False}, + {'key': 'user_agent', 'label': 'User Agent', 'default_name': 'Browser/User Agent', 'enabled': False}, + {'key': 'latitude', 'label': 'Latitude', 'default_name': 'GPS Latitude', 'enabled': False}, + {'key': 'longitude', 'label': 'Longitude', 'default_name': 'GPS Longitude', 'enabled': False}, + {'key': 'accuracy', 'label': 'GPS Accuracy', 'default_name': 'GPS Accuracy (meters)', 'enabled': False}, + ] + + # Add location accuracy column if feature exists + if has_location_accuracy: + available_columns.append({ + 'key': 'location_accuracy', + 'label': 'Location Accuracy', + 'default_name': 'Distance', + 'enabled': True # Changed from False to True + }) + + logger_handler.logger.debug(f"Rendering export configuration with {len(available_columns)} columns") + + return render_template('export_configuration.html', + available_columns=available_columns, + filters=filters, + project_name=project_name, + has_location_accuracy_feature=has_location_accuracy) + + except Exception as e: + logger_handler.logger.error(f"Error in export_configuration route: {e}", exc_info=True) + + # Use your existing logger error method with correct parameters + try: + logger_handler.log_flask_error( + 'export_configuration_error', + str(e), + stack_trace=traceback.format_exc() + ) + except Exception as log_error: + logger_handler.logger.warning(f"Could not log error: {log_error}") + + flash('Error loading export configuration page.', 'error') + return redirect(url_for('attendance.attendance_report')) + +@bp.route('/generate-excel-export', methods=['POST'], endpoint='generate_excel_export') +@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', 'accounting']: + 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.attendance_report')) + + logger_handler.logger.info(f"Excel export started by user {session.get('username', 'unknown')}") + + # Log export action using your existing logger + try: + logger_handler.logger.info(f"User {session.get('username', 'unknown')} generated Excel export") + except Exception: + pass + + # Get selected columns and custom names from form + selected_columns_raw = request.form.getlist('selected_columns') + logger_handler.logger.debug(f"Selected columns (raw): {selected_columns_raw}") + + # Get column order from form + column_order_json = request.form.get('column_order', '[]') + try: + column_order = json.loads(column_order_json) if column_order_json else [] + except (json.JSONDecodeError, TypeError): + column_order = [] + + logger_handler.logger.debug(f"Column order from form: {column_order}") + + # Determine final column order + if column_order: + # Use the specified order, but only include actually selected columns + selected_columns = [col for col in column_order if col in selected_columns_raw] + # Add any selected columns that weren't in the order (shouldn't happen, but safety check) + for col in selected_columns_raw: + if col not in selected_columns: + selected_columns.append(col) + else: + # Fallback to raw selection order + selected_columns = selected_columns_raw + + logger_handler.logger.debug(f"Final column order: {selected_columns}") + + if not selected_columns: + flash('Please select at least one column to export.', 'error') + return redirect(url_for('attendance.export_configuration')) + + column_names = {} + for column in selected_columns: + column_names[column] = request.form.get(f'name_{column}', column) + + # Get filters + filters = { + 'date_from': request.form.get('date_from'), + 'date_to': request.form.get('date_to'), + 'location_filter': request.form.get('location_filter'), + 'employee_filter': request.form.get('employee_filter'), + 'project_filter': request.form.get('project_filter') + } + + logger_handler.logger.debug(f"Export filters: {filters}") + + # Save user preferences in session for next time + session['export_preferences'] = { + 'selected_columns': selected_columns, + 'column_names': column_names, + 'column_order': selected_columns # This is now the ordered list + } + + # Generate Excel file with ordered columns + excel_file = create_excel_export_ordered(selected_columns, column_names, filters) + + if excel_file: + # Get project name if project filter exists + project_name_for_filename = '' + if filters.get('project_filter'): + try: + project = db.session.get(Project, int(filters['project_filter'])) + if project: + # Replace spaces and special characters with underscores + project_name_safe = project.name.replace(' ', '_').replace('/', '_').replace('\\', '_') + project_name_for_filename = f"{project_name_safe}_" + except Exception as e: + logger_handler.logger.warning(f"Error getting project name for filename: {e}") + + # Format dates for filename (MMDDYYYY format) + date_from_formatted = '' + date_to_formatted = '' + if filters.get('date_from'): + try: + date_obj = datetime.strptime(filters['date_from'], '%Y-%m-%d') + date_from_formatted = date_obj.strftime('%m%d%Y') + except ValueError: + pass + + if filters.get('date_to'): + try: + date_obj = datetime.strptime(filters['date_to'], '%Y-%m-%d') + date_to_formatted = date_obj.strftime('%m%d%Y') + except ValueError: + pass + + # Build filename components + # Format: [project_name_]attendance_report_[fromdate_todate].xlsx + date_range_str = '' + if date_from_formatted and date_to_formatted: + date_range_str = f"{date_from_formatted}_{date_to_formatted}" + elif date_from_formatted: + date_range_str = f"{date_from_formatted}" + elif date_to_formatted: + date_range_str = f"{date_to_formatted}" + + filename = f'{project_name_for_filename}attendance_report_{date_range_str}.xlsx' + + logger_handler.logger.info(f"Excel export generated successfully: {filename}") + + # Log successful export using your existing logger + try: + logger_handler.logger.info(f"Excel export generated successfully with {len(selected_columns)} columns in custom order by user {session.get('username', 'unknown')}: {filename}") + except Exception: + pass + + return send_file( + excel_file, + as_attachment=True, + download_name=filename, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + else: + flash('Error generating Excel file.', 'error') + return redirect(url_for('attendance.export_configuration')) + + except Exception as e: + logger_handler.logger.error(f"Error in generate_excel_export route: {e}", exc_info=True) + + # Use your existing logger error method with correct parameters + try: + logger_handler.log_flask_error( + 'excel_export_error', + str(e), + stack_trace=traceback.format_exc() + ) + except Exception as log_error: + logger_handler.logger.warning(f"Could not log error: {log_error}") + + flash('Error generating Excel export.', 'error') + return redirect(url_for('attendance.export_configuration')) + +def create_excel_export(selected_columns, column_names, filters): + """Create Excel file with selected attendance data - Updated to include employee names""" + try: + logger_handler.logger.info(f"Creating Excel export with {len(selected_columns)} columns") + + # Import openpyxl modules + try: + from openpyxl import Workbook + from openpyxl.styles import Font, Alignment, PatternFill + from openpyxl.utils import get_column_letter + except ImportError as e: + logger_handler.logger.error(f"openpyxl import error: {e}. Run: pip install openpyxl") + return None + + # Build query based on filters - JOIN with QRCode to get location_event and location_address + # Now also JOIN with Employee table to get employee names + query = db.session.query(AttendanceData, QRCode, Employee).join( + QRCode, AttendanceData.qr_code_id == QRCode.id + ).outerjoin( + Employee, text("CAST(attendance_data.employee_id AS UNSIGNED) = employee.id") + ) + + # Apply date filters + if filters.get('date_from'): + try: + date_from = datetime.strptime(filters['date_from'], '%Y-%m-%d').date() + query = query.filter(AttendanceData.check_in_date >= date_from) + logger_handler.logger.debug(f"Applied date_from filter: {date_from}") + except ValueError as e: + logger_handler.logger.warning(f"Invalid date_from format: {e}") + + if filters.get('date_to'): + try: + date_to = datetime.strptime(filters['date_to'], '%Y-%m-%d').date() + query = query.filter(AttendanceData.check_in_date <= date_to) + logger_handler.logger.debug(f"Applied date_to filter: {date_to}") + except ValueError as e: + logger_handler.logger.warning(f"Invalid date_to format: {e}") + + # Apply location filter + if filters.get('location_filter'): + query = query.filter(AttendanceData.location_name.like(f"%{filters['location_filter']}%")) + logger_handler.logger.debug(f"Applied location filter: {filters['location_filter']}") + + # Apply employee filter — supports comma-separated multi-employee values. + # Each ID is expanded into its SP/PW/PT spellings so extra-work check-ins + # are exported alongside regular ones (same rule as the attendance report). + if filters.get('employee_filter'): + emp_ids = [e.strip() for e in filters['employee_filter'].split(',') if e.strip()] + if emp_ids: + exact_variants, regex_patterns = expand_employee_id_filter(emp_ids) + if regex_patterns: + query = query.filter(or_( + AttendanceData.employee_id.in_(exact_variants), + employee_id_regex_condition(AttendanceData.employee_id, regex_patterns) + )) + else: + query = query.filter(AttendanceData.employee_id.in_(exact_variants)) + logger_handler.logger.debug(f"Applied employee filter: {emp_ids}") + + # Apply project filter + if filters.get('project_filter'): + try: + project_id = int(filters['project_filter']) + # For standard QR records: match by the QR code's own project_id. + # For dynamic QR records: the dynamic QR may not belong to any project, + # but the employee-selected location corresponds to a standard QR in that + # project. Include them by matching location_name against standard QRs + # in the selected project. + query = query.filter( + or_( + QRCode.project_id == project_id, + and_( + AttendanceData.is_dynamic_qr == True, + AttendanceData.location_name.in_( + db.session.query(QRCode.location) + .filter( + QRCode.project_id == project_id, + QRCode.qr_type == 'standard', + QRCode.location.isnot(None), + QRCode.location != '' + ) + .subquery() + ) + ) + ) + ) + logger_handler.logger.debug(f"Applied project filter: {project_id}") + except (ValueError, TypeError) as e: + logger_handler.logger.warning(f"Invalid project filter: {e}") + + # Order by date and time + query = query.order_by(AttendanceData.check_in_date.desc(), AttendanceData.check_in_time.desc()) + + # Execute query + results = query.all() + logger_handler.logger.debug(f"Query returned {len(results)} records for export") + + if not results: + logger_handler.logger.warning("No records found for export") + return None + + # Create workbook + wb = Workbook() + ws = wb.active + ws.title = "Attendance Report" + + # Header styling + header_font = Font(bold=True, color="FFFFFF") + header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + header_alignment = Alignment(horizontal="center", vertical="center") + + # Set headers based on selected columns + headers = [] + for column_key in selected_columns: + header_name = column_names.get(column_key, column_key) + headers.append(header_name) + + # Write headers + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + + # Write data rows + for row_idx, (attendance_record, qr_record, employee_record) in enumerate(results, 2): + for col_idx, column_key in enumerate(selected_columns, 1): + cell = ws.cell(row=row_idx, column=col_idx) + + try: + # Handle each column type + if column_key == 'employee_id': + cell.value = format_employee_id_for_excel(attendance_record.employee_id) + elif column_key == 'employee_name': + # NEW: Handle employee name from joined Employee table + if employee_record: + cell.value = f"{employee_record.lastName}, {employee_record.firstName}" + else: + cell.value = f"Unknown (ID: {attendance_record.employee_id})" + elif column_key == 'location_name': + cell.value = attendance_record.location_name or '' + elif column_key == 'status': + cell.value = qr_record.location_event if qr_record.location_event else 'Check In' + elif column_key == 'check_in_date': + cell.value = attendance_record.check_in_date.strftime('%Y-%m-%d') if attendance_record.check_in_date else '' + elif column_key == 'check_in_time': + cell.value = attendance_record.check_in_time.strftime('%H:%M:%S') if attendance_record.check_in_time else '' + elif column_key == 'qr_address': + # Use attendance-level qr_address first (set for dynamic QR check-ins), + # fall back to the QR code's location_address for standard QR. + cell.value = ( + getattr(attendance_record, 'qr_address', None) + or (qr_record.location_address if qr_record else '') + or '' + ) + elif column_key == 'address': + # Check-in address logic based on location accuracy WITH HYPERLINKS + # If location accuracy < 0.3 miles, use QR address; otherwise use actual check-in address + if hasattr(attendance_record, 'location_accuracy') and attendance_record.location_accuracy is not None: + try: + accuracy_value = float(attendance_record.location_accuracy) + if accuracy_value < 0.3: + # High accuracy - use QR code ADDRESS (not location) with hyperlink + address_text = ( + getattr(attendance_record, 'qr_address', None) + or (qr_record.location_address if qr_record and qr_record.location_address else '') + or '' + ) + if address_text and hasattr(qr_record, 'address_latitude') and hasattr(qr_record, 'address_longitude') and qr_record.address_latitude and qr_record.address_longitude: + # Format coordinates with 10 decimal places + lat_formatted = f"{float(qr_record.address_latitude):.10f}" + lng_formatted = f"{float(qr_record.address_longitude):.10f}" + hyperlink_formula = f'=HYPERLINK("http://maps.google.com/maps?q={lat_formatted},{lng_formatted}","{address_text.strip()}")' + cell.value = hyperlink_formula + logger_handler.logger.debug(f"Added QR address hyperlink for employee {attendance_record.employee_id}") + else: + cell.value = address_text + logger_handler.logger.debug(f"Using QR address for employee {attendance_record.employee_id} (accuracy: {accuracy_value:.3f} miles)") + else: + # Lower accuracy - use actual check-in address with hyperlink + address_text = attendance_record.address or '' + if address_text and attendance_record.latitude and attendance_record.longitude: + # Format coordinates with 10 decimal places + lat_formatted = f"{float(attendance_record.latitude):.10f}" + lng_formatted = f"{float(attendance_record.longitude):.10f}" + hyperlink_formula = f'=HYPERLINK("http://maps.google.com/maps?q={lat_formatted},{lng_formatted}","{address_text.strip()}")' + cell.value = hyperlink_formula + logger_handler.logger.debug(f"Added check-in address hyperlink for employee {attendance_record.employee_id}") + else: + cell.value = address_text + logger_handler.logger.debug(f"Using check-in address for employee {attendance_record.employee_id} (accuracy: {accuracy_value:.3f} miles)") + except (ValueError, TypeError): + # If accuracy can't be converted to float, use check-in address with hyperlink + address_text = attendance_record.address or '' + if address_text and attendance_record.latitude and attendance_record.longitude: + # Format coordinates with 10 decimal places + lat_formatted = f"{float(attendance_record.latitude):.10f}" + lng_formatted = f"{float(attendance_record.longitude):.10f}" + hyperlink_formula = f'=HYPERLINK("http://maps.google.com/maps?q={lat_formatted},{lng_formatted}","{address_text.strip()}")' + cell.value = hyperlink_formula + logger_handler.logger.debug(f"Added check-in address hyperlink (fallback) for employee {attendance_record.employee_id}") + else: + cell.value = address_text + else: + # No location accuracy data - use actual check-in address with hyperlink + address_text = attendance_record.address or '' + if address_text and attendance_record.latitude and attendance_record.longitude: + # Format coordinates with 10 decimal places + lat_formatted = f"{float(attendance_record.latitude):.10f}" + lng_formatted = f"{float(attendance_record.longitude):.10f}" + hyperlink_formula = f'=HYPERLINK("http://maps.google.com/maps?q={lat_formatted},{lng_formatted}","{address_text.strip()}")' + cell.value = hyperlink_formula + logger_handler.logger.debug(f"Added check-in address hyperlink (no accuracy data) for employee {attendance_record.employee_id}") + else: + cell.value = address_text + elif column_key == 'device_info': + cell.value = attendance_record.device_info or '' + elif column_key == 'ip_address': + cell.value = attendance_record.ip_address or '' + elif column_key == 'user_agent': + cell.value = attendance_record.user_agent or '' + elif column_key == 'latitude': + cell.value = attendance_record.latitude or '' + elif column_key == 'longitude': + cell.value = attendance_record.longitude or '' + elif column_key == 'accuracy': + cell.value = attendance_record.accuracy or '' + elif column_key == 'location_accuracy': + cell.value = attendance_record.location_accuracy or '' + else: + cell.value = '' + except Exception as cell_error: + logger_handler.logger.warning(f"Error setting cell value for {column_key}: {cell_error}") + cell.value = '' + + # Auto-adjust column widths based on content and header + for col_idx, column_key in enumerate(selected_columns, 1): + column_letter = get_column_letter(col_idx) + max_length = 0 + + # Get header name length + header_name = column_names.get(column_key, column_key) + max_length = len(str(header_name)) + + # Check content in all rows (sample first 100 rows for performance) + for row_idx in range(2, min(102, ws.max_row + 1)): + cell = ws.cell(row=row_idx, column=col_idx) + try: + cell_value = str(cell.value) if cell.value else '' + # For HYPERLINK formulas, extract the display text + if cell_value.startswith('=HYPERLINK'): + # Extract text between last quotes: HYPERLINK("url","display_text") + import re + match = re.search(r',"([^"]+)"\)$', cell_value) + if match: + cell_value = match.group(1) + + if len(cell_value) > max_length: + max_length = len(cell_value) + except Exception: + pass # Non-string cell value — skip width measurement + + # Set width based on column type with reasonable limits + # Define optimal widths for specific column types + column_width_rules = { + 'employee_id': {'min': 8, 'max': 15}, + 'employee_name': {'min': 20, 'max': 30}, + 'location_name': {'min': 15, 'max': 35}, + 'status': {'min': 12, 'max': 20}, + 'check_in_date': {'min': 12, 'max': 15}, + 'check_in_time': {'min': 10, 'max': 12}, + 'qr_address': {'min': 20, 'max': 40}, + 'address': {'min': 20, 'max': 45}, + 'device_info': {'min': 12, 'max': 20}, + 'ip_address': {'min': 14, 'max': 18}, + 'user_agent': {'min': 15, 'max': 30}, + 'latitude': {'min': 12, 'max': 15}, + 'longitude': {'min': 12, 'max': 15}, + 'accuracy': {'min': 10, 'max': 15}, + 'location_accuracy': {'min': 10, 'max': 15} + } + + # Get rules for this column or use defaults + rules = column_width_rules.get(column_key, {'min': 10, 'max': 40}) + + # Calculate adjusted width: add 2 for padding, respect min/max + adjusted_width = max_length + 2 + adjusted_width = max(rules['min'], min(adjusted_width, rules['max'])) + + ws.column_dimensions[column_letter].width = adjusted_width + + logger_handler.logger.debug(f"Column {column_letter} ({column_key}): width={adjusted_width} (max_content={max_length})") + + # Save to BytesIO + excel_buffer = io.BytesIO() + wb.save(excel_buffer) + excel_buffer.seek(0) + + logger_handler.logger.info("Excel file created successfully with employee names") + + # Log export action with employee name column + try: + logger_handler.logger.info(f"Excel export with employee names generated by user {session.get('username', 'unknown')}") + except Exception: + pass + + return excel_buffer + + except Exception as e: + logger_handler.logger.error(f"Error creating Excel export: {e}", exc_info=True) + + # Log error + try: + logger_handler.log_flask_error( + 'excel_export_error', + str(e), + stack_trace=traceback.format_exc() + ) + except Exception as log_error: + logger_handler.logger.warning(f"Could not log error: {log_error}") + + return None + +def format_employee_id_for_excel(employee_id): + if not employee_id: + return '' + emp_id_str = str(employee_id).strip() + if emp_id_str.isdigit(): + return int(emp_id_str) + else: + return emp_id_str + +def create_excel_export_ordered(selected_columns, column_names, filters): + """Create Excel file with selected attendance data in specified column order""" + try: + logger_handler.logger.info(f"Creating Excel export with {len(selected_columns)} columns") + + # Import openpyxl modules + try: + from openpyxl import Workbook + from openpyxl.styles import Font, Alignment, PatternFill + from openpyxl.utils import get_column_letter + except ImportError as e: + logger_handler.logger.error(f"openpyxl import error: {e}. Run: pip install openpyxl") + return None + + # Build query based on filters - JOIN with QRCode to get location_event and location_address + # Now also JOIN with Employee table to get employee names + query = db.session.query(AttendanceData, QRCode, Employee).join( + QRCode, AttendanceData.qr_code_id == QRCode.id + ).outerjoin( + Employee, text("CAST(attendance_data.employee_id AS UNSIGNED) = employee.id") + ) + + # Apply date filters + if filters.get('date_from'): + try: + date_from = datetime.strptime(filters['date_from'], '%Y-%m-%d').date() + query = query.filter(AttendanceData.check_in_date >= date_from) + logger_handler.logger.debug(f"Applied date_from filter: {date_from}") + except ValueError as e: + logger_handler.logger.warning(f"Invalid date_from format: {e}") + + if filters.get('date_to'): + try: + date_to = datetime.strptime(filters['date_to'], '%Y-%m-%d').date() + query = query.filter(AttendanceData.check_in_date <= date_to) + logger_handler.logger.debug(f"Applied date_to filter: {date_to}") + except ValueError as e: + logger_handler.logger.warning(f"Invalid date_to format: {e}") + + # Apply location filter + if filters.get('location_filter'): + query = query.filter(AttendanceData.location_name.like(f"%{filters['location_filter']}%")) + logger_handler.logger.debug(f"Applied location filter: {filters['location_filter']}") + + # Apply employee filter — supports comma-separated multi-employee values. + # Each ID is expanded into its SP/PW/PT spellings so extra-work check-ins + # are exported alongside regular ones (same rule as the attendance report). + if filters.get('employee_filter'): + emp_ids = [e.strip() for e in filters['employee_filter'].split(',') if e.strip()] + if emp_ids: + exact_variants, regex_patterns = expand_employee_id_filter(emp_ids) + if regex_patterns: + query = query.filter(or_( + AttendanceData.employee_id.in_(exact_variants), + employee_id_regex_condition(AttendanceData.employee_id, regex_patterns) + )) + else: + query = query.filter(AttendanceData.employee_id.in_(exact_variants)) + logger_handler.logger.debug(f"Applied employee filter: {emp_ids}") + + # Apply project filter + if filters.get('project_filter'): + try: + project_id = int(filters['project_filter']) + # For standard QR records: match by the QR code's own project_id. + # For dynamic QR records: the dynamic QR may not belong to any project, + # but the employee-selected location corresponds to a standard QR in that + # project. Include them by matching location_name against standard QRs + # in the selected project. + query = query.filter( + or_( + QRCode.project_id == project_id, + and_( + AttendanceData.is_dynamic_qr == True, + AttendanceData.location_name.in_( + db.session.query(QRCode.location) + .filter( + QRCode.project_id == project_id, + QRCode.qr_type == 'standard', + QRCode.location.isnot(None), + QRCode.location != '' + ) + .subquery() + ) + ) + ) + ) + logger_handler.logger.debug(f"Applied project filter: {project_id}") + except (ValueError, TypeError) as e: + logger_handler.logger.warning(f"Invalid project filter: {e}") + + # Order by date and time + query = query.order_by(AttendanceData.check_in_date.desc(), AttendanceData.check_in_time.desc()) + + # Execute query + results = query.all() + logger_handler.logger.debug(f"Query returned {len(results)} records for export") + + if not results: + logger_handler.logger.warning("No records found for export") + return None + + # Create workbook + wb = Workbook() + ws = wb.active + ws.title = "Attendance Report" + + # Header styling + header_font = Font(bold=True, color="FFFFFF") + header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + header_alignment = Alignment(horizontal="center", vertical="center") + + # Verification status color fills for location_accuracy column + # Yellow for pending, Green for approved, Red for rejected + verification_fill_pending = PatternFill(start_color="FFFF00", end_color="FFFF00", fill_type="solid") # Yellow + verification_fill_approved = PatternFill(start_color="90EE90", end_color="90EE90", fill_type="solid") # Light Green + verification_fill_rejected = PatternFill(start_color="FF6B6B", end_color="FF6B6B", fill_type="solid") # Light Red + + # Set headers based on selected columns in the specified order + headers = [] + for column_key in selected_columns: + header_name = column_names.get(column_key, column_key) + headers.append(header_name) + + # Write headers + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + + # Write data rows + for row_idx, (attendance_record, qr_record, employee_record) in enumerate(results, 2): + for col_idx, column_key in enumerate(selected_columns, 1): + cell = ws.cell(row=row_idx, column=col_idx) + + try: + # Handle each column type + if column_key == 'employee_id': + cell.value = format_employee_id_for_excel(attendance_record.employee_id) + elif column_key == 'employee_name': + # NEW: Handle employee name from joined Employee table + if employee_record: + cell.value = f"{employee_record.lastName}, {employee_record.firstName}" + else: + cell.value = f"Unknown (ID: {attendance_record.employee_id})" + elif column_key == 'location_name': + cell.value = attendance_record.location_name or '' + elif column_key == 'status': + cell.value = qr_record.location_event if qr_record.location_event else 'Check In' + elif column_key == 'check_in_date': + cell.value = attendance_record.check_in_date.strftime('%Y-%m-%d') if attendance_record.check_in_date else '' + elif column_key == 'check_in_time': + cell.value = attendance_record.check_in_time.strftime('%H:%M:%S') if attendance_record.check_in_time else '' + elif column_key == 'qr_address': + # Use attendance-level qr_address first (set for dynamic QR check-ins), + # fall back to the QR code's location_address for standard QR. + cell.value = ( + getattr(attendance_record, 'qr_address', None) + or (qr_record.location_address if qr_record else '') + or '' + ) + elif column_key == 'address': + # Check-in address logic based on location accuracy WITH HYPERLINKS + # If location accuracy < 0.3 miles, use QR address; otherwise use actual check-in address + if hasattr(attendance_record, 'location_accuracy') and attendance_record.location_accuracy is not None: + try: + accuracy_value = float(attendance_record.location_accuracy) + if accuracy_value < 0.3: + # High accuracy - use QR code ADDRESS (not location) with hyperlink + address_text = ( + getattr(attendance_record, 'qr_address', None) + or (qr_record.location_address if qr_record and qr_record.location_address else '') + or '' + ) + if address_text and hasattr(qr_record, 'address_latitude') and hasattr(qr_record, 'address_longitude') and qr_record.address_latitude and qr_record.address_longitude: + # Format coordinates with 10 decimal places + lat_formatted = f"{float(qr_record.address_latitude):.10f}" + lng_formatted = f"{float(qr_record.address_longitude):.10f}" + hyperlink_formula = f'=HYPERLINK("http://maps.google.com/maps?q={lat_formatted},{lng_formatted}","{address_text.strip()}")' + cell.value = hyperlink_formula + logger_handler.logger.debug(f"Added QR address hyperlink for employee {attendance_record.employee_id}") + else: + cell.value = address_text + logger_handler.logger.debug(f"Using QR address for employee {attendance_record.employee_id} (accuracy: {accuracy_value:.3f} miles)") + else: + # Lower accuracy - use actual check-in address with hyperlink + address_text = attendance_record.address or '' + if address_text and attendance_record.latitude and attendance_record.longitude: + # Format coordinates with 10 decimal places + lat_formatted = f"{float(attendance_record.latitude):.10f}" + lng_formatted = f"{float(attendance_record.longitude):.10f}" + hyperlink_formula = f'=HYPERLINK("http://maps.google.com/maps?q={lat_formatted},{lng_formatted}","{address_text.strip()}")' + cell.value = hyperlink_formula + logger_handler.logger.debug(f"Added check-in address hyperlink for employee {attendance_record.employee_id}") + else: + cell.value = address_text + logger_handler.logger.debug(f"Using check-in address for employee {attendance_record.employee_id} (accuracy: {accuracy_value:.3f} miles)") + except (ValueError, TypeError): + # If accuracy can't be converted to float, use check-in address with hyperlink + address_text = attendance_record.address or '' + if address_text and attendance_record.latitude and attendance_record.longitude: + # Format coordinates with 10 decimal places + lat_formatted = f"{float(attendance_record.latitude):.10f}" + lng_formatted = f"{float(attendance_record.longitude):.10f}" + hyperlink_formula = f'=HYPERLINK("http://maps.google.com/maps?q={lat_formatted},{lng_formatted}","{address_text.strip()}")' + cell.value = hyperlink_formula + logger_handler.logger.debug(f"Added check-in address hyperlink (fallback) for employee {attendance_record.employee_id}") + else: + cell.value = address_text + else: + # No location accuracy data - use actual check-in address with hyperlink + address_text = attendance_record.address or '' + if address_text and attendance_record.latitude and attendance_record.longitude: + # Format coordinates with 10 decimal places + lat_formatted = f"{float(attendance_record.latitude):.10f}" + lng_formatted = f"{float(attendance_record.longitude):.10f}" + hyperlink_formula = f'=HYPERLINK("http://maps.google.com/maps?q={lat_formatted},{lng_formatted}","{address_text.strip()}")' + cell.value = hyperlink_formula + logger_handler.logger.debug(f"Added check-in address hyperlink (no accuracy data) for employee {attendance_record.employee_id}") + else: + cell.value = address_text + elif column_key == 'device_info': + cell.value = attendance_record.device_info or '' + elif column_key == 'ip_address': + cell.value = attendance_record.ip_address or '' + elif column_key == 'user_agent': + cell.value = attendance_record.user_agent or '' + elif column_key == 'latitude': + cell.value = attendance_record.latitude or '' + elif column_key == 'longitude': + cell.value = attendance_record.longitude or '' + elif column_key == 'accuracy': + cell.value = attendance_record.accuracy or '' + elif column_key == 'location_accuracy': + cell.value = attendance_record.location_accuracy or '' + # Apply color fill based on verification_status + # Only apply color if verification_status is not NULL + if hasattr(attendance_record, 'verification_status') and attendance_record.verification_status: + if attendance_record.verification_status == 'pending': + cell.fill = verification_fill_pending # Yellow + elif attendance_record.verification_status == 'approved': + cell.fill = verification_fill_approved # Green + elif attendance_record.verification_status == 'rejected': + cell.fill = verification_fill_rejected # Red + else: + cell.value = '' + except Exception as cell_error: + logger_handler.logger.warning(f"Error setting cell value for {column_key}: {cell_error}") + cell.value = '' + + # Auto-adjust column widths based on content and header + for col_idx, column_key in enumerate(selected_columns, 1): + column_letter = get_column_letter(col_idx) + max_length = 0 + + # Get header name length + header_name = column_names.get(column_key, column_key) + max_length = len(str(header_name)) + + # Check content in all rows (sample first 100 rows for performance) + for row_idx in range(2, min(102, ws.max_row + 1)): + cell = ws.cell(row=row_idx, column=col_idx) + try: + cell_value = str(cell.value) if cell.value else '' + # For HYPERLINK formulas, extract the display text + if cell_value.startswith('=HYPERLINK'): + # Extract text between last quotes: HYPERLINK("url","display_text") + import re + match = re.search(r',"([^"]+)"\)$', cell_value) + if match: + cell_value = match.group(1) + + if len(cell_value) > max_length: + max_length = len(cell_value) + except Exception: + pass # Non-string cell value — skip width measurement + + # Set width based on column type with reasonable limits + # Define optimal widths for specific column types + column_width_rules = { + 'employee_id': {'min': 8, 'max': 15}, + 'employee_name': {'min': 20, 'max': 30}, + 'location_name': {'min': 15, 'max': 35}, + 'status': {'min': 12, 'max': 20}, + 'check_in_date': {'min': 12, 'max': 15}, + 'check_in_time': {'min': 10, 'max': 12}, + 'qr_address': {'min': 20, 'max': 40}, + 'address': {'min': 20, 'max': 45}, + 'device_info': {'min': 12, 'max': 20}, + 'ip_address': {'min': 14, 'max': 18}, + 'user_agent': {'min': 15, 'max': 30}, + 'latitude': {'min': 12, 'max': 15}, + 'longitude': {'min': 12, 'max': 15}, + 'accuracy': {'min': 10, 'max': 15}, + 'location_accuracy': {'min': 10, 'max': 15} + } + + # Get rules for this column or use defaults + rules = column_width_rules.get(column_key, {'min': 10, 'max': 40}) + + # Calculate adjusted width: add 2 for padding, respect min/max + adjusted_width = max_length + 2 + adjusted_width = max(rules['min'], min(adjusted_width, rules['max'])) + + ws.column_dimensions[column_letter].width = adjusted_width + + logger_handler.logger.debug(f"Column {column_letter} ({column_key}): width={adjusted_width} (max_content={max_length})") + + # Save to BytesIO + excel_buffer = io.BytesIO() + wb.save(excel_buffer) + excel_buffer.seek(0) + + logger_handler.logger.info("Excel file created successfully with employee names and verification status coloring") + + # Log export action with employee name column and verification status coloring + try: + logger_handler.logger.info(f"Excel export with employee names and verification status coloring generated by user {session.get('username', 'unknown')}") + except Exception: + pass + + return excel_buffer + + except Exception as e: + logger_handler.logger.error(f"Error creating Excel export: {e}", exc_info=True) + + # Log error + try: + logger_handler.log_flask_error( + 'excel_export_ordered_error', + str(e), + stack_trace=traceback.format_exc() + ) + except Exception as log_error: + logger_handler.logger.warning(f"Could not log error: {log_error}") + + return None \ No newline at end of file diff --git a/routes/auth.py b/routes/auth.py new file mode 100644 index 0000000..9059d69 --- /dev/null +++ b/routes/auth.py @@ -0,0 +1,301 @@ +""" +routes/auth.py +============== +Authentication and user-profile routes. + +Routes: /, /register, /login, /logout, /profile +""" +from flask import Blueprint, render_template, request, redirect, flash, session, jsonify, url_for +from datetime import datetime +from urllib.parse import urlparse, urljoin +import json + +from extensions import db, logger_handler +from models.user import User +from logger_handler import log_user_activity, log_database_operations +from utils.helpers import admin_required, login_required, staff_or_admin_required +from turnstile_utils import turnstile_utils + +bp = Blueprint('auth', __name__) + + + +@bp.route('/', endpoint='index') +def index(): + """Home page - redirect to login if not authenticated""" + if 'user_id' in session: + return redirect(url_for('dashboard.dashboard')) + return redirect(url_for('auth.login')) + +@bp.route('/register', methods=['GET', 'POST'], endpoint='register') +@login_required +@admin_required +@log_user_activity('user_registration') +def register(): + """User registration endpoint""" + if request.method == 'POST': + try: + full_name = request.form['full_name'] + email = request.form['email'] + username = request.form['username'] + password = request.form['password'] + + # Check if user already exists + if User.query.filter_by(username=username).first(): + flash('Username already exists.', 'error') + return render_template('register.html') + + if User.query.filter_by(email=email).first(): + flash('Email already registered.', 'error') + return render_template('register.html') + + # Create new user (default role: staff) + new_user = User( + full_name=full_name, + email=email, + username=username, + role='staff' + ) + new_user.set_password(password) + + db.session.add(new_user) + db.session.commit() + + # Log successful user registration + logger_handler.logger.info(f"New user registered: {username} ({email})") + + flash('Registration successful! Please log in.', 'success') + return redirect(url_for('auth.login')) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('user_registration', e) + flash('Registration failed. Please try again.', 'error') + + return render_template('register.html') + +@bp.route('/login', methods=['GET', 'POST'], endpoint='login') +def login(): + """Enhanced user authentication with Turnstile and comprehensive logging""" + if request.method == 'POST': + username = request.form.get('username', '').strip() + password = request.form.get('password', '') + turnstile_response = request.form.get('cf-turnstile-response', '') + + if not username or not password: + flash('Please enter both username and password.', 'error') + return render_template('login.html') + + # Rate-limit check — blocks IPs with 5+ failed attempts in 15 minutes + from flask import current_app + sec_mgr = getattr(current_app, 'security_manager', None) + if sec_mgr and sec_mgr.is_auth_rate_limited(): + logger_handler.log_security_event( + event_type="login_rate_limited", + description=f"Login blocked by rate limiter for username: {username}", + severity="HIGH" + ) + flash('Too many failed attempts. Please wait 15 minutes before trying again.', 'error') + return render_template('login.html') + + # Verify Turnstile if enabled + if turnstile_utils.is_enabled(): + if not turnstile_utils.verify_turnstile(turnstile_response): + # Log failed Turnstile attempt + logger_handler.log_security_event( + event_type="turnstile_verification_failed", + description=f"Failed Turnstile verification for username: {username}", + severity="HIGH" + ) + flash('Please complete the security verification.', 'error') + return render_template('login.html') + + try: + # Find user (case-insensitive username) + user = User.query.filter( + User.username.like(username), + User.active_status == True + ).first() + + if user and user.check_password(password): + # Check if "Remember Me" is checked + remember_me = request.form.get('remember_me') == 'on' + + # Invalidate the pre-login session to prevent session fixation attacks, + # then re-apply the remember_me permanence flag on the fresh session. + session.clear() + + # Set session as permanent if "Remember Me" is checked + if remember_me: + session.permanent = True + session['remember_me'] = True + else: + session.permanent = False + session['remember_me'] = False + + # Successful login + session['user_id'] = user.id + session['username'] = user.username + session['role'] = user.role + session['full_name'] = user.full_name + session['login_time'] = datetime.now().isoformat() + + # Create a secure session token (also clears failed attempts for this IP) + if sec_mgr: + sec_mgr.create_secure_session(user.id) + + # Update last login date + user.last_login_date = datetime.utcnow() + db.session.commit() + + # Log successful login with Turnstile info + logger_handler.log_user_login( + user_id=user.id, + username=user.username, + success=True + ) + + # Log successful Turnstile verification + if turnstile_utils.is_enabled(): + logger_handler.log_security_event( + event_type="turnstile_verification_success", + description=f"Successful Turnstile verification for user: {user.username}", + severity="INFO" + ) + + flash(f'Welcome back, {user.full_name}!', 'success') + logger_handler.logger.info(f"User {user.username} (ID: {user.id}) logged in successfully") + + # Redirect to intended page or dashboard. + # Validate next is a relative path on this host to prevent open-redirect attacks. + def _is_safe_url(target): + ref_url = urlparse(request.host_url) + test_url = urlparse(urljoin(request.host_url, target)) + return (test_url.scheme in ('http', 'https') + and ref_url.netloc == test_url.netloc) + + next_page = request.args.get('next') + if next_page and _is_safe_url(next_page): + return redirect(next_page) + return redirect(url_for('attendance.attendance_report')) + + else: + # Invalid credentials — record failed attempt for rate limiting + if sec_mgr: + sec_mgr.record_failed_attempt(username) + + user_id = user.id if user else None + logger_handler.log_user_login( + user_id=user_id, + username=username, + success=False, + failure_reason="Invalid credentials" + ) + + flash('Invalid username or password.', 'error') + logger_handler.logger.warning(f"Failed login attempt for username: {username}") + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('user_login', e) + logger_handler.logger.error(f"Login error for username '{username}': {e}") + flash('Login error. Please try again.', 'error') + + return render_template('login.html') + +@bp.route('/logout', endpoint='logout') +def logout(): + """User logout endpoint with session duration logging""" + user_id = session.get('user_id') + username = session.get('username') + login_time_str = session.get('login_time') + + # Calculate session duration + session_duration = None + if login_time_str: + try: + login_time = datetime.fromisoformat(login_time_str) + session_duration = (datetime.now() - login_time).total_seconds() / 60 # minutes + except Exception as e: + logger_handler.logger.debug(f"Could not parse login_time for session duration: {e}") + + # Log user logout + if user_id and username: + logger_handler.log_user_logout( + user_id=user_id, + username=username, + session_duration=session_duration + ) + + session.clear() + flash('You have been logged out.', 'info') + return redirect(url_for('auth.login')) + +@bp.route('/profile', methods=['GET', 'POST'], endpoint='profile') +@login_required +@log_user_activity('profile_update') +def profile(): + """User profile management with logging""" + try: + user = db.session.get(User, session['user_id']) + + if request.method == 'POST': + form_type = request.form.get('form_type') + + if form_type == 'profile': + # Track changes for logging + old_name = user.full_name + old_email = user.email + + # Update profile information + user.full_name = request.form['full_name'] + user.email = request.form['email'] + + # Check for changes + changes = {} + if old_name != user.full_name: + changes['full_name'] = {'old': old_name, 'new': user.full_name} + if old_email != user.email: + changes['email'] = {'old': old_email, 'new': user.email} + + db.session.commit() + + # Log profile update if there were changes + if changes: + logger_handler.logger.info(f"User profile updated: {user.username} - Changes: {json.dumps(changes)}") + + flash('Profile updated successfully!', 'success') + + elif form_type == 'password': + # Update password + current_password = request.form['current_password'] + new_password = request.form['new_password'] + + if user.check_password(current_password): + user.set_password(new_password) + db.session.commit() + + # Log password change + logger_handler.log_security_event( + event_type="password_change", + description=f"User {user.username} changed password", + severity="MEDIUM" + ) + + flash('Password updated successfully!', 'success') + else: + # Log failed password change attempt + logger_handler.log_security_event( + event_type="password_change_failed", + description=f"Failed password change attempt for user {user.username}", + severity="HIGH" + ) + flash('Current password is incorrect.', 'error') + + return render_template('profile.html', user=user) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('profile_update', e) + flash('Profile update failed. Please try again.', 'error') + return redirect(url_for('dashboard.dashboard')) diff --git a/routes/dashboard.py b/routes/dashboard.py new file mode 100644 index 0000000..07c48b7 --- /dev/null +++ b/routes/dashboard.py @@ -0,0 +1,250 @@ +""" +routes/dashboard.py +=================== +Dashboard and related API routes. + +Routes: /dashboard, /project/<id>/qr-codes, /dashboard/search, + /api/dashboard/stats, /api/dashboard/realtime +""" +from flask import abort, Blueprint, render_template, request, redirect, flash, session, jsonify, url_for +from datetime import datetime, timedelta, date, time + +from extensions import db, logger_handler +from models.attendance import AttendanceData +from models.project import Project +from models.qrcode import QRCode +from models.user import User +from logger_handler import log_user_activity, log_database_operations +from utils.helpers import login_required + +bp = Blueprint('dashboard', __name__) + + + +@bp.route('/dashboard', endpoint='dashboard') +@login_required +def dashboard(): + """Enhanced project-centric dashboard with search filters""" + try: + user = db.session.get(User, session['user_id']) + + # Get search parameters from URL + search_name = request.args.get('search_name', '').strip() + search_status = request.args.get('search_status', '').strip() + + # Build QR codes query with filters + qr_query = QRCode.query + + # Apply name filter if provided + if search_name: + qr_query = qr_query.filter(QRCode.name.ilike(f'%{search_name}%')) + + # Apply status filter if provided + if search_status == 'active': + qr_query = qr_query.filter(QRCode.active_status == True) + elif search_status == 'inactive': + qr_query = qr_query.filter(QRCode.active_status == False) + + # Execute query + qr_codes = qr_query.order_by(QRCode.created_date.desc()).all() + projects = Project.query.order_by(Project.name.asc()).all() + + # Log dashboard access with filter info + filter_info = [] + if search_name: + filter_info.append(f"name contains '{search_name}'") + if search_status: + filter_info.append(f"status is {search_status}") + + log_message = f"User {session['username']} accessed dashboard: {len(qr_codes)} QR codes" + if filter_info: + log_message += f" (filtered: {', '.join(filter_info)})" + + logger_handler.logger.info(log_message) + + return render_template('dashboard.html', + user=user, + qr_codes=qr_codes, + projects=projects, + search_name=search_name, + search_status=search_status) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('dashboard_load', e) + flash('Error loading dashboard. Please try again.', 'error') + return redirect(url_for('auth.login')) + +@bp.route('/project/<int:project_id>/qr-codes', endpoint='project_qr_codes') +@login_required +def project_qr_codes(project_id): + """ + View all QR codes for a specific project with search filters + Allows filtering by name and status within the project + """ + try: + # Get the project + project = db.session.get(Project, project_id) + if project is None: + abort(404) + + # Get search parameters from URL + search_name = request.args.get('search_name', '').strip() + search_status = request.args.get('search_status', '').strip() + + # Build QR codes query with filters for this project only + qr_query = QRCode.query.filter_by(project_id=project_id) + + # Apply name filter if provided + if search_name: + qr_query = qr_query.filter(QRCode.name.ilike(f'%{search_name}%')) + + # Apply status filter if provided + if search_status == 'active': + qr_query = qr_query.filter(QRCode.active_status == True) + elif search_status == 'inactive': + qr_query = qr_query.filter(QRCode.active_status == False) + + # Execute query + qr_codes = qr_query.order_by(QRCode.created_date.desc()).all() + + # Log access with filter info + filter_info = [] + if search_name: + filter_info.append(f"name contains '{search_name}'") + if search_status: + filter_info.append(f"status is {search_status}") + + log_message = f"User {session['username']} viewed project '{project.name}' QR codes: {len(qr_codes)} QR codes" + if filter_info: + log_message += f" (filtered: {', '.join(filter_info)})" + + logger_handler.logger.info(log_message) + + return render_template('project_qr_codes.html', + project=project, + qr_codes=qr_codes, + search_name=search_name, + search_status=search_status) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('project_qr_codes_view', e) + flash('Error loading project QR codes. Please try again.', 'error') + return redirect(url_for('dashboard.dashboard')) + +@bp.route('/dashboard/search', methods=['GET'], endpoint='search_qr_codes') +@login_required +def search_qr_codes(): + """Search QR codes - redirect to dashboard with filters""" + search_name = request.args.get('search_name', '').strip() + search_status = request.args.get('search_status', '').strip() + + # Log search activity + logger_handler.logger.info( + f"User {session['username']} searched QR codes: " + f"name='{search_name}', status='{search_status}'" + ) + + # Redirect to dashboard with search parameters + return redirect(url_for('dashboard.dashboard', search_name=search_name, search_status=search_status)) + +@bp.route('/api/dashboard/stats', endpoint='dashboard_stats_api') +@login_required +def dashboard_stats_api(): + """API endpoint for dashboard statistics""" + try: + # Get current stats + total_qr_codes = QRCode.query.filter_by(active_status=True).count() + + # Today's check-ins + today = datetime.utcnow().date() + today_checkins = AttendanceData.query.filter( + AttendanceData.check_in_date == today + ).count() + + # Active projects + active_projects = Project.query.filter_by(active_status=True).count() + + # Unique locations + unique_locations = db.session.query( + AttendanceData.location_name + ).distinct().count() + + # Calculate trends (compared to last month) + last_month = datetime.utcnow() - timedelta(days=30) + + # QR codes trend + old_qr_count = QRCode.query.filter( + QRCode.created_date <= last_month, + QRCode.active_status == True + ).count() + qr_change = ((total_qr_codes - old_qr_count) / max(old_qr_count, 1)) * 100 + + # Check-ins trend (yesterday) + yesterday = today - timedelta(days=1) + yesterday_checkins = AttendanceData.query.filter( + AttendanceData.check_in_date == yesterday + ).count() + checkin_change = ((today_checkins - yesterday_checkins) / max(yesterday_checkins, 1)) * 100 + + return jsonify({ + 'success': True, + 'total_qr_codes': total_qr_codes, + 'today_checkins': today_checkins, + 'active_projects': active_projects, + 'unique_locations': unique_locations, + 'qr_change': round(qr_change, 1), + 'checkin_change': round(checkin_change, 1), + 'project_change': 0, # You can calculate this based on your needs + 'location_change': 0 # You can calculate this based on your needs + }) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('dashboard_stats_api', e) + return jsonify({ + 'success': False, + 'error': 'Failed to fetch dashboard statistics' + }), 500 + +@bp.route('/api/dashboard/realtime', endpoint='dashboard_realtime_api') +@login_required +def dashboard_realtime_api(): + """API endpoint for real-time dashboard data""" + try: + # Get recent activity (last 10 check-ins) + recent_activity = db.session.query( + AttendanceData.employee_id, + AttendanceData.location_name, + AttendanceData.check_in_time, + AttendanceData.check_in_date + ).order_by( + AttendanceData.check_in_date.desc(), + AttendanceData.check_in_time.desc() + ).limit(10).all() + + activity_data = [ + { + 'employee_id': activity.employee_id, + 'location': activity.location_name, + 'time': activity.check_in_time.strftime('%H:%M'), + 'date': activity.check_in_date.strftime('%Y-%m-%d') + } + for activity in recent_activity + ] + + return jsonify({ + 'success': True, + 'recent_activity': activity_data + }) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('dashboard_realtime_api', e) + return jsonify({ + 'success': False, + 'error': 'Failed to fetch real-time data' + }), 500 + +# USER MANAGEMENT ROUTES \ No newline at end of file diff --git a/routes/employees.py b/routes/employees.py new file mode 100644 index 0000000..ec1375c --- /dev/null +++ b/routes/employees.py @@ -0,0 +1,379 @@ +""" +routes/employees.py +=================== +Employee CRUD and search routes. + +Routes: /employees, /employees/create, /employees/<id>/edit, + /employees/<id>/delete, /api/employees/search, /employees/<id> +""" +from flask import abort, Blueprint, render_template, request, redirect, flash, session, jsonify, url_for +from datetime import datetime, date + +from extensions import db, logger_handler +from models.attendance import AttendanceData +from models.employee import Employee +from models.project import Project +from models.qrcode import QRCode +from models.user import User +from logger_handler import log_user_activity, log_database_operations +from utils.helpers import ( + admin_required, + has_admin_privileges, + has_staff_level_access, + login_required, + staff_or_admin_required) + +bp = Blueprint('employees', __name__) + + + +@bp.route('/employees', endpoint='employees') +@login_required +def employees(): + """Display employee management page with search and pagination""" + try: + logger_handler.logger.info(f"User {session['username']} accessed employee management list") + + # Get search parameters + search = request.args.get('search', '').strip() + page = request.args.get('page', 1, type=int) + per_page = 20 # Number of employees per page + + # Build query based on search + query = Employee.query.outerjoin(Project, Employee.contractId == Project.id) + + if search: + search_pattern = f"%{search}%" + query = query.filter( + db.or_( + Employee.firstName.like(search_pattern), + Employee.lastName.like(search_pattern), + Employee.title.like(search_pattern), + Employee.id.like(search_pattern) + ) + ) + + # Order by first name, then last name + query = query.order_by(Employee.firstName, Employee.lastName) + + # Paginate results + employees = query.paginate( + page=page, + per_page=per_page, + error_out=False + ) + + # Get summary statistics + total_employees = Employee.query.count() + employees_with_title = Employee.query.filter(Employee.title.isnot(None)).filter(Employee.title != '').count() + unique_titles = db.session.query(Employee.title).filter(Employee.title.isnot(None)).filter(Employee.title != '').distinct().count() + + stats = { + 'total_employees': total_employees, + 'employees_with_title': employees_with_title, + 'unique_titles': unique_titles, + 'search_results': employees.total if search else total_employees + } + + return render_template('employees.html', + employees=employees, + search=search, + stats=stats) + + except Exception as e: + logger_handler.log_database_error('employee_list', e) + flash('Error loading employee list. Please try again.', 'error') + return redirect(url_for('dashboard.dashboard')) + +@bp.route('/employees/create', methods=['GET', 'POST'], endpoint='create_employee') +@login_required +@log_database_operations('employee_creation') +def create_employee(): + """Create new employee (Admin only)""" + if request.method == 'POST': + try: + # Get form data + employee_id = request.form['employee_id'].strip() + first_name = request.form['first_name'].strip() + last_name = request.form['last_name'].strip() + title = request.form.get('title', '').strip() + contract_id = request.form.get('contract_id', '1').strip() + + # Validate required fields + if not all([employee_id, first_name, last_name, contract_id]): + flash('Employee ID, First Name, Last Name, and Project are required.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + return render_template('create_employee.html', projects=projects) + + # Validate employee ID is numeric + try: + employee_id_int = int(employee_id) + contract_id_int = int(contract_id) + except ValueError: + flash('Employee ID must be numeric and Project must be selected.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + return render_template('create_employee.html', projects=projects) + + # Check if employee ID already exists + existing_employee = Employee.query.filter_by(id=employee_id_int).first() + if existing_employee: + flash(f'Employee with ID {employee_id} already exists.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + return render_template('create_employee.html', projects=projects) + + # Create new employee + new_employee = Employee( + id=employee_id_int, + firstName=first_name, + lastName=last_name, + title=title if title else None, + contractId=contract_id_int + ) + + db.session.add(new_employee) + db.session.commit() + + # Log employee creation with project info + project = db.session.get(Project, contract_id_int) + project_name = project.name if project else f"Project {contract_id_int}" + logger_handler.logger.info( + f"User {session['username']} created new employee: " + f"{employee_id_int} - {first_name} {last_name} assigned to {project_name}" + ) + + flash(f'Employee "{first_name} {last_name}" (ID: {employee_id}) created successfully.', 'success') + return redirect(url_for('employees.employees')) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('employee_creation', e) + flash('Failed to create employee. Please try again.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + return render_template('create_employee.html', projects=projects) + + # GET request - load the form with projects + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + return render_template('create_employee.html', projects=projects) + +@bp.route('/employees/<int:employee_index>/edit', methods=['GET', 'POST'], endpoint='edit_employee') +@login_required +@log_database_operations('employee_update') +def edit_employee(employee_index): + """Edit existing employee (Admin only)""" + try: + # Get employee by index (primary key) + employee = db.session.get(Employee, employee_index) + if employee is None: + abort(404) + + if request.method == 'POST': + # Get form data + employee_id = request.form['employee_id'].strip() + first_name = request.form['first_name'].strip() + last_name = request.form['last_name'].strip() + title = request.form.get('title', '').strip() + contract_id = request.form.get('contract_id', '1').strip() + + # Validate required fields + if not all([employee_id, first_name, last_name, contract_id]): + flash('Employee ID, First Name, Last Name, and Project are required.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + return render_template('edit_employee.html', employee=employee, projects=projects) + + # Validate numeric fields + try: + employee_id_int = int(employee_id) + contract_id_int = int(contract_id) + except ValueError: + flash('Employee ID must be numeric and Project must be selected.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + return render_template('edit_employee.html', employee=employee, projects=projects) + + # Check if employee ID already exists (but not for this employee) + existing_employee = Employee.query.filter_by(id=employee_id_int).first() + if existing_employee and existing_employee.index != employee.index: + flash(f'Employee with ID {employee_id} already exists.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + return render_template('edit_employee.html', employee=employee, projects=projects) + + # Store original values for logging + original_data = { + 'id': employee.id, + 'firstName': employee.firstName, + 'lastName': employee.lastName, + 'title': employee.title, + 'contractId': employee.contractId + } + + # Update employee data + employee.id = employee_id_int + employee.firstName = first_name + employee.lastName = last_name + employee.title = title if title else None + employee.contractId = contract_id_int + + db.session.commit() + + # Log employee update with project info + project = db.session.get(Project, contract_id_int) + project_name = project.name if project else f"Project {contract_id_int}" + logger_handler.logger.info( + f"User {session['username']} updated employee: " + f"{employee_index} - {first_name} {last_name} assigned to {project_name}" + ) + + flash(f'Employee "{first_name} {last_name}" updated successfully.', 'success') + return redirect(url_for('employees.employees')) + + # GET request - load the form with projects + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + return render_template('edit_employee.html', employee=employee, projects=projects) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('employee_update', e) + flash('Error updating employee. Please try again.', 'error') + return redirect(url_for('employees.employees')) + +@bp.route('/employees/<int:employee_index>/delete', methods=['POST'], endpoint='delete_employee') +@login_required +@log_database_operations('employee_deletion') +def delete_employee(employee_index): + """Delete employee (Admin only)""" + try: + logger_handler.logger.info( + f"User {session.get('username', 'Unknown')} initiated delete for employee index {employee_index}" + ) + + # Get employee by index (primary key) + employee = db.session.get(Employee, employee_index) + if employee is None: + abort(404) + + # Store employee data for logging before deletion + employee_data = { + 'index': employee.index, + 'id': employee.id, + 'firstName': employee.firstName, + 'lastName': employee.lastName, + 'title': employee.title, + 'contractId': employee.contractId + } + + # Check if employee has attendance records + attendance_count = AttendanceData.query.filter_by(employee_id=str(employee.id)).count() + + if attendance_count > 0: + error_msg = ( + f'Cannot delete employee "{employee.full_name}". ' + f'Employee has {attendance_count} attendance records. ' + f'Please contact system administrator.' + ) + logger_handler.logger.warning( + f"Deletion blocked for employee {employee_data['id']} " + f"({employee_data['firstName']} {employee_data['lastName']}): " + f"{attendance_count} attendance records exist" + ) + flash(error_msg, 'error') + return redirect(url_for('employees.employees')) + + db.session.delete(employee) + db.session.commit() + + logger_handler.logger.info( + f"User {session['username']} deleted employee: " + f"{employee_data['firstName']} {employee_data['lastName']} (ID: {employee_data['id']})" + ) + + flash( + f'Employee "{employee_data["firstName"]} {employee_data["lastName"]}" deleted successfully.', + 'success' + ) + return redirect(url_for('employees.employees')) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('employee_deletion', e) + flash('Error deleting employee. Please try again.', 'error') + return redirect(url_for('employees.employees')) + +@bp.route('/api/employees/search', endpoint='api_employees_search') +@login_required +def api_employees_search(): + """API endpoint for employee search (for AJAX)""" + try: + search = request.args.get('q', '').strip() + limit = request.args.get('limit', 10, type=int) + + if not search: + return jsonify({'employees': []}) + + employees = Employee.search_employees(search)[:limit] + + result = { + 'employees': [emp.to_dict() for emp in employees] + } + + return jsonify(result) + + except Exception as e: + logger_handler.log_database_error('employee_search_api', e) + return jsonify({'error': 'Search failed'}), 500 + +@bp.route('/employees/<int:employee_index>', endpoint='employee_detail') +@login_required +def employee_detail(employee_index): + """View employee details with attendance summary""" + try: + # Get employee by index (primary key) + employee = Employee.query.outerjoin(Project, Employee.contractId == Project.id).filter(Employee.index == employee_index).first_or_404() + + # Get attendance statistics for this employee + + # Total attendance records + total_attendance = AttendanceData.query.filter_by(employee_id=str(employee.id)).count() + + # Recent attendance (last 30 days) + from datetime import datetime, timedelta + thirty_days_ago = datetime.now() - timedelta(days=30) + recent_attendance = AttendanceData.query.filter( + AttendanceData.employee_id == str(employee.id), + AttendanceData.check_in_date >= thirty_days_ago.date() + ).count() + + # Most recent attendance record + latest_attendance = AttendanceData.query.filter_by(employee_id=str(employee.id)).order_by( + AttendanceData.check_in_date.desc(), + AttendanceData.check_in_time.desc() + ).first() + + # Get unique projects this employee has attended + unique_projects = db.session.query(Project).join( + QRCode, Project.id == QRCode.project_id + ).join( + AttendanceData, QRCode.id == AttendanceData.qr_code_id + ).filter( + AttendanceData.employee_id == str(employee.id) + ).distinct().all() + + attendance_stats = { + 'total_attendance': total_attendance, + 'recent_attendance': recent_attendance, + 'latest_attendance': latest_attendance, + 'unique_projects': len(unique_projects), + 'projects': unique_projects + } + + # Log employee detail view + logger_handler.logger.info( + f"User {session['username']} viewed employee detail: {employee.full_name} (ID: {employee.id})" + ) + + return render_template('employee_detail.html', + employee=employee, + attendance_stats=attendance_stats) + + except Exception as e: + logger_handler.log_database_error('employee_detail', e) + flash('Error loading employee details. Please try again.', 'error') + return redirect(url_for('employees.employees')) diff --git a/routes/legacy_attendance.py b/routes/legacy_attendance.py new file mode 100644 index 0000000..0e460a3 --- /dev/null +++ b/routes/legacy_attendance.py @@ -0,0 +1,129 @@ +""" +routes/legacy_attendance.py +============================ +"Legacy Attendance" — same look/feel as Time Attendance (dashboard, +records list, Excel export) but sourced LIVE from the old remote MySQL +server (contract / employee / locations / records tables) instead of +Excel imports. Read-only: nothing is written to the remote server, and +nothing is copied into the local database. + +Routes: /legacy-attendance, /legacy-attendance/records, + /legacy-attendance/export +""" +from flask import Blueprint, render_template, request, redirect, flash, send_file, url_for +from datetime import datetime + +from extensions import logger_handler +from logger_handler import log_user_activity +from utils.helpers import login_required +from legacy_attendance_service import ( + LegacyDbUnavailable, + get_legacy_dashboard_stats, + get_legacy_unique_locations, + get_legacy_records, + get_legacy_records_for_export, + build_legacy_export_workbook, +) + +bp = Blueprint('legacy_attendance', __name__) + +# Fixed dropdown values — confirmed values stored in the legacy `records.type` column +LEGACY_RECORD_TYPES = ['CHECK IN', 'CHECK OUT'] + + +def _filters_from_request(): + return { + 'employee_search': request.args.get('employee_search', ''), + 'location': request.args.get('location', ''), + 'record_type': request.args.get('record_type', ''), + 'start_date': request.args.get('start_date', ''), + 'end_date': request.args.get('end_date', ''), + } + + +@bp.route('/legacy-attendance', endpoint='legacy_attendance_dashboard') +@login_required +@log_user_activity('legacy_attendance_view') +def legacy_attendance_dashboard(): + """Display legacy attendance dashboard with summary stats.""" + stats = { + 'total_records': 0, + 'unique_employees': 0, + 'unique_locations': 0, + 'earliest_record': None, + 'latest_record': None, + } + try: + stats = get_legacy_dashboard_stats() + except LegacyDbUnavailable as e: + flash(str(e), 'error') + except Exception as e: + logger_handler.logger.error(f"Error loading legacy attendance dashboard: {e}") + flash('Error loading legacy attendance dashboard. The legacy database may be unreachable.', 'error') + + return render_template('legacy_attendance_dashboard.html', stats=stats) + + +@bp.route('/legacy-attendance/records', endpoint='legacy_attendance_records') +@login_required +@log_user_activity('legacy_attendance_records_view') +def legacy_attendance_records(): + """Display legacy attendance records with filtering + pagination.""" + filters = _filters_from_request() + page = request.args.get('page', 1, type=int) + per_page = 50 + + records = None + unique_locations = [] + try: + unique_locations = get_legacy_unique_locations() + records = get_legacy_records(filters, page=page, per_page=per_page) + except LegacyDbUnavailable as e: + flash(str(e), 'error') + return redirect(url_for('legacy_attendance.legacy_attendance_dashboard')) + except Exception as e: + logger_handler.logger.error(f"Error loading legacy attendance records: {e}") + flash('Error loading legacy attendance records. The legacy database may be unreachable.', 'error') + return redirect(url_for('legacy_attendance.legacy_attendance_dashboard')) + + return render_template( + 'legacy_attendance_records.html', + records=records, + unique_locations=unique_locations, + record_types=LEGACY_RECORD_TYPES, + filters=filters, + ) + + +@bp.route('/legacy-attendance/export', endpoint='export_legacy_attendance') +@login_required +@log_user_activity('legacy_attendance_export') +def export_legacy_attendance(): + """Export the currently filtered legacy attendance records to Excel.""" + filters = _filters_from_request() + + try: + rows = get_legacy_records_for_export(filters) + except LegacyDbUnavailable as e: + flash(str(e), 'error') + return redirect(url_for('legacy_attendance.legacy_attendance_records', **filters)) + except Exception as e: + logger_handler.logger.error(f"Error exporting legacy attendance records: {e}") + flash('Error generating export file. Please try again.', 'error') + return redirect(url_for('legacy_attendance.legacy_attendance_records', **filters)) + + if not rows: + flash('No legacy records found to export.', 'warning') + return redirect(url_for('legacy_attendance.legacy_attendance_records', **filters)) + + logger_handler.logger.info(f"Exported {len(rows)} legacy attendance records") + + buffer = build_legacy_export_workbook(rows) + filename = f"legacy_attendance_{datetime.now().strftime('%m%d%Y_%H%M%S')}.xlsx" + + return send_file( + buffer, + as_attachment=True, + download_name=filename, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) diff --git a/routes/projects.py b/routes/projects.py new file mode 100644 index 0000000..d974b52 --- /dev/null +++ b/routes/projects.py @@ -0,0 +1,170 @@ +""" +routes/projects.py +================== +Project CRUD and related API routes. + +Routes: /projects, /projects/create, /projects/<id>/edit, + /projects/<id>/toggle, /api/projects/active +""" +from flask import abort, Blueprint, render_template, request, redirect, flash, session, jsonify, url_for +import json +from datetime import datetime + +from extensions import db, logger_handler +from models.project import Project +from models.user import User +from logger_handler import log_user_activity, log_database_operations +from utils.helpers import admin_required, login_required, staff_or_admin_required + +bp = Blueprint('projects', __name__) + + + +@bp.route('/projects', endpoint='projects') +@admin_required +def projects(): + """Display all projects""" + try: + projects = Project.query.order_by(Project.created_date.desc()).all() + return render_template('projects.html', projects=projects) + except Exception as e: + logger_handler.log_database_error('projects_list', e) + flash('Error loading projects list.', 'error') + return redirect(url_for('dashboard.dashboard')) + +@bp.route('/projects/create', methods=['GET', 'POST'], endpoint='create_project') +@admin_required +@log_database_operations('project_creation') +def create_project(): + """Create new project""" + if request.method == 'POST': + try: + name = request.form['name'] + description = request.form.get('description', '') + + # Check if project name already exists + if Project.query.filter_by(name=name).first(): + flash('Project name already exists.', 'error') + return render_template('create_project.html') + + # Create new project + new_project = Project( + name=name, + description=description, + created_by=session['user_id'] + ) + + db.session.add(new_project) + db.session.commit() + + # Log project creation + logger_handler.logger.info(f"User {session['username']} created new project: {name}") + + flash(f'Project "{name}" created successfully.', 'success') + return redirect(url_for('projects.projects')) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('project_creation', e) + flash('Project creation failed. Please try again.', 'error') + + return render_template('create_project.html') + +@bp.route('/projects/<int:project_id>/edit', methods=['GET', 'POST'], endpoint='edit_project') +@admin_required +@log_database_operations('project_edit') +def edit_project(project_id): + """Edit existing project""" + try: + project = db.session.get(Project, project_id) + if project is None: + abort(404) + + if request.method == 'POST': + old_name = project.name + old_description = project.description + + project.name = request.form['name'] + project.description = request.form.get('description', '') + + db.session.commit() + + # Log project update + changes = {} + if old_name != project.name: + changes['name'] = {'old': old_name, 'new': project.name} + if old_description != project.description: + changes['description'] = {'old': old_description, 'new': project.description} + + if changes: + logger_handler.logger.info(f"User {session['username']} updated project {project_id}: {json.dumps(changes)}") + + flash(f'Project "{project.name}" updated successfully.', 'success') + return redirect(url_for('projects.projects')) + + return render_template('edit_project.html', project=project) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('project_edit', e) + flash('Project update failed. Please try again.', 'error') + return redirect(url_for('projects.projects')) + +@bp.route('/projects/<int:project_id>/toggle', methods=['POST'], endpoint='toggle_project') +@admin_required +@log_database_operations('project_toggle') +def toggle_project(project_id): + """Toggle project active status""" + try: + project = db.session.get(Project, project_id) + if project is None: + abort(404) + old_status = project.active_status + project.active_status = not project.active_status + + db.session.commit() + + # Log status change + status = "activated" if project.active_status else "deactivated" + logger_handler.logger.info(f"User {session['username']} {status} project: {project.name}") + + flash(f'Project "{project.name}" {status} successfully.', 'success') + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('project_toggle', e) + flash('Failed to update project status.', 'error') + + return redirect(url_for('projects.projects')) + +# API ENDPOINTS FOR DROPDOWN FUNCTIONALITY +@bp.route('/api/projects/active', endpoint='api_active_projects') +@login_required +def api_active_projects(): + """Get active projects for dropdown""" + try: + projects = Project.query.filter_by(active_status=True).order_by(Project.name.asc()).all() + + projects_data = [ + { + 'id': project.id, + 'name': project.name, + 'description': project.description, + 'qr_count': project.qr_count + } + for project in projects + ] + + return jsonify({ + 'success': True, + 'projects': projects_data + }) + + except Exception as e: + logger_handler.log_database_error('api_active_projects', e) + return jsonify({ + 'success': False, + 'error': 'Failed to fetch projects' + }), 500 + +# QR CODE MANAGEMENT ROUTES \ No newline at end of file diff --git a/routes/qr_codes.py b/routes/qr_codes.py new file mode 100644 index 0000000..3b3de21 --- /dev/null +++ b/routes/qr_codes.py @@ -0,0 +1,1440 @@ +""" +routes/qr_codes.py +================== +QR code management and destination handler routes. + +Routes: /qr-codes/create, /qr-codes/bulk-import, /qr-codes/<id>/*, + /qr/<string:qr_url> +""" +from flask import abort, Blueprint, render_template, request, redirect, flash, session, jsonify, send_file, current_app, url_for +from datetime import datetime, date, timedelta, time +import io, os, base64, re, uuid, json, traceback + +from extensions import db, logger_handler +from models.attendance import AttendanceData +from models.employee import Employee +from models.project import Project +from models.qrcode import QRCode, QRCodeStyle, QRCodeLocation # ADDED: QRCodeLocation for dynamic QR +from models.user import User +from werkzeug.utils import secure_filename +from logger_handler import log_user_activity, log_database_operations +from sqlalchemy import or_ +from utils.helpers import ( + admin_required, + employee_id_regex_condition, + expand_employee_id_filter, + detect_device_info, + generate_default_qr_code, + generate_qr_code, + generate_qr_url, + get_client_ip, + get_employee_checkin_history, + get_qr_styling, + login_required, + staff_or_admin_required) +from utils.geocoding import ( + calculate_location_accuracy_enhanced, + get_location_accuracy_level_enhanced, + process_location_data_enhanced, + reverse_geocode_coordinates, + get_coordinates_from_address_enhanced) +from qr_code_import_service import QRCodeImportService +from turnstile_utils import turnstile_utils +from working_hours_calculator import parse_employee_id_for_work_type +import openpyxl + +bp = Blueprint('qr_codes', __name__) + +# Work-type codes accepted from the check-in page's "Type of Work" dropdown. +# The code is appended to the numeric employee ID on save ("1234" -> "1234SP"), +# which is the format WorkingHoursCalculator and the Excel exports already parse. +# PT is accepted for backward compatibility with IDs created before the dropdown. +VALID_CHECKIN_WORK_TYPES = ('SP', 'PW', 'PT', 'C') + +# Bilingual labels for each work type, echoed back to the check-in page so the +# submit button, the success card, and the check-out reminder all name the type +# the same way the dropdown does. Keyed by code; '' is Regular (no code stored). +WORK_TYPE_LABELS = { + '': {'en': 'Regular', 'es': 'Trabajo Regular'}, + 'PW': {'en': 'Periodic Work', 'es': 'Trabajo Periódico'}, + 'SP': {'en': 'Special Project', 'es': 'Proyecto Especial'}, + 'PT': {'en': 'Project Team', 'es': 'Equipo de Proyecto'}, + 'C': {'en': 'Covering', 'es': 'Cobertura'}, +} + + +def _work_type_label(work_type): + """Bilingual label pair for a work-type code ('' / None = Regular).""" + return WORK_TYPE_LABELS.get((work_type or '').upper(), WORK_TYPE_LABELS['']) + + +# --- ADDED: helper — returns distinct (location, location_address) pairs from +# all standard QR codes, used to auto-populate the dynamic QR location list --- +def get_unique_qr_locations(): + """ + Query all unique (location, location_address) pairs from the qr_codes table + (standard QR codes only). Returns a list of dicts: + [{'name': str, 'address': str}, ...] + Sorted alphabetically by name, duplicates removed. + """ + rows = ( + db.session.query(QRCode.location, QRCode.location_address) + .filter( + QRCode.qr_type == 'standard', + QRCode.location.isnot(None), + QRCode.location != '' + ) + .distinct() + .order_by(QRCode.location.asc()) + .all() + ) + seen = set() + result = [] + for loc, addr in rows: + key = loc.strip().lower() + if key not in seen: + seen.add(key) + result.append({'name': loc.strip(), 'address': (addr or '').strip()}) + return result +# --- END ADDED --- + + + +@bp.route('/qr-codes/create', methods=['GET', 'POST'], endpoint='create_qr_code') +@login_required +@log_database_operations('qr_code_creation') +def create_qr_code(): + """Enhanced create QR code with customization options""" + if request.method == 'POST': + try: + # Existing form data + name = request.form['name'] + qr_type = request.form.get('qr_type', 'standard') # read type first + + # For dynamic QR codes, location/address are auto-managed (not user-entered) + if qr_type == 'dynamic': + location = 'Dynamic' # placeholder — selectable locations come from standard QR codes at scan time + location_address = '' # no single fixed address + else: + location = request.form.get('location', '').strip() + location_address = request.form.get('location_address', '').strip() + if not location: + flash('Location Name is required for Standard QR codes.', 'error') + return render_template('create_qr_code.html', + projects=Project.query.filter_by(active_status=True).all(), + styles=QRCodeStyle.query.all()) + if not location_address: + flash('Address is required for Standard QR codes.', 'error') + return render_template('create_qr_code.html', + projects=Project.query.filter_by(active_status=True).all(), + styles=QRCodeStyle.query.all()) + + location_event = request.form.get('location_event', '') + project_id = request.form.get('project_id') + + # Extract coordinates data from form + latitude = request.form.get('latitude') + longitude = request.form.get('longitude') + coordinate_accuracy = request.form.get('coordinate_accuracy', 'geocoded') + + # NEW: QR Code customization data + fill_color = request.form.get('fill_color', '#000000') + back_color = request.form.get('back_color', '#FFFFFF') + box_size = int(request.form.get('box_size', 10)) + border = int(request.form.get('border', 4)) + error_correction = request.form.get('error_correction', 'L') + style_id = request.form.get('style_id') # Pre-defined style + + # Validate colors (basic hex validation) + if not (fill_color.startswith('#') and len(fill_color) == 7): + fill_color = '#000000' + if not (back_color.startswith('#') and len(back_color) == 7): + back_color = '#FFFFFF' + + # Convert coordinates to float if they exist + address_latitude = None + address_longitude = None + has_coordinates = False + + if latitude and longitude: + try: + address_latitude = float(latitude) + address_longitude = float(longitude) + has_coordinates = True + logger_handler.logger.debug(f"Coordinates received: {address_latitude}, {address_longitude}") + except (ValueError, TypeError) as e: + logger_handler.logger.warning(f"Invalid coordinates format: {e}") + address_latitude = None + address_longitude = None + has_coordinates = False + + # Validate project_id if provided + project = None + if project_id: + try: + project_id = int(project_id) + project = db.session.get(Project, project_id) + if not project or not project.active_status: + flash('Selected project is not valid or inactive.', 'error') + return render_template('create_qr_code.html', + projects=Project.query.filter_by(active_status=True).all(), + styles=QRCodeStyle.query.all()) + except (ValueError, TypeError): + flash('Invalid project selection.', 'error') + return render_template('create_qr_code.html', + projects=Project.query.filter_by(active_status=True).all(), + styles=QRCodeStyle.query.all()) + + # Create new QR code record first (without URL and image) + photo_verification_enabled = request.form.get('photo_verification_enabled', '1') != '0' + + new_qr_code = QRCode( + name=name, + location=location, + location_address=location_address, + location_event=location_event, + qr_code_image="", # Will be updated after URL generation + qr_url="", # Will be updated after ID is assigned + created_by=session['user_id'], + project_id=project_id, + address_latitude=address_latitude, + address_longitude=address_longitude, + coordinate_accuracy=coordinate_accuracy if has_coordinates else None, + coordinates_updated_date=datetime.utcnow() if has_coordinates else None, + qr_type=qr_type, # ADDED: store QR type + photo_verification_enabled=photo_verification_enabled, + # NEW: Customization fields (only if columns exist) + **({ + 'fill_color': fill_color, + 'back_color': back_color, + 'box_size': box_size, + 'border': border, + 'error_correction': error_correction, + 'style_id': int(style_id) if style_id and style_id.isdigit() else None + } if hasattr(QRCode, 'fill_color') else {}) + ) + + # Add to session and flush to get the ID + db.session.add(new_qr_code) + db.session.flush() # This assigns the ID without committing + + # Now generate the readable URL using the ID + qr_url = generate_qr_url(name, new_qr_code.id) + + # Generate QR code data with the destination URL and custom styling + qr_data = f"{request.url_root}qr/{qr_url}" + qr_image = generate_qr_code( + data=qr_data, + fill_color=fill_color, + back_color=back_color, + box_size=box_size, + border=border, + error_correction=error_correction + ) + + # Update the QR code with the URL and image + new_qr_code.qr_url = qr_url + new_qr_code.qr_code_image = qr_image + + # Now commit all changes + db.session.commit() + + # Enhanced logging with customization information + 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={ + 'location': location, + 'location_address': location_address, + 'location_event': location_event, + 'has_coordinates': has_coordinates, + 'photo_verification_enabled': photo_verification_enabled, + 'customization': { + 'fill_color': fill_color, + 'back_color': back_color, + 'box_size': box_size, + 'border': border, + 'error_correction': error_correction + } + } + ) + + # Success message with customization 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 "" + style_info = f" with custom styling (Fill: {fill_color}, Background: {back_color})" + + flash(f'QR Code "{name}" created successfully{project_info}{coord_info}{style_info}! URL: {qr_url}', 'success') + return redirect(url_for('dashboard.dashboard')) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('qr_code_creation', e) + flash('QR Code creation failed. Please try again.', 'error') + logger_handler.logger.error(f"QR Code creation error: {e}", exc_info=True) + + # Get active projects and styles for dropdown + projects = Project.query.filter_by(active_status=True).order_by(Project.name.asc()).all() + styles = QRCodeStyle.query.order_by(QRCodeStyle.name.asc()).all() + + return render_template('create_qr_code.html', projects=projects, styles=styles) + +@bp.route('/qr-codes/bulk-import', methods=['GET', 'POST'], endpoint='import_bulk_qr_codes') +@login_required +@log_database_operations('qr_code_bulk_import') +def import_bulk_qr_codes(): + """Bulk import QR codes from Excel file""" + + if request.method == 'GET': + return render_template('bulk_qr_import.html') + + try: + proceed_import = request.form.get('proceed_import') == 'true' + + if proceed_import: + if 'pending_qr_import_file' not in session or 'pending_qr_import_filename' not in session: + flash('Import session expired. Please upload the file again.', 'error') + return redirect(url_for('qr_codes.import_bulk_qr_codes')) + + temp_path = session['pending_qr_import_file'] + filename = session['pending_qr_import_filename'] + + if not os.path.exists(temp_path): + flash('Temporary file not found. Please upload the file again.', 'error') + session.pop('pending_qr_import_file', None) + session.pop('pending_qr_import_filename', None) + return redirect(url_for('qr_codes.import_bulk_qr_codes')) + else: + if 'file' not in request.files: + flash('No file uploaded.', 'error') + return redirect(request.url) + + file = request.files['file'] + if file.filename == '': + flash('No file selected.', 'error') + return redirect(request.url) + + if not file.filename.lower().endswith(('.xlsx', '.xls')): + flash('Please upload an Excel file (.xlsx or .xls).', 'error') + return redirect(request.url) + + filename = secure_filename(file.filename) + temp_path = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/tmp'), + f"temp_qr_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}") + + os.makedirs(os.path.dirname(temp_path), exist_ok=True) + file.save(temp_path) + + session['pending_qr_import_file'] = temp_path + session['pending_qr_import_filename'] = filename + + validate_only = request.form.get('validate_only') == 'true' and not proceed_import + + import_service = QRCodeImportService(db, logger_handler) + + if validate_only: + validation_result = import_service.validate_excel_file(temp_path) + + if validation_result['success']: + flash(f"Validation successful! Found {validation_result['valid_rows']} valid records.", 'success') + else: + flash(f"Validation found errors. Please fix them before importing.", 'error') + + return render_template('bulk_qr_import.html', validation_result=validation_result) + + projects = Project.query.filter_by(active_status=True).all() + project_lookup = {p.name: p.id for p in projects} + + import_result = import_service.import_from_excel( + file_path=temp_path, + created_by=session['user_id'], + generate_qr_code_func=generate_qr_code, + generate_qr_url_func=generate_qr_url, + request_url_root=request.url_root, + project_lookup=project_lookup, + QRCode=QRCode, + Project=Project, + geocode_func=get_coordinates_from_address_enhanced + ) + + if import_result['success']: + logger_handler.logger.info( + f"User {session['username']} successfully imported {import_result['imported_records']} QR codes via bulk import " + f"({import_result.get('geocoded_records', 0)} addresses auto-geocoded)" + ) + + flash(f"Import successful! Imported {import_result['imported_records']} QR codes " + f"out of {import_result['total_rows']} total records.", 'success') + + # Show geocoding info + if import_result.get('geocoded_records', 0) > 0: + flash(f"✓ {import_result['geocoded_records']} addresses were automatically geocoded using Google Maps.", 'info') + + if import_result['failed_records'] > 0: + flash(f"Note: {import_result['failed_records']} records failed to import. " + f"Check the error details below.", 'warning') + else: + flash(f"Import failed: {import_result.get('error', 'Unknown error')}", 'error') + + session.pop('pending_qr_import_file', None) + session.pop('pending_qr_import_filename', None) + + try: + if os.path.exists(temp_path): + os.remove(temp_path) + except Exception as cleanup_error: + logger_handler.logger.warning(f"Failed to cleanup temp file: {cleanup_error}") + + return render_template('bulk_qr_import.html', import_result=import_result) + + except Exception as e: + logger_handler.log_database_error('qr_code_bulk_import', e) + flash(f'Import failed: {str(e)}', 'error') + return redirect(url_for('qr_codes.import_bulk_qr_codes')) + + +@bp.route('/qr-codes/bulk-import/template', endpoint='download_qr_import_template') +@login_required +def download_qr_import_template(): + """Download Excel template for bulk QR code import""" + try: + from openpyxl import Workbook + from openpyxl.styles import Font, Alignment, PatternFill + + wb = Workbook() + ws = wb.active + ws.title = "QR Code Import Template" + + headers = [ + 'QR Code Name', + 'QR Code Location', + 'Project', + 'Location Address', + 'Event', + 'Latitude', + 'Longitude' + ] + + header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid') + header_font = Font(bold=True, color='FFFFFF') + header_alignment = Alignment(horizontal='center', vertical='center') + + for col_num, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_num) + cell.value = header + cell.fill = header_fill + cell.font = header_font + cell.alignment = header_alignment + + example_data = [ + ['HQ-Entrance', 'Main Building', 'Corporate HQ', '123 Main St, Springfield, IL 62701', 'Check IN', 39.781721, -89.650148], + ['HQ-Exit', 'Main Building', 'Corporate HQ', '123 Main St, Springfield, IL 62701', 'Check OUT', 39.781721, -89.650148], + ['Site-A-Gate1', 'Construction Site A', 'Construction Projects', '456 Oak Ave, Chicago, IL 60601', 'Check IN', '', ''] + ] + + for row_num, row_data in enumerate(example_data, 2): + for col_num, value in enumerate(row_data, 1): + ws.cell(row=row_num, column=col_num, value=value) + + column_widths = [20, 20, 20, 40, 15, 15, 15] + for col_num, width in enumerate(column_widths, 1): + ws.column_dimensions[ws.cell(row=1, column=col_num).column_letter].width = width + + excel_buffer = io.BytesIO() + wb.save(excel_buffer) + excel_buffer.seek(0) + + logger_handler.logger.info(f"User {session.get('username', 'unknown')} downloaded QR import template") + + return send_file( + excel_buffer, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name='QR_Code_Import_Template.xlsx' + ) + + except Exception as e: + logger_handler.log_flask_error('qr_import_template_download', str(e)) + flash('Error generating template. Please try again.', 'error') + return redirect(url_for('qr_codes.import_bulk_qr_codes')) + +@bp.route('/qr-codes/<int:qr_id>/edit', methods=['GET', 'POST'], endpoint='edit_qr_code') +@login_required +@log_database_operations('qr_code_edit') +def edit_qr_code(qr_id): + """Enhanced edit QR code with customization support""" + try: + qr_code = db.session.get(QRCode, qr_id) + if qr_code is None: + abort(404) + + if request.method == 'POST': + # Track changes for logging + old_data = { + 'name': qr_code.name, + 'location': qr_code.location, + 'location_address': qr_code.location_address, + 'location_event': qr_code.location_event, + 'project_id': qr_code.project_id, + 'qr_url': qr_code.qr_url, + 'address_latitude': qr_code.address_latitude, + 'address_longitude': qr_code.address_longitude, + 'coordinate_accuracy': qr_code.coordinate_accuracy, + # Track old styling + 'fill_color': getattr(qr_code, 'fill_color', '#000000'), + 'back_color': getattr(qr_code, 'back_color', '#FFFFFF') + } + + # Update QR code fields + # Name is locked after creation — ignore any submitted value to preserve QR URL integrity + new_name = qr_code.name + + # --- ADDED: for dynamic QR codes, location/address are auto-managed --- + new_qr_type = request.form.get('qr_type', 'standard') + qr_code.qr_type = new_qr_type + + if new_qr_type == 'dynamic': + qr_code.location = 'Dynamic' # placeholder — selectable locations come from standard QR codes at scan time + qr_code.location_address = '' # no single fixed address + else: + qr_code.location = request.form.get('location', '').strip() + qr_code.location_address = request.form.get('location_address', '').strip() + # --- END ADDED --- + + qr_code.location_event = request.form.get('location_event', '') + + # Handle coordinates + latitude = request.form.get('address_latitude') + longitude = request.form.get('address_longitude') + coordinate_accuracy = request.form.get('coordinate_accuracy', 'geocoded') + + if latitude and longitude: + try: + qr_code.address_latitude = float(latitude) + qr_code.address_longitude = float(longitude) + qr_code.coordinate_accuracy = coordinate_accuracy + qr_code.coordinates_updated_date = datetime.utcnow() + except (ValueError, TypeError): + pass + elif latitude == '' and longitude == '': + qr_code.address_latitude = None + qr_code.address_longitude = None + qr_code.coordinate_accuracy = None + qr_code.coordinates_updated_date = None + + # Handle project association + new_project_id = request.form.get('project_id') + if new_project_id and new_project_id.strip(): + try: + new_project_id = int(new_project_id) + project = db.session.get(Project, new_project_id) + if project and project.active_status: + qr_code.project_id = new_project_id + else: + flash('Selected project is not valid or inactive.', 'error') + return render_template('edit_qr_code.html', qr_code=qr_code, + projects=Project.query.filter_by(active_status=True).all(), + styles=QRCodeStyle.query.all()) + except (ValueError, TypeError): + flash('Invalid project selection.', 'error') + return render_template('edit_qr_code.html', qr_code=qr_code, + projects=Project.query.filter_by(active_status=True).all(), + styles=QRCodeStyle.query.all()) + else: + qr_code.project_id = None + + # Per-QR photo verification toggle + qr_code.photo_verification_enabled = request.form.get('photo_verification_enabled', '1') != '0' + + # Handle QR code customization (only if columns exist) + fill_color = request.form.get('fill_color', '#000000') + back_color = request.form.get('back_color', '#FFFFFF') + box_size = int(request.form.get('box_size', 10)) + border = int(request.form.get('border', 4)) + error_correction = request.form.get('error_correction', 'L') + style_id = request.form.get('style_id') + + # Update styling fields if they exist + if hasattr(qr_code, 'fill_color'): + qr_code.fill_color = fill_color + qr_code.back_color = back_color + qr_code.box_size = box_size + qr_code.border = border + qr_code.error_correction = error_correction + qr_code.style_id = int(style_id) if style_id and style_id.isdigit() else None + + # Check if QR code needs regeneration + name_changed = old_data['name'] != new_name + styling_changed = (hasattr(qr_code, 'fill_color') and + (old_data['fill_color'] != fill_color or + old_data['back_color'] != back_color)) + + if name_changed: + new_qr_url = generate_qr_url(new_name, qr_code.id) + qr_code.qr_url = new_qr_url + + # Regenerate QR code if name or styling changed + if name_changed or styling_changed: + qr_data = f"{request.url_root}qr/{qr_code.qr_url}" + + # Use new styling if available, otherwise use defaults + styling = get_qr_styling(qr_code) + qr_code.qr_code_image = generate_qr_code( + data=qr_data, + fill_color=styling['fill_color'], + back_color=styling['back_color'], + box_size=styling['box_size'], + border=styling['border'], + error_correction=styling['error_correction'] + ) + + db.session.commit() + + logger_handler.logger.info( + f"QR Code '{qr_code.name}' (ID: {qr_id}) updated by user {session.get('username', 'unknown')} — " + f"photo_verification_enabled={qr_code.photo_verification_enabled}" + ) + + # Success message + flash(f'QR Code "{qr_code.name}" updated successfully!', 'success') + return redirect(url_for('dashboard.dashboard')) + + # GET request - render edit form + projects = Project.query.filter_by(active_status=True).order_by(Project.name.asc()).all() + styles = QRCodeStyle.query.order_by(QRCodeStyle.name.asc()).all() + return render_template('edit_qr_code.html', qr_code=qr_code, + projects=projects, styles=styles) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('qr_code_edit', e) + flash('QR Code update failed. Please try again.', 'error') + return redirect(url_for('dashboard.dashboard')) + +@bp.route('/qr-codes/<int:qr_id>/delete', methods=['GET', 'POST'], endpoint='delete_qr_code') +@admin_required +@log_database_operations('qr_code_deletion') +def delete_qr_code(qr_id): + """Permanently delete QR code (Admin only) - Hard delete - PRESERVING EXACT ROUTE""" + try: + qr_code = db.session.get(QRCode, qr_id) + if qr_code is None: + abort(404) + logger_handler.logger.debug(f"Found QR Code for delete: {qr_code.name} (ID: {qr_id})") + + if request.method == 'POST': + qr_name = qr_code.name + qr_code_id = qr_code.id + logger_handler.logger.info(f"User {session.get('username', 'unknown')} attempting to delete QR code: {qr_name} (ID: {qr_id})") + + # Check if QR exists before delete + before_count = QRCode.query.count() + logger_handler.logger.debug(f"QR count before delete: {before_count}") + + # Log QR code deletion before actual deletion + logger_handler.log_qr_code_deleted( + qr_code_id=qr_code_id, + qr_code_name=qr_name, + deleted_by_user_id=session['user_id'] + ) + + # Delete the QR code + db.session.delete(qr_code) + + + db.session.commit() + + + # Check count after delete + after_count = QRCode.query.count() + logger_handler.logger.debug(f"QR count after delete: {after_count}") + logger_handler.logger.info(f"QR code deleted successfully: {qr_name} (ID: {qr_id}), removed {before_count - after_count} records") + + flash(f'QR code "{qr_name}" has been permanently deleted!', 'success') + return redirect(url_for('dashboard.dashboard')) + + # GET request - show confirmation page + logger_handler.logger.debug(f"Showing delete confirmation page for QR code ID: {qr_id}") + return render_template('confirm_delete_qr.html', qr_code=qr_code) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('qr_code_deletion', e) + logger_handler.logger.error(f"Error in QR code delete route (ID: {qr_id}): {e}", exc_info=True) + flash('Error deleting QR code. Please try again.', 'error') + return redirect(url_for('dashboard.dashboard')) + +@bp.route('/qr/<string:qr_url>', endpoint='qr_destination') +def qr_destination(qr_url): + """QR code destination page where staff check in - PRESERVING EXACT ROUTE""" + try: + # Find QR code by URL + qr_code = QRCode.query.filter_by(qr_url=qr_url, active_status=True).first() + + if not qr_code: + # Log invalid QR code access attempt + logger_handler.log_security_event( + event_type="invalid_qr_access", + description=f"Attempt to access invalid QR code URL: {qr_url}", + severity="MEDIUM" + ) + flash('QR code not found or inactive.', 'error') + return redirect(url_for('auth.index')) + + # Log QR code access + logger_handler.log_qr_code_accessed( + qr_code_id=qr_code.id, + qr_code_name=qr_code.name, + access_method='scan' + ) + + # Load selectable locations for dynamic QR — auto-generated from all + # active standard QR codes' unique (location, location_address) pairs. + locations = [] + if getattr(qr_code, 'qr_type', 'standard') == 'dynamic': + locations = ( + db.session.query(QRCode.location, QRCode.location_address) + .filter( + QRCode.qr_type == 'standard', + QRCode.active_status == True, + QRCode.location.isnot(None), + QRCode.location != '', + QRCode.location != 'Dynamic' + ) + .distinct() + .order_by(QRCode.location.asc()) + .all() + ) + + return render_template('qr_destination.html', qr_code=qr_code, locations=locations) + + except Exception as e: + logger_handler.log_database_error('qr_code_scan', e) + flash('Error processing QR code scan.', 'error') + return redirect(url_for('auth.index')) + +@bp.route('/qr/<string:qr_url>/checkin', methods=['POST'], endpoint='qr_checkin') +def qr_checkin(qr_url): + """ + Enhanced staff check-in with location accuracy calculation + Allows multiple check-ins with minimum interval between them + PRESERVES coordinate-to-address conversion functionality + """ + try: + logger_handler.logger.debug(f"Starting check-in process for QR URL: {qr_url}") + + # Find QR code by URL + qr_code = QRCode.query.filter_by(qr_url=qr_url, active_status=True).first() + + if not qr_code: + logger_handler.logger.warning(f"QR code not found or inactive: {qr_url}") + return jsonify({ + 'success': False, + 'message': 'QR code not found or inactive.' + }), 404 + + logger_handler.logger.debug(f"Found QR code: {qr_code.name} (ID: {qr_code.id}), location: {qr_code.location}") + + # Get and validate employee ID + employee_id = request.form.get('employee_id', '').strip() + + # --- ADDED: type of work selected on the check-in page --- + # The employee enters a numeric ID and picks a work type; the code is + # appended to the ID so the stored value keeps the existing storage + # format ("1234SP") that every calculator and export already parses. + # Empty selection = Regular work — the ID is stored unchanged. + work_type = request.form.get('work_type', '').strip().upper() + if work_type and work_type not in VALID_CHECKIN_WORK_TYPES: + logger_handler.logger.warning( + f"Check-in rejected: invalid work type '{work_type}' for employee {employee_id}" + ) + return jsonify({ + 'success': False, + 'message': 'Invalid type of work selected. / Tipo de trabajo no valido.' + }), 400 + + if employee_id and work_type: + # Strip any code the employee may already have typed so the + # dropdown selection never produces "1234SPSP". + base_employee_id, existing_work_type = parse_employee_id_for_work_type(employee_id) + employee_id = f"{base_employee_id}{work_type}" + if existing_work_type != 'regular' and existing_work_type != work_type: + logger_handler.logger.info( + f"Check-in work type from dropdown ('{work_type}') overrides " + f"typed suffix ('{existing_work_type}') for employee {base_employee_id}" + ) + # --- END ADDED --- + + # --- ADDED: dynamic QR — resolve effective location from the employee's selection --- + selected_location_name = request.form.get('selected_location_name', '').strip() + selected_location_address = request.form.get('selected_location_address', '').strip() + + # Server-side guard: if this is a dynamic QR and no location was submitted, + # reject the check-in so "Dynamic" is never stored as location_name. + if getattr(qr_code, 'qr_type', 'standard') == 'dynamic' and not selected_location_name: + logger_handler.logger.warning( + f"DYNAMIC check-in REJECTED: employee={employee_id}, " + f"qr_id={qr_code.id} — no location selected" + ) + return jsonify({ + 'success': False, + 'message': ( + 'Please select a location before checking in. / ' + 'Por favor seleccione una ubicación antes de registrarse.' + ) + }), 400 + + if getattr(qr_code, 'qr_type', 'standard') == 'dynamic' and selected_location_name: + effective_location_name = selected_location_name + effective_location_address = selected_location_address or '' + + # Look up the matching standard QR code so we can inherit its + # location_event, coordinates, and exact address — this ensures all + # calculations (GPS accuracy, interval messages, success labels) behave + # exactly as if the employee had scanned that standard QR directly. + matching_qr = QRCode.query.filter_by( + location=selected_location_name, + qr_type='standard', + active_status=True + ).first() + + if matching_qr: + # Use the standard QR's address if the selection has none + if not effective_location_address: + effective_location_address = matching_qr.location_address or '' + effective_location_event = matching_qr.location_event or qr_code.location_event or 'Check In' + effective_address_latitude = matching_qr.address_latitude + effective_address_longitude = matching_qr.address_longitude + logger_handler.logger.info( + f"DYNAMIC check-in: employee={employee_id}, " + f"selected='{selected_location_name}', " + f"matched standard QR #{matching_qr.id} '{matching_qr.name}'" + ) + else: + # No matching standard QR — use whatever the dynamic QR has + effective_location_event = qr_code.location_event or 'Check In' + effective_address_latitude = None + effective_address_longitude = None + logger_handler.logger.info( + f"DYNAMIC check-in: employee={employee_id}, " + f"selected='{selected_location_name}', no matching standard QR found" + ) + else: + effective_location_name = qr_code.location + effective_location_address = qr_code.location_address + effective_location_event = qr_code.location_event + effective_address_latitude = qr_code.address_latitude + effective_address_longitude = qr_code.address_longitude + # --- END ADDED --- + + if not employee_id: + return jsonify({ + 'success': False, + 'message': 'Employee ID is required.' + }), 400 + + # Check for recent check-ins with 30-minute interval validation + today = date.today() + current_time = datetime.now() + time_interval = current_app.config.get('TIME_INTERVAL', 30) + the_last_checkin_time = current_time - timedelta(minutes=time_interval) + + # Find the most recent check-in for this employee at this location today + recent_checkin = AttendanceData.query.filter_by( + qr_code_id=qr_code.id, + employee_id=employee_id.upper(), + check_in_date=today + ).order_by(AttendanceData.check_in_time.desc()).first() + + if recent_checkin: + # Convert check_in_time (time) to datetime for comparison + recent_checkin_datetime = datetime.combine(today, recent_checkin.check_in_time) + + # Check if 30 minutes have passed since the last check-in + if recent_checkin_datetime > the_last_checkin_time: + minutes_remaining = time_interval - int((current_time - recent_checkin_datetime).total_seconds() / 60) + logger_handler.logger.info(f"Too soon for another {effective_location_event} for employee {employee_id}: {minutes_remaining} minutes remaining") + + checkin_time_str = recent_checkin.check_in_time.strftime('%H:%M') + return jsonify({ + 'success': False, + 'message': ( + f'You can {effective_location_event} again in {minutes_remaining} minutes. ' + f'Last {effective_location_event} was at {checkin_time_str}. \n' + f'Puedes volver a registrarte en {minutes_remaining} minutos. ' + f'El ultimo registro fue a las {checkin_time_str}.' + ) + }), 400 + else: + logger_handler.logger.debug(f"{time_interval}-minute interval satisfied for employee {employee_id}") + else: + logger_handler.logger.debug(f"First {effective_location_event} today for employee {employee_id}") + + # Process location data with coordinate-to-address conversion + location_data = process_location_data_enhanced(request.form) + + # Get device and network info + user_agent_string = request.headers.get('User-Agent', '') + device_info = detect_device_info(user_agent_string) + client_ip = get_client_ip() + + logger_handler.logger.debug(f"Check-in device: {device_info}, IP: {client_ip}") + + # Create attendance record + logger_handler.logger.debug("Creating attendance record") + + # Flag whether this check-in came from a dynamic QR scan + is_dynamic = getattr(qr_code, 'qr_type', 'standard') == 'dynamic' and bool(selected_location_name) + # Store clean location_name (no suffix) so filtering/export works normally + record_location_name = effective_location_name + # For dynamic QR: store the selected location's address as the QR-side address + record_qr_address = effective_location_address if is_dynamic else None + + attendance = AttendanceData( + qr_code_id=qr_code.id, + employee_id=employee_id.upper(), + check_in_date=today, + check_in_time=datetime.now().time(), + device_info=device_info, + user_agent=user_agent_string, + ip_address=client_ip, + location_name=record_location_name, + qr_address=record_qr_address, + is_dynamic_qr=is_dynamic, + latitude=location_data['latitude'], + longitude=location_data['longitude'], + accuracy=location_data['accuracy'], + altitude=location_data['altitude'], + location_source=location_data['source'], + address=location_data['address'], + status='present', + verification_required=False, # Will be set below if needed + verification_status=None + ) + + logger_handler.logger.debug("Created base attendance record") + + # Calculate location accuracy + logger_handler.logger.debug( + f"Location accuracy check: QR='{qr_code.name}' (ID={qr_code.id}), " + f"lat={location_data['latitude']}, lng={location_data['longitude']}, " + f"source={location_data['source']}" + ) + + location_accuracy = None + + try: + # Check if we have the required data + # CHANGED: use effective_location_address (respects dynamic QR selection) + if not effective_location_address: + logger_handler.logger.warning(f"QR code location_address is empty or None for QR ID: {qr_code.id}") + elif not location_data['address'] and not (location_data['latitude'] and location_data['longitude']): + logger_handler.logger.warning( + f"No check-in address or coordinates available: " + f"address={location_data['address']!r}, " + f"coords={location_data['latitude']}, {location_data['longitude']}" + ) + else: + logger_handler.logger.debug("Required location data available, proceeding with accuracy calculation") + + location_accuracy = calculate_location_accuracy_enhanced( + qr_address=effective_location_address, # CHANGED: dynamic QR uses selected location address + checkin_address=location_data['address'], + checkin_lat=location_data['latitude'], + checkin_lng=location_data['longitude'] + ) + + logger_handler.logger.debug(f"Location accuracy calculation result: {location_accuracy}") + + if location_accuracy is not None: + attendance.location_accuracy = location_accuracy + accuracy_level = get_location_accuracy_level_enhanced(location_accuracy) + logger_handler.logger.debug(f"Location accuracy set: {location_accuracy:.4f} miles ({accuracy_level})") + else: + logger_handler.logger.warning("Could not calculate location accuracy — calculation returned None") + + # CHECK DISTANCE THRESHOLD FOR PHOTO VERIFICATION + logger_handler.logger.debug(f"Photo verification — global: {current_app.config.get('PHOTO_VERIFICATION_ENABLED', True)}, per-QR: {getattr(qr_code, 'photo_verification_enabled', True)}") + requires_verification = False + verification_photo_data = None + + qr_photo_verification = getattr(qr_code, 'photo_verification_enabled', True) + if qr_photo_verification and current_app.config.get('PHOTO_VERIFICATION_ENABLED', True) and location_accuracy is not None and location_accuracy > current_app.config.get('DISTANCE_THRESHOLD_FOR_VERIFICATION', 0.3): + logger_handler.logger.info(f"Distance ({location_accuracy:.3f} mi) exceeds verification threshold for employee {employee_id}") + + # Check if photo was provided + verification_photo_data = request.form.get('verification_photo', None) + + if verification_photo_data: + logger_handler.logger.debug(f"Verification photo provided (size: {len(verification_photo_data)} chars)") + + # Server-side size enforcement — mirrors client-side MAX_SIZE check + # but cannot be bypassed by a malicious client. + max_photo_bytes = current_app.config.get('VERIFICATION_PHOTO_MAX_SIZE', 5 * 1024 * 1024) + if len(verification_photo_data.encode('utf-8')) > max_photo_bytes: + logger_handler.logger.warning( + f"Verification photo rejected — size {len(verification_photo_data)} chars " + f"exceeds limit of {max_photo_bytes} bytes for employee {employee_id}" + ) + return jsonify({ + 'success': False, + 'message': 'Photo is too large. Please use a smaller image and try again.', + 'requires_verification': True + }), 413 + + # Validate photo data format (basic validation) + if verification_photo_data.startswith('data:image/'): + attendance.verification_photo = verification_photo_data + attendance.verification_required = True + attendance.verification_status = 'pending' + attendance.verification_timestamp = datetime.now() + logger_handler.logger.info(f"Photo verification set to PENDING for employee {employee_id}") + else: + logger_handler.logger.warning(f"Invalid photo format provided for employee {employee_id}") + return jsonify({ + 'success': False, + 'message': 'Invalid photo format. Please try again.', + 'requires_verification': True + }), 400 + else: + logger_handler.logger.warning(f"Photo verification required but not provided for employee {employee_id}") + return jsonify({ + 'success': False, + 'message': 'Photo verification required. Distance from location is too far.', + 'requires_verification': True, + 'distance': round(location_accuracy, 3), + 'threshold': current_app.config.get('DISTANCE_THRESHOLD_FOR_VERIFICATION', 0.3) + }), 400 + else: + logger_handler.logger.debug(f"Distance within threshold for employee {employee_id} — no verification needed") + + except Exception as e: + logger_handler.logger.error(f"Error in location accuracy calculation: {e}", exc_info=True) + + # ENHANCED DEBUG: Save to database with verification + try: + logger_handler.logger.debug(f"Saving attendance record: employee={attendance.employee_id}, location={attendance.location_name}, accuracy={attendance.location_accuracy}") + + db.session.add(attendance) + db.session.commit() + + # Log verification if required + if attendance.verification_required: + logger_handler.log_photo_verification( + employee_id=attendance.employee_id, + qr_code_id=qr_code.id, + distance=location_accuracy, + status='pending' + ) + + # VERIFICATION: Read back from database + saved_record = db.session.get(AttendanceData, attendance.id) + logger_handler.logger.info(f"Saved attendance record ID: {attendance.id}, db accuracy: {saved_record.location_accuracy}") + + if saved_record.location_accuracy != attendance.location_accuracy: + logger_handler.logger.warning(f"DB accuracy mismatch: object={attendance.location_accuracy}, db={saved_record.location_accuracy}") + + # Add enhanced logging for location accuracy save + if attendance.location_accuracy is not None: + logger_handler.logger.info(f"Location accuracy calculated and saved: {attendance.location_accuracy:.4f} miles for employee {attendance.employee_id}") + else: + logger_handler.logger.warning(f"Location accuracy could not be calculated for employee {attendance.employee_id} at QR {qr_code.name}") + + # Count total check-ins for today for this employee at this location + today_checkin_count = AttendanceData.query.filter_by( + qr_code_id=qr_code.id, + employee_id=employee_id.upper(), + check_in_date=today + ).count() + + checkin_sequence_text = f"{effective_location_event} details" + + except Exception as e: + logger_handler.logger.error(f"Database error saving attendance record: {e}", exc_info=True) + db.session.rollback() + logger_handler.log_database_error('checkin_save', e) + return jsonify({ + 'success': False, + 'message': 'Database error occurred.' + }), 500 + + # Return success response with sequence information + response_data = { + 'success': True, + 'message': f'Check-in successful! {checkin_sequence_text} for today.', + 'data': { + 'employee_id': attendance.employee_id, + # Work type the employee picked — shown on the success card so a + # wrong selection is visible immediately, not at payroll time. + 'work_type': work_type, + 'work_type_label': _work_type_label(work_type), + 'location': effective_location_name, # CHANGED: use selected location name + 'location_event': effective_location_event, # CHANGED: use resolved event + 'event': effective_location_event, # CHANGED: use resolved event + 'check_in_time': attendance.check_in_time.strftime('%I:%M %p'), + 'check_in_date': attendance.check_in_date.strftime('%B %d, %Y'), + 'device_info': attendance.device_info, + 'ip_address': attendance.ip_address, + 'location_accuracy': location_accuracy, + 'checkin_count_today': today_checkin_count, + 'checkin_sequence': checkin_sequence_text + } + } + + if location_data['address']: + response_data['data']['address'] = location_data['address'] + + if location_data['latitude'] and location_data['longitude']: + response_data['data']['coordinates'] = f"{location_data['latitude']:.10f}, {location_data['longitude']:.10f}" + + # Enhanced logging for successful check-in with all details + logger_handler.logger.info( + f"Check-in completed: employee={attendance.employee_id}, " + f"action={qr_code.location_event}, location={attendance.location_name}, " + f"time={attendance.check_in_time.strftime('%H:%M')}, count_today={today_checkin_count}" + ) + + # Log to database for audit trail + logger_handler.logger.info(f"Check-in success - Employee: {attendance.employee_id}, Location: {attendance.location_name}, Time: {attendance.check_in_time}, Action: {qr_code.location_event}") + + return jsonify(response_data), 200 + + except Exception as e: + db.session.rollback() + logger_handler.logger.error(f"Unexpected error in check-in process (QR: {qr_url}): {e}", exc_info=True) + + return jsonify({ + 'success': False, + 'message': 'An unexpected error occurred during check-in.' + }), 500 + +# --- ADDED: API endpoint returning selectable locations for a dynamic QR code --- +@bp.route('/qr/<string:qr_url>/locations', methods=['GET'], endpoint='qr_get_locations') +def qr_get_locations(qr_url): + """ + Return JSON list of active selectable locations for a dynamic QR code. + Used by the scan page to populate the location selector. + """ + try: + qr_code = QRCode.query.filter_by(qr_url=qr_url, active_status=True).first() + if not qr_code: + return jsonify({'success': False, 'message': 'QR code not found or inactive.'}), 404 + + if getattr(qr_code, 'qr_type', 'standard') != 'dynamic': + return jsonify({'success': False, 'message': 'Not a dynamic QR code.'}), 400 + + locations = ( + db.session.query(QRCode.location, QRCode.location_address) + .filter( + QRCode.qr_type == 'standard', + QRCode.active_status == True, + QRCode.location.isnot(None), + QRCode.location != '', + QRCode.location != 'Dynamic' + ) + .distinct() + .order_by(QRCode.location.asc()) + .all() + ) + + logger_handler.logger.info( + f"qr_get_locations: QR '{qr_url}' returned {len(locations)} locations" + ) + + return jsonify({ + 'success': True, + 'locations': [ + { + 'name': loc.location, + 'address': loc.location_address or '' + } + for loc in locations + ] + }), 200 + + except Exception as e: + logger_handler.logger.error( + f"Error fetching locations for QR '{qr_url}': {e}", exc_info=True + ) + return jsonify({'success': False, 'message': 'Server error fetching locations.'}), 500 +# --- END ADDED --- + + +# --- ADDED: work type of the employee's still-open check-in --------------- +# Used by the check-in page on CHECK OUT scans: the page pre-selects the same +# type of work the employee checked IN with, so an SP check-in is not paired +# with a Regular check-out. The employee can still change it. +def _resolve_qr_event(qr_code, selected_location_name=''): + """ + Effective location_event for a scan. + + A dynamic QR inherits the event of the standard QR behind the location the + employee selected — the same resolution qr_checkin() performs. Returns None + when a dynamic QR has no location selected yet, i.e. the event is not known. + """ + if getattr(qr_code, 'qr_type', 'standard') != 'dynamic': + return qr_code.location_event or 'Check In' + + if not selected_location_name: + return None + + matching_qr = QRCode.query.filter_by( + location=selected_location_name, + qr_type='standard', + active_status=True + ).first() + if matching_qr and matching_qr.location_event: + return matching_qr.location_event + return qr_code.location_event or 'Check In' + + +def _resolve_record_event(record): + """ + Effective location_event of a stored attendance record. + + attendance_data does not store the event, so it comes from the record's QR + code — resolved through the selected location for dynamic check-ins. + """ + qr_code = db.session.get(QRCode, record.qr_code_id) if record.qr_code_id else None + if qr_code is None: + return 'Check In' + if getattr(record, 'is_dynamic_qr', False) and record.location_name: + return _resolve_qr_event(qr_code, record.location_name) or 'Check In' + return _resolve_qr_event(qr_code) or 'Check In' + + +def _no_store_json(payload): + """ + JSON response iOS Safari will not cache. + + Safari caches plain GETs aggressively; a stale "no suggestion" answer would + look exactly like the bug the last-work-type endpoint exists to prevent. + """ + response = jsonify(payload) + response.headers['Cache-Control'] = 'no-store, no-cache, max-age=0, must-revalidate' + response.headers['Pragma'] = 'no-cache' + return response, 200 + + +@bp.route('/qr/<string:qr_url>/last-work-type', methods=['GET'], endpoint='qr_last_work_type') +def qr_last_work_type(qr_url): + """ + Return the work type of this employee's most recent OPEN check-in. + + Only answers on a Check Out scan — on a check-in there is nothing to stay + consistent with. A quiet {'work_type': None} is returned whenever there is + no suggestion to make, so the page simply keeps its Regular default. + """ + empty = {'success': True, 'work_type': None} + + try: + qr_code = QRCode.query.filter_by(qr_url=qr_url, active_status=True).first() + if not qr_code: + return _no_store_json(empty) + + employee_id = request.args.get('employee_id', '').strip() + # The check-in page allows digits only; anything else cannot be matched. + if not employee_id.isdigit(): + return _no_store_json(empty) + + selected_location_name = request.args.get('selected_location_name', '').strip() + current_event = _resolve_qr_event(qr_code, selected_location_name) + if current_event != 'Check Out': + return _no_store_json(empty) + + # Look back one day so an overnight shift's morning check-out still + # finds the previous evening's check-in. + since = date.today() - timedelta(days=1) + + # Match every stored spelling of this ID (1234, 1234SP, "1234 PW", ...) + exact_variants, regex_patterns = expand_employee_id_filter([employee_id]) + id_condition = AttendanceData.employee_id.in_(exact_variants) + if regex_patterns: + id_condition = or_( + id_condition, + employee_id_regex_condition(AttendanceData.employee_id, regex_patterns) + ) + + # The employee's latest scan in the window. If it was a check-in, it is + # still open and its work type is what this check-out should carry; if it + # was a check-out, the pair is closed and there is nothing to suggest. + last_record = ( + AttendanceData.query + .filter(id_condition, AttendanceData.check_in_date >= since) + .order_by( + AttendanceData.check_in_date.desc(), + AttendanceData.check_in_time.desc() + ) + .first() + ) + + if last_record is None: + return _no_store_json(empty) + + if _resolve_record_event(last_record) != 'Check In': + logger_handler.logger.debug( + f"last-work-type: employee {employee_id} last scan was a check-out — no suggestion" + ) + return _no_store_json(empty) + + _, work_type = parse_employee_id_for_work_type(str(last_record.employee_id)) + work_type_code = '' if work_type == 'regular' else work_type + + logger_handler.logger.info( + f"last-work-type: employee {employee_id} has an open check-in " + f"as '{work_type_code or 'Regular'}' at '{last_record.location_name}' — " + f"suggesting it for check-out at QR '{qr_url}'" + ) + + return _no_store_json({ + 'success': True, + 'work_type': work_type_code, + 'work_type_label': _work_type_label(work_type_code), + 'checked_in_time': last_record.check_in_time.strftime('%I:%M %p') if last_record.check_in_time else '', + 'checked_in_location': last_record.location_name or '' + }) + + except Exception as e: + # Never block a check-out because the hint could not be computed. + logger_handler.logger.error( + f"Error resolving last work type for QR '{qr_url}': {e}", exc_info=True + ) + return _no_store_json(empty) +# --- END ADDED --- + + +@bp.route('/qr-codes/<int:qr_id>/toggle-status', methods=['POST'], endpoint='toggle_qr_status') +@login_required +def toggle_qr_status(qr_id): + """Toggle QR code active/inactive status""" + try: + qr_code = db.session.get(QRCode, qr_id) + if qr_code is None: + abort(404) + + # Toggle the status + qr_code.active_status = not qr_code.active_status + db.session.commit() + + status_text = "activated" if qr_code.active_status else "deactivated" + flash(f'QR code "{qr_code.name}" has been {status_text} successfully!', 'success') + + return jsonify({ + 'success': True, + 'new_status': qr_code.active_status, + 'status_text': 'Active' if qr_code.active_status else 'Inactive', + 'message': f'QR code {status_text} successfully!' + }) + + except Exception as e: + db.session.rollback() + logger_handler.logger.error(f"Error toggling QR status (ID: {qr_id}): {e}", exc_info=True) + return jsonify({ + 'success': False, + 'message': 'Error updating QR code status. Please try again.' + }), 500 + +@bp.route('/qr-codes/<int:qr_id>/copy-url', methods=['POST'], endpoint='copy_qr_url') +@login_required +def copy_qr_url(qr_id): + """Log QR code URL copy action""" + try: + qr_code = db.session.get(QRCode, qr_id) + if qr_code is None: + abort(404) + + # Log URL copy action + logger_handler.logger.info(f"User {session.get('username', 'unknown')} copied URL for QR code {qr_code.name} (ID: {qr_id})") + + return jsonify({ + 'success': True, + 'message': f'QR code URL copied to clipboard!', + 'url': f"{request.url_root}qr/{qr_code.qr_url}" + }) + + except Exception as e: + logger_handler.logger.error(f"Error copying QR URL for ID {qr_id}: {e}") + return jsonify({ + 'success': False, + 'message': 'Error copying QR code URL.' + }), 500 + +@bp.route('/qr-codes/<int:qr_id>/open-link', methods=['POST'], endpoint='open_qr_link') +@login_required +def open_qr_link(qr_id): + """Log QR code link open action""" + try: + qr_code = db.session.get(QRCode, qr_id) + if qr_code is None: + abort(404) + + # Log link open action + logger_handler.logger.info(f"User {session.get('username', 'unknown')} opened link for QR code {qr_code.name} (ID: {qr_id})") + + return jsonify({ + 'success': True, + 'message': f'Opening QR code link...', + 'url': f"{request.url_root}qr/{qr_code.qr_url}" + }) + + except Exception as e: + logger_handler.logger.error(f"Error opening QR link for ID {qr_id}: {e}") + return jsonify({ + 'success': False, + 'message': 'Error opening QR code link.' + }), 500 + +@bp.route('/qr-codes/<int:qr_id>/activate', methods=['POST'], endpoint='activate_qr_code') +@login_required +def activate_qr_code(qr_id): + """Activate a QR code""" + try: + qr_code = db.session.get(QRCode, qr_id) + if qr_code is None: + abort(404) + qr_code.active_status = True + db.session.commit() + + flash(f'QR code "{qr_code.name}" has been activated successfully!', 'success') + return jsonify({ + 'success': True, + 'new_status': True, + 'status_text': 'Active', + 'message': 'QR code activated successfully!' + }) + + except Exception as e: + db.session.rollback() + logger_handler.logger.error(f"Error activating QR code (ID: {qr_id}): {e}", exc_info=True) + return jsonify({ + 'success': False, + 'message': 'Error activating QR code. Please try again.' + }), 500 + +@bp.route('/qr-codes/<int:qr_id>/deactivate', methods=['POST'], endpoint='deactivate_qr_code') +@login_required +def deactivate_qr_code(qr_id): + """Deactivate a QR code""" + try: + qr_code = db.session.get(QRCode, qr_id) + if qr_code is None: + abort(404) + qr_code.active_status = False + db.session.commit() + + flash(f'QR code "{qr_code.name}" has been deactivated successfully!', 'success') + return jsonify({ + 'success': True, + 'new_status': False, + 'status_text': 'Inactive', + 'message': 'QR code deactivated successfully!' + }) + + except Exception as e: + db.session.rollback() + logger_handler.logger.error(f"Error deactivating QR code (ID: {qr_id}): {e}", exc_info=True) + return jsonify({ + 'success': False, + 'message': 'Error deactivating QR code. Please try again.' + }), 500 \ No newline at end of file diff --git a/routes/statistics.py b/routes/statistics.py new file mode 100644 index 0000000..2b26ee4 --- /dev/null +++ b/routes/statistics.py @@ -0,0 +1,315 @@ +""" +routes/statistics.py +==================== +Statistics dashboard and export routes. + +Routes: /statistics, /api/statistics/export +""" +from flask import Blueprint, render_template, request, redirect, flash, session, jsonify, send_file, make_response, current_app, url_for +from datetime import datetime, date, timedelta +import io, json, traceback + +from extensions import db, logger_handler +from models.employee import Employee +from models.project import Project +from models.user import User +from sqlalchemy import text +from logger_handler import log_user_activity, log_database_operations +from utils.helpers import login_required, staff_or_admin_required + +bp = Blueprint('statistics', __name__) + + + +@bp.route('/statistics', endpoint='qr_statistics') +@login_required +def qr_statistics(): + """QR Code Statistics Dashboard with comprehensive analytics""" + try: + # Log statistics page access + logger_handler.logger.info(f"User {session.get('username', 'unknown')} accessed QR code statistics dashboard") + + # Get filter parameters + date_from = request.args.get('date_from', '') + date_to = request.args.get('date_to', '') + qr_code_filter = request.args.get('qr_code', '') + project_filter = request.args.get('project', '') + + # Build parameterized filter conditions (fixes SQL injection) + conditions = [] + params = {} + + if date_from: + conditions.append("ad.check_in_date >= :date_from") + params["date_from"] = date_from + if date_to: + conditions.append("ad.check_in_date <= :date_to") + params["date_to"] = date_to + if qr_code_filter: + try: + params["qr_code_id"] = int(qr_code_filter) + conditions.append("ad.qr_code_id = :qr_code_id") + except (ValueError, TypeError): + logger_handler.logger.warning(f"Invalid qr_code filter value ignored: {qr_code_filter!r}") + if project_filter: + try: + params["project_id"] = int(project_filter) + conditions.append("qc.project_id = :project_id") + except (ValueError, TypeError): + logger_handler.logger.warning(f"Invalid project filter value ignored: {project_filter!r}") + + # Compose a reusable AND clause (empty string when no filters applied) + filter_clause = (" AND " + " AND ".join(conditions)) if conditions else "" + + # 1. General Statistics + general_stats = db.session.execute(text(""" + SELECT + COUNT(*) as total_scans, + COUNT(DISTINCT ad.employee_id) as unique_users, + COUNT(DISTINCT ad.qr_code_id) as active_qr_codes, + COUNT(DISTINCT DATE(ad.check_in_date)) as active_days, + COUNT(CASE WHEN ad.check_in_date = CURRENT_DATE THEN 1 END) as today_scans, + COUNT(CASE WHEN ad.check_in_date >= DATE_SUB(CURRENT_DATE, INTERVAL 7 DAY) THEN 1 END) as week_scans, + COUNT(CASE WHEN ad.latitude IS NOT NULL AND ad.longitude IS NOT NULL THEN 1 END) as gps_enabled_scans + FROM attendance_data ad + LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id + WHERE 1=1 + """ + filter_clause), params).fetchone() + + # 2. Device Statistics + device_stats = db.session.execute(text(""" + SELECT + CASE + WHEN device_info LIKE '%iPhone%' OR device_info LIKE '%iOS%' THEN 'iOS' + WHEN device_info LIKE '%Android%' THEN 'Android' + WHEN device_info LIKE '%Windows%' THEN 'Windows' + WHEN device_info LIKE '%Mac%' OR device_info LIKE '%macOS%' THEN 'macOS' + WHEN device_info LIKE '%Linux%' THEN 'Linux' + ELSE 'Other' + END as device_type, + COUNT(*) as scan_count, + COUNT(DISTINCT employee_id) as unique_users + FROM attendance_data ad + LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id + WHERE device_info IS NOT NULL + """ + filter_clause + """ + GROUP BY device_type + ORDER BY scan_count DESC + """), params).fetchall() + + # 3. Browser Statistics (from User Agent) + browser_stats = db.session.execute(text(""" + SELECT + CASE + WHEN user_agent LIKE '%Chrome%' AND user_agent NOT LIKE '%Edge%' THEN 'Chrome' + WHEN user_agent LIKE '%Safari%' AND user_agent NOT LIKE '%Chrome%' THEN 'Safari' + WHEN user_agent LIKE '%Firefox%' THEN 'Firefox' + WHEN user_agent LIKE '%Edge%' THEN 'Edge' + WHEN user_agent LIKE '%Opera%' THEN 'Opera' + ELSE 'Other' + END as browser_type, + COUNT(*) as scan_count, + COUNT(DISTINCT employee_id) as unique_users + FROM attendance_data ad + LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id + WHERE user_agent IS NOT NULL + """ + filter_clause + """ + GROUP BY browser_type + ORDER BY scan_count DESC + """), params).fetchall() + + # 4. Location Statistics + location_stats = db.session.execute(text(""" + SELECT + qc.name as qr_name, + qc.location as qr_location, + qc.location_event, + COUNT(*) as total_scans, + COUNT(DISTINCT ad.employee_id) as unique_users, + COUNT(CASE WHEN ad.latitude IS NOT NULL THEN 1 END) as gps_scans, + MIN(ad.check_in_date) as first_scan, + MAX(ad.check_in_date) as last_scan + FROM attendance_data ad + JOIN qr_codes qc ON ad.qr_code_id = qc.id + WHERE 1=1 + """ + filter_clause + """ + GROUP BY qc.id, qc.name, qc.location, qc.location_event + ORDER BY total_scans DESC + """), params).fetchall() + + # 5. IP Address Analysis (Top 3 Most Active) + ip_stats = db.session.execute(text(""" + SELECT + ip_address, + COUNT(*) as scan_count, + COUNT(DISTINCT employee_id) as unique_users, + COUNT(DISTINCT qr_code_id) as qr_codes_used, + MIN(check_in_date) as first_scan, + MAX(check_in_date) as last_scan + FROM attendance_data ad + LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id + WHERE ip_address IS NOT NULL + """ + filter_clause + """ + GROUP BY ip_address + ORDER BY scan_count DESC + LIMIT 3 + """), params).fetchall() + + # 6. Project Statistics (if projects exist) + project_stats = db.session.execute(text(""" + SELECT + p.id, + p.name as project_name, + COUNT(*) as total_scans, + COUNT(DISTINCT ad.employee_id) as unique_users, + COUNT(DISTINCT ad.qr_code_id) as qr_codes_in_project, + AVG(CASE WHEN ad.latitude IS NOT NULL THEN 1.0 ELSE 0.0 END) * 100 as gps_usage_percentage + FROM attendance_data ad + JOIN qr_codes qc ON ad.qr_code_id = qc.id + LEFT JOIN projects p ON qc.project_id = p.id + WHERE p.id IS NOT NULL + """ + filter_clause + """ + GROUP BY p.id, p.name + ORDER BY total_scans DESC + """), params).fetchall() + + # Get dropdown options for filters + qr_codes_list = db.session.execute(text(""" + SELECT DISTINCT qc.id, qc.name, qc.location + FROM qr_codes qc + JOIN attendance_data ad ON qc.id = ad.qr_code_id + WHERE qc.active_status = true + ORDER BY qc.name + """)).fetchall() + + projects_list = db.session.execute(text(""" + SELECT DISTINCT p.id, p.name + FROM projects p + JOIN qr_codes qc ON p.id = qc.project_id + JOIN attendance_data ad ON qc.id = ad.qr_code_id + WHERE p.active_status = true + ORDER BY p.name + """)).fetchall() + + # Log successful statistics generation + logger_handler.logger.info( + f"Generated statistics report for user {session.get('username', 'unknown')} " + f"with {general_stats.total_scans} total scans. Filters applied: " + f"date_from={date_from}, date_to={date_to}, qr_code={qr_code_filter}, project={project_filter}" + ) + + return render_template('statistics.html', + general_stats=general_stats, + device_stats=device_stats, + browser_stats=browser_stats, + location_stats=location_stats, + ip_stats=ip_stats, + project_stats=project_stats, + qr_codes_list=qr_codes_list, + projects_list=projects_list, + date_from=date_from, + date_to=date_to, + qr_code_filter=qr_code_filter, + project_filter=project_filter, + today_date=datetime.now().strftime('%Y-%m-%d')) + + except Exception as e: + db.session.rollback() + # Log the error using the correct method + logger_handler.log_database_error('statistics_page_error', e) + flash('Error loading statistics. Please try again.', 'error') + return redirect(url_for('dashboard.dashboard')) + + +@bp.route('/api/statistics/export', endpoint='export_statistics') +@login_required +def export_statistics(): + """Export statistics data to CSV/Excel""" + try: + # Check permissions + if session.get('role') not in ['admin', 'payroll', 'accounting']: + return jsonify({'error': 'Access denied'}), 403 + + # Log export attempt + logger_handler.logger.info( + f"User {session.get('username', 'unknown')} (role: {session.get('role')}) " + f"attempted to export statistics data in {request.args.get('format', 'csv')} format" + ) + + # Get comprehensive statistics for export + export_data = db.session.execute(text(""" + SELECT + ad.id, + ad.employee_id, + COALESCE(CONCAT(e.firstName, ' ', e.lastName), ad.employee_id) as employee_name, + ad.check_in_date, + ad.check_in_time, + qc.name as qr_code_name, + qc.location as qr_location, + qc.location_event, + p.name as project_name, + ad.device_info, + ad.user_agent, + ad.ip_address, + ad.latitude, + ad.longitude, + ad.address, + ad.location_name, + ad.created_timestamp + FROM attendance_data ad + JOIN qr_codes qc ON ad.qr_code_id = qc.id + LEFT JOIN projects p ON qc.project_id = p.id + LEFT JOIN employee e ON CAST(ad.employee_id AS UNSIGNED) = e.id + ORDER BY ad.created_timestamp DESC + """)).fetchall() + + # Create CSV content + import csv + output = io.StringIO() + writer = csv.writer(output) + + # Write headers + writer.writerow([ + 'ID', 'Employee ID', 'Employee Name', 'Date', 'Time', + 'QR Code', 'QR Location', 'Event', 'Project', 'Device', + 'Browser Info', 'IP Address', 'Latitude', 'Longitude', + 'Address', 'Location Name', 'Timestamp' + ]) + + # Write data + for row in export_data: + writer.writerow([ + row.id, row.employee_id, row.employee_name, + str(row.check_in_date), str(row.check_in_time), + row.qr_code_name, row.qr_location, row.location_event, + row.project_name or 'No Project', row.device_info or 'Unknown', + row.user_agent or 'Unknown', row.ip_address or 'Unknown', + row.latitude or '', row.longitude or '', + row.address or '', row.location_name or '', + str(row.created_timestamp) + ]) + + output.seek(0) + + # Create response with proper file handling + csv_data = output.getvalue() + + # Log successful export + logger_handler.logger.info( + f"User {session.get('username', 'unknown')} successfully exported " + f"{len(export_data)} statistics records" + ) + + # Create response + response = make_response(csv_data) + response.headers["Content-Disposition"] = f"attachment; filename=qr_statistics_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + response.headers["Content-type"] = "text/csv" + + return response + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('statistics_export_error', e) + return jsonify({'error': 'Export failed'}), 500 + +# EMPLOYEE MANAGEMENT ROUTES \ No newline at end of file diff --git a/routes/time_attendance.py b/routes/time_attendance.py new file mode 100644 index 0000000..f5c7268 --- /dev/null +++ b/routes/time_attendance.py @@ -0,0 +1,1694 @@ +""" +routes/time_attendance.py +========================= +Time attendance dashboard, import pipeline, export (Excel / by-building), +and records management routes. + +Routes: /time-attendance, /time-attendance/import/*, + /time-attendance/export*, /time-attendance/records, + /time-attendance/record/<id>, /time-attendance/delete/<id>, + /api/time-attendance/* +""" +from flask import abort, Blueprint, render_template, request, redirect, flash, session, jsonify, send_file, Response, g, current_app, url_for +from datetime import datetime, date, timedelta, time +import io, os, json, re, uuid, traceback +import time as _time + +from extensions import db, logger_handler +from models.employee import Employee +from models.project import Project +from models.qrcode import QRCode +from models.time_attendance import TimeAttendance +from models.user import User +from sqlalchemy import text, or_ +from werkzeug.utils import secure_filename +from logger_handler import log_user_activity, log_database_operations +from utils.helpers import ( + admin_required, + employee_id_regex_condition, + expand_employee_id_filter, + has_admin_privileges, + has_staff_level_access, + login_required, + staff_or_admin_required) +from utils.geocoding import calculate_location_accuracy_enhanced +from working_hours_calculator import WorkingHoursCalculator, round_time_to_quarter_hour, convert_minutes_to_base100, round_base100_hours +from time_attendance_import_service import TimeAttendanceImportService +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side, numbers +from openpyxl.utils import get_column_letter +import openpyxl.cell.cell + +bp = Blueprint('time_attendance', __name__) + + +def build_time_attendance_employee_filter(employee_ids): + """ + Build the employee_id filter condition for time_attendance queries. + + Time attendance IDs come straight from imported Excel files, so a person's + extra-work rows can be spelled several ways ("1234SP", "1234 SP", "SP 1234", + "1234-SP"). Matching only the plain numeric ID hid every one of those rows + from the records list and its exports. + + Two OR'd branches: + 1. exact match on the raw column — index-friendly, covers the known spellings + 2. REGEXP match — covers any separator the source file happened to use + ("1759.PW", "1759-SP", "01759 PT") while still anchoring the number, so + 17590 and 1759.0 never leak into a search for 1759 + + Returns a SQLAlchemy condition. Falls back to a plain IN() on the selected IDs + if expansion produced nothing, so the filter never silently matches everything. + """ + exact_variants, regex_patterns = expand_employee_id_filter(employee_ids) + + if not exact_variants: + return TimeAttendance.employee_id.in_(list(employee_ids or [])) + + if not regex_patterns: + return TimeAttendance.employee_id.in_(exact_variants) + + return or_( + TimeAttendance.employee_id.in_(exact_variants), + employee_id_regex_condition(TimeAttendance.employee_id, regex_patterns) + ) + +from routes.time_attendance_export import ( + calculate_possible_violation, + _overnight_aware_sort_key, + _qtr, + export_time_attendance_excel, + export_time_attendance_by_building_excel, +) + + +@bp.route('/time-attendance', endpoint='time_attendance_dashboard') +@login_required +@log_user_activity('time_attendance_view') +def time_attendance_dashboard(): + """Display time attendance dashboard with table layout""" + try: + # Initialize default values + total_records = 0 + unique_employees = 0 + unique_locations = 0 + recent_imports = [] + recent_records = [] + employees = [] + locations = [] + + # Try to get data from TimeAttendance model if it exists + try: + + # Get summary statistics + total_records = TimeAttendance.query.count() + + if total_records > 0: + unique_employees = db.session.query(TimeAttendance.employee_id).distinct().count() + unique_locations = db.session.query(TimeAttendance.location_name).distinct().count() + + # Get recent records (last 20 records for table display) + recent_records = TimeAttendance.query.order_by( + TimeAttendance.attendance_date.desc(), + TimeAttendance.attendance_time.desc() + ).limit(20).all() + + # Get recent imports (last 10 import batches) + recent_imports = db.session.query( + TimeAttendance.import_batch_id, + TimeAttendance.import_date, + TimeAttendance.import_source, + db.func.count(TimeAttendance.id).label('record_count') + ).filter( + TimeAttendance.import_batch_id.isnot(None) + ).group_by( + TimeAttendance.import_batch_id, + TimeAttendance.import_date, + TimeAttendance.import_source + ).order_by( + TimeAttendance.import_date.desc() + ).limit(10).all() + + # Get filter options + employees = TimeAttendance.get_unique_employees() + locations = TimeAttendance.get_unique_locations() + + except ImportError: + # TimeAttendance model doesn't exist yet - use defaults + pass + except Exception as e: + # Database table doesn't exist yet or other error - use defaults + logger_handler.logger.error(f"TimeAttendance query error: {e}", exc_info=True) + pass + + return render_template('time_attendance_dashboard.html', + total_records=total_records, + unique_employees=unique_employees, + unique_locations=unique_locations, + recent_imports=recent_imports, + recent_records=recent_records, + employees=employees, + locations=locations) + + except Exception as e: + logger_handler.logger.error(f"Error in time attendance dashboard: {e}") + flash('Error loading time attendance dashboard.', 'error') + return redirect(url_for('dashboard.dashboard')) + +@bp.route('/time-attendance/import', methods=['GET', 'POST'], endpoint='import_time_attendance') +@login_required +@log_database_operations('time_attendance_import') +def import_time_attendance(): + """Enhanced import with duplicate review""" + if request.method == 'GET': + # Load active projects for dropdown + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + return render_template('time_attendance_import.html', projects=projects) + + if request.method == 'POST': + try: + # Check if this is coming from invalid review (file is already in session) + coming_from_invalid_review = request.form.get('from_invalid_review', 'false').lower() == 'true' + coming_from_duplicate_review = request.form.get('from_duplicate_review', 'false').lower() == 'true' + + logger_handler.logger.debug( + f"Import flow: coming_from_invalid={coming_from_invalid_review}, " + f"coming_from_duplicate={coming_from_duplicate_review}" + ) + + if coming_from_invalid_review or coming_from_duplicate_review: + # Retrieve file from session + if 'pending_import_file' not in session or 'pending_import_filename' not in session: + flash('Session expired. Please upload the file again.', 'error') + return redirect(url_for('time_attendance.import_time_attendance')) + + temp_path = session['pending_import_file'] + filename = session['pending_import_filename'] + + # Verify file still exists + if not os.path.exists(temp_path): + flash('Temporary file not found. Please upload the file again.', 'error') + session.pop('pending_import_file', None) + session.pop('pending_import_filename', None) + return redirect(url_for('time_attendance.import_time_attendance')) + + logger_handler.logger.debug(f"Retrieved file from session: {filename}, exists={os.path.exists(temp_path)}") + + else: + # Normal file upload flow - now supports multiple files + if 'files' not in request.files: + flash('No files uploaded.', 'error') + return redirect(request.url) + + files = request.files.getlist('files') + if not files or len(files) == 0: + flash('No files selected.', 'error') + return redirect(request.url) + + # Validate all files and save them temporarily + temp_paths = [] + filenames = [] + + for file in files: + if file.filename == '': + continue + + # Validate file extension + if not file.filename.lower().endswith(('.xlsx', '.xls')): + flash(f'Invalid file format: {file.filename}. Please upload only Excel files (.xlsx or .xls).', 'error') + # Clean up already saved files + for saved_path in temp_paths: + if os.path.exists(saved_path): + os.remove(saved_path) + return redirect(request.url) + + # Save uploaded file temporarily + filename = secure_filename(file.filename) + temp_path = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/tmp'), + f"temp_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}") + + os.makedirs(os.path.dirname(temp_path), exist_ok=True) + file.save(temp_path) + + temp_paths.append(temp_path) + filenames.append(filename) + + logger_handler.logger.debug(f"Uploaded file {len(temp_paths)}: {filename} saved to {temp_path}") + + if len(temp_paths) == 0: + flash('No valid files selected.', 'error') + return redirect(request.url) + + # Store file paths in session for duplicate/invalid review + session['pending_import_file'] = temp_paths[0] if len(temp_paths) == 1 else temp_paths + session['pending_import_filename'] = filenames[0] if len(filenames) == 1 else filenames + session['pending_import_files_multiple'] = len(temp_paths) > 1 + + temp_path = temp_paths[0] if len(temp_paths) == 1 else temp_paths + filename = filenames[0] if len(filenames) == 1 else ', '.join(filenames) + + logger_handler.logger.debug(f"Total files uploaded: {len(temp_paths)}") + + # Determine if we're processing multiple files + is_multiple_files = session.get('pending_import_files_multiple', False) + files_to_process = [] + + if is_multiple_files: + # Multiple files mode + if isinstance(temp_path, list): + files_to_process = list(zip(temp_path, filename.split(', ') if isinstance(filename, str) else filename)) + else: + files_to_process = [(temp_path, filename)] + else: + # Single file mode (existing behavior) + files_to_process = [(temp_path, filename)] + + logger_handler.logger.info(f"Processing {len(files_to_process)} file(s) for time attendance import") + + try: + import_service = TimeAttendanceImportService(db, logger_handler) + + # Get import options + skip_duplicates = request.form.get('skip_duplicates', 'true').lower() == 'true' + validate_only = request.form.get('validate_only', 'false').lower() == 'true' + analyze_duplicates = request.form.get('analyze_duplicates', 'false').lower() == 'true' + analyze_invalid = request.form.get('analyze_invalid', 'false').lower() == 'true' + + logger_handler.logger.debug( + f"Import options: skip_duplicates={skip_duplicates}, validate_only={validate_only}, " + f"analyze_duplicates={analyze_duplicates}, analyze_invalid={analyze_invalid}, " + f"coming_from_invalid={coming_from_invalid_review}" + ) + + # Store combined results for multiple files + all_results = { + 'total_files': len(files_to_process), + 'successful_files': 0, + 'failed_files': 0, + 'total_imported': 0, + 'total_duplicates': 0, + 'total_failed': 0, + 'file_results': [], + 'errors': [], + 'warnings': [] + } + + # Process each file + for file_index, (current_temp_path, current_filename) in enumerate(files_to_process, 1): + logger_handler.logger.info(f"Processing file {file_index}/{len(files_to_process)}: {current_filename}") + + import_result = None # Initialize to prevent reference errors + + try: + # For multiple files, skip review screens and import directly + if is_multiple_files: + logger_handler.logger.debug("Batch mode: processing directly without review screens") + + # Validate the file first + validation_result = import_service.validate_excel_file(current_temp_path) + + if not validation_result['valid']: + raise Exception(f"Validation failed: {'; '.join(validation_result['errors'])}") + + # Get import settings + project_id = request.form.get('project_id') + project_id = int(project_id) if project_id and project_id != '' else None + import_source = request.form.get('import_source', f"Batch Import - {current_filename}") + + # Import the file (always skip duplicates in batch mode) + import_result = import_service.import_from_excel( + current_temp_path, + created_by=session['user_id'], + import_source=import_source, + skip_duplicates=True, # Always skip duplicates in batch mode + force_import_hashes=set(), + project_id=project_id + ) + + else: + # Single file - use existing review workflow logic below + # This continues to the existing code after the loop + pass + + # Accumulate results if import was performed + if import_result and import_result.get('success'): + all_results['successful_files'] += 1 + all_results['total_imported'] += import_result.get('imported_records', 0) + all_results['total_duplicates'] += import_result.get('duplicate_records', 0) + all_results['file_results'].append({ + 'filename': current_filename, + 'status': 'success', + 'imported': import_result.get('imported_records', 0), + 'batch_id': import_result.get('batch_id', '') + }) + logger_handler.logger.info(f"Imported {import_result.get('imported_records', 0)} records from {current_filename}") + elif import_result: + # Import ran but failed + all_results['failed_files'] += 1 + all_results['total_failed'] += import_result.get('failed_records', 0) + all_results['errors'].append(f"{current_filename}: Import failed") + all_results['file_results'].append({ + 'filename': current_filename, + 'status': 'failed', + 'error': 'Import returned unsuccessful status' + }) + + except Exception as file_error: + logger_handler.logger.error(f"Error processing file {current_filename}: {file_error}", exc_info=True) + logger_handler.logger.error(f"Error processing file {current_filename}: {file_error}") + all_results['failed_files'] += 1 + all_results['errors'].append(f"{current_filename}: {str(file_error)}") + all_results['file_results'].append({ + 'filename': current_filename, + 'status': 'failed', + 'error': str(file_error) + }) + continue + + finally: + # Cleanup individual file (only for multiple file mode, single file cleanup happens later) + if is_multiple_files and os.path.exists(current_temp_path): + try: + os.remove(current_temp_path) + logger_handler.logger.debug(f"Cleaned up temp file: {current_temp_path}") + except Exception as cleanup_error: + logger_handler.logger.warning(f"Failed to cleanup temp file: {cleanup_error}") + + # After processing all files + if is_multiple_files: + # Log the batch import activity + logger_handler.logger.info( + f"Batch Import: User {session.get('username', 'unknown')} imported time attendance data from {len(files_to_process)} files - " + f"Successful: {all_results['successful_files']}/{all_results['total_files']}, " + f"Total imported: {all_results['total_imported']}, " + f"Duplicates: {all_results['total_duplicates']}" + ) + + # Show combined results + if all_results['successful_files'] > 0: + flash(f"✅ Successfully imported {all_results['total_imported']} records from {all_results['successful_files']}/{all_results['total_files']} files.", 'success') + + if all_results['total_duplicates'] > 0: + flash(f"ℹ️ Skipped {all_results['total_duplicates']} duplicate records across all files.", 'info') + + if all_results['failed_files'] > 0: + flash(f"❌ {all_results['failed_files']} file(s) failed to import.", 'error') + + # Show first few error details + for error in all_results['errors'][:3]: + flash(f"Error: {error}", 'error') + + if len(all_results['errors']) > 3: + flash(f"...and {len(all_results['errors']) - 3} more errors", 'error') + + # Clear session + session.pop('pending_import_file', None) + session.pop('pending_import_filename', None) + session.pop('pending_import_files_multiple', None) + + logger_handler.logger.info( + f"Batch import summary: files={all_results['total_files']}, " + f"successful={all_results['successful_files']}, failed={all_results['failed_files']}, " + f"imported={all_results['total_imported']}, duplicates={all_results['total_duplicates']}" + ) + + return redirect(url_for('time_attendance.time_attendance_dashboard')) + + # Check if this is coming from duplicate review + force_import_hashes = request.form.getlist('force_import_hashes[]') + + # If analyzing for duplicates, show review page (but not if coming from invalid/duplicate review) + if analyze_duplicates and not force_import_hashes and not coming_from_invalid_review and not coming_from_duplicate_review: + logger_handler.logger.debug("Analyzing for duplicates") + project_id_for_analysis = request.form.get('project_id') + project_id_for_analysis = int(project_id_for_analysis) if project_id_for_analysis and project_id_for_analysis != '' else None + duplicate_analysis = import_service.analyze_for_duplicates(temp_path, project_id=project_id_for_analysis) + + # Surface project-mismatch errors immediately + if duplicate_analysis.get('errors'): + for err in duplicate_analysis['errors']: + flash(err, 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + return render_template('time_attendance_import.html', projects=projects) + + if duplicate_analysis['duplicate_records'] > 0: + logger_handler.logger.info(f"Found {duplicate_analysis['duplicate_records']} duplicates") + # Show duplicate review page + return render_template('time_attendance_duplicate_review.html', + analysis=duplicate_analysis, + filename=filename, + project_id=project_id_for_analysis) + else: + logger_handler.logger.debug("No duplicates found") + flash('No duplicates found. Proceeding with import.', 'info') + + # Check for invalid rows and show review if any (but not if coming from invalid review) + if analyze_invalid and not coming_from_invalid_review: + logger_handler.logger.debug("Analyzing for invalid rows") + project_id_for_analysis = request.form.get('project_id') + project_id_for_analysis = int(project_id_for_analysis) if project_id_for_analysis and project_id_for_analysis != '' else None + invalid_analysis = import_service.analyze_for_invalid_rows(temp_path, project_id=project_id_for_analysis) + + # Surface project-mismatch errors immediately + if invalid_analysis.get('errors'): + for err in invalid_analysis['errors']: + flash(err, 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + return render_template('time_attendance_import.html', projects=projects) + + if invalid_analysis['invalid_rows'] > 0: + logger_handler.logger.info(f"Found {invalid_analysis['invalid_rows']} invalid rows") + # Show invalid row review page + return render_template('time_attendance_invalid_review.html', + analysis=invalid_analysis, + filename=filename, + project_id=project_id_for_analysis) + else: + logger_handler.logger.debug("All rows are valid") + flash('All rows are valid. Proceeding with import.', 'info') + + # If coming from invalid review, skip validation (already done) + if not coming_from_invalid_review: + logger_handler.logger.debug("Validating file") + # Validate file + validation_result = import_service.validate_excel_file(temp_path) + + if not validation_result['valid']: + logger_handler.logger.warning(f"Validation failed: {validation_result['errors']}") + flash(f"File validation failed: {'; '.join(validation_result['errors'])}", 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + return render_template('time_attendance_import.html', + projects=projects, + validation_result=validation_result) + + if validation_result['warnings']: + for warning in validation_result['warnings']: + flash(warning, 'warning') + + if validate_only: + logger_handler.logger.info(f"Validation successful: {validation_result['valid_rows']} valid records") + flash(f"File validation successful! Found {validation_result['valid_rows']} valid records.", 'success') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + return render_template('time_attendance_import.html', + projects=projects, + validation_result=validation_result) + else: + logger_handler.logger.debug("Skipping validation (already validated)") + + # Proceed with import + logger_handler.logger.info("Starting import process") + import_source = request.form.get('import_source', f"Manual Import - {filename}") + project_id = request.form.get('project_id') + project_id = int(project_id) if project_id and project_id != '' else None + + import_result = import_service.import_from_excel( + temp_path, + created_by=session['user_id'], + import_source=import_source, + skip_duplicates=skip_duplicates, + force_import_hashes=force_import_hashes, + project_id=project_id + ) + + if import_result['success']: + logger_handler.logger.info( + f"Import successful: batch_id={import_result['batch_id']}, " + f"imported={import_result['imported_records']}/{import_result['total_records']}, " + f"duplicates={import_result['duplicate_records']}, failed={import_result['failed_records']}" + ) + + logger_handler.logger.info( + f"User {session['username']} successfully imported time attendance data - " + f"Batch: {import_result['batch_id']}, " + f"Records: {import_result['imported_records']}/{import_result['total_records']}, " + f"Duplicates: {import_result['duplicate_records']}, " + f"Forced: {import_result['forced_duplicates']}, " + f"Failed: {import_result['failed_records']}" + ) + + flash(f"Import successful! Imported {import_result['imported_records']} records " + f"out of {import_result['total_records']} total records.", 'success') + + if import_result['duplicate_records'] > 0: + flash(f"Skipped {import_result['duplicate_records']} duplicate records.", 'info') + + if import_result['forced_duplicates'] > 0: + flash(f"Imported {import_result['forced_duplicates']} duplicate records as requested.", 'info') + + if import_result['failed_records'] > 0: + flash(f"Note: {import_result['failed_records']} records failed to import. " + f"Check the error details below.", 'warning') + + # Clean up temp file after successful import + if os.path.exists(temp_path): + try: + os.remove(temp_path) + session.pop('pending_import_file', None) + session.pop('pending_import_filename', None) + logger_handler.logger.debug(f"Cleaned up temp file: {temp_path}") + except Exception as cleanup_error: + logger_handler.logger.warning(f"Failed to cleanup temp file: {cleanup_error}") + + return render_template('time_attendance_import_result.html', + import_result=import_result) + else: + logger_handler.logger.error(f"Import failed: {import_result['errors']}") + flash(f"Import failed: {'; '.join(import_result['errors'][:3])}", 'error') + if len(import_result['errors']) > 3: + flash(f"...and {len(import_result['errors']) - 3} more errors", 'warning') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + return render_template('time_attendance_import.html', + projects=projects, + import_result=import_result) + + except Exception as import_error: + logger_handler.logger.error(f"Import exception: {import_error}", exc_info=True) + raise + + except Exception as e: + logger_handler.log_database_error('time_attendance_import', e) + logger_handler.logger.error(f"Top-level import exception: {e}", exc_info=True) + flash('Import failed due to an unexpected error.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + return render_template('time_attendance_import.html', projects=projects) + + # GET request + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + return render_template('time_attendance_import.html', projects=projects) + + +@bp.route('/time-attendance/import/analyze-duplicates', methods=['POST'], endpoint='analyze_import_duplicates') +@login_required +def analyze_import_duplicates(): + """AJAX endpoint to analyze file for duplicates""" + try: + if 'file' not in request.files: + return jsonify({'success': False, 'message': 'No file provided'}), 400 + + file = request.files['file'] + if file.filename == '': + return jsonify({'success': False, 'message': 'No file selected'}), 400 + + if not file.filename.lower().endswith(('.xlsx', '.xls')): + return jsonify({'success': False, 'message': 'Invalid file format'}), 400 + + # Save temporarily + filename = secure_filename(file.filename) + temp_path = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/tmp'), + f"analyze_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}") + + os.makedirs(os.path.dirname(temp_path), exist_ok=True) + file.save(temp_path) + + # Store in session + session['pending_import_file'] = temp_path + session['pending_import_filename'] = filename + + try: + import_service = TimeAttendanceImportService(db, logger_handler) + analysis = import_service.analyze_for_duplicates(temp_path) + + # Convert datetime objects to strings for JSON + for duplicate in analysis.get('duplicates', []): + if 'new_record' in duplicate: + if duplicate['new_record'].get('attendance_date'): + duplicate['new_record']['attendance_date'] = str(duplicate['new_record']['attendance_date']) + if duplicate['new_record'].get('attendance_time'): + duplicate['new_record']['attendance_time'] = str(duplicate['new_record']['attendance_time']) + + if 'existing_record' in duplicate: + if duplicate['existing_record'].get('attendance_date'): + duplicate['existing_record']['attendance_date'] = str(duplicate['existing_record']['attendance_date']) + if duplicate['existing_record'].get('attendance_time'): + duplicate['existing_record']['attendance_time'] = str(duplicate['existing_record']['attendance_time']) + if duplicate['existing_record'].get('import_date'): + duplicate['existing_record']['import_date'] = str(duplicate['existing_record']['import_date']) + + return jsonify({ + 'success': True, + 'analysis': analysis + }) + + except Exception as e: + # Cleanup on error + if os.path.exists(temp_path): + os.remove(temp_path) + raise e + + except Exception as e: + logger_handler.logger.error(f"Duplicate analysis error: {e}") + return jsonify({ + 'success': False, + 'message': f'Analysis failed: {str(e)}' + }), 500 + +@bp.route('/time-attendance/import/analyze-invalid', methods=['POST'], endpoint='analyze_import_invalid') +@login_required +def analyze_import_invalid(): + """AJAX endpoint to analyze file for invalid rows""" + try: + if 'file' not in request.files: + return jsonify({'success': False, 'message': 'No file provided'}), 400 + + file = request.files['file'] + if file.filename == '': + return jsonify({'success': False, 'message': 'No file selected'}), 400 + + if not file.filename.lower().endswith(('.xlsx', '.xls')): + return jsonify({'success': False, 'message': 'Invalid file format'}), 400 + + # Save temporarily + filename = secure_filename(file.filename) + temp_path = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/tmp'), + f"analyze_invalid_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}") + + os.makedirs(os.path.dirname(temp_path), exist_ok=True) + file.save(temp_path) + + # Store in session + session['pending_import_file'] = temp_path + session['pending_import_filename'] = filename + + try: + import_service = TimeAttendanceImportService(db, logger_handler) + analysis = import_service.analyze_for_invalid_rows(temp_path) + + # Convert datetime objects to strings for JSON + for invalid in analysis.get('invalid_details', []): + if 'row_data' in invalid: + if invalid['row_data'].get('attendance_date'): + invalid['row_data']['attendance_date'] = str(invalid['row_data']['attendance_date']) + if invalid['row_data'].get('attendance_time'): + invalid['row_data']['attendance_time'] = str(invalid['row_data']['attendance_time']) + + return jsonify({ + 'success': True, + 'analysis': analysis + }) + + except Exception as e: + logger_handler.logger.error(f"Invalid row analysis error: {e}") + return jsonify({ + 'success': False, + 'message': f'Analysis failed: {str(e)}' + }), 500 + + except Exception as e: + logger_handler.logger.error(f"Invalid row analysis error: {e}") + return jsonify({ + 'success': False, + 'message': f'Analysis failed: {str(e)}' + }), 500 + + +# --------------------------------------------------------------------------- +# Time Attendance Import — SSE progress streaming (disk-based, multi-worker safe) +# +# Design: progress state is written to a small JSON file on disk so that any +# gunicorn worker process can read it. No shared in-memory state is required. +# The /stream endpoint runs the import itself (synchronously inside the SSE +# generator) while writing progress to the file and yielding events to the +# browser — compatible with gunicorn gevent workers. +# --------------------------------------------------------------------------- + +def _progress_file_path(job_id: str, upload_dir: str = '/tmp') -> str: + """Return the path for the on-disk progress file for a given job_id.""" + os.makedirs(upload_dir, exist_ok=True) + return os.path.join(upload_dir, f"import_progress_{job_id}.json") + + +def _write_progress(job_id: str, event: dict, upload_dir: str = '/tmp') -> None: + """Atomically write the latest progress event to disk.""" + path = _progress_file_path(job_id, upload_dir) + try: + tmp = path + '.tmp' + with open(tmp, 'w') as f: + json.dump(event, f) + os.replace(tmp, path) + except Exception: + pass # Best-effort; import will continue regardless + + +@bp.route('/time-attendance/import/start', methods=['POST'], endpoint='start_import_job') +@login_required +def start_import_job(): + """ + Validates the uploaded file, saves it to disk, stores import options in a + progress file, then returns a job_id. The actual import runs inside the + SSE stream endpoint so no background thread or shared memory is needed. + """ + try: + if 'files' not in request.files: + return jsonify({'success': False, 'error': 'No file uploaded.'}), 400 + + files = request.files.getlist('files') + if not files or files[0].filename == '': + return jsonify({'success': False, 'error': 'No file selected.'}), 400 + + file = files[0] + if not file.filename.lower().endswith(('.xlsx', '.xls')): + return jsonify({'success': False, 'error': 'Invalid file format.'}), 400 + + filename = secure_filename(file.filename) + upload_dir = current_app.config.get('UPLOAD_FOLDER', '/tmp') + os.makedirs(upload_dir, exist_ok=True) + job_id = str(uuid.uuid4()) + temp_path = os.path.join(upload_dir, + f"stream_{job_id}_{filename}") + file.save(temp_path) + + # Store import options alongside the file so the stream endpoint can + # read them without depending on session or shared memory. + job_meta = { + 'type': 'pending', + 'temp_path': temp_path, + 'filename': filename, + 'skip_duplicates': request.form.get('skip_duplicates', 'true').lower() == 'true', + 'project_id': int(request.form.get('project_id')) if request.form.get('project_id') else None, + 'import_source': request.form.get('import_source', f"Manual Import - {filename}"), + 'created_by': session['user_id'], + 'username': session.get('username', 'unknown'), + } + _write_progress(job_id, job_meta, upload_dir) + + logger_handler.logger.info( + f"User {job_meta['username']} queued time attendance import job {job_id} for file {filename}" + ) + return jsonify({'success': True, 'job_id': job_id}) + + except Exception as e: + logger_handler.logger.error(f"Error queuing import job: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@bp.route('/time-attendance/import/stream/<job_id>', endpoint='stream_import_progress') +@login_required +def stream_import_progress(job_id): + """ + SSE endpoint — runs the import synchronously while streaming progress to + the browser. Works across multiple gunicorn workers because all state is + stored on disk (no in-memory job store). + """ + # Capture upload_dir HERE in the request context — current_app is NOT + # available inside the background thread (_run) or after context teardown. + upload_dir = current_app.config.get('UPLOAD_FOLDER', '/tmp') + progress_path = _progress_file_path(job_id, upload_dir) + # Capture real app object in request context — safe to use in background thread + _real_app = current_app._get_current_object() + + def generate(): + import time as _time + + # ── Read the job metadata written by /start ──────────────────────── + deadline = _time.time() + 15 # Wait up to 15 s for the file to appear + meta = None + while _time.time() < deadline: + if os.path.exists(progress_path): + try: + with open(progress_path) as f: + meta = json.load(f) + break + except Exception: + pass + yield "data: " + json.dumps({'type': 'heartbeat'}) + "\n\n" + _time.sleep(0.3) + + if not meta or meta.get('type') != 'pending': + yield "data: " + json.dumps({ + 'type': 'error', + 'message': 'Job metadata not found. Please try importing again.' + }) + "\n\n" + return + + temp_path = meta['temp_path'] + skip_dupes = meta['skip_duplicates'] + project_id = meta['project_id'] + import_source= meta['import_source'] + created_by = meta['created_by'] + username = meta['username'] + + if not os.path.exists(temp_path): + yield "data: " + json.dumps({ + 'type': 'error', + 'message': 'Uploaded file not found. Please try importing again.' + }) + "\n\n" + return + + yield "data: " + json.dumps({'type': 'status', 'message': 'Reading and validating file...'}) + "\n\n" + + # ── Run the import with a progress callback ──────────────────────── + try: + svc = TimeAttendanceImportService(db, logger_handler) + + # progress_callback writes to disk AND yields an SSE event. + # We collect events in a list so the generator can yield them. + _pending_events = [] + + def on_progress(current, total, message): + pct = int(current / total * 100) if total else 0 + event = { + 'type': 'progress', + 'current': current, + 'total': total, + 'percent': pct, + 'message': message, + } + _write_progress(job_id, event, upload_dir) + _pending_events.append(event) + + # We need to interleave yielding with the synchronous import loop. + # Strategy: run import_from_excel; the callback appends to + # _pending_events; after every DB commit batch (50 records) we + # flush pending events to the SSE stream. + import threading as _threading + result_holder = [None] + error_holder = [None] + done_event = _threading.Event() + + def _run(): + # Push an application context so the thread can access + # Flask-SQLAlchemy, Employee.query, etc. + with _real_app.app_context(): + try: + result_holder[0] = svc.import_from_excel( + temp_path, + created_by=created_by, + import_source=import_source, + skip_duplicates=skip_dupes, + force_import_hashes=[], + project_id=project_id, + progress_callback=on_progress) + except Exception as exc: + error_holder[0] = exc + finally: + done_event.set() + + t = _threading.Thread(target=_run, daemon=True) + t.start() + + # Yield progress events as they arrive while the import thread runs + while not done_event.is_set(): + while _pending_events: + yield "data: " + json.dumps(_pending_events.pop(0)) + "\n\n" + yield "data: " + json.dumps({'type': 'heartbeat'}) + "\n\n" + _time.sleep(0.4) + + # Drain any remaining events after the thread finishes + while _pending_events: + yield "data: " + json.dumps(_pending_events.pop(0)) + "\n\n" + + if error_holder[0]: + raise error_holder[0] + + result = result_holder[0] + + if result and result['success']: + logger_handler.logger.info( + f"User {username} imported {result['imported_records']} time attendance records " + f"via stream (batch: {result['batch_id']})" + ) + + # Sanitize result dict for JSON serialization — convert any + # datetime objects (e.g. import_date) to ISO-format strings. + if result and isinstance(result.get('import_date'), datetime): + result['import_date'] = result['import_date'].isoformat() + done_event_data = {'type': 'done', 'result': result} + _write_progress(job_id, done_event_data, upload_dir) + yield "data: " + json.dumps(done_event_data) + "\n\n" + + except Exception as e: + logger_handler.logger.error(f"Import stream error for job {job_id}: {e}") + error_event = {'type': 'error', 'message': str(e)} + _write_progress(job_id, error_event, upload_dir) + yield "data: " + json.dumps(error_event) + "\n\n" + + finally: + # Clean up temp files + for path in (temp_path, progress_path): + try: + if os.path.exists(path): + os.remove(path) + except Exception: + pass + + return Response( + generate(), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', # Disable nginx buffering for SSE + } + ) + + +@bp.route('/time-attendance/import/cancel-pending', endpoint='cancel_pending_import') +@login_required +def cancel_pending_import(): + """Cancel pending import and cleanup temp file""" + try: + if 'pending_import_file' in session: + temp_path = session['pending_import_file'] + if os.path.exists(temp_path): + os.remove(temp_path) + session.pop('pending_import_file') + + if 'pending_import_filename' in session: + session.pop('pending_import_filename') + + flash('Import cancelled.', 'info') + except Exception as e: + logger_handler.logger.error(f"Error cancelling import: {e}") + + return redirect(url_for('time_attendance.import_time_attendance')) + + + +@bp.route('/time-attendance/import/validate', methods=['POST'], endpoint='validate_import_file') +@login_required +def validate_import_file(): + """AJAX endpoint to validate Excel file before import""" + try: + if 'file' not in request.files: + return jsonify({'success': False, 'message': 'No file provided'}), 400 + + file = request.files['file'] + if file.filename == '': + return jsonify({'success': False, 'message': 'No file selected'}), 400 + + # Validate file extension + if not file.filename.lower().endswith(('.xlsx', '.xls')): + return jsonify({'success': False, 'message': 'Invalid file format'}), 400 + + # Save temporarily + filename = secure_filename(file.filename) + temp_path = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/tmp'), + f"validate_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}") + + os.makedirs(os.path.dirname(temp_path), exist_ok=True) + file.save(temp_path) + + try: + # Validate file + import_service = TimeAttendanceImportService(db, logger_handler) + validation_result = import_service.validate_excel_file(temp_path) + + return jsonify({ + 'success': True, + 'validation': validation_result + }) + + finally: + # Cleanup + if os.path.exists(temp_path): + os.remove(temp_path) + + except Exception as e: + logger_handler.logger.error(f"Validation error: {e}") + return jsonify({ + 'success': False, + 'message': f'Validation failed: {str(e)}' + }), 500 + +@bp.route('/time-attendance/import/batch/<batch_id>', endpoint='view_import_batch') +@login_required +@log_user_activity('view_import_batch') +def view_import_batch(batch_id): + """View details of a specific import batch""" + try: + import_service = TimeAttendanceImportService(db, logger_handler) + batch_summary = import_service.get_import_summary(batch_id) + + if not batch_summary: + flash('Import batch not found.', 'error') + return redirect(url_for('time_attendance.time_attendance_dashboard')) + + return render_template('time_attendance_batch_detail.html', + batch_summary=batch_summary) + + except Exception as e: + logger_handler.logger.error(f"Error viewing batch {batch_id}: {e}") + flash('Error loading batch details.', 'error') + return redirect(url_for('time_attendance.time_attendance_dashboard')) + + +@bp.route('/time-attendance/import/batch/<batch_id>/delete', methods=['POST'], endpoint='delete_import_batch') +@admin_required +@log_database_operations('delete_import_batch') +def delete_import_batch(batch_id): + """Delete an entire import batch""" + try: + import_service = TimeAttendanceImportService(db, logger_handler) + result = import_service.delete_import_batch(batch_id, deleted_by=session['user_id']) + + if result['success']: + flash(result['message'], 'success') + logger_handler.logger.info( + f"User {session['username']} deleted import batch {batch_id} - " + f"{result['deleted_count']} records removed" + ) + else: + flash(result['message'], 'error') + + return redirect(url_for('time_attendance.time_attendance_dashboard')) + + except Exception as e: + logger_handler.logger.error(f"Error deleting batch {batch_id}: {e}") + flash('Error deleting import batch.', 'error') + return redirect(url_for('time_attendance.time_attendance_dashboard')) + + +@bp.route('/time-attendance/import/download-template', endpoint='download_import_template') +@login_required +def download_import_template(): + """Download Excel template for time attendance import""" + try: + import io + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment + from flask import send_file + + # Create workbook + wb = Workbook() + ws = wb.active + ws.title = "Time Attendance Template" + + # Define headers + headers = ['ID', 'Name', 'Platform', 'Date', 'Time', 'Location Name', + 'Action Description', 'Event Description', 'Recorded Address', 'Distance'] + + # Style headers + header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") + header_font = Font(bold=True, color="FFFFFF") + + for col_num, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_num) + cell.value = header + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center') + + # Add sample data rows + sample_data = [ + ['12345', 'John Doe', 'iPhone - iOS', '2025-10-06', '09:00:00', + 'HQ Suite 210', 'Check In', 'Main Office', '123 Main St', '0.125'], + ['67890', 'Jane Smith', 'Android', '2025-10-06', '08:45:00', + 'Branch Office', 'Check In', 'Morning Entry', '456 Oak Avenue', '0.250'], + ] + + for row_num, row_data in enumerate(sample_data, 2): + for col_num, value in enumerate(row_data, 1): + ws.cell(row=row_num, column=col_num, value=value) + + # Adjust column widths + for col in ws.columns: + max_length = 0 + col_letter = col[0].column_letter + for cell in col: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except Exception: + pass # Non-string cell value — skip width measurement + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[col_letter].width = adjusted_width + + # Add instructions sheet + ws_instructions = wb.create_sheet("Instructions") + instructions = [ + ["Time Attendance Import Template - Instructions"], + [""], + ["Required Columns:"], + ["- ID: Employee ID (required)"], + ["- Name: Employee full name (required)"], + ["- Date: Attendance date in YYYY-MM-DD format (required)"], + ["- Time: Attendance time in HH:MM:SS format (required)"], + ["- Location Name: Location where attendance was recorded (required)"], + ["- Action Description: Type of action (e.g., Check In, Check Out) (required)"], + [""], + ["Optional Columns:"], + ["- Platform: Device platform (e.g., iPhone - iOS, Android)"], + ["- Event Description: Additional event details"], + ["- Recorded Address: Physical address where attendance was recorded"], + ["- Distance: Distance in miles between Building and Recorded Address (optional)"], + [""], + ["Important Notes:"], + ["- Do not modify the header row"], + ["- Ensure all required fields have values"], + ["- Date format must be YYYY-MM-DD (e.g., 2025-10-06)"], + ["- Time format must be HH:MM:SS (e.g., 09:00:00)"], + ["- Remove the sample data rows before importing your actual data"], + ["- Duplicate records will be automatically detected and skipped"], + ] + + for row_num, instruction in enumerate(instructions, 1): + ws_instructions.cell(row=row_num, column=1, value=instruction[0]) + + ws_instructions.column_dimensions['A'].width = 80 + + # Save to bytes + output = io.BytesIO() + wb.save(output) + output.seek(0) + + # Log download + logger_handler.logger.info(f"User {session['username']} downloaded import template") + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'time_attendance_template_{datetime.now().strftime("%Y%m%d")}.xlsx' + ) + + except Exception as e: + logger_handler.logger.error(f"Error generating template: {e}") + flash('Error generating template file.', 'error') + return redirect(url_for('time_attendance.import_time_attendance')) + +@bp.route('/time-attendance/export', endpoint='export_time_attendance') +@login_required +@log_user_activity('time_attendance_export') +def export_time_attendance(): + """Export time attendance records to CSV or Excel""" + try: + export_format = request.args.get('format', 'excel').lower() + + # Get filter parameters (same as records page) + employee_filter = request.args.get('employee_id') + location_filter = request.args.get('location_name') + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + import_batch = request.args.get('import_batch') + project_filter = request.args.get('project_id') + # unlimited=true skips the 14-day cap; default is capped (2-week) mode + unlimited = request.args.get('unlimited', 'false').lower() == 'true' + + # Build query with same filters as the view + query = TimeAttendance.query + + # Apply filters — employee_id supports comma-separated multi-employee values + if employee_filter: + employee_ids_export = [e.strip() for e in employee_filter.split(',') if e.strip()] + query = query.filter(build_time_attendance_employee_filter(employee_ids_export)) + + if location_filter: + query = query.filter(TimeAttendance.location_name == location_filter) + + if start_date: + try: + start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() + query = query.filter(TimeAttendance.attendance_date >= start_date_obj) + except ValueError: + flash('Invalid start date format.', 'error') + return redirect(url_for('time_attendance.time_attendance_records')) + + if end_date: + try: + end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date() + # Fetch one extra calendar day beyond the requested end date so that + # early-morning check-out records stored on Day N+1 (overnight shifts + # ending after midnight on the last report day) are available for the + # overnight pairing detection inside export_time_attendance_excel. + # The displayed date range is controlled by start_date_filter / + # end_date_filter inside that function and is not affected. + query = query.filter(TimeAttendance.attendance_date <= end_date_obj + timedelta(days=1)) + except ValueError: + flash('Invalid end date format.', 'error') + return redirect(url_for('time_attendance.time_attendance_records')) + + if import_batch: + query = query.filter(TimeAttendance.import_batch_id == import_batch) + + if project_filter: + query = query.filter(TimeAttendance.project_id == project_filter) + + # Order by date and time (most recent first) + records = query.order_by( + TimeAttendance.attendance_date.desc(), + TimeAttendance.attendance_time.desc() + ).all() + + if not records: + flash('No records found to export.', 'warning') + return redirect(url_for('time_attendance.time_attendance_records')) + + # Get project name if project filter exists + project_name_for_filename = '' + if project_filter: + try: + project = db.session.get(Project, int(project_filter)) + if project: + # Replace spaces and special characters with underscores + project_name_safe = project.name.replace(' ', '_').replace('/', '_').replace('\\', '_') + project_name_for_filename = f"{project_name_safe}_" + except Exception as e: + logger_handler.logger.warning(f"Error getting project name for filename: {e}") + + # Log export + logger_handler.logger.info( + f"User {session['username']} exported {len(records)} time attendance records " + f"in {export_format.upper()} format (unlimited={unlimited})" + ) + + # Format dates for filename (MMDDYYYY format) + date_from_formatted = '' + date_to_formatted = '' + if start_date: + try: + date_obj = datetime.strptime(start_date, '%Y-%m-%d') + date_from_formatted = date_obj.strftime('%m%d%Y') + except ValueError: + pass + + if end_date: + try: + date_obj = datetime.strptime(end_date, '%Y-%m-%d') + date_to_formatted = date_obj.strftime('%m%d%Y') + except ValueError: + pass + + # Build filename with date range + # Format: [project_name_]time_attendance_[fromdate_todate].xlsx/csv + date_range_str = '' + if date_from_formatted and date_to_formatted: + date_range_str = f"{date_from_formatted}_{date_to_formatted}" + elif date_from_formatted: + date_range_str = f"from_{date_from_formatted}" + elif date_to_formatted: + date_range_str = f"to_{date_to_formatted}" + + # Keep the filter_str for backward compatibility (but not in filename anymore) + filter_desc = [] + if employee_filter: + filter_desc.append(f"emp_{employee_filter}") + if location_filter: + filter_desc.append(f"loc_{location_filter[:10]}") + + filter_str = "_".join(filter_desc) if filter_desc else "all" + + return export_time_attendance_excel(records, project_name_for_filename, date_range_str, filter_str, start_date, end_date, unlimited=unlimited) + + except Exception as e: + logger_handler.logger.error(f"Error exporting time attendance records: {e}") + flash('Error generating export file. Please try again.', 'error') + return redirect(url_for('time_attendance.time_attendance_records')) + +@bp.route('/time-attendance/export/excel', endpoint='excel_export_time_attendance') +@login_required +@log_user_activity('time_attendance_excel_export') +def excel_export_time_attendance(): + """Excel export with current page filters""" + # Redirect to main export with Excel format + return redirect(url_for('time_attendance.export_time_attendance', format='excel', **request.args)) + +@bp.route('/time-attendance/export-by-building', endpoint='export_time_attendance_by_building') +@login_required +@log_user_activity('time_attendance_export_by_building') +def export_time_attendance_by_building(): + """Export time attendance records grouped by building/location to Excel""" + try: + # Get filter parameters (same as records page) + employee_filter = request.args.get('employee_id') + location_filter = request.args.get('location_name') + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + import_batch = request.args.get('import_batch') + project_filter = request.args.get('project_id') + # unlimited=true skips the 14-day cap; default is capped (2-week) mode + unlimited = request.args.get('unlimited', 'false').lower() == 'true' + + # Build query with same filters as the view + query = TimeAttendance.query + + # Apply filters — employee_id supports comma-separated multi-employee values + if employee_filter: + employee_ids_export = [e.strip() for e in employee_filter.split(',') if e.strip()] + query = query.filter(build_time_attendance_employee_filter(employee_ids_export)) + + if location_filter: + query = query.filter(TimeAttendance.location_name == location_filter) + try: + start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() + query = query.filter(TimeAttendance.attendance_date >= start_date_obj) + except ValueError: + flash('Invalid start date format.', 'error') + return redirect(url_for('time_attendance.time_attendance_records')) + + if end_date: + try: + end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date() + # Fetch one extra calendar day so that early-morning check-out records + # stored on Day N+1 (overnight shifts ending after midnight on the last + # report day) are included for overnight pairing detection. + # The display range remains controlled by start_date_filter/end_date_filter + # inside export_time_attendance_by_building_excel and is not affected. + query = query.filter(TimeAttendance.attendance_date <= end_date_obj + timedelta(days=1)) + except ValueError: + flash('Invalid end date format.', 'error') + return redirect(url_for('time_attendance.time_attendance_records')) + + if import_batch: + query = query.filter(TimeAttendance.import_batch_id == import_batch) + + if project_filter: + query = query.filter(TimeAttendance.project_id == project_filter) + + # Order by location, date, and time + records = query.order_by( + TimeAttendance.location_name, + TimeAttendance.attendance_date.desc(), + TimeAttendance.attendance_time.desc() + ).all() + + if not records: + flash('No records found to export.', 'warning') + return redirect(url_for('time_attendance.time_attendance_records')) + + # Get project name if project filter exists + project_name_for_filename = '' + if project_filter: + try: + project = db.session.get(Project, int(project_filter)) + if project: + # Replace spaces and special characters with underscores + project_name_safe = project.name.replace(' ', '_').replace('/', '_').replace('\\', '_') + project_name_for_filename = f"{project_name_safe}_" + except Exception as e: + logger_handler.logger.warning(f"Error getting project name for filename: {e}") + + # Log export + logger_handler.logger.info( + f"User {session['username']} exported {len(records)} time attendance records " + f"by building in Excel format (unlimited={unlimited})" + ) + + # Format dates for filename (MMDDYYYY format) + date_from_formatted = '' + date_to_formatted = '' + if start_date: + try: + date_obj = datetime.strptime(start_date, '%Y-%m-%d') + date_from_formatted = date_obj.strftime('%m%d%Y') + except ValueError: + pass + + if end_date: + try: + date_obj = datetime.strptime(end_date, '%Y-%m-%d') + date_to_formatted = date_obj.strftime('%m%d%Y') + except ValueError: + pass + + # Build filename with date range + date_range_str = '' + if date_from_formatted and date_to_formatted: + date_range_str = f"{date_from_formatted}_{date_to_formatted}" + elif date_from_formatted: + date_range_str = f"from_{date_from_formatted}" + elif date_to_formatted: + date_range_str = f"to_{date_to_formatted}" + + return export_time_attendance_by_building_excel(records, project_name_for_filename, date_range_str, start_date, end_date, unlimited=unlimited) + + except Exception as e: + logger_handler.logger.error(f"Error exporting time attendance records by building: {e}") + flash('Error generating export file. Please try again.', 'error') + return redirect(url_for('time_attendance.time_attendance_records')) + +@bp.route('/time-attendance/records', endpoint='time_attendance_records') +@login_required +@log_user_activity('time_attendance_records_view') +def time_attendance_records(): + """Display time attendance records with filtering options""" + try: + # Get filter parameters + employee_filter = request.args.get('employee_id', '') + location_filter = request.args.get('location_name') + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + project_filter = request.args.get('project_id') + page = request.args.get('page', 1, type=int) + per_page = 50 # Records per page + + # Build list of selected employee IDs (comma-separated multi-employee support) + employee_ids = [e.strip() for e in employee_filter.split(',') if e.strip()] if employee_filter else [] + + # Build display names for each selected employee + import re as _re + employee_display_names = [] + for eid in employee_ids: + try: + numeric_only = _re.search(r'\d+', str(eid)) + if numeric_only: + emp = Employee.query.filter_by(id=int(numeric_only.group(0))).first() + if emp: + employee_display_names.append({'id': eid, 'name': f"{emp.lastName}, {emp.firstName}"}) + else: + employee_display_names.append({'id': eid, 'name': f"ID: {eid}"}) + else: + employee_display_names.append({'id': eid, 'name': eid}) + except (ValueError, TypeError): + employee_display_names.append({'id': eid, 'name': eid}) + + employee_display_name = ', '.join([e['name'] for e in employee_display_names]) + + # Build query + query = TimeAttendance.query + + # Apply filters + if employee_ids: + # Expand each base ID to include all SP/PW/PT work-type variants so that + # cross-type pairs are included in results and exports. + query = query.filter(build_time_attendance_employee_filter(employee_ids)) + logger_handler.logger.info( + f"Time attendance records filtered by employee IDs: {employee_ids} " + f"by user {session.get('username', 'unknown')}" + ) + + if location_filter: + query = query.filter(TimeAttendance.location_name == location_filter) + + if project_filter: + query = query.filter(TimeAttendance.project_id == project_filter) + + if start_date: + try: + start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() + query = query.filter(TimeAttendance.attendance_date >= start_date_obj) + except ValueError: + flash('Invalid start date format.', 'error') + + if end_date: + try: + end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date() + query = query.filter(TimeAttendance.attendance_date <= end_date_obj) + except ValueError: + flash('Invalid end date format.', 'error') + + # Order by date and time (most recent first) + query = query.order_by( + TimeAttendance.attendance_date.desc(), + TimeAttendance.attendance_time.desc() + ) + + # Paginate results + records = query.paginate(page=page, per_page=per_page, error_out=False) + + # Enhance records with QR address and location accuracy + for record in records.items: + # Find matching QR code by location name + qr_code = QRCode.query.filter_by(location=record.location_name).first() + + if qr_code: + record.qr_address = qr_code.location_address + + # Calculate location accuracy if coordinates are available + if record.recorded_address and qr_code.location_address: + try: + # Try to calculate location accuracy + location_accuracy = calculate_location_accuracy_enhanced( + qr_address=qr_code.location_address, + checkin_address=record.recorded_address, + checkin_lat=None, # TimeAttendance doesn't have GPS coords + checkin_lng=None + ) + record.location_accuracy = location_accuracy + except Exception as e: + logger_handler.logger.warning(f"Could not calculate location accuracy for record {record.id}: {e}") + record.location_accuracy = None + else: + record.location_accuracy = None + else: + record.qr_address = None + record.location_accuracy = None + + # Resolve employee name by stripping work type prefix/suffix (SP, PW, PT) + # from employee_id ONLY for the lookup. The original employee_id is kept intact. + # e.g. '3937SP', 'SP3937', 'PW3937' -> lookup by numeric '3937' + try: + import re as _re + numeric_only = _re.search(r'\d+', str(record.employee_id or '')) + if numeric_only: + emp = Employee.query.filter_by(id=int(numeric_only.group(0))).first() + record.resolved_employee_name = f"{emp.lastName}, {emp.firstName}" if emp else record.employee_name + else: + record.resolved_employee_name = record.employee_name + except Exception as e: + logger_handler.logger.warning(f"Could not resolve employee name for ID {record.employee_id}: {e}") + record.resolved_employee_name = record.employee_name + + # Get unique employees and locations for filters + unique_employees = TimeAttendance.get_unique_employees() + unique_locations = TimeAttendance.get_unique_locations() + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + + return render_template( + 'time_attendance_records.html', + records=records, + unique_employees=unique_employees, + unique_locations=unique_locations, + projects=projects, + employee_display_name=employee_display_name, + employee_display_names=employee_display_names, + employee_filter=employee_filter, + employee_ids=employee_ids + ) + + except Exception as e: + logger_handler.logger.error(f"Error displaying time attendance records: {e}") + flash('Error loading attendance records.', 'error') + return redirect(url_for('time_attendance.time_attendance_dashboard')) + +@bp.route('/time-attendance/record/<int:record_id>', endpoint='time_attendance_record_detail') +@login_required +@log_user_activity('time_attendance_record_detail') +def time_attendance_record_detail(record_id): + """Display detailed view of a time attendance record""" + try: + record = db.session.get(TimeAttendance, record_id) + if record is None: + abort(404) + return render_template('time_attendance_record_detail.html', record=record) + + except Exception as e: + logger_handler.logger.error(f"Error viewing time attendance record {record_id}: {e}") + flash('Error loading record details.', 'error') + return redirect(url_for('time_attendance.time_attendance_records')) + +@bp.route('/time-attendance/delete/<int:record_id>', methods=['POST'], endpoint='delete_time_attendance_record') +@admin_required +@log_database_operations('time_attendance_delete') +def delete_time_attendance_record(record_id): + """Delete a time attendance record""" + try: + record = db.session.get(TimeAttendance, record_id) + if record is None: + abort(404) + + # Store record info for logging + employee_info = f"{record.employee_name} (ID: {record.employee_id})" + location_info = record.location_name + date_info = record.attendance_date + + # Delete the record + db.session.delete(record) + db.session.commit() + + # Log deletion + logger_handler.logger.info( + f"User {session['username']} deleted time attendance record {record_id} - " + f"Employee: {employee_info}, Location: {location_info}, Date: {date_info}" + ) + + flash(f'Time attendance record for {employee_info} deleted successfully.', 'success') + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('time_attendance_delete', e) + flash('Failed to delete time attendance record.', 'error') + + # Get filter parameters from BOTH request.form (POST) and request.args (GET query params) + # This handles both the records list page and the detail page + filter_params = {} + + # List of possible filter parameters + filter_keys = ['employee_id', 'location_name', 'project_id', 'start_date', 'end_date', 'page'] + + for key in filter_keys: + # Try to get from form data first (records list page) + value = request.form.get(key) + # If not in form, try query parameters (detail page) + if not value: + value = request.args.get(key) + # Only include if value exists and is not empty + if value: + filter_params[key] = value + + # Redirect back with filters preserved + return redirect(url_for('time_attendance.time_attendance_records', **filter_params)) + +@bp.route('/api/time-attendance/employee/<employee_id>', endpoint='api_time_attendance_by_employee') +@login_required +def api_time_attendance_by_employee(employee_id): + """API endpoint to get time attendance records for a specific employee""" + try: + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + + start_date_obj = None + end_date_obj = None + + if start_date: + start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() + if end_date: + end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date() + + records = TimeAttendance.get_by_employee_id(employee_id, start_date_obj, end_date_obj) + + return jsonify({ + 'success': True, + 'employee_id': employee_id, + 'total_records': len(records), + 'records': [record.to_dict() for record in records] + }) + + except Exception as e: + logger_handler.logger.error(f"API error getting time attendance for employee {employee_id}: {e}") + return jsonify({ + 'success': False, + 'error': 'Failed to retrieve time attendance records' + }), 500 + +@bp.route('/api/time-attendance/location/<location_name>', endpoint='api_time_attendance_by_location') +@login_required +def api_time_attendance_by_location(location_name): + """API endpoint to get time attendance records for a specific location""" + try: + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + + start_date_obj = None + end_date_obj = None + + if start_date: + start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() + if end_date: + end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date() + + records = TimeAttendance.get_by_location(location_name, start_date_obj, end_date_obj) + + return jsonify({ + 'success': True, + 'location_name': location_name, + 'total_records': len(records), + 'records': [record.to_dict() for record in records] + }) + + except Exception as e: + logger_handler.logger.error(f"API error getting time attendance for location {location_name}: {e}") + return jsonify({ + 'success': False, + 'error': 'Failed to retrieve time attendance records' + }), 500 + +# Jinja2 filters for better template functionality \ No newline at end of file diff --git a/routes/time_attendance_export.py b/routes/time_attendance_export.py new file mode 100644 index 0000000..5f71191 --- /dev/null +++ b/routes/time_attendance_export.py @@ -0,0 +1,2385 @@ +""" +routes/time_attendance_export.py +================================= +Excel export logic for Time Attendance — helper functions called by +routes in time_attendance.py. + +Contains: + - calculate_possible_violation() + - _overnight_aware_sort_key() + - _qtr() + - export_time_attendance_excel() (single-employee / all-employees) + - export_time_attendance_by_building_excel() +""" +from flask import send_file, g, current_app +from datetime import datetime, date, timedelta, time +import io, os, json, re +import time as _time + +from extensions import db, logger_handler +from models.employee import Employee +from models.project import Project +from models.qrcode import QRCode +from models.time_attendance import TimeAttendance +from sqlalchemy import text +from working_hours_calculator import WorkingHoursCalculator, round_time_to_quarter_hour, convert_minutes_to_base100, round_base100_hours +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side, numbers +from openpyxl.utils import get_column_letter +import openpyxl.cell.cell + +# Export helpers — no Blueprint needed, these are plain functions +# called from routes in time_attendance.py + +def calculate_possible_violation(distance_value): + """ + Calculate possible violation status based on distance + + Args: + distance_value: Distance in miles (float or None) + + Returns: + 'Yes' if distance > 0.3, 'No' otherwise + """ + if distance_value is None: + return 'No' + + try: + distance_float = float(distance_value) + return 'Yes' if distance_float > 0.3 else 'No' + except (ValueError, TypeError): + return 'No' + +def _overnight_aware_sort_key(record): + """ + Sort key for attendance records within a single calendar-date bucket. + + Problem 1: when an overnight shift spans midnight, the check-out record's + check_in_time (e.g. 00:01 AM) sorts numerically BEFORE the check-in time + (e.g. 20:00 PM), producing an orphaned OUT followed by an orphaned IN. + Fix: push early-morning check-outs (hour <= 3) past midnight by adding + 24 h worth of seconds so they sort after same-day evening check-ins. + + Problem 2: two records in the same minute (e.g. IN 06:22:04, OUT 06:22:52) + had identical sort keys because seconds were not included, leaving the + database-delivery order intact (DESC → OUT first). The pairing loop then + encountered the OUT before the IN, emitting an orphaned-OUT row followed + by an orphaned-IN row — reversed from chronological order. + Fix: include seconds in the key so true chronological order is preserved. + """ + from datetime import time as _time + t = record.check_in_time + if isinstance(t, _time): + # Use fractional minutes (hours*60 + minutes + seconds/60) so that + # records sharing the same HH:MM still sort by their seconds component. + seconds_total = t.hour * 3600 + t.minute * 60 + t.second + else: + seconds_total = 0 + action = (record.action_description or '').lower() + is_out = 'out' in action or 'checkout' in action + # Push early-morning check-outs past midnight to end of day order. + # Use seconds-based offset (24 h = 86400 s) to remain consistent with + # the seconds-granularity key above. + if is_out and t.hour <= 3: + seconds_total += 24 * 3600 + return seconds_total + +def _qtr(decimal_hours: float) -> float: + """ + Round a decimal-hours value to the nearest quarter hour (.00/.25/.50/.75). + Pipeline: decimal hours → minutes → quarter-hour rounding → base-100 → quarter rounding. + Examples: 4.03 → 4.0, 4.08 → 4.25, 3.87 → 4.0, 4.16 → 4.25 + Returns 0.0 for negative or zero input. + """ + if decimal_hours <= 0: + return 0.0 + minutes = decimal_hours * 60.0 + rounded_minutes = round_time_to_quarter_hour(minutes) + base100 = convert_minutes_to_base100(rounded_minutes) + return round_base100_hours(base100) + +# --------------------------------------------------------------------------- +# Private export helpers — shared by both export functions below. +# --------------------------------------------------------------------------- + +def _resolve_date_range(start_date_filter, end_date_filter, records, export_label='TA Excel export', unlimited=False): + """ + Resolve and validate the export date range. + + Returns (start_date, end_date, filtered_records) where: + - start_date / end_date are date objects + - filtered_records is the input list capped to end_date + 1 day (overnight buffer) + - the window is capped to a maximum 14 days unless unlimited=True: + * a start-date filter was supplied -> keep it, trim end_date forward + * no start-date filter (start derived from the records) -> keep end_date + and move start_date back, so the export covers the MOST RECENT 14 days + rather than the oldest 14 days in the database + Returns None when there are no records and no date filters. + """ + MAX_EXPORT_DAYS = 14 + + def _as_date(value): + if not value: + return None + if isinstance(value, str): + return datetime.strptime(value, '%Y-%m-%d').date() + return value + + filter_start = _as_date(start_date_filter) + filter_end = _as_date(end_date_filter) + + if not records and not (filter_start and filter_end): + return None + + record_min = min(r.attendance_date for r in records) if records else None + record_max = max(r.attendance_date for r in records) if records else None + + start_date = filter_start or record_min + end_date = filter_end or record_max + + if start_date is None or end_date is None: + return None + + # Which end of the window is fixed when the 14-day cap has to trim it: + # a user-supplied start date is honoured (trim the end); otherwise the range + # was derived from the data and we keep the most recent day (move start back). + anchor_start = filter_start is not None + + if not unlimited and (end_date - start_date).days >= MAX_EXPORT_DAYS: + if anchor_start: + # The user supplied a start date: keep it and trim the end. + capped_end_date = start_date + timedelta(days=MAX_EXPORT_DAYS - 1) + logger_handler.logger.info( + f"{export_label}: date range [{start_date} - {end_date}] exceeds " + f"{MAX_EXPORT_DAYS} days; capping end_date to {capped_end_date}." + ) + end_date = capped_end_date + else: + # No start-date filter: start_date came from the oldest record in the + # whole dataset. Capping forward from there exports the OLDEST 14 days + # of history - typically only the few employees active back then. + # Anchor to the most recent day instead, so an unfiltered export + # covers the latest 14 days of data (and every employee in them). + capped_start_date = end_date - timedelta(days=MAX_EXPORT_DAYS - 1) + logger_handler.logger.info( + f"{export_label}: derived date range [{start_date} - {end_date}] exceeds " + f"{MAX_EXPORT_DAYS} days; anchoring to the most recent data, " + f"start_date set to {capped_start_date}." + ) + start_date = capped_start_date + + # Apply both bounds: + # - Lower bound: exclude records before start_date (prevents historical data leaking in). + # - Upper bound (+1 day buffer): early-morning check-out records stored on Day N+1 + # must remain available for overnight pairing detection; the display range is + # still controlled by each export's sorted_dates (capped to end_date). + filtered_records = [ + r for r in records + if start_date <= r.attendance_date <= end_date + timedelta(days=1) + ] + + return start_date, end_date, filtered_records + + +def _convert_ta_records(records): + """ + Convert TimeAttendance ORM records to the lightweight anonymous-class + format expected by WorkingHoursCalculator and the Excel rendering loops. + + Returns a list of converted record objects. + """ + from working_hours_calculator import parse_employee_id_for_work_type + + converted = [] + for record in records: + distance_value = getattr(record, 'distance', None) + + record_type = 'check_in' + if hasattr(record, 'action_description') and record.action_description: + action_lower = record.action_description.lower() + if 'out' in action_lower or 'checkout' in action_lower: + record_type = 'check_out' + + _, work_type = parse_employee_id_for_work_type(str(record.employee_id)) + + base_location_name = record.location_name + if work_type and work_type in ('PT', 'SP', 'PW', 'C'): + display_location_name = f"{base_location_name} ({work_type})" + else: + display_location_name = base_location_name + + converted_record = type('Record', (), { + 'id': record.id, + 'employee_id': str(record.employee_id), + 'employee_name': getattr(record, 'employee_name', ''), + 'check_in_date': record.attendance_date, + 'check_in_time': record.attendance_time, + 'location_name': display_location_name, + 'original_location_name': base_location_name, + 'work_type': work_type, + 'latitude': None, + 'longitude': None, + 'distance': distance_value, + 'record_type': record_type, + 'action_description': record.action_description, + 'event_description': record.event_description or '', + 'recorded_address': record.recorded_address or '', + 'qr_code': type('QRCode', (), { + 'location': base_location_name, + 'location_address': record.recorded_address or '', + 'project': None + })() + })() + converted.append(converted_record) + + return converted + + +def _build_employee_name_map(records): + """ + Build a {base_employee_id: "Lastname, Firstname"} map for export headers. + + Looks up the Employee table by numeric base ID so work-type suffixes + (e.g. '3937SP') in the stored employee_name column do not pollute labels. + Falls back to the stored employee_name on lookup failure. + """ + from working_hours_calculator import parse_employee_id_for_work_type + + employee_names = {} + for record in records: + base_id, _ = parse_employee_id_for_work_type(str(record.employee_id)) + if base_id not in employee_names: + try: + emp = Employee.query.filter_by(id=int(base_id)).first() + if emp: + employee_names[base_id] = f"{emp.lastName}, {emp.firstName}" + else: + employee_names[base_id] = getattr(record, 'employee_name', f'Employee {base_id}') + logger_handler.logger.warning( + f"Employee ID {base_id} not found in employee table during export; " + f"using stored name." + ) + except Exception as e: + employee_names[base_id] = getattr(record, 'employee_name', f'Employee {base_id}') + logger_handler.logger.warning( + f"Could not lookup employee name for ID {base_id} during export: {e}" + ) + return employee_names + + +def _make_export_styles(): + """ + Return a dict of openpyxl style objects shared by both export functions. + + Keys: header_font, header_fill, data_font, bold_font, italic_bold_font, + border, missed_punch_fill, border_day_middle, border_day_last, + border_day_single, border_day_first, amber_fill + """ + header_font = Font(name='Aptos Narrow', size=11, bold=True, color='FFFFFF') + header_fill = PatternFill(start_color='000000', end_color='000000', fill_type='solid') + data_font = Font(name='Aptos Narrow', size=11) + bold_font = Font(name='Aptos Narrow', size=11, bold=True) + italic_bold_font = Font(name='Aptos Narrow', size=11, bold=True, italic=True) + border = Border( + left=Side(style='thin'), + right=Side(style='thin'), + top=Side(style='thin'), + bottom=Side(style='thin') + ) + missed_punch_fill = PatternFill(start_color='FFC000', end_color='FFC000', fill_type='solid') + amber_fill = PatternFill(start_color='FFC000', end_color='FFC000', fill_type='solid') + border_day_middle = Border() + border_day_last = Border(bottom=Side(style='thin')) + border_day_single = Border(bottom=Side(style='thin')) + border_day_first = Border() + return { + 'header_font': header_font, + 'header_fill': header_fill, + 'data_font': data_font, + 'bold_font': bold_font, + 'italic_bold_font': italic_bold_font, + 'border': border, + 'missed_punch_fill': missed_punch_fill, + 'amber_fill': amber_fill, + 'border_day_middle': border_day_middle, + 'border_day_last': border_day_last, + 'border_day_single': border_day_single, + 'border_day_first': border_day_first, + } + + +def export_time_attendance_excel(records, project_name_for_filename, date_range_str, filter_str, start_date_filter=None, end_date_filter=None, unlimited=False): + """Generate Excel export with template format matching the provided template""" + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Border, Side, Alignment + from openpyxl.utils import get_column_letter + import io + + # Create workbook + wb = Workbook() + ws = wb.active + ws.title = "Sheet0" + + # Resolve date range; skip 14-day cap when unlimited=True + result = _resolve_date_range(start_date_filter, end_date_filter, records, 'TA Excel export', unlimited=unlimited) + if result is None: + return None + start_date, end_date, records = result + + # Import parse function at the beginning for work type detection + from working_hours_calculator import parse_employee_id_for_work_type + + # Convert TimeAttendance records to format expected by calculator + converted_records = _convert_ta_records(records) + + # Calculate working hours using WorkingHoursCalculator + calculator = WorkingHoursCalculator() + hours_data = calculator.calculate_all_employees_hours( + datetime.combine(start_date, datetime.min.time()), + datetime.combine(end_date, datetime.max.time()), + converted_records + ) + + # Build employee name map (Lastname, Firstname keyed by base employee ID) + employee_names = _build_employee_name_map(records) + + # Setup styles (shared objects) + _styles = _make_export_styles() + header_font = _styles['header_font'] + header_fill = _styles['header_fill'] + data_font = _styles['data_font'] + bold_font = _styles['bold_font'] + italic_bold_font = _styles['italic_bold_font'] + border = _styles['border'] + missed_punch_fill = _styles['missed_punch_fill'] + amber_fill = _styles['amber_fill'] + border_day_middle = _styles['border_day_middle'] + border_day_last = _styles['border_day_last'] + border_day_single = _styles['border_day_single'] + border_day_first = _styles['border_day_first'] + + def get_day_border(row_position, total_rows): + """ + Get appropriate border style based on row position within a day. + Matches sample.xlsx: only the LAST row of each day group has a bottom border. + + Args: + row_position: Current row number (0-indexed) within the day + total_rows: Total number of rows for this day + + Returns: + Border object + """ + if total_rows == 1: + return border_day_single + elif row_position == total_rows - 1: + return border_day_last + else: + return border_day_middle + + # Orange background for Missed Punch + missed_punch_fill = PatternFill(start_color='FFC000', end_color='FFC000', fill_type='solid') + + # Write main headers + current_row = 1 + + # Row 1: Company name + ws.merge_cells(f'A{current_row}:N{current_row}') + title_cell = ws.cell(row=current_row, column=1, value=current_app.config.get('COMPANY_NAME', 'QR Code Management System')) + title_cell.font = Font(name='Aptos Narrow', size=14, bold=True) + title_cell.alignment = Alignment(horizontal='left') + current_row += 1 + + # Row 2: Summary title + ws.merge_cells(f'A{current_row}:N{current_row}') + summary_cell = ws.cell(row=current_row, column=1, value='Summary report of Hours worked') + summary_cell.font = Font(name='Aptos Narrow', size=12, bold=True) + summary_cell.alignment = Alignment(horizontal='left') + current_row += 1 + + # Row 3: Project name + project_display = project_name_for_filename.replace('_', ' ').strip() if project_name_for_filename else "[Project Name]" + project_cell = ws.cell(row=current_row, column=1, value=project_display) + project_cell.font = Font(name='Aptos Narrow', size=11, bold=True) + project_cell.alignment = Alignment(horizontal='left') + current_row += 1 + + # Row 4: Date range + date_range_text = f"Date range: {start_date.strftime('%m/%d/%Y')} to {end_date.strftime('%m/%d/%Y')}" + ws.merge_cells(f'A{current_row}:N{current_row}') + date_cell = ws.cell(row=current_row, column=1, value=date_range_text) + date_cell.font = Font(name='Aptos Narrow', size=11) + date_cell.alignment = Alignment(horizontal='left') + current_row += 1 + + # Row 5: Empty row + current_row += 1 + + # Empty row before first employee + current_row += 1 + + # Sort employees by name for organized output + sorted_employees = sorted( + hours_data['employees'].items(), + key=lambda x: employee_names.get(x[0], f'Employee {x[0]}').lower() + ) + + # Write data for each employee (sorted by name) + for employee_id, emp_data in sorted_employees: + employee_name = employee_names.get(employee_id, f'Employee {employee_id}') + + # Employee header row (merged A to O) + ws.merge_cells(f'A{current_row}:O{current_row}') + emp_header = ws.cell(row=current_row, column=1, + value=f'Employee ID {employee_id}: {employee_name}') + emp_header.font = Font(name='Aptos Narrow', size=11, bold=True) + emp_header.alignment = Alignment(horizontal='left') + current_row += 1 + + # Column headers + headers = ['Day', 'Date', 'In', 'Out', 'Location', 'Zone', 'Hours/Building', + 'Daily Total', 'Regular Hours', 'OT Hours', 'Building Address', + 'Recorded Location', 'Distance (Mile)', 'Possible Violation'] + + for col, header in enumerate(headers, 1): + cell = ws.cell(row=current_row, column=col, value=header) + # White bold text on black background; no border (matching sample.xlsx) + cell.font = header_font + cell.fill = header_fill + cell.alignment = Alignment(horizontal='center', vertical='center') + current_row += 1 + + # Group records by date AND location for separate rows per location + daily_location_data = {} + # Import parse function to match base employee ID with all variants (SP, PW, PT, C) + from working_hours_calculator import parse_employee_id_for_work_type + + # Filter records where the BASE employee ID matches (includes 1234, 1234 SP, 1234 PW, 1234 PT, 1234 C) + employee_records = [] + for r in converted_records: + record_base_id, _ = parse_employee_id_for_work_type(str(r.employee_id)) + if record_base_id == employee_id: + employee_records.append(r) + + for record in employee_records: + date_key = record.check_in_date.strftime('%Y-%m-%d') + location_key = record.location_name or 'Unknown Location' + + # Create nested structure: date -> location -> records + if date_key not in daily_location_data: + daily_location_data[date_key] = {} + + if location_key not in daily_location_data[date_key]: + daily_location_data[date_key][location_key] = { + 'records': [], + 'location_name': location_key + } + + daily_location_data[date_key][location_key]['records'].append(record) + + # ------------------------------------------------------------------- + # OVERNIGHT SHIFT DETECTION + # The midnight check-out record is stored in the DB with the next + # calendar day's date (e.g. checkout at 12:18 AM on Thursday is + # stored as check_in_date = 2026-02-26). We need to move it into + # Wednesday's bucket so it pairs with the 8:18 PM check-in. + # + # Condition to move an early-morning checkout from Day N+1 -> Day N: + # Day N: has an unmatched late check-in (>= 18:00) + # Day N+1: has an early check-out (<= 06:00) that belongs to Day N, + # detected by the absence of a non-evening IN on Day N+1 + # that could own the early OUT (or raw count imbalance). + # ------------------------------------------------------------------- + def _is_out(r): + a = (r.action_description or '').lower() + return 'out' in a or 'checkout' in a + + sorted_dk = sorted(daily_location_data.keys()) + for _di, _dk in enumerate(sorted_dk): + if _di + 1 >= len(sorted_dk): + continue + + # Guard: _dk or _ndk may have been deleted by a prior iteration + # when all its records were moved to the previous day's bucket. + # Without this check, iterating the stale sorted_dk snapshot raises KeyError. + if _dk not in daily_location_data: + continue + + _ndk = sorted_dk[_di + 1] + if _ndk not in daily_location_data: + continue + + # Must be consecutive calendar days + _dn = datetime.strptime(_dk, '%Y-%m-%d').date() + _dn1 = datetime.strptime(_ndk, '%Y-%m-%d').date() + if (_dn1 - _dn).days != 1: + continue + + # Flatten all records for Day N and Day N+1 across locations + _day_recs = [r for loc in daily_location_data[_dk].values() for r in loc['records']] + _next_recs = [r for loc in daily_location_data[_ndk].values() for r in loc['records']] + + _day_ins = [r for r in _day_recs if not _is_out(r)] + _day_outs = [r for r in _day_recs if _is_out(r)] + _nxt_ins = [r for r in _next_recs if not _is_out(r)] + _nxt_outs = [r for r in _next_recs if _is_out(r)] + + # Early-morning OUTs on Day N (hour <= 3) are overnight orphans from + # Day N-1. Counting them as regular Day N outs inflates the out-count + # and makes the day appear balanced, which suppresses detection of an + # unmatched late IN that needs a next-day OUT. Exclude them. + _day_outs_non_early = [r for r in _day_outs if r.check_in_time.hour > 3] + + # Day N must have an unmatched late check-in (more INs than non-early OUTs, + # with at least one IN at or after 12:00 PM) + if len(_day_ins) <= len(_day_outs_non_early): + continue + _late_ins = [r for r in _day_ins if r.check_in_time.hour >= 12] + if not _late_ins: + continue + + # Find early-morning OUTs (<=03:00) on Day N+1 + _early_outs = [r for r in _nxt_outs if r.check_in_time.hour <= 3] + if not _early_outs: + continue + + # Determine whether the early OUT belongs to Day N or Day N+1. + # It belongs to Day N when Day N+1 has no morning (< 12:00) check-in + # that could own it, OR when OUTs outnumber INs on Day N+1. + # This handles both cases: + # Case A: Day N+1 has only afternoon/evening INs (all >= 12:00) -> early OUT is Day N's + # Case B: Day N+1 has more OUTs than INs overall -> early OUT is unmatched + # A morning IN on Day N+1 can only own an early OUT when that IN + # occurs STRICTLY BEFORE the early OUT's time (IN → OUT is time-ordered). + # An IN that starts AFTER the early OUT cannot own it and must NOT block + # the overnight move (e.g. 01:55 AM IN cannot own a 01:00 AM OUT). + _nxt_non_evening_ins = [ + r for r in _nxt_ins + if r.check_in_time.hour < 12 + and any(r.check_in_time < eo.check_in_time for eo in _early_outs) + ] + if _nxt_non_evening_ins and len(_nxt_outs) <= len(_nxt_ins): + # Day N+1 has a morning IN that can own the early OUT, and counts + # are balanced -> do NOT move + continue + + # Move up to as many early OUTs as there are unmatched late INs on Day N + _to_move = _early_outs[:len(_late_ins)] + for _co in _to_move: + _co_loc = _co.location_name or 'Unknown Location' + # Add to Day N bucket + if _co_loc not in daily_location_data[_dk]: + daily_location_data[_dk][_co_loc] = {'records': [], 'location_name': _co_loc} + daily_location_data[_dk][_co_loc]['records'].append(_co) + # Remove from Day N+1 bucket + if _ndk in daily_location_data and _co_loc in daily_location_data[_ndk]: + try: + daily_location_data[_ndk][_co_loc]['records'].remove(_co) + except ValueError: + pass + if not daily_location_data[_ndk][_co_loc]['records']: + del daily_location_data[_ndk][_co_loc] + if _ndk in daily_location_data and not daily_location_data[_ndk]: + del daily_location_data[_ndk] + logger_handler.logger.info( + f"TA Export overnight shift: moved checkout {_co.check_in_time} " + f"from {_ndk} to {_dk} for employee {employee_id}" + ) + # ------------------------------------------------------------------- + # END OVERNIGHT SHIFT DETECTION + # ------------------------------------------------------------------- + + + # Track weekly hours for overtime calculation + weekly_total_hours = 0 + current_week_start = None + grand_regular_hours = 0 + grand_ot_hours = 0 + # Accumulate SP/PW/PT/C hours from cross-type pairs (where the calculator + # could not detect them because it processes each work-type stream independently). + cross_type_sp_hours = 0.0 + cross_type_pw_hours = 0.0 + cross_type_pt_hours = 0.0 + cross_type_c_hours = 0.0 + # Accumulate raw (uncapped) hours from regular (non-SP/PW/PT) pairs only. + # Used to populate the "Regular" summary row when an employee also has + # special work-type hours (SP/PW/PT/C). + regular_only_hours = 0.0 + + # Get all dates that have records (not all weekdays) + dates_with_records = sorted([ + date_str for date_str, day_data in emp_data['daily_hours'].items() + if day_data.get('records_count', 0) > 0 + ]) + + # Write daily data (ONLY DAYS WITH RECORDS) + for date_str in dates_with_records: + date_obj = datetime.strptime(date_str, '%Y-%m-%d') + day_data = emp_data['daily_hours'][date_str] + + # Check for week boundary anchored to the resolved report start date + # (not calendar Monday, and never the current row's own date — that + # would restart the week on every single day). + _report_start = start_date + week_start = (_report_start + timedelta(days=((date_obj.date() - _report_start).days // 7) * 7)) + if current_week_start is not None and week_start != current_week_start: + # Write weekly total row + week_regular = min(weekly_total_hours, 40.0) + week_overtime = max(0, weekly_total_hours - 40.0) + + ws.cell(row=current_row, column=7, value='Weekly Total: ').font = bold_font + ws.cell(row=current_row, column=8, value=_qtr(weekly_total_hours)).font = bold_font + ws.cell(row=current_row, column=9, value=_qtr(week_regular)).font = bold_font + ws.cell(row=current_row, column=10, value=_qtr(week_overtime)).font = bold_font + + grand_regular_hours += week_regular + grand_ot_hours += week_overtime + current_row += 1 + + weekly_total_hours = 0 + + current_week_start = week_start + + # Get all locations for this date + date_locations = daily_location_data.get(date_str, {}) + total_locations = len(date_locations) + + total_hours = day_data['total_hours'] + is_miss_punch = day_data.get('is_miss_punch', False) + + # Re-evaluate is_miss_punch from actual records in daily_location_data. + # The overnight detection may have moved a checkout into this day's bucket + # AFTER working_hours_calculator ran, so emp_data may still say + # is_miss_punch=True even though the records now form a valid IN/OUT pair. + if is_miss_punch and total_locations > 0: + _all_recs_check = [r for loc in date_locations.values() for r in loc['records']] + _ins_c = sum(1 for r in _all_recs_check if not _is_out(r)) + _outs_c = sum(1 for r in _all_recs_check if _is_out(r)) + if _ins_c > 0 and _outs_c > 0 and _ins_c == _outs_c: + # Balanced pairs — overnight fix resolved the miss punch + is_miss_punch = False + total_hours = 0.0 # will be recalculated below + + # Calculate total hours for the day by mirroring the display pairing logic: + # group records by base location, apply the OUT-after-IN guard within each + # group, and sum only complete pairs. This ensures the daily total in + # column H matches exactly the pairs rendered in the export rows. + _day_total_hours = 0.0 + # Track which records are consumed by same-building pairing so the + # cross-building pass only considers true orphans. + _same_building_used_ids = set() + for _loc_data in date_locations.values(): + _loc_recs = sorted(_loc_data['records'], key=_overnight_aware_sort_key) + _loc_ins = [r for r in _loc_recs if not _is_out(r)] + _loc_outs = [r for r in _loc_recs if _is_out(r)] + _out_used = [False] * len(_loc_outs) + for _in_r in _loc_ins: + for _oi2, _out_r in enumerate(_loc_outs): + if _out_used[_oi2]: + continue + # Time-only pairing guard (mirrors Step 1/2 pairing logic). + _in_t_d = _in_r.check_in_time + _out_t_d = _out_r.check_in_time + if _out_t_d.hour <= 3: + if _in_t_d.hour < 12: + continue + # Orphan guard: an early-morning OUT whose check_in_date + # matches the current day is an orphan from the PREVIOUS + # overnight shift — it must NOT steal an evening IN. + # Only OUTs moved in by overnight detection (check_in_date + # is later than the current day) are valid partners. + _out_orig_date = _out_r.check_in_date + if hasattr(_out_orig_date, 'date'): + _out_orig_date = _out_orig_date.date() + if _out_orig_date <= date_obj.date(): + continue + elif _out_t_d <= _in_t_d: + continue + _in_ts = datetime.combine(_in_r.check_in_date, _in_r.check_in_time) + _out_ts = datetime.combine(_out_r.check_in_date, _out_r.check_in_time) + if _out_ts < _in_ts: + _out_ts += timedelta(days=1) + _duration = (_out_ts - _in_ts).total_seconds() / 3600.0 + if _duration > 24: + continue + _day_total_hours += _duration + _out_used[_oi2] = True + _same_building_used_ids.add(id(_in_r)) + _same_building_used_ids.add(id(_out_r)) + break + + # ------------------------------------------------------------------- + # CROSS-BUILDING PAIRING + # After same-building pairing, collect all orphaned INs and OUTs + # across every location group for this day. Pair them chronologically + # (earliest available OUT that is strictly after the IN). This handles + # employees who check in at one building and check out at another. + # ------------------------------------------------------------------- + _all_day_recs_flat = [] + for _loc_data in date_locations.values(): + _all_day_recs_flat.extend(_loc_data['records']) + + _orphan_ins = sorted( + [r for r in _all_day_recs_flat if not _is_out(r) and id(r) not in _same_building_used_ids], + key=_overnight_aware_sort_key + ) + _orphan_outs = sorted( + [r for r in _all_day_recs_flat if _is_out(r) and id(r) not in _same_building_used_ids], + key=_overnight_aware_sort_key + ) + + # Pre-compute cross-building pairs for this day (used both for totals + # and for row writing after the location_groups loop). + cross_building_pairs = [] # list of {'check_in': r, 'check_out': r, 'hours': float} + _cb_out_used = [False] * len(_orphan_outs) + + for _cb_in in _orphan_ins: + for _cb_oi, _cb_out in enumerate(_orphan_outs): + if _cb_out_used[_cb_oi]: + continue + # Same time-only guard as Steps 1–3 + _cb_in_t = _cb_in.check_in_time + _cb_out_t = _cb_out.check_in_time + if _cb_out_t.hour <= 3: + if _cb_in_t.hour < 12: + continue + # Orphan guard: same-day early-morning OUT is from previous + # overnight shift — skip it. Only moved OUTs (check_in_date + # later than current day) are valid overnight partners. + _cb_out_orig = _cb_out.check_in_date + if hasattr(_cb_out_orig, 'date'): + _cb_out_orig = _cb_out_orig.date() + if _cb_out_orig <= date_obj.date(): + continue + elif _cb_out_t <= _cb_in_t: + continue + _cb_in_ts = datetime.combine(_cb_in.check_in_date, _cb_in.check_in_time) + _cb_out_ts = datetime.combine(_cb_out.check_in_date, _cb_out.check_in_time) + if _cb_out_ts < _cb_in_ts: + _cb_out_ts += timedelta(days=1) + _cb_dur = (_cb_out_ts - _cb_in_ts).total_seconds() / 3600.0 + if _cb_dur > 24: + continue + _cb_out_used[_cb_oi] = True + cross_building_pairs.append({ + 'check_in': _cb_in, + 'check_out': _cb_out, + 'hours': _cb_dur, + }) + _day_total_hours += _cb_dur + logger_handler.logger.info( + f"[TA Export] Cross-building pair for employee {employee_id} on {date_str}: " + f"IN {_cb_in.location_name} @ {_cb_in.check_in_time} → " + f"OUT {_cb_out.location_name} @ {_cb_out.check_in_time} " + f"({_cb_dur:.2f} h)" + ) + break + + # Build a set of record ids that are part of a cross-building pair so + # the single-record group path can suppress its Missed Punch row. + _cross_building_record_ids = set() + for _cbp in cross_building_pairs: + _cross_building_record_ids.add(id(_cbp['check_in'])) + _cross_building_record_ids.add(id(_cbp['check_out'])) + # ------------------------------------------------------------------- + # END CROSS-BUILDING PAIRING PRE-COMPUTATION + # ------------------------------------------------------------------- + + total_hours = _qtr(_day_total_hours) + weekly_total_hours += total_hours + + # Daily total display (only shown on last location's last row) + daily_total_display = _qtr(total_hours) if total_hours > 0 else '' + + # Get all records for the day and sort by time FIRST, then group by BASE location. + # Grouping by base location (original_location_name) ensures that records from the + # same building but different work types (e.g. regular IN + SP OUT) land in the + # same group so the cross-type pairing rule can resolve them. + all_day_records = [] + for loc_data in date_locations.values(): + all_day_records.extend(loc_data['records']) + + # Sort all records by time chronologically, overnight-aware + all_day_records_sorted = sorted(all_day_records, key=_overnight_aware_sort_key) + + # Group consecutive records by BASE location (original_location_name without work-type + # suffix) while maintaining time order. + def _base_loc(r): + return getattr(r, 'original_location_name', None) or r.location_name or 'Unknown Location' + + location_groups = [] + current_base_location = None + current_group = [] + + for record in all_day_records_sorted: + bloc = _base_loc(record) + if current_base_location is None or bloc == current_base_location: + current_base_location = bloc + current_group.append(record) + else: + if current_group: + location_groups.append({ + 'location': current_base_location, + 'records': current_group + }) + current_base_location = bloc + current_group = [record] + + # Add the last group + if current_group: + location_groups.append({ + 'location': current_base_location, + 'records': current_group + }) + + # Process each location group in chronological order + total_groups = len(location_groups) + for group_index, group_data in enumerate(location_groups): + location_count = group_index + 1 + is_last_location = (location_count == total_groups) + + location_name = group_data['location'] + sorted_records = group_data['records'] + + if len(sorted_records) == 1: + # Single record for this location + single_record = sorted_records[0] + + # If this record has been resolved by cross-building pairing, + # suppress the Missed Punch row here — it will be written after + # all location groups have been processed (Touch Point 3). + if id(single_record) in _cross_building_record_ids: + continue + + # Get the original TimeAttendance record to check action_description + original_record = None + for rec in records: + if (rec.employee_id == single_record.employee_id and + rec.attendance_date == single_record.check_in_date and + rec.attendance_time == single_record.check_in_time): + original_record = rec + break + + # Determine if this is a check-in or check-out + is_check_out = False + if original_record and original_record.action_description: + action_lower = original_record.action_description.lower() + is_check_out = 'out' in action_lower or 'checkout' in action_lower + + # Show day name and date only for first group's first record + day_display = date_obj.strftime('%A').upper() if location_count == 1 else '' + date_display = date_obj.strftime('%m/%d/%Y') if location_count == 1 else '' + + # Show daily total only if this is the last group + current_daily_total = daily_total_display if is_last_location else '' + + if is_check_out: + # Orphaned check-out + row_data = [ + day_display, + date_display, + '', # No check-in time + single_record.check_in_time.strftime('%I:%M:%S %p'), # Out + single_record.location_name, + '', + 'Missed Punch', + current_daily_total, + '', + '', + single_record.event_description or '', + single_record.recorded_address or '', + getattr(single_record, 'distance', None) or '', + calculate_possible_violation(getattr(single_record, 'distance', None)) + ] + else: + # Orphaned check-in + row_data = [ + day_display, + date_display, + single_record.check_in_time.strftime('%I:%M:%S %p'), # In + '', # No check-out time + single_record.location_name, + '', + 'Missed Punch', + current_daily_total, + '', + '', + single_record.event_description or '', + single_record.recorded_address or '', + getattr(single_record, 'distance', None) or '', + calculate_possible_violation(getattr(single_record, 'distance', None)) + ] + + day_border = border_day_last if is_last_location else border_day_middle + for col, value in enumerate(row_data, 1): + cell = ws.cell(row=current_row, column=col, value=value) + cell.font = data_font + cell.border = day_border + # Apply orange background to Missed Punch cell (column G) + if col == 7: + cell.fill = missed_punch_fill + current_row += 1 + + else: + # Multiple records for this location group. + # Build record_info with work_type included. + record_info = [] + for record in sorted_records: + action_desc = record.action_description.lower() if record.action_description else '' + is_out = 'out' in action_desc or 'checkout' in action_desc + wt = getattr(record, 'work_type', None) # None = regular + record_info.append({ + 'record': record, + 'is_out': is_out, + 'work_type': wt, # None means regular + 'used': False + }) + logger_handler.logger.debug(f"TA Export record: time={record.check_in_time}, action='{record.action_description}', is_out={is_out}, work_type={wt}") + + ins = [ri for ri in record_info if not ri['is_out']] + outs = [ri for ri in record_info if ri['is_out']] + + pairs_to_write = [] + + # ── STEP 1: same-type pairing ────────────────────────────────────── + # Pair each IN with an OUT of the same work type first. + # Sort INs chronologically and OUTs with overnight-aware key so that + # an early-morning OUT (e.g. 00:30 moved in by overnight detection) + # sorts AFTER same-day evening OUTs and does not steal a daytime IN. + ins_sorted = sorted(ins, key=lambda ri: _overnight_aware_sort_key(ri['record'])) + outs_sorted = sorted(outs, key=lambda ri: _overnight_aware_sort_key(ri['record'])) + + for in_ri in ins_sorted: + if in_ri['used']: + continue + for out_ri in outs_sorted: + if out_ri['used']: + continue + # Guard: time-only pairing rule. + # An early-morning OUT (hour<=3) is only valid for an afternoon/evening IN (hour>=12). + # For all other OUTs, the OUT time must be strictly after the IN time. + # Using time-only (not datetime) avoids false positives from moved overnight + # OUT records whose check_in_date is still a later date. + _in_t = in_ri['record'].check_in_time + _out_t = out_ri['record'].check_in_time + if _out_t.hour <= 3: + if _in_t.hour < 12: + continue # early-morning OUT cannot pair with morning IN + # Orphan guard: same-day early-morning OUT is from a + # previous overnight shift — not a valid partner for + # this evening IN. Only moved OUTs (check_in_date + # later than current day) should pair. + _out_orig_d = out_ri['record'].check_in_date + if hasattr(_out_orig_d, 'date'): + _out_orig_d = _out_orig_d.date() + if _out_orig_d <= date_obj.date(): + continue + elif _out_t <= _in_t: + continue # same-day OUT must be strictly after IN + if out_ri['work_type'] == in_ri['work_type']: + # Matched same work type — standard pair + in_ri['used'] = True + out_ri['used'] = True + pairs_to_write.append({ + 'check_in': in_ri['record'], + 'check_out': out_ri['record'], + 'is_miss_punch': False, + 'effective_work_type': in_ri['work_type'] + }) + break + + # ── STEP 2: cross-type pairing (forgot the work code) ────────────── + # If any INs or OUTs remain unmatched after same-type pairing, + # attempt to pair an unmatched IN with an unmatched OUT of a + # *different* work type. Hours count as the special type's hours + # (if either side is special, the pair is treated as special; + # if both are different special types, use the OUT's type as + # the authoritative code — it's the scan that carries the code). + unmatched_ins = [ri for ri in ins_sorted if not ri['used']] + unmatched_outs = [ri for ri in outs_sorted if not ri['used']] + + for in_ri in unmatched_ins: + if in_ri['used']: + continue + for out_ri in unmatched_outs: + if out_ri['used']: + continue + # Guard: same time-only rule as Step 1. + _in_t2 = in_ri['record'].check_in_time + _out_t2 = out_ri['record'].check_in_time + if _out_t2.hour <= 3: + if _in_t2.hour < 12: + continue + # Orphan guard: same-day early-morning OUT is from a + # previous overnight shift — not a valid partner for + # this evening IN. Only moved OUTs (check_in_date + # later than current day) should pair. + _out_orig_d2 = out_ri['record'].check_in_date + if hasattr(_out_orig_d2, 'date'): + _out_orig_d2 = _out_orig_d2.date() + if _out_orig_d2 <= date_obj.date(): + continue + elif _out_t2 <= _in_t2: + continue + # Cross-type pair: one side is regular, other is special + # (or both special but different codes — treat OUT's type as definitive) + effective_wt = out_ri['work_type'] if out_ri['work_type'] else in_ri['work_type'] + in_ri['used'] = True + out_ri['used'] = True + pairs_to_write.append({ + 'check_in': in_ri['record'], + 'check_out': out_ri['record'], + 'is_miss_punch': False, + 'effective_work_type': effective_wt, + 'is_cross_type': True + }) + break + + # ── STEP 3: remaining unmatched records → Missed Punch ───────────── + for ri in record_info: + if not ri['used']: + ri['used'] = True + if ri['is_out']: + pairs_to_write.append({ + 'check_in': None, + 'check_out': ri['record'], + 'is_miss_punch': True, + 'effective_work_type': ri['work_type'] + }) + else: + pairs_to_write.append({ + 'check_in': ri['record'], + 'check_out': None, + 'is_miss_punch': True, + 'effective_work_type': ri['work_type'] + }) + + logger_handler.logger.debug(f"TA Export: created {len(pairs_to_write)} pairs for export") + + # Sort pairs chronologically by the anchor record's time so that + # orphaned records (assembled last in Steps 2-3) appear in the + # correct time-order position relative to complete pairs. + def _pair_sort_key(pd): + anchor = pd['check_in'] or pd['check_out'] + return _overnight_aware_sort_key(anchor) if anchor else 0 + pairs_to_write.sort(key=_pair_sort_key) + + # Write all pairs + for pair_idx, pair_data in enumerate(pairs_to_write): + check_in_record = pair_data['check_in'] + check_out_record = pair_data['check_out'] + is_miss_punch = pair_data['is_miss_punch'] + + # Show day name and date only for first pair of first location + day_display = date_obj.strftime('%A').upper() if (location_count == 1 and pair_idx == 0) else '' + date_display = date_obj.strftime('%m/%d/%Y') if (location_count == 1 and pair_idx == 0) else '' + + # Calculate hours if complete pair + if check_in_record and check_out_record and not is_miss_punch: + pair_datetime_in = datetime.combine(check_in_record.check_in_date, check_in_record.check_in_time) + pair_datetime_out = datetime.combine(check_out_record.check_in_date, check_out_record.check_in_time) + # If check-out time is before check-in time (overnight shift), + # add one day to the check-out datetime so the duration is positive and correct. + if pair_datetime_out < pair_datetime_in: + pair_datetime_out += timedelta(days=1) + pair_hours = (pair_datetime_out - pair_datetime_in).total_seconds() / 3600.0 + pair_hours = round(pair_hours, 2) + else: + pair_hours = 'Missed Punch' + + # Accumulate SP/PW/PT/C hours for CROSS-TYPE pairs only. + # Same-type SP/PW/PT pairs are already captured in grand_totals + # by WorkingHoursCalculator; adding them again here would double-count. + if not is_miss_punch and isinstance(pair_hours, (int, float)) and pair_data.get('is_cross_type', False): + _ewt = pair_data.get('effective_work_type') + if _ewt == 'SP': + cross_type_sp_hours += pair_hours + elif _ewt == 'PW': + cross_type_pw_hours += pair_hours + elif _ewt == 'PT': + cross_type_pt_hours += pair_hours + elif _ewt == 'C': + cross_type_c_hours += pair_hours + + # Accumulate raw regular-only hours (non-SP/PW/PT completed pairs). + # This feeds the "Regular" summary row shown when an employee also + # has SP/PW/PT/C hours. Cross-type pairs are excluded here because + # their effective_work_type is SP/PW/PT, not regular. + if not is_miss_punch and isinstance(pair_hours, (int, float)): + _pair_ewt = pair_data.get('effective_work_type') + if _pair_ewt not in ('SP', 'PW', 'PT', 'C'): + regular_only_hours += pair_hours + + # Determine whether this is an overnight pair: + # check-in is late evening (>= 20:00) AND check-out is early morning (<= 03:00) + # Both records share the same check_in_date in the DB for this scenario. + _is_overnight_pair = ( + check_in_record and check_out_record and + check_in_record.check_in_time.hour >= 20 and + check_out_record.check_in_time.hour <= 3 + ) + + # Show daily total on last pair of last location + is_last_pair = (pair_idx == len(pairs_to_write) - 1) and is_last_location + current_daily_total = daily_total_display if is_last_pair else '' + + # Build Out-time string (plain time only) + _out_time_str = check_out_record.check_in_time.strftime('%I:%M:%S %p') if check_out_record else '' + + # Build Location string. + # For a complete pair, derive the display name from effective_work_type: + # - regular pair → base location name (no suffix) + # - SP/PW/PT pair → base location name + " (SP/PW/PT/C)" + # 'regular' is treated identically to None — no suffix is shown. + # For orphaned records keep their own location_name. + _effective_wt = pair_data.get('effective_work_type') + _is_special_wt = _effective_wt in ('SP', 'PW', 'PT', 'C') + _ref_record = check_in_record or check_out_record + if check_in_record and check_out_record: + _base = _base_loc(check_in_record) + if _is_special_wt: + _location_str = f"{_base} ({_effective_wt})" + else: + _location_str = _base + else: + _location_str = _ref_record.location_name if _ref_record else '' + + if _is_overnight_pair: + _location_str = f"{_location_str} (midnight shift)" + + # Build row data + if check_in_record and check_out_record: + row_data = [ + day_display, + date_display, + check_in_record.check_in_time.strftime('%I:%M:%S %p'), # In + _out_time_str, # Out + _location_str, # Location (effective work type + optional midnight label) + '', + pair_hours, + current_daily_total, + '', + '', + check_in_record.event_description or '', + check_in_record.recorded_address or '', + getattr(check_in_record, 'distance', None) or '', + calculate_possible_violation(getattr(check_in_record, 'distance', None)) + ] + elif check_in_record: # IN without OUT + row_data = [ + day_display, + date_display, + check_in_record.check_in_time.strftime('%I:%M:%S %p'), # In + '', # No OUT + _location_str, + '', + 'Missed Punch', + current_daily_total, + '', + '', + check_in_record.event_description or '', + check_in_record.recorded_address or '', + getattr(check_in_record, 'distance', None) or '', + calculate_possible_violation(getattr(check_in_record, 'distance', None)) + ] + else: # OUT without IN + row_data = [ + day_display, + date_display, + '', # No IN + check_out_record.check_in_time.strftime('%I:%M:%S %p'), # Out + _location_str, + '', + 'Missed Punch', + current_daily_total, + '', + '', + check_out_record.event_description or '', + check_out_record.recorded_address or '', + getattr(check_out_record, 'distance', None) or '', + calculate_possible_violation(getattr(check_out_record, 'distance', None)) + ] + + day_border = border_day_last if is_last_pair else border_day_middle + for col, value in enumerate(row_data, 1): + cell = ws.cell(row=current_row, column=col, value=value) + cell.font = data_font + cell.border = day_border + # Apply orange background to Missed Punch cell + if col == 7 and value == 'Missed Punch': + cell.fill = missed_punch_fill + current_row += 1 + + # ------------------------------------------------------------------- + # CROSS-BUILDING PAIR ROW WRITING (Touch Point 3) + # Write one row per cross-building pair identified during pre-computation. + # The day-name and date columns are only shown for the very first row + # of this day that is actually rendered; we track that with a flag. + # ------------------------------------------------------------------- + if cross_building_pairs: + # Determine whether any non-cross-building rows were already written + # for this day. We look at how many rows were consumed since the + # start of this date's block. The simplest proxy: check whether + # the first location group had at least one real (non-skipped) record. + # We use a dedicated flag instead to keep this clean. + _cb_first_row_of_day = not any( + id(r) not in _cross_building_record_ids + for loc_data in date_locations.values() + for r in loc_data['records'] + ) + + for _cb_idx, _cbp in enumerate(cross_building_pairs): + _cb_in_rec = _cbp['check_in'] + _cb_out_rec = _cbp['check_out'] + _cb_hours = _cbp['hours'] + _cb_pair_hours = round(_cb_hours, 2) + + _is_last_cb = (_cb_idx == len(cross_building_pairs) - 1) + + # Show day/date only on the very first row written for this date + # (either this is the first row overall, or prior groups had records) + if _cb_idx == 0 and _cb_first_row_of_day: + _cb_day_display = date_obj.strftime('%A').upper() + _cb_date_display = date_obj.strftime('%m/%d/%Y') + else: + _cb_day_display = '' + _cb_date_display = '' + + # Show daily total on the last cross-building row if it is + # also the last row written for this day. + _cb_daily_total = daily_total_display if _is_last_cb else '' + + # Location label: clearly identifies both buildings + _cb_in_loc = _base_loc(_cb_in_rec) + _cb_out_loc = _base_loc(_cb_out_rec) + _cb_loc_str = f"IN: {_cb_in_loc} → OUT: {_cb_out_loc}" + + row_data = [ + _cb_day_display, + _cb_date_display, + _cb_in_rec.check_in_time.strftime('%I:%M:%S %p'), # In + _cb_out_rec.check_in_time.strftime('%I:%M:%S %p'), # Out + _cb_loc_str, + '', + _cb_pair_hours, + _cb_daily_total, + '', + '', + _cb_in_rec.event_description or '', + _cb_in_rec.recorded_address or '', + getattr(_cb_in_rec, 'distance', None) or '', + calculate_possible_violation(getattr(_cb_in_rec, 'distance', None)) + ] + + _cb_border = border_day_last if _is_last_cb else border_day_middle + for col, value in enumerate(row_data, 1): + cell = ws.cell(row=current_row, column=col, value=value) + cell.font = data_font + cell.border = _cb_border + current_row += 1 + # ------------------------------------------------------------------- + # END CROSS-BUILDING PAIR ROW WRITING + # ------------------------------------------------------------------- + + # Write final weekly total for this employee + if weekly_total_hours > 0: + week_regular = min(weekly_total_hours, 40.0) + week_overtime = max(0, weekly_total_hours - 40.0) + + ws.cell(row=current_row, column=7, value='Weekly Total: ').font = bold_font + ws.cell(row=current_row, column=8, value=_qtr(weekly_total_hours)).font = bold_font + ws.cell(row=current_row, column=9, value=_qtr(week_regular)).font = bold_font + ws.cell(row=current_row, column=10, value=_qtr(week_overtime)).font = bold_font + + grand_regular_hours += week_regular + grand_ot_hours += week_overtime + current_row += 1 + + # Write extra working hours rows (SP/PW/PT/C) if employee has any. + # Get extra hours from emp_data grand_totals, then add any cross-type hours + # accumulated during rendering (pairs the calculator could not detect). + grand_totals = emp_data.get('grand_totals', {}) + sp_hours = grand_totals.get('sp_hours', 0.0) + cross_type_sp_hours + pw_hours = grand_totals.get('pw_hours', 0.0) + cross_type_pw_hours + pt_hours = grand_totals.get('pt_hours', 0.0) + cross_type_pt_hours + c_hours = grand_totals.get('c_hours', 0.0) + cross_type_c_hours + + _has_special_hours = sp_hours > 0 or pw_hours > 0 or pt_hours > 0 or c_hours > 0 + _summary_font = Font(name='Aptos Narrow', size=11, bold=True, italic=True) + + # Write SP row if hours > 0 (abbreviated label in col 8, hours in col 9) + if sp_hours > 0: + ws.cell(row=current_row, column=8, value='SP').font = _summary_font + ws.cell(row=current_row, column=9, value=_qtr(sp_hours)).font = _summary_font + logger_handler.logger.info(f"Export: Employee {employee_id} SP hours: {sp_hours:.2f}") + current_row += 1 + + # Write PW row if hours > 0 + if pw_hours > 0: + ws.cell(row=current_row, column=8, value='PW').font = _summary_font + ws.cell(row=current_row, column=9, value=_qtr(pw_hours)).font = _summary_font + logger_handler.logger.info(f"Export: Employee {employee_id} PW hours: {pw_hours:.2f}") + current_row += 1 + + # Write PT row if hours > 0 + if pt_hours > 0: + ws.cell(row=current_row, column=8, value='PT').font = _summary_font + ws.cell(row=current_row, column=9, value=_qtr(pt_hours)).font = _summary_font + logger_handler.logger.info(f"Export: Employee {employee_id} PT hours: {pt_hours:.2f}") + current_row += 1 + + # Write C (Covering) row if hours > 0 + if c_hours > 0: + ws.cell(row=current_row, column=8, value='C').font = _summary_font + ws.cell(row=current_row, column=9, value=_qtr(c_hours)).font = _summary_font + logger_handler.logger.info(f"Export: Employee {employee_id} C hours: {c_hours:.2f}") + current_row += 1 + + # Write Regular row — only when the employee has at least one special + # work-type (SP/PW/PT/C). Shows raw accumulated hours from non-special pairs. + if _has_special_hours: + ws.cell(row=current_row, column=8, value='Regular').font = _summary_font + ws.cell(row=current_row, column=9, value=_qtr(regular_only_hours)).font = _summary_font + logger_handler.logger.info(f"Export: Employee {employee_id} Regular hours: {regular_only_hours:.2f}") + current_row += 1 + + # Write GRAND TOTAL row + ws.cell(row=current_row, column=7, value='GRAND TOTAL: ').font = Font(name='Aptos Narrow', size=11, bold=True) + ws.cell(row=current_row, column=9, value=_qtr(grand_regular_hours)).font = Font(name='Aptos Narrow', size=11, bold=True) + ws.cell(row=current_row, column=10, value=_qtr(grand_ot_hours)).font = Font(name='Aptos Narrow', size=11, bold=True) + current_row += 1 + + # Empty row after each employee + current_row += 1 + + # Auto-size columns - handle merged cells properly + for col_idx in range(1, 15): + column_letter = get_column_letter(col_idx) + + # Set fixed width for Day column (column A) + if col_idx == 1: + ws.column_dimensions[column_letter].width = 18 + continue + + max_length = 0 + for row in ws.iter_rows(min_col=col_idx, max_col=col_idx): + for cell in row: + if isinstance(cell, openpyxl.cell.cell.MergedCell): + continue + try: + if cell.value and len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except Exception: + pass # Non-string cell value — skip width measurement + + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + # Save to BytesIO + output = io.BytesIO() + wb.save(output) + output.seek(0) + + # Filename + if date_range_str: + filename = f'{project_name_for_filename}time_attendance_{date_range_str}.xlsx' + else: + filename = f'{project_name_for_filename}time_attendance.xlsx' + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) + + +# --------------------------------------------------------------------------- +# Export by Building — extra sheets ("Filtered Report" + "Weekly Hours by +# Location"). Both are built from the row bookkeeping collected while Sheet0 +# is written; Sheet0 itself is never modified. +# --------------------------------------------------------------------------- + +def _normalize_base_id(employee_id): + """'04921' and '4921' are the same employee for exclusion matching.""" + s = str(employee_id or '').strip().upper() + return s.lstrip('0') or s + + +def _format_id_list(ids): + """['4921', '4944', '4816'] -> '4921, 4944, and 4816'.""" + ids = [str(i) for i in ids] + if len(ids) <= 2: + return ' and '.join(ids) + return ', '.join(ids[:-1]) + f', and {ids[-1]}' + + +def _kept_building_employees(block, excluded): + """Employee blocks of one building whose base ID is not excluded.""" + return [e for e in block['employees'] + if _normalize_base_id(e['employee_id']) not in excluded] + + +def _build_filtered_building_sheet(wb, src_ws, blocks, sp_summary_rows, excluded_ids): + """ + Add a "Filtered Report" sheet — a copy of Sheet0 that: + - removes every employee block whose base ID is in excluded_ids, and the + building header too when no employee is left (buildings renumbered); + - removes the SP summary rows ('SP' in the Daily Total column, its hours + in the Regular Hours column); + - keeps everything else, including punch rows whose location carries + '(SP)' and every GRAND TOTAL row. + """ + from copy import copy + + excluded = {_normalize_base_id(e) for e in excluded_ids} + dst_ws = wb.create_sheet('Filtered Report') + + # Output plan: (source_row, building-header text override or None) + plan = [] + first_block_row = blocks[0]['header_row'] if blocks else src_ws.max_row + 1 + plan.extend((r, None) for r in range(1, first_block_row)) + + new_index = 0 + for block in blocks: + kept = _kept_building_employees(block, excluded) + if not kept: + continue + new_index += 1 + plan.append((block['header_row'], f"{new_index}) {block['name']} - Zone {block['zone']}")) + for emp in kept: + plan.extend((r, None) for r in range(emp['start_row'], emp['end_row'] + 1) + if r not in sp_summary_rows) + # Trailing blank row(s) after the building + plan.extend((r, None) for r in range(block['employees'][-1]['end_row'] + 1, + block['end_row'] + 1)) + + merged_by_row = {} + for rng in src_ws.merged_cells.ranges: + if rng.min_row == rng.max_row: + merged_by_row.setdefault(rng.min_row, []).append((rng.min_col, rng.max_col)) + + max_col = src_ws.max_column + for new_r, (src_r, override) in enumerate(plan, 1): + for c in range(1, max_col + 1): + src_cell = src_ws.cell(row=src_r, column=c) + if isinstance(src_cell, openpyxl.cell.cell.MergedCell): + continue + if src_cell.value is None and not src_cell.has_style: + continue + dst_cell = dst_ws.cell(row=new_r, column=c, value=src_cell.value) + if src_cell.has_style: + dst_cell.font = copy(src_cell.font) + dst_cell.fill = copy(src_cell.fill) + dst_cell.border = copy(src_cell.border) + dst_cell.alignment = copy(src_cell.alignment) + dst_cell.number_format = src_cell.number_format + if override is not None: + dst_ws.cell(row=new_r, column=1).value = override + for c1, c2 in merged_by_row.get(src_r, []): + dst_ws.merge_cells(start_row=new_r, start_column=c1, end_row=new_r, end_column=c2) + + for key, dim in src_ws.column_dimensions.items(): + dst_ws.column_dimensions[key].width = dim.width + + # Row 5 is blank in Sheet0 (between the date range and the first building) + if first_block_row > 6: + removed = [f"employee IDs {_format_id_list(excluded_ids)}"] if excluded_ids else [] + removed.append("SP summary rows") + note = dst_ws.cell(row=5, column=1, value=f"Filtered - removed: {'; '.join(removed)}.") + note.font = Font(name='Aptos Narrow', size=11, italic=True, color='9C5700') + + logger_handler.logger.info( + f"Export by Building: Filtered Report built — {new_index} buildings, " + f"{len(plan)} rows (excluded IDs: {', '.join(excluded_ids) or 'none'})" + ) + + +def _build_weekly_hours_by_location_sheet(wb, blocks, excluded_ids, start_date, end_date, project_display): + """ + Add a "Weekly Hours by Location" sheet: one row per employee per building + with non-SP hours per report week, plus a Location Totals table and the + project's total hours worked. Weeks are anchored to the report start date, + exactly like the Weekly Total rows in Sheet0. Excluded employee IDs are + left out; totals are Excel formulas so they follow manual edits. + """ + excluded = {_normalize_base_id(e) for e in excluded_ids} + ws = wb.create_sheet('Weekly Hours by Location') + ws.sheet_view.showGridLines = False + + n_weeks = max(1, (end_date - start_date).days // 7 + 1) + week_ranges = [] + for w in range(n_weeks): + w_start = start_date + timedelta(days=7 * w) + w_end = min(w_start + timedelta(days=6), end_date) + week_ranges.append(f"{w_start.strftime('%m/%d')}-{w_end.strftime('%m/%d')}") + + rows, locations = [], [] + for block in blocks: + kept = _kept_building_employees(block, excluded) + if not kept: + continue + locations.append(block['name']) + for emp in kept: + weeks = [round(emp['weeks'].get(w, 0.0), 2) for w in range(n_weeks)] + rows.append((block['name'], emp['employee_id'], emp['name'], weeks)) + + # Column layout: detail table, one gap column, location totals table + first_week_col = 4 + total_col = first_week_col + n_weeks + loc_col = total_col + 2 + loc_total_col = loc_col + n_weeks + 1 + L = get_column_letter + + title_fill = PatternFill(start_color='1F4E78', end_color='1F4E78', fill_type='solid') + header_fill = PatternFill(start_color='203864', end_color='203864', fill_type='solid') + total_fill = PatternFill(start_color='548235', end_color='548235', fill_type='solid') + band_fills = (PatternFill(start_color='FFFFFF', end_color='FFFFFF', fill_type='solid'), + PatternFill(start_color='F7FAFC', end_color='F7FAFC', fill_type='solid')) + white_bold = Font(name='Cambria', size=11, bold=True, color='FFFFFF') + note_font = Font(name='Cambria', size=11, color='9C5700') + data_font = Font(name='Calibri', size=11) + header_align = Alignment(horizontal='center', vertical='center', wrap_text=True) + + # Title, report period, total hours, notes + ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=total_col) + c = ws.cell(row=1, column=1, value=f"{project_display} - Weekly Hours Review") + c.font = Font(name='Cambria', size=16, bold=True, color='FFFFFF') + c.fill = title_fill + c.alignment = Alignment(horizontal='center', vertical='center') + ws.row_dimensions[1].height = 24.75 + + ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=total_col) + ws.cell(row=2, column=1, + value=f"Report period: {start_date.strftime('%m/%d/%Y')} to {end_date.strftime('%m/%d/%Y')}" + ).font = Font(name='Cambria', size=11, color='404040') + + first_data_row = 7 + last_data_row = first_data_row + len(rows) - 1 + + c = ws.cell(row=3, column=1, value='Total Hours Worked') + c.font, c.fill = white_bold, total_fill + c = ws.cell(row=3, column=2, + value=f"=SUM({L(total_col)}{first_data_row}:{L(total_col)}{last_data_row})" if rows else 0) + c.font = Font(name='Cambria', size=14, bold=True, color='FFFFFF') + c.fill, c.number_format = total_fill, '0.00' + ws.row_dimensions[3].height = 17.25 + + excl_text = 'Special Project (SP) hours.' + if excluded_ids: + excl_text = f"Employee IDs {_format_id_list(excluded_ids)}; Special Project (SP) hours." + ws.cell(row=4, column=1, value='Exclusions:').font = Font(name='Cambria', size=11, bold=True, color='9C5700') + ws.merge_cells(start_row=4, start_column=2, end_row=4, end_column=total_col) + ws.cell(row=4, column=2, value=excl_text).font = note_font + + zero_rows = sum(1 for r in rows if not any(r[3])) + if zero_rows: + ws.cell(row=5, column=1, value='Note:').font = Font(name='Cambria', size=11, bold=True, color='9C5700') + ws.merge_cells(start_row=5, start_column=2, end_row=5, end_column=total_col) + ws.cell(row=5, column=2, + value=f"{zero_rows} employee-location records have 0.00 weekly hours " + f"(only SP or missed-punch time at that location).").font = note_font + + # Detail table header + headers = ['Location', 'Employee ID', 'Employee Name'] + headers += [f"Week {w + 1} Hours\n{week_ranges[w]}" for w in range(n_weeks)] + headers.append('Total Hours') + for col, text in enumerate(headers, 1): + c = ws.cell(row=6, column=col, value=text) + c.font, c.fill, c.alignment = white_bold, header_fill, header_align + + # Location totals header + ws.merge_cells(start_row=1, start_column=loc_col, end_row=1, end_column=loc_total_col) + c = ws.cell(row=1, column=loc_col, value='Location Totals') + c.font = Font(name='Cambria', size=14, bold=True, color='FFFFFF') + c.fill = title_fill + c.alignment = Alignment(horizontal='center', vertical='center') + loc_headers = ['Location'] + [f"Week {w + 1}" for w in range(n_weeks)] + ['Total'] + for offset, text in enumerate(loc_headers): + c = ws.cell(row=6, column=loc_col + offset, value=text) + c.font, c.fill, c.alignment = white_bold, header_fill, header_align + ws.row_dimensions[6].height = 33.75 + + if not rows: + ws.cell(row=first_data_row, column=1, + value='No records remain after exclusions.').font = data_font + else: + # Detail rows + for i, (location, emp_id, emp_name, weeks) in enumerate(rows): + r = first_data_row + i + fill = band_fills[i % 2] + values = [location, int(emp_id) if str(emp_id).isdigit() else emp_id, emp_name] + weeks + values.append(f"=SUM({L(first_week_col)}{r}:{L(total_col - 1)}{r})") + for col, value in enumerate(values, 1): + c = ws.cell(row=r, column=col, value=value) + c.font, c.fill = data_font, fill + if col >= first_week_col: + c.number_format = '0.00' + + # Location totals rows (SUMIF over the detail table) + loc_range = f"$A${first_data_row}:$A${last_data_row}" + for i, location in enumerate(locations): + r = first_data_row + i + ws.cell(row=r, column=loc_col, value=location).font = data_font + for w in range(n_weeks): + wl = L(first_week_col + w) + c = ws.cell(row=r, column=loc_col + 1 + w, + value=f"=SUMIF({loc_range},{L(loc_col)}{r},${wl}${first_data_row}:${wl}${last_data_row})") + c.font, c.number_format = data_font, '0.00' + c = ws.cell(row=r, column=loc_total_col, + value=f"=SUM({L(loc_col + 1)}{r}:{L(loc_total_col - 1)}{r})") + c.font, c.number_format = data_font, '0.00' + + # Project total row under the location totals + loc_last = first_data_row + len(locations) - 1 + pr = loc_last + 2 + c = ws.cell(row=pr, column=loc_col, value='Project Total') + c.font, c.fill = white_bold, total_fill + for col in range(loc_col + 1, loc_total_col + 1): + cl = L(col) + c = ws.cell(row=pr, column=col, value=f"=SUM({cl}{first_data_row}:{cl}{loc_last})") + c.font, c.fill, c.number_format = white_bold, total_fill, '0.00' + + ws.auto_filter.ref = f"A6:{L(total_col)}{last_data_row}" + + # Column widths (mirrors the reference layout) + ws.column_dimensions['A'].width = 32 + ws.column_dimensions['B'].width = 13 + ws.column_dimensions['C'].width = 30 + for w in range(n_weeks): + ws.column_dimensions[L(first_week_col + w)].width = 17 + ws.column_dimensions[L(loc_col + 1 + w)].width = 12 + ws.column_dimensions[L(total_col)].width = 14 + ws.column_dimensions[L(total_col + 1)].width = 3 + ws.column_dimensions[L(loc_col)].width = 32 + ws.column_dimensions[L(loc_total_col)].width = 12 + ws.freeze_panes = f"A{first_data_row}" + + logger_handler.logger.info( + f"Export by Building: Weekly Hours by Location built — {len(rows)} employee-location rows, " + f"{len(locations)} locations, {n_weeks} week(s)" + ) + + +def export_time_attendance_by_building_excel(records, project_name_for_filename, date_range_str, start_date_filter=None, end_date_filter=None, unlimited=False): + """Generate Excel export grouped by building/location with template format""" + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Border, Side, Alignment + from openpyxl.utils import get_column_letter + import io + + # Create workbook + wb = Workbook() + ws = wb.active + ws.title = "Sheet0" + + # Resolve date range; skip 14-day cap when unlimited=True + result = _resolve_date_range(start_date_filter, end_date_filter, records, 'TA by-building Excel export', unlimited=unlimited) + if result is None: + return None + start_date, end_date, records = result + + from working_hours_calculator import parse_employee_id_for_work_type + + # Convert TimeAttendance records to format expected by calculator + converted_records = _convert_ta_records(records) + + # Group records by location (building) + location_groups = {} + for record in converted_records: + loc_name = record.original_location_name or 'Unknown Location' + if loc_name not in location_groups: + location_groups[loc_name] = [] + location_groups[loc_name].append(record) + + # Sort locations alphabetically + sorted_locations = sorted(location_groups.keys()) + + # Log grouping info + logger_handler.logger.info( + f"Export by Building: Grouped {len(converted_records)} records into {len(sorted_locations)} locations" + ) + + # Calculate working hours using WorkingHoursCalculator for SP/PT/PW hours + calculator = WorkingHoursCalculator() + hours_data = calculator.calculate_all_employees_hours( + datetime.combine(start_date, datetime.min.time()), + datetime.combine(end_date, datetime.max.time()), + converted_records + ) + + # Build employee name map (Lastname, Firstname keyed by base employee ID) + employee_names = _build_employee_name_map(records) + + # Setup styles (shared objects) + _styles = _make_export_styles() + header_font = _styles['header_font'] + header_fill = _styles['header_fill'] + data_font = _styles['data_font'] + bold_font = _styles['bold_font'] + italic_bold_font = _styles['italic_bold_font'] + border = _styles['border'] + missed_punch_fill = _styles['missed_punch_fill'] + amber_fill = _styles['amber_fill'] + border_day_middle = _styles['border_day_middle'] + border_day_last = _styles['border_day_last'] + + # Write main headers + current_row = 1 + + # Row 1: Company name + ws.merge_cells(f'A{current_row}:N{current_row}') + title_cell = ws.cell(row=current_row, column=1, value=current_app.config.get('COMPANY_NAME', 'QR Code Management System')) + title_cell.font = Font(name='Aptos Narrow', size=14, bold=True) + title_cell.alignment = Alignment(horizontal='left') + current_row += 1 + + # Row 2: Summary title + ws.merge_cells(f'A{current_row}:N{current_row}') + summary_cell = ws.cell(row=current_row, column=1, value='Summary report of Hours worked') + summary_cell.font = Font(name='Aptos Narrow', size=12, bold=True) + summary_cell.alignment = Alignment(horizontal='left') + current_row += 1 + + # Row 3: Project name + project_display = project_name_for_filename.replace('_', ' ').strip() if project_name_for_filename else "[Project Name]" + project_cell = ws.cell(row=current_row, column=1, value=project_display) + project_cell.font = Font(name='Aptos Narrow', size=11, bold=True) + project_cell.alignment = Alignment(horizontal='left') + current_row += 1 + + # Row 4: Date range + date_range_text = f"Date range: {start_date.strftime('%m/%d/%Y')} to {end_date.strftime('%m/%d/%Y')}" + ws.merge_cells(f'A{current_row}:N{current_row}') + date_cell = ws.cell(row=current_row, column=1, value=date_range_text) + date_cell.font = Font(name='Aptos Narrow', size=11) + date_cell.alignment = Alignment(horizontal='left') + current_row += 1 + + # Empty rows before first building + current_row += 2 + + # Row bookkeeping for the extra "Filtered Report" and "Weekly Hours by + # Location" sheets — records where each building/employee block lands in + # Sheet0, which rows are SP summary rows, and each employee's weekly + # non-SP hours. Sheet0 itself is written exactly as before. + _bb_blocks = [] + _bb_sp_summary_rows = set() + + # Process each building/location + for location_index, location_name in enumerate(sorted_locations, 1): + location_records = location_groups[location_name] + + # Get zone info from QR code if available + zone_info = '' + try: + qr_code = QRCode.query.filter_by(location=location_name).first() + if qr_code: + zone_info = getattr(qr_code, 'zone', '') or '' + except Exception as e: + logger_handler.logger.debug(f"Could not retrieve zone info for location '{location_name}': {e}") + + # Building header row + building_header = f"{location_index}) {location_name} - Zone {zone_info}" + ws.merge_cells(f'A{current_row}:O{current_row}') + building_cell = ws.cell(row=current_row, column=1, value=building_header) + building_cell.font = Font(name='Aptos Narrow', size=11, bold=True) + building_cell.alignment = Alignment(horizontal='left') + _bb_block = {'name': location_name, 'zone': zone_info, + 'header_row': current_row, 'employees': []} + current_row += 1 + + # Get unique employees for this location + employees_at_location = {} + for record in location_records: + base_id, _ = parse_employee_id_for_work_type(record.employee_id) + if base_id not in employees_at_location: + employees_at_location[base_id] = [] + employees_at_location[base_id].append(record) + + # Sort employees by name + sorted_employee_ids = sorted( + employees_at_location.keys(), + key=lambda emp_id: employee_names.get(emp_id, f'Employee {emp_id}').lower() + ) + + # Process each employee at this location + for employee_id in sorted_employee_ids: + emp_records = employees_at_location[employee_id] + emp_name = employee_names.get(employee_id, f'Employee {employee_id}') + + # Compute SP/PW/PT/C hours from the records already scoped to this + # building and employee (emp_records). Using the calculator's + # grand_totals here would be incorrect: those totals are GLOBAL + # (across all buildings), so an employee with SP hours at Building A + # would incorrectly show an SP row at Building B where they have none. + # + # Strategy: pair same-building SP/PW/PT records the same way the + # main loop pairs regular records, and sum the durations. + def _building_special_hours(emp_recs, work_type_code): + """Sum paired hours for a given work-type code at this building.""" + from datetime import datetime as _dt, timedelta as _td + wt_recs = [r for r in emp_recs if getattr(r, 'work_type', None) == work_type_code] + if not wt_recs: + return 0.0 + # Group by date + by_date = {} + for r in wt_recs: + dk = r.check_in_date.strftime('%Y-%m-%d') if hasattr(r.check_in_date, 'strftime') else str(r.check_in_date) + by_date.setdefault(dk, []).append(r) + total = 0.0 + for dk, day_recs in by_date.items(): + day_recs_s = sorted(day_recs, key=_overnight_aware_sort_key) + ins_r = [r for r in day_recs_s if not ('out' in (r.action_description or '').lower() or 'checkout' in (r.action_description or '').lower())] + outs_r = [r for r in day_recs_s if ('out' in (r.action_description or '').lower() or 'checkout' in (r.action_description or '').lower())] + used = [False] * len(outs_r) + d_obj = _dt.strptime(dk, '%Y-%m-%d') + for in_r in ins_r: + for oi, out_r in enumerate(outs_r): + if used[oi]: + continue + in_dt = _dt.combine(d_obj, in_r.check_in_time) + out_dt = _dt.combine(d_obj, out_r.check_in_time) + if out_dt < in_dt: + out_dt += _td(days=1) + dur = (out_dt - in_dt).total_seconds() / 3600.0 + if 0 < dur < 24: + total += dur + used[oi] = True + break + return total + + sp_hours = _building_special_hours(emp_records, 'SP') + pw_hours = _building_special_hours(emp_records, 'PW') + pt_hours = _building_special_hours(emp_records, 'PT') + c_hours = _building_special_hours(emp_records, 'C') + + _bb_emp_meta = {'employee_id': employee_id, 'name': emp_name, + 'start_row': current_row, 'weeks': {}} + + # Employee header row + ws.merge_cells(f'A{current_row}:O{current_row}') + emp_header = ws.cell(row=current_row, column=1, + value=f'Employee ID {employee_id}: {emp_name}') + emp_header.font = Font(name='Aptos Narrow', size=11, bold=True) + emp_header.alignment = Alignment(horizontal='left') + current_row += 1 + + # Column headers + headers = ['Day', 'Date', 'In', 'Out', 'Location', 'Zone', 'Hours/Building', + 'Daily Total', 'Regular Hours', 'OT Hours', 'Building Address', + 'Recorded Location', 'Distance (Mile)', 'Possible Violation'] + + for col, header in enumerate(headers, 1): + cell = ws.cell(row=current_row, column=col, value=header) + cell.font = header_font + cell.fill = header_fill + cell.border = border + cell.alignment = Alignment(horizontal='center', vertical='center') + current_row += 1 + + # Group employee records by date + daily_records = {} + for record in emp_records: + date_key = record.check_in_date.strftime('%Y-%m-%d') + if date_key not in daily_records: + daily_records[date_key] = [] + daily_records[date_key].append(record) + + # ----------------------------------------------------------- + # OVERNIGHT SHIFT DETECTION (by-building export) + # The midnight check-out record is stored in the DB on the + # next calendar day's date (e.g. checkout at 01:00 AM on + # Thursday is stored as check_in_date = Thursday). Move it + # into Wednesday's bucket so it pairs with the 8 PM check-in. + # + # Mirrors the identical logic in export_time_attendance_excel. + # ----------------------------------------------------------- + def _bb_is_out(r): + a = (r.action_description or '').lower() + return 'out' in a or 'checkout' in a + + _bb_sorted_dk = sorted(daily_records.keys()) + for _bb_di, _bb_dk in enumerate(_bb_sorted_dk): + if _bb_di + 1 >= len(_bb_sorted_dk): + continue + # Guard: bucket may have been emptied by a prior iteration + if _bb_dk not in daily_records: + continue + _bb_ndk = _bb_sorted_dk[_bb_di + 1] + if _bb_ndk not in daily_records: + continue + # Must be consecutive calendar days + _bb_dn = datetime.strptime(_bb_dk, '%Y-%m-%d').date() + _bb_dn1 = datetime.strptime(_bb_ndk, '%Y-%m-%d').date() + if (_bb_dn1 - _bb_dn).days != 1: + continue + # Collect INs/OUTs for Day N and Day N+1 + _bb_day_recs = daily_records[_bb_dk] + _bb_next_recs = daily_records[_bb_ndk] + _bb_day_ins = [r for r in _bb_day_recs if not _bb_is_out(r)] + _bb_day_outs = [r for r in _bb_day_recs if _bb_is_out(r)] + _bb_nxt_ins = [r for r in _bb_next_recs if not _bb_is_out(r)] + _bb_nxt_outs = [r for r in _bb_next_recs if _bb_is_out(r)] + # Exclude early-morning OUTs on Day N from the balance check: + # they are overnight orphans from Day N-1, not Day N regulars. + _bb_day_outs_non_early = [r for r in _bb_day_outs if r.check_in_time.hour > 3] + # Day N must have an unmatched late check-in (>= 12:00 PM) + if len(_bb_day_ins) <= len(_bb_day_outs_non_early): + continue + _bb_late_ins = [r for r in _bb_day_ins if r.check_in_time.hour >= 12] + if not _bb_late_ins: + continue + # Find early-morning OUTs (<= 03:00) on Day N+1 + _bb_early_outs = [r for r in _bb_nxt_outs if r.check_in_time.hour <= 3] + if not _bb_early_outs: + continue + # Non-morning INs guard: do NOT move if Day N+1 has a morning + # IN (< 12:00) that precedes the early OUT (i.e. it can own the early OUT) + # and the counts are balanced. + _bb_nxt_non_evening_ins = [ + r for r in _bb_nxt_ins + if r.check_in_time.hour < 12 + and any(r.check_in_time < eo.check_in_time for eo in _bb_early_outs) + ] + if _bb_nxt_non_evening_ins and len(_bb_nxt_outs) <= len(_bb_nxt_ins): + continue + # Move up to as many early OUTs as there are unmatched late INs + _bb_to_move = _bb_early_outs[:len(_bb_late_ins)] + for _bb_co in _bb_to_move: + daily_records[_bb_dk].append(_bb_co) + daily_records[_bb_ndk].remove(_bb_co) + if not daily_records[_bb_ndk]: + del daily_records[_bb_ndk] + logger_handler.logger.info( + f"[TA by-building Export] Overnight: moved checkout " + f"{_bb_co.check_in_time} from {_bb_ndk} to {_bb_dk} " + f"for employee {employee_id} at {location_name}" + ) + # ----------------------------------------------------------- + # END OVERNIGHT SHIFT DETECTION + # ----------------------------------------------------------- + + # Track weekly hours for overtime calculation + weekly_total_hours = 0 + current_week_start = None + grand_regular_hours = 0 + grand_ot_hours = 0 + # Accumulate raw regular-only (non-SP/PW/PT) pair hours. + # Used for the "Regular" summary row when the employee also has + # special work-type hours. + regular_only_hours = 0.0 + + # Sort dates (re-sort after overnight detection may have removed buckets). + # CRITICAL: cap to end_date — daily_records may contain the +1 buffer day + # (fetched so overnight checkout records are available for pairing) but + # that extra day must never be rendered, or it creates a spurious 3rd week. + sorted_dates = sorted( + dk for dk in daily_records.keys() + if datetime.strptime(dk, '%Y-%m-%d').date() <= end_date + ) + + for date_str in sorted_dates: + date_obj = datetime.strptime(date_str, '%Y-%m-%d') + # Sort records overnight-aware: early-morning OUTs (<=03:00) sort after + # evening records so they pair with the correct evening check-in. + day_records = sorted(daily_records[date_str], key=_overnight_aware_sort_key) + + # Check for week boundary anchored to the resolved report start date + # (not calendar Monday, and never the current row's own date — that + # would restart the week on every single day). + _report_start = start_date + week_start = (_report_start + timedelta(days=((date_obj.date() - _report_start).days // 7) * 7)) + if current_week_start is not None and week_start != current_week_start: + # Write weekly total row + week_regular = min(weekly_total_hours, 40.0) + week_overtime = max(0, weekly_total_hours - 40.0) + + ws.cell(row=current_row, column=7, value='Weekly Total: ').font = bold_font + ws.cell(row=current_row, column=8, value=_qtr(weekly_total_hours)).font = bold_font + ws.cell(row=current_row, column=9, value=_qtr(week_regular)).font = bold_font + ws.cell(row=current_row, column=10, value=_qtr(week_overtime)).font = bold_font + + grand_regular_hours += week_regular + grand_ot_hours += week_overtime + current_row += 1 + + weekly_total_hours = 0 + + current_week_start = week_start + + # Re-evaluate miss-punch status after overnight detection may + # have moved a next-day checkout into this day's bucket. + # If INs and OUTs are now balanced, this day is no longer a + # miss punch (mirrors logic in export_time_attendance_excel). + _bb_all_day = day_records + _bb_ins_count = sum(1 for r in _bb_all_day if not _bb_is_out(r)) + _bb_outs_count = sum(1 for r in _bb_all_day if _bb_is_out(r)) + _bb_day_is_miss_punch = (_bb_ins_count != _bb_outs_count) + + # Process day records - create IN/OUT pairs + record_info = [] + for record in day_records: + action_desc = record.action_description.lower() if record.action_description else '' + is_out = 'out' in action_desc or 'checkout' in action_desc + record_info.append({ + 'record': record, + 'is_out': is_out, + 'used': False + }) + + # Create pairs + pairs = [] + ins = [ri for ri in record_info if not ri['is_out']] + outs = [ri for ri in record_info if ri['is_out']] + + if len(ins) > len(outs) and len(outs) > 0: + # Odd-IN rule: discard all but the LATEST IN; pair it with the earliest OUT. + # Use overnight-aware sort so early-morning OUTs sort after evening OUTs. + ins_sorted = sorted(ins, key=lambda ri: _overnight_aware_sort_key(ri['record'])) + outs_sorted = sorted(outs, key=lambda ri: _overnight_aware_sort_key(ri['record'])) + + latest_in = ins_sorted[-1] + excess_ins = ins_sorted[:-1] + + # Orphan guard: when the latest IN is afternoon/evening (>=12h), skip + # early-morning OUTs (<=3h) whose check_in_date matches the + # current day — they are orphans from a previous overnight shift. + _oi_in_hour = latest_in['record'].check_in_time.hour + earliest_out = None + _oi_skip = [] + for _oi_ri in outs_sorted: + if (earliest_out is None + and _oi_in_hour >= 12 + and _oi_ri['record'].check_in_time.hour <= 3): + _oi_out_d = _oi_ri['record'].check_in_date + if hasattr(_oi_out_d, 'date'): + _oi_out_d = _oi_out_d.date() + if _oi_out_d <= date_obj.date(): + _oi_skip.append(_oi_ri) + continue + if earliest_out is None: + earliest_out = _oi_ri + break + + for ri in excess_ins: + ri['used'] = True + pairs.append({'check_in': ri['record'], 'check_out': None, 'is_miss_punch': True}) + + if earliest_out is not None: + latest_in['used'] = True + earliest_out['used'] = True + pairs.append({'check_in': latest_in['record'], 'check_out': earliest_out['record'], 'is_miss_punch': False}) + else: + latest_in['used'] = True + pairs.append({'check_in': latest_in['record'], 'check_out': None, 'is_miss_punch': True}) + + for ri in outs_sorted: + if not ri['used'] and ri not in _oi_skip: + ri['used'] = True + pairs.append({'check_in': None, 'check_out': ri['record'], 'is_miss_punch': True}) + # Orphan OUTs that were skipped + for ri in _oi_skip: + ri['used'] = True + pairs.append({'check_in': None, 'check_out': ri['record'], 'is_miss_punch': True}) + + else: + # Standard pairing + i = 0 + while i < len(record_info): + if record_info[i]['used']: + i += 1 + continue + + if not record_info[i]['is_out']: # IN + out_found = False + for j in range(i + 1, len(record_info)): + if record_info[j]['used']: + continue + if record_info[j]['is_out']: + # Orphan guard: when this IN is an afternoon/evening + # check-in (>=12h) and the candidate OUT is + # early-morning (<=3h), the OUT is only a + # valid partner if it was moved in by overnight + # detection (check_in_date > current day). + # Same-day early-morning OUTs are orphans from + # a previous overnight shift. + _in_rec = record_info[i]['record'] + _out_rec = record_info[j]['record'] + if (_in_rec.check_in_time.hour >= 12 + and _out_rec.check_in_time.hour <= 3): + _out_bb_date = _out_rec.check_in_date + if hasattr(_out_bb_date, 'date'): + _out_bb_date = _out_bb_date.date() + if _out_bb_date <= date_obj.date(): + continue # orphan — skip + pairs.append({ + 'check_in': record_info[i]['record'], + 'check_out': record_info[j]['record'], + 'is_miss_punch': False + }) + record_info[i]['used'] = True + record_info[j]['used'] = True + out_found = True + break + + if not out_found: + pairs.append({ + 'check_in': record_info[i]['record'], + 'check_out': None, + 'is_miss_punch': True + }) + record_info[i]['used'] = True + else: # Orphaned OUT + pairs.append({ + 'check_in': None, + 'check_out': record_info[i]['record'], + 'is_miss_punch': True + }) + record_info[i]['used'] = True + + i += 1 + + # Calculate daily hours + daily_hours = 0 + _bb_day_non_sp_hours = 0.0 # weekly summary sheet excludes SP time + for pair in pairs: + if pair['check_in'] and pair['check_out'] and not pair['is_miss_punch']: + pair_in = datetime.combine(date_obj, pair['check_in'].check_in_time) + pair_out = datetime.combine(date_obj, pair['check_out'].check_in_time) + # Overnight shift correction: if OUT is before IN on the same + # calendar date, the employee worked past midnight — advance + # pair_out by one day so the duration is always positive. + if pair_out < pair_in: + pair_out += timedelta(days=1) + _bb_dur = (pair_out - pair_in).total_seconds() / 3600.0 + # 24h guard: reject implausible durations (data errors) + if _bb_dur <= 24: + daily_hours += _bb_dur + # Accumulate regular-only hours: pairs where neither record + # carries a special work type (SP/PW/PT/C). + _bb_in_wt = getattr(pair['check_in'], 'work_type', None) + _bb_out_wt = getattr(pair['check_out'], 'work_type', None) + _bb_eff_wt = _bb_out_wt or _bb_in_wt # prefer OUT's type (mirrors main export) + if _bb_eff_wt not in ('SP', 'PW', 'PT', 'C'): + regular_only_hours += _bb_dur + if _bb_eff_wt != 'SP': + _bb_day_non_sp_hours += _bb_dur + + daily_hours = _qtr(daily_hours) + weekly_total_hours += daily_hours + + # Same week anchoring as the Weekly Total rows (report start date) + _bb_week_idx = (date_obj.date() - start_date).days // 7 + _bb_emp_meta['weeks'][_bb_week_idx] = ( + _bb_emp_meta['weeks'].get(_bb_week_idx, 0.0) + _qtr(_bb_day_non_sp_hours) + ) + + # Write pairs + for pair_idx, pair in enumerate(pairs): + check_in = pair['check_in'] + check_out = pair['check_out'] + is_miss_punch = pair['is_miss_punch'] + + # Day/date only on first row + day_display = date_obj.strftime('%A').upper() if pair_idx == 0 else '' + date_display = date_obj.strftime('%m/%d/%Y') if pair_idx == 0 else '' + + # Calculate hours for this pair + if check_in and check_out and not is_miss_punch: + _pair_in_dt = datetime.combine(date_obj, check_in.check_in_time) + _pair_out_dt = datetime.combine(date_obj, check_out.check_in_time) + # Overnight shift correction: advance OUT by one day when it + # falls before IN (employee crossed midnight). + if _pair_out_dt < _pair_in_dt: + _pair_out_dt += timedelta(days=1) + pair_hours = round((_pair_out_dt - _pair_in_dt).total_seconds() / 3600.0, 2) + else: + pair_hours = 'Missed Punch' + + # Daily total only on last row of day + daily_total_display = daily_hours if pair_idx == len(pairs) - 1 else '' + + # Get record for address/distance info + ref_record = check_in or check_out + + # Build row data + row_data = [ + day_display, + date_display, + check_in.check_in_time.strftime('%I:%M:%S %p') if check_in else '', + check_out.check_in_time.strftime('%I:%M:%S %p') if check_out else '', + ref_record.location_name if ref_record else '', + zone_info, + pair_hours, + daily_total_display if daily_total_display else '', + '', # Regular Hours + '', # OT Hours + '', # Building Address (will be HYPERLINK) + '', # Recorded Location (will be HYPERLINK) + getattr(ref_record, 'distance', None) or '' if ref_record else '', + calculate_possible_violation(getattr(ref_record, 'distance', None)) if ref_record else '' + ] + + # Use bottom-only border on the last pair row of the day; + # no borders on intermediate rows (matches normal TA export). + _bb_is_last_pair = (pair_idx == len(pairs) - 1) + _bb_row_border = border_day_last if _bb_is_last_pair else border_day_middle + for col, value in enumerate(row_data, 1): + cell = ws.cell(row=current_row, column=col, value=value) + cell.font = data_font + cell.border = _bb_row_border + if col == 7 and value == 'Missed Punch': + cell.fill = missed_punch_fill + + # Add HYPERLINK formulas for addresses + if ref_record: + building_address = ref_record.event_description or '' + if building_address: + encoded_addr = building_address.replace(' ', '+').replace(',', '%2C') + hyperlink_formula = f'=HYPERLINK("https://www.google.com/maps/place/{encoded_addr}","{building_address}")' + ws.cell(row=current_row, column=11, value=hyperlink_formula) + + recorded_addr = ref_record.recorded_address or '' + if recorded_addr: + encoded_recorded = recorded_addr.replace(' ', '+').replace(',', '%2C') + recorded_hyperlink = f'=HYPERLINK("https://www.google.com/maps/place/{encoded_recorded}","{recorded_addr}")' + ws.cell(row=current_row, column=12, value=recorded_hyperlink) + + current_row += 1 + + # Write final weekly total + if weekly_total_hours > 0: + week_regular = min(weekly_total_hours, 40.0) + week_overtime = max(0, weekly_total_hours - 40.0) + + ws.cell(row=current_row, column=7, value='Weekly Total: ').font = bold_font + ws.cell(row=current_row, column=8, value=_qtr(weekly_total_hours)).font = bold_font + ws.cell(row=current_row, column=9, value=_qtr(week_regular)).font = bold_font + ws.cell(row=current_row, column=10, value=_qtr(week_overtime)).font = bold_font + + grand_regular_hours += week_regular + grand_ot_hours += week_overtime + current_row += 1 + + # ================================================================ + # Write extra working hours rows (SP/PW/PT/C) if employee has any. + # Abbreviated label in col 8, hours in col 9. + # A "Regular" row follows whenever at least one special type is present. + # ================================================================ + + _has_special_hours = sp_hours > 0 or pw_hours > 0 or pt_hours > 0 or c_hours > 0 + + # Write SP row if hours > 0 + if sp_hours > 0: + ws.cell(row=current_row, column=8, value='SP').font = italic_bold_font + ws.cell(row=current_row, column=9, value=_qtr(sp_hours)).font = italic_bold_font + logger_handler.logger.info(f"Export by Building: Employee {employee_id} SP hours: {sp_hours:.2f}") + _bb_sp_summary_rows.add(current_row) + current_row += 1 + + # Write PW row if hours > 0 + if pw_hours > 0: + ws.cell(row=current_row, column=8, value='PW').font = italic_bold_font + ws.cell(row=current_row, column=9, value=_qtr(pw_hours)).font = italic_bold_font + logger_handler.logger.info(f"Export by Building: Employee {employee_id} PW hours: {pw_hours:.2f}") + current_row += 1 + + # Write PT row if hours > 0 + if pt_hours > 0: + ws.cell(row=current_row, column=8, value='PT').font = italic_bold_font + ws.cell(row=current_row, column=9, value=_qtr(pt_hours)).font = italic_bold_font + logger_handler.logger.info(f"Export by Building: Employee {employee_id} PT hours: {pt_hours:.2f}") + current_row += 1 + + # Write C (Covering) row if hours > 0 + if c_hours > 0: + ws.cell(row=current_row, column=8, value='C').font = italic_bold_font + ws.cell(row=current_row, column=9, value=_qtr(c_hours)).font = italic_bold_font + logger_handler.logger.info(f"Export by Building: Employee {employee_id} C hours: {c_hours:.2f}") + current_row += 1 + + # Write Regular row — only when the employee has special work-type hours. + if _has_special_hours: + ws.cell(row=current_row, column=8, value='Regular').font = italic_bold_font + ws.cell(row=current_row, column=9, value=_qtr(regular_only_hours)).font = italic_bold_font + logger_handler.logger.info(f"Export by Building: Employee {employee_id} Regular hours: {regular_only_hours:.2f}") + current_row += 1 + + # ================================================================ + # End of extra working hours section + # ================================================================ + + # Write GRAND TOTAL row + ws.cell(row=current_row, column=7, value='GRAND TOTAL: ').font = Font(name='Aptos Narrow', size=11, bold=True) + ws.cell(row=current_row, column=9, value=_qtr(grand_regular_hours)).font = Font(name='Aptos Narrow', size=11, bold=True) + ws.cell(row=current_row, column=10, value=_qtr(grand_ot_hours)).font = Font(name='Aptos Narrow', size=11, bold=True) + current_row += 1 + + # Empty row after each employee + current_row += 1 + _bb_emp_meta['end_row'] = current_row - 1 + _bb_block['employees'].append(_bb_emp_meta) + + # Empty row after each building + current_row += 1 + _bb_block['end_row'] = current_row - 1 + _bb_blocks.append(_bb_block) + + # Auto-size columns + for col_idx in range(1, 15): + column_letter = get_column_letter(col_idx) + + if col_idx == 1: + ws.column_dimensions[column_letter].width = 18 + continue + + max_length = 0 + for row in ws.iter_rows(min_col=col_idx, max_col=col_idx): + for cell in row: + if isinstance(cell, openpyxl.cell.cell.MergedCell): + continue + try: + if cell.value and len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except Exception: + pass # Non-string cell value — skip width measurement + + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + # Extra sheets: filtered copy of Sheet0 + weekly hours by location. + # A failure here must never block the Sheet0 export itself. + excluded_ids = current_app.config.get('BUILDING_SUMMARY_EXCLUDED_EMPLOYEE_IDS', []) + try: + _build_filtered_building_sheet(wb, ws, _bb_blocks, _bb_sp_summary_rows, excluded_ids) + _build_weekly_hours_by_location_sheet(wb, _bb_blocks, excluded_ids, + start_date, end_date, project_display) + except Exception as e: + logger_handler.logger.error(f"Export by Building: could not build summary sheets: {e}", exc_info=True) + for _extra in ('Filtered Report', 'Weekly Hours by Location'): + if _extra in wb.sheetnames: + wb.remove(wb[_extra]) + + # Save to BytesIO + output = io.BytesIO() + wb.save(output) + output.seek(0) + + # Filename + if date_range_str: + filename = f'{project_name_for_filename}time_attendance_by_building_{date_range_str}.xlsx' + else: + filename = f'{project_name_for_filename}time_attendance_by_building.xlsx' + + # Log successful export + logger_handler.logger.info( + f"Export by Building completed: {filename} with {len(sorted_locations)} buildings" + ) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) \ No newline at end of file diff --git a/routes/users.py b/routes/users.py new file mode 100644 index 0000000..e8f098e --- /dev/null +++ b/routes/users.py @@ -0,0 +1,974 @@ +""" +routes/users.py +=============== +User management routes (admin-only operations). + +Routes: /users/*, /api/users/stats, /api/locations-by-projects, + /api/roles/permissions, /api/geocode, /api/reverse-geocode +""" +from flask import abort, Blueprint, render_template, request, redirect, flash, session, jsonify, url_for +from datetime import datetime, timedelta +import json + +from extensions import db, logger_handler +from models.permissions import UserLocationPermission, UserProjectPermission +from models.project import Project +from models.qrcode import QRCode +from models.user import User +from sqlalchemy import text +from logger_handler import log_user_activity, log_database_operations +from utils.helpers import ( + admin_required, + generate_qr_code, + get_qr_styling, + get_role_permissions, + has_admin_privileges, + has_staff_level_access, + is_valid_role, + login_required, + staff_or_admin_required, + VALID_ROLES, + STAFF_LEVEL_ROLES) +from utils.geocoding import (geocode_address_enhanced, + get_all_locations_from_qr_codes, + get_coordinates_from_address_enhanced, + reverse_geocode_coordinates, + gmaps_client) +from werkzeug.security import generate_password_hash + +bp = Blueprint('users', __name__) + + + +@bp.route('/users', endpoint='users') +@admin_required +def users(): + """Display all users (Admin only)""" + try: + users = User.query.order_by(User.created_date.desc()).all() + return render_template('users.html', users=users) + except Exception as e: + logger_handler.log_database_error('users_list', e) + flash('Error loading users list.', 'error') + return redirect(url_for('dashboard.dashboard')) + +@bp.route('/users/create', methods=['GET', 'POST'], endpoint='create_user') +@admin_required +@log_database_operations('user_creation') +def create_user(): + """Create new user (Admin only) with Project Manager permissions support""" + if request.method == 'POST': + try: + # Get basic form data + full_name = request.form.get('full_name', '').strip() + email = request.form.get('email', '').strip() + username = request.form.get('username', '').strip() + password = request.form.get('password', '') + role = request.form.get('role', '') + + # Validate required fields + if not all([full_name, email, username, password, role]): + flash('All fields are required.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + locations = get_all_locations_from_qr_codes() + return render_template('create_user.html', projects=projects, locations=locations) + + # Validate role + if role not in VALID_ROLES: + flash(f'Invalid role selected. Valid roles: {", ".join(VALID_ROLES)}', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + locations = get_all_locations_from_qr_codes() + return render_template('create_user.html', projects=projects, locations=locations) + + # Check if user already exists + if User.query.filter_by(username=username).first(): + flash('Username already exists.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + locations = get_all_locations_from_qr_codes() + return render_template('create_user.html', projects=projects, locations=locations) + + if User.query.filter_by(email=email).first(): + flash('Email already registered.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + locations = get_all_locations_from_qr_codes() + return render_template('create_user.html', projects=projects, locations=locations) + + # Create new user + new_user = User( + full_name=full_name, + email=email, + username=username, + role=role, + created_by=session['user_id'] + ) + new_user.set_password(password) + + db.session.add(new_user) + db.session.flush() # Get the user ID without committing + + # Handle Project Manager permissions + if role == 'project_manager': + # Get selected projects - getlist returns empty list if field doesn't exist + selected_projects = request.form.getlist('assigned_projects') + + # Validate and filter project IDs + valid_project_ids = [] + if selected_projects: + for pid in selected_projects: + try: + project_id = int(pid) + # Verify project exists + if db.session.get(Project, project_id): + valid_project_ids.append(project_id) + except (ValueError, TypeError): + logger_handler.logger.warning(f"Invalid project ID received: {pid}") + + # Add project permissions + if valid_project_ids: + for project_id in valid_project_ids: + try: + permission = UserProjectPermission( + user_id=new_user.id, + project_id=project_id + ) + db.session.add(permission) + except Exception as e: + logger_handler.logger.error(f"Error adding project permission: {e}") + + logger_handler.logger.info( + f"Admin {session['username']} assigned {len(valid_project_ids)} projects to new Project Manager {username}" + ) + + # Get selected locations + selected_locations = request.form.getlist('assigned_locations') + + # Filter and clean location names + valid_locations = [] + if selected_locations: + for location in selected_locations: + location_clean = location.strip() + if location_clean: + valid_locations.append(location_clean) + + # Add location permissions + if valid_locations: + for location_name in valid_locations: + try: + permission = UserLocationPermission( + user_id=new_user.id, + location_name=location_name + ) + db.session.add(permission) + except Exception as e: + logger_handler.logger.error(f"Error adding location permission: {e}") + + logger_handler.logger.info( + f"Admin {session['username']} assigned {len(valid_locations)} locations to new Project Manager {username}" + ) + + # Commit all changes + db.session.commit() + + # Log user creation + logger_handler.logger.info(f"Admin user {session['username']} created new user: {username} with role {role}") + + flash(f'User "{full_name}" created successfully with role "{role}".', 'success') + return redirect(url_for('users.users')) + + except KeyError as e: + db.session.rollback() + logger_handler.logger.error(f"Missing form field: {e}") + flash(f'Missing required field: {e}. Please fill in all fields.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + locations = get_all_locations_from_qr_codes() + return render_template('create_user.html', projects=projects, locations=locations) + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('user_creation', e) + logger_handler.logger.error(f"User creation error details: {str(e)}") + flash('User creation failed. Please try again.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + locations = get_all_locations_from_qr_codes() + return render_template('create_user.html', projects=projects, locations=locations) + + # GET request - load form with projects and locations + try: + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + locations = get_all_locations_from_qr_codes() + return render_template('create_user.html', projects=projects, locations=locations) + except Exception as e: + logger_handler.logger.error(f"Error loading create user form: {e}") + flash('Error loading form. Please try again.', 'error') + return redirect(url_for('users.users')) + +@bp.route('/users/<int:user_id>/delete', methods=['GET', 'POST'], endpoint='delete_user') +@admin_required +def delete_user(user_id): + """Deactivate user (Admin only) - Fixed with proper validation""" + try: + user_to_delete = db.session.get(User, user_id) + current_user = db.session.get(User, session['user_id']) + + if not user_to_delete: + flash('User not found.', 'error') + return redirect(url_for('users.users')) + + # Prevent self-deletion + if user_to_delete.id == current_user.id: + flash('You cannot deactivate your own account. Ask another admin to do this.', 'error') + return redirect(url_for('users.users')) + + # Check if trying to delete the last admin + if user_to_delete.role == 'admin': + active_admin_count = User.query.filter_by(role='admin', active_status=True).count() + if active_admin_count <= 1: + flash('Cannot deactivate the last admin user. Promote another user to admin first.', 'error') + return redirect(url_for('users.users')) + + # Deactivate the user instead of deleting + user_to_delete.active_status = False + db.session.commit() + + logger_handler.logger.info( + f"Admin {current_user.username} deactivated user: {user_to_delete.username} (ID: {user_to_delete.id})" + ) + flash(f'User "{user_to_delete.full_name}" has been deactivated successfully.', 'success') + + return redirect(url_for('users.users')) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('user_deactivation', e) + flash('Error deactivating user. Please try again.', 'error') + return redirect(url_for('users.users')) + +@bp.route('/users/<int:user_id>/reactivate', methods=['GET', 'POST'], endpoint='reactivate_user') +@admin_required +def reactivate_user(user_id): + """Reactivate a deactivated user (Admin only)""" + try: + user_to_reactivate = db.session.get(User, user_id) + current_user = db.session.get(User, session['user_id']) + + if not user_to_reactivate: + flash('User not found.', 'error') + return redirect(url_for('users.users')) + + if user_to_reactivate.active_status: + flash('User is already active.', 'info') + else: + user_to_reactivate.active_status = True + db.session.commit() + logger_handler.logger.info( + f"Admin {current_user.username} reactivated user: {user_to_reactivate.username} (ID: {user_to_reactivate.id})" + ) + flash(f'User "{user_to_reactivate.full_name}" has been reactivated successfully.', 'success') + + return redirect(url_for('users.users')) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('user_reactivation', e) + flash('Error reactivating user. Please try again.', 'error') + return redirect(url_for('users.users')) + +@bp.route('/users/<int:user_id>/promote', methods=['GET', 'POST'], endpoint='promote_user') +@admin_required +def promote_user(user_id): + """Promote a staff user to admin (Admin only)""" + try: + user_to_promote = db.session.get(User, user_id) + current_user = db.session.get(User, session['user_id']) + + if not user_to_promote: + flash('User not found.', 'error') + return redirect(url_for('users.users')) + + if user_to_promote.role == 'admin': + flash('User is already an admin.', 'info') + else: + user_to_promote.role = 'admin' + db.session.commit() + logger_handler.logger.info( + f"Admin {current_user.username} promoted user {user_to_promote.username} (ID: {user_to_promote.id}) to admin" + ) + flash(f'"{user_to_promote.full_name}" has been promoted to admin.', 'success') + + return redirect(url_for('users.users')) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('user_promotion', e) + flash('Error promoting user. Please try again.', 'error') + return redirect(url_for('users.users')) + +@bp.route('/users/<int:user_id>/demote', methods=['GET', 'POST'], endpoint='demote_user') +@admin_required +def demote_user(user_id): + """Demote an admin user to staff (Admin only)""" + try: + user_to_demote = db.session.get(User, user_id) + current_user = db.session.get(User, session['user_id']) + + if not user_to_demote: + flash('User not found.', 'error') + return redirect(url_for('users.users')) + + # Prevent self-demotion + if user_to_demote.id == current_user.id: + flash('You cannot demote yourself. Have another admin do this.', 'error') + return redirect(url_for('users.users')) + + # Check if this is the last admin + active_admin_count = User.query.filter_by(role='admin', active_status=True).count() + if active_admin_count <= 1 and user_to_demote.role == 'admin': + flash('Cannot demote the last admin user. Promote another user to admin first.', 'error') + return redirect(url_for('users.users')) + + if has_staff_level_access(user_to_demote.role): + flash('User already has staff-level permissions.', 'info') + else: + user_to_demote.role = 'staff' + db.session.commit() + logger_handler.logger.info( + f"Admin {current_user.username} demoted user {user_to_demote.username} (ID: {user_to_demote.id}) to staff" + ) + flash(f'"{user_to_demote.full_name}" has been demoted to staff.', 'success') + + return redirect(url_for('users.users')) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('user_demotion', e) + flash('Error demoting user. Please try again.', 'error') + return redirect(url_for('users.users')) + +@bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'], endpoint='edit_user') +@admin_required +@log_database_operations('user_edit') +def edit_user(user_id): + """Edit existing user with Project Manager permissions support""" + try: + user_to_edit = db.session.get(User, user_id) + if user_to_edit is None: + abort(404) + + # Track old role for permission cleanup + old_role = user_to_edit.role + + if request.method == 'POST': + # Store old values for change tracking + old_values = { + 'full_name': user_to_edit.full_name, + 'email': user_to_edit.email, + 'username': user_to_edit.username, + 'role': user_to_edit.role, + 'active_status': user_to_edit.active_status + } + changes = {} + + # Update basic info with validation + full_name = request.form.get('full_name', '').strip() + email = request.form.get('email', '').strip() + username = request.form.get('username', '').strip() + + if not all([full_name, email, username]): + flash('Name, email, and username are required.', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + locations = get_all_locations_from_qr_codes() + assigned_project_ids = [] + assigned_location_names = [] + if user_to_edit.role == 'project_manager': + assigned_project_ids = [p.project_id for p in UserProjectPermission.query.filter_by(user_id=user_id).all()] + assigned_location_names = [l.location_name for l in UserLocationPermission.query.filter_by(user_id=user_id).all()] + return render_template('edit_user.html', user=user_to_edit, valid_roles=VALID_ROLES, + projects=projects, locations=locations, + assigned_project_ids=assigned_project_ids, + assigned_location_names=assigned_location_names) + + user_to_edit.full_name = full_name + user_to_edit.email = email + user_to_edit.username = username + + # Update role with validation + new_role = request.form.get('role', '') + if new_role not in VALID_ROLES: + flash(f'Invalid role selected. Valid roles: {", ".join(VALID_ROLES)}', 'error') + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + locations = get_all_locations_from_qr_codes() + assigned_project_ids = [] + assigned_location_names = [] + if user_to_edit.role == 'project_manager': + assigned_project_ids = [p.project_id for p in UserProjectPermission.query.filter_by(user_id=user_id).all()] + assigned_location_names = [l.location_name for l in UserLocationPermission.query.filter_by(user_id=user_id).all()] + return render_template('edit_user.html', user=user_to_edit, valid_roles=VALID_ROLES, + projects=projects, locations=locations, + assigned_project_ids=assigned_project_ids, + assigned_location_names=assigned_location_names) + + user_to_edit.role = new_role + + # Handle password update if provided + new_password = request.form.get('new_password', '') + if new_password and new_password.strip(): + user_to_edit.set_password(new_password) + changes['password'] = 'Password updated' + # Log password change + logger_handler.log_security_event( + event_type="admin_password_change", + description=f"Admin {session['username']} changed password for user {user_to_edit.username}", + severity="MEDIUM" + ) + + # Handle Project Manager permissions + if new_role == 'project_manager': + # Update project permissions + # First, remove existing project permissions + try: + UserProjectPermission.query.filter_by(user_id=user_id).delete() + except Exception as e: + logger_handler.logger.error(f"Error deleting old project permissions: {e}") + + # Add new project permissions + selected_projects = request.form.getlist('assigned_projects') + + # Validate project IDs + valid_project_ids = [] + if selected_projects: + for pid in selected_projects: + try: + project_id = int(pid) + # Verify project exists + if db.session.get(Project, project_id): + valid_project_ids.append(project_id) + except (ValueError, TypeError): + logger_handler.logger.warning(f"Invalid project ID received: {pid}") + + # Add validated project permissions + if valid_project_ids: + for project_id in valid_project_ids: + try: + permission = UserProjectPermission( + user_id=user_id, + project_id=project_id + ) + db.session.add(permission) + except Exception as e: + logger_handler.logger.error(f"Error adding project permission: {e}") + + changes['assigned_projects'] = f'{len(valid_project_ids)} projects assigned' + logger_handler.logger.info( + f"Admin {session['username']} updated project permissions for Project Manager {user_to_edit.username}: {len(valid_project_ids)} projects" + ) + + # Update location permissions + # First, remove existing location permissions + try: + UserLocationPermission.query.filter_by(user_id=user_id).delete() + except Exception as e: + logger_handler.logger.error(f"Error deleting old location permissions: {e}") + + # Add new location permissions + selected_locations = request.form.getlist('assigned_locations') + + # Validate and clean locations + valid_locations = [] + if selected_locations: + for location in selected_locations: + location_clean = location.strip() + if location_clean: + valid_locations.append(location_clean) + + # Add validated location permissions + if valid_locations: + for location_name in valid_locations: + try: + permission = UserLocationPermission( + user_id=user_id, + location_name=location_name + ) + db.session.add(permission) + except Exception as e: + logger_handler.logger.error(f"Error adding location permission: {e}") + + changes['assigned_locations'] = f'{len(valid_locations)} locations assigned' + logger_handler.logger.info( + f"Admin {session['username']} updated location permissions for Project Manager {user_to_edit.username}: {len(valid_locations)} locations" + ) + + # If role changed from project_manager to something else, remove permissions + elif old_role == 'project_manager' and new_role != 'project_manager': + try: + UserProjectPermission.query.filter_by(user_id=user_id).delete() + UserLocationPermission.query.filter_by(user_id=user_id).delete() + logger_handler.logger.info( + f"Admin {session['username']} removed Project Manager permissions from user {user_to_edit.username} (role changed to {new_role})" + ) + except Exception as e: + logger_handler.logger.error(f"Error removing permissions: {e}") + + # Track changes + for field, old_value in old_values.items(): + new_value = getattr(user_to_edit, field) + if old_value != new_value: + changes[field] = {'old': old_value, 'new': new_value} + + # Commit all changes + db.session.commit() + + # Log user update + if changes: + logger_handler.logger.info(f"Admin user {session['username']} updated user {user_to_edit.username}: {json.dumps(changes, default=str)}") + + flash(f'User "{user_to_edit.full_name}" updated successfully.', 'success') + return redirect(url_for('users.users')) + + # GET request - load form with current assignments + try: + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + locations = get_all_locations_from_qr_codes() + + # Get current assignments if user is a project manager + assigned_project_ids = [] + assigned_location_names = [] + + if user_to_edit.role == 'project_manager': + try: + assigned_project_ids = [p.project_id for p in UserProjectPermission.query.filter_by(user_id=user_id).all()] + assigned_location_names = [l.location_name for l in UserLocationPermission.query.filter_by(user_id=user_id).all()] + except Exception as e: + logger_handler.logger.error(f"Error loading current permissions: {e}") + + return render_template('edit_user.html', + user=user_to_edit, + valid_roles=VALID_ROLES, + projects=projects, + locations=locations, + assigned_project_ids=assigned_project_ids, + assigned_location_names=assigned_location_names) + except Exception as e: + logger_handler.logger.error(f"Error loading edit user form: {e}") + flash('Error loading edit form. Please try again.', 'error') + return redirect(url_for('users.users')) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('user_update', e) + logger_handler.logger.error(f"User update error details: {str(e)}") + flash('Error updating user. Please try again.', 'error') + return redirect(url_for('users.users')) + +@bp.route('/users/<int:user_id>/toggle-status', methods=['POST'], endpoint='toggle_user_status') +@admin_required +def toggle_user_status(user_id): + """Toggle user active status via AJAX (Admin only)""" + try: + user_to_toggle = db.session.get(User, user_id) + current_user = db.session.get(User, session['user_id']) + + if not user_to_toggle: + return jsonify({ + 'success': False, + 'message': 'User not found.' + }), 404 + + # Prevent self-deactivation + if user_to_toggle.id == current_user.id: + return jsonify({ + 'success': False, + 'message': 'You cannot deactivate yourself.' + }), 400 + + # Check if trying to deactivate the last admin + if (user_to_toggle.role == 'admin' and + user_to_toggle.active_status and + User.query.filter_by(role='admin', active_status=True).count() <= 1): + return jsonify({ + 'success': False, + 'message': 'Cannot deactivate the last admin user.' + }), 400 + + # Toggle the status + new_status = not user_to_toggle.active_status + user_to_toggle.active_status = new_status + db.session.commit() + + action = 'activated' if new_status else 'deactivated' + message = f'"{user_to_toggle.full_name}" has been {action} successfully.' + + # Log status change + logger_handler.logger.info( + f"Admin {current_user.username} {action} user {user_to_toggle.username} (ID: {user_to_toggle.id})" + ) + + return jsonify({ + 'success': True, + 'message': message, + 'new_status': new_status, + 'user_id': user_id + }) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('user_status_toggle', e) + return jsonify({ + 'success': False, + 'message': 'Error updating user status. Please try again.' + }), 500 + +@bp.route('/users/<int:user_id>/activate', methods=['GET', 'POST'], endpoint='activate_user') +@admin_required +def activate_user(user_id): + """Activate a user (Admin only) - Alternative route""" + try: + user_to_activate = db.session.get(User, user_id) + current_user = db.session.get(User, session['user_id']) + + if not user_to_activate: + flash('User not found.', 'error') + return redirect(url_for('users.users')) + + if user_to_activate.active_status: + flash('User is already active.', 'info') + else: + user_to_activate.active_status = True + db.session.commit() + + # Log activation + logger_handler.logger.info( + f"Admin {current_user.username} activated user {user_to_activate.username} (ID: {user_to_activate.id})" + ) + + flash(f'"{user_to_activate.full_name}" has been activated.', 'success') + + return redirect(url_for('users.users')) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('user_activation', e) + flash('Error activating user. Please try again.', 'error') + return redirect(url_for('users.users')) + +@bp.route('/users/<int:user_id>/deactivate', methods=['GET', 'POST'], endpoint='deactivate_user') +@admin_required +def deactivate_user(user_id): + """Deactivate a user (Admin only) - Alternative route""" + try: + user_to_deactivate = db.session.get(User, user_id) + current_user = db.session.get(User, session['user_id']) + + if not user_to_deactivate: + flash('User not found.', 'error') + return redirect(url_for('users.users')) + + # Prevent self-deactivation + if user_to_deactivate.id == current_user.id: + flash('You cannot deactivate yourself.', 'error') + return redirect(url_for('users.users')) + + # Check if this is the last admin + if user_to_deactivate.role == 'admin' and user_to_deactivate.active_status: + active_admin_count = User.query.filter_by(role='admin', active_status=True).count() + if active_admin_count <= 1: + flash('Cannot deactivate the last admin user.', 'error') + return redirect(url_for('users.users')) + + if not user_to_deactivate.active_status: + flash('User is already inactive.', 'info') + else: + user_to_deactivate.active_status = False + db.session.commit() + + # Log deactivation + logger_handler.logger.info( + f"Admin {current_user.username} deactivated user {user_to_deactivate.username} (ID: {user_to_deactivate.id})" + ) + + flash(f'"{user_to_deactivate.full_name}" has been deactivated.', 'success') + + return redirect(url_for('users.users')) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('user_deactivation', e) + flash('Error deactivating user. Please try again.', 'error') + return redirect(url_for('users.users')) + +# ENHANCED USER STATISTICS API +@bp.route('/api/users/stats', endpoint='user_stats_api') +@admin_required +def user_stats_api(): + """API endpoint to get user statistics for dashboard""" + try: + # Get current date for recent activity calculations + one_week_ago = datetime.now() - timedelta(days=7) + + total_users = User.query.count() + active_users = User.query.filter_by(active_status=True).count() + admin_users = User.query.filter_by(role='admin', active_status=True).count() + staff_users = User.query.filter_by(role='staff', active_status=True).count() + payroll_users = User.query.filter_by(role='payroll', active_status=True).count() + project_manager_users = User.query.filter_by(role='project_manager', active_status=True).count() + accounting_users = User.query.filter_by(role='accounting', active_status=True).count() + inactive_users = User.query.filter_by(active_status=False).count() + + recent_registrations = User.query.filter( + User.created_date >= one_week_ago + ).count() + + recent_logins = User.query.filter( + User.last_login_date >= one_week_ago + ).count() + + return jsonify({ + 'total_users': total_users, + 'active_users': active_users, + 'admin_users': admin_users, + 'staff_users': staff_users, + 'payroll_users': payroll_users, + 'project_manager_users': project_manager_users, + 'accounting_users': accounting_users, + 'inactive_users': inactive_users, + 'recent_registrations': recent_registrations, + 'recent_logins': recent_logins + }) + + except Exception as e: + logger_handler.log_database_error('user_stats_api', e) + return jsonify({'error': 'Failed to fetch user statistics'}), 500 + +@bp.route('/api/locations-by-projects', methods=['POST'], endpoint='get_locations_by_projects') +@admin_required +def get_locations_by_projects(): + """Get locations that belong to selected projects""" + try: + data = request.get_json() + project_ids = data.get('project_ids', []) + + if not project_ids: + # No projects selected, return empty list + return jsonify({ + 'success': True, + 'locations': [], + 'message': 'No projects selected' + }) + + # Get unique locations from QR codes that belong to selected projects + result = db.session.execute(text(""" + SELECT DISTINCT location + FROM qr_codes + WHERE project_id IN :project_ids + AND location IS NOT NULL + AND active_status = 1 + ORDER BY location + """), {'project_ids': tuple(project_ids)}) + + locations = [row[0] for row in result.fetchall()] + + return jsonify({ + 'success': True, + 'locations': locations, + 'count': len(locations) + }) + + except Exception as e: + logger_handler.logger.error(f"Error fetching locations by projects: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + +@bp.route('/api/roles/permissions', endpoint='role_permissions_api') +@admin_required +def role_permissions_api(): + """API endpoint to get role permissions data""" + try: + permissions_data = {} + for role in VALID_ROLES: + permissions_data[role] = get_role_permissions(role) + + return jsonify({ + 'success': True, + 'roles': permissions_data, + 'valid_roles': VALID_ROLES, + 'staff_level_roles': STAFF_LEVEL_ROLES + }) + + except Exception as e: + logger_handler.log_database_error('role_permissions_api', e) + return jsonify({'error': 'Failed to fetch role permissions'}), 500 + +@bp.route('/api/geocode', methods=['POST'], endpoint='geocode_address_api') +@login_required +def geocode_address_api(): + """API endpoint to geocode an address and return coordinates using Google Maps""" + try: + data = request.get_json() + address = data.get('address', '').strip() + + if not address: + return jsonify({ + 'success': False, + 'message': 'Address is required' + }), 400 + + # Log API geocoding request + logger_handler.logger.info(f"API geocoding request from user {session.get('username', 'unknown')}: {address[:50]}") + + # Use the enhanced function that returns 3 values + lat, lng, accuracy = get_coordinates_from_address_enhanced(address) + + if lat is not None and lng is not None: + logger_handler.logger.info( + f"API geocoding success for user {session.get('username', 'unknown')}: " + f"{address[:50]} -> {lat}, {lng} ({accuracy})" + ) + + return jsonify({ + 'success': True, + 'data': { + 'latitude': lat, + 'longitude': lng, + 'accuracy': accuracy, + 'coordinates_display': f"{lat:.10f}, {lng:.10f}", + 'service_used': 'Google Maps' if gmaps_client else 'OpenStreetMap' + }, + 'message': f'Address geocoded successfully with {accuracy} accuracy using {"Google Maps" if gmaps_client else "OpenStreetMap"}' + }) + else: + logger_handler.logger.warning( + f"API geocoding failed for user {session.get('username', 'unknown')}: {address[:50]}" + ) + + return jsonify({ + 'success': False, + 'message': 'Unable to geocode the provided address. Please verify the address is complete and accurate.' + }), 404 + + except Exception as e: + logger_handler.log_flask_error('api_geocoding_error', f'API geocoding error: {str(e)}') + + return jsonify({ + 'success': False, + 'message': 'Internal server error during geocoding. Please try again.' + }), 500 + +@bp.route('/api/reverse-geocode', methods=['POST'], endpoint='reverse_geocode_api') +@login_required +def reverse_geocode_api(): + """API endpoint for reverse geocoding coordinates to address using Google Maps""" + try: + data = request.get_json() + latitude = data.get('latitude') + longitude = data.get('longitude') + + if not latitude or not longitude: + return jsonify({ + 'success': False, + 'message': 'Latitude and longitude are required' + }), 400 + + # Log API reverse geocoding request + logger_handler.logger.info( + f"API reverse geocoding request from user {session.get('username', 'unknown')}: {latitude}, {longitude}" + ) + + # Use the reverse geocoding function + address = reverse_geocode_coordinates(latitude, longitude) + + if address: + return jsonify({ + 'success': True, + 'data': { + 'address': address, + 'coordinates': f"{latitude}, {longitude}", + 'service_used': 'Google Maps' if gmaps_client else 'OpenStreetMap' + }, + 'message': f'Coordinates reverse geocoded successfully using {"Google Maps" if gmaps_client else "OpenStreetMap"}' + }) + else: + return jsonify({ + 'success': False, + 'message': 'Unable to reverse geocode the provided coordinates.' + }), 404 + + except Exception as e: + logger_handler.log_flask_error('api_reverse_geocoding_error', f'API reverse geocoding error: {str(e)}') + + return jsonify({ + 'success': False, + 'message': 'Internal server error during reverse geocoding. Please try again.' + }), 500 + +@bp.route('/users/<int:user_id>/permanently-delete', methods=['GET', 'POST'], endpoint='permanently_delete_user') +@admin_required +def permanently_delete_user(user_id): + """Permanently delete user but preserve associated QR codes (Admin only)""" + try: + user_to_delete = db.session.get(User, user_id) + if user_to_delete is None: + abort(404) + current_user = db.session.get(User, session['user_id']) + + # Security checks + if user_to_delete.id == current_user.id: + flash('You cannot delete your own account.', 'error') + return redirect(url_for('users.users')) + + # Only allow deletion of inactive users for safety + if user_to_delete.active_status: + flash('User must be deactivated before permanent deletion.', 'error') + return redirect(url_for('users.users')) + + # If deleting an admin, ensure at least one admin remains + if user_to_delete.role == 'admin': + active_admin_count = User.query.filter_by(role='admin', active_status=True).count() + if active_admin_count <= 1: + flash('Cannot delete the last admin user in the system.', 'error') + return redirect(url_for('users.users')) + + user_name = user_to_delete.full_name + user_qr_count = user_to_delete.created_qr_codes.count() + username = user_to_delete.username + + # MODIFIED: Preserve QR codes by setting created_by to NULL instead of deleting them + orphaned_qr_codes = QRCode.query.filter_by(created_by=user_id).all() + for qr_code in orphaned_qr_codes: + qr_code.created_by = None + + # Update any users that were created by this user (set created_by to None) + created_users = User.query.filter_by(created_by=user_id).all() + for created_user in created_users: + created_user.created_by = None + + # Log user deletion before actual deletion + logger_handler.log_security_event( + event_type="user_permanent_deletion", + description=f"Admin {current_user.username} permanently deleted user {username}", + severity="HIGH", + additional_data={'deleted_user': username, 'qr_codes_orphaned': user_qr_count} + ) + + # Delete the user + db.session.delete(user_to_delete) + db.session.commit() + + logger_handler.logger.info( + f"Admin {current_user.username} permanently deleted user: {username}, " + f"preserved {user_qr_count} QR codes" + ) + flash( + f'User "{user_name}" has been permanently deleted. ' + f'{user_qr_count} QR codes created by this user are now orphaned but preserved.', + 'success' + ) + + return redirect(url_for('users.users')) + + except Exception as e: + db.session.rollback() + logger_handler.log_database_error('user_permanent_deletion', e) + flash('Error deleting user. Please try again.', 'error') + return redirect(url_for('users.users')) + +# Admin logging routes \ No newline at end of file diff --git a/routes/verification.py b/routes/verification.py new file mode 100644 index 0000000..3e20694 --- /dev/null +++ b/routes/verification.py @@ -0,0 +1,422 @@ +""" +routes/verification.py +====================== +Verification review routes for attendance records requiring photo verification. + +Routes: /verification-review, /verification-review/<id>, + /verification-review/<id>/update, + /api/attendance/<id>/verification-details, + /api/attendance/stats + +""" +from flask import abort, Blueprint, render_template, request, redirect, flash, session, jsonify, send_file, url_for +from datetime import datetime, date, timedelta, time +import io, os, json, re, traceback + +from extensions import db, logger_handler +from models.attendance import AttendanceData +from models.employee import Employee +from models.permissions import UserLocationPermission, UserProjectPermission +from models.project import Project +from models.qrcode import QRCode +from models.user import User +from sqlalchemy import text, or_, and_ +from logger_handler import log_user_activity, log_database_operations +from utils.helpers import ( + admin_required, + get_client_ip, + has_admin_privileges, + has_staff_level_access, + login_required, + staff_or_admin_required) +from utils.geocoding import (calculate_location_accuracy_enhanced, process_location_data_enhanced, + check_location_accuracy_column_exists) +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter + +from routes.attendance import bp # shared blueprint — do not redefine + + +@bp.route('/verification-review', endpoint='verification_review') +@login_required +def verification_review(): + """Admin page to review pending photo verifications""" + try: + # Only admins can access + if session.get('role') not in ['admin', 'payroll', 'accounting']: + flash('Unauthorized access.', 'error') + return redirect(url_for('dashboard.dashboard')) + + # Get filter parameters + status_filter = request.args.get('status', 'pending') + date_from = request.args.get('date_from', '') + date_to = request.args.get('date_to', '') + project_filter = request.args.get('project', '') + location_filter = request.args.get('location', '') + employee_filter = request.args.get('employee', '') + + # Build query - join with QRCode to access project_id + query = AttendanceData.query.join(QRCode).filter( + AttendanceData.verification_required == True + ) + + if status_filter and status_filter != 'all': + query = query.filter(AttendanceData.verification_status == status_filter) + + if date_from: + query = query.filter(AttendanceData.check_in_date >= date_from) + + if date_to: + query = query.filter(AttendanceData.check_in_date <= date_to) + + # Apply project filter + if project_filter: + try: + query = query.filter(QRCode.project_id == int(project_filter)) + except (ValueError, TypeError): + pass + + # Apply location filter + if location_filter: + query = query.filter(AttendanceData.location_name.ilike(f'%{location_filter}%')) + + # Apply employee ID filter + if employee_filter: + query = query.filter(AttendanceData.employee_id.ilike(f'%{employee_filter}%')) + + # Get records with QR code information + verifications = query.order_by( + AttendanceData.verification_timestamp.desc() + ).all() + + # Build a dictionary for employee names lookup + employee_names = {} + for record in verifications: + if record.employee_id and record.employee_id not in employee_names: + try: + employee = Employee.query.filter_by(id=int(record.employee_id)).first() + if employee: + employee_names[record.employee_id] = f"{employee.lastName}, {employee.firstName}" + else: + employee_names[record.employee_id] = None + except (ValueError, TypeError): + employee_names[record.employee_id] = None + + # Build a dictionary for project names lookup + project_names = {} + for record in verifications: + if record.qr_code and record.qr_code.project_id: + project_id = record.qr_code.project_id + if project_id not in project_names: + try: + project = db.session.get(Project, project_id) + if project: + project_names[project_id] = project.name + else: + project_names[project_id] = None + except Exception: + project_names[project_id] = None + + # Get counts for status badges + pending_count = AttendanceData.query.filter( + AttendanceData.verification_status == 'pending' + ).count() + + approved_count = AttendanceData.query.filter( + AttendanceData.verification_status == 'approved' + ).count() + + rejected_count = AttendanceData.query.filter( + AttendanceData.verification_status == 'rejected' + ).count() + + # Get all projects for filter dropdown + projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() + + # Get unique locations for filter dropdown + locations = db.session.query(AttendanceData.location_name).filter( + AttendanceData.verification_required == True + ).distinct().order_by(AttendanceData.location_name).all() + location_list = [loc[0] for loc in locations if loc[0]] + + # Log access + logger_handler.logger.info( + f"User {session.get('username')} ({session.get('role')}) accessed verification review page" + ) + + return render_template('verification_review.html', + verifications=verifications, + pending_count=pending_count, + approved_count=approved_count, + rejected_count=rejected_count, + status_filter=status_filter, + date_from=date_from, + date_to=date_to, + project_filter=project_filter, + location_filter=location_filter, + employee_filter=employee_filter, + projects=projects, + locations=location_list, + employee_names=employee_names, + project_names=project_names) + + except Exception as e: + logger_handler.logger.error(f"Error in verification review: {e}") + flash('Error loading verification review.', 'error') + return redirect(url_for('dashboard.dashboard')) + +@bp.route('/verification-review/<int:record_id>/update', methods=['POST'], endpoint='update_verification_status') +@login_required +@log_database_operations('verification_update') +def update_verification_status(record_id): + """Update verification status (approve/reject)""" + try: + # Only admins can update + if session.get('role') not in ['admin', 'payroll', 'accounting']: + return jsonify({ + 'success': False, + 'message': 'Unauthorized access' + }), 403 + + record = db.session.get(AttendanceData, record_id) + if record is None: + abort(404) + + new_status = request.json.get('status') + admin_note = request.json.get('note', '') + + if new_status not in ['approved', 'rejected']: + return jsonify({ + 'success': False, + 'message': 'Invalid status' + }), 400 + + # Update record + record.verification_status = new_status + record.edit_note = f"Verification {new_status} by {session.get('username')}. {admin_note}" + + db.session.commit() + + # Log the action + logger_handler.log_photo_verification( + employee_id=record.employee_id, + qr_code_id=record.qr_code_id, + distance=record.location_accuracy or 0, + status=new_status + ) + + return jsonify({ + 'success': True, + 'message': f'Verification {new_status} successfully' + }) + + except Exception as e: + db.session.rollback() + logger_handler.logger.error(f"Error updating verification: {e}") + return jsonify({ + 'success': False, + 'message': 'Error updating verification status' + }), 500 + +@bp.route('/api/attendance/<int:record_id>/verification-details', endpoint='get_verification_details') +@login_required +def get_verification_details(record_id): + """API endpoint to get verification details for a specific record""" + try: + # Get the attendance record with verification data + record = db.session.get(AttendanceData, record_id) + if record is None: + abort(404) + + # DEBUG: Log record details + logger_handler.logger.debug( + f"Verification details: record={record.id}, employee={record.employee_id}, " + f"date={record.check_in_date}, time={record.check_in_time}, " + f"has_photo={record.verification_photo is not None}, status={record.verification_status}" + ) + + # Check if user has permission to view + # Allow admin and payroll staff to view verification details + if session.get('role') not in ['admin', 'payroll', 'accounting']: + return jsonify({ + 'success': False, + 'message': 'Unauthorized access' + }), 403 + + # Log the access for security audit + logger_handler.logger.info(f"User {session.get('username')} ({session.get('role')}) accessed verification details for record {record_id}") + + # Safely format dates/times with error handling + try: + check_in_date_str = record.check_in_date.strftime('%Y-%m-%d') if record.check_in_date else 'N/A' + except Exception as e: + logger_handler.logger.warning(f"Error formatting check_in_date for record {record_id}: {e}") + check_in_date_str = str(record.check_in_date) if record.check_in_date else 'N/A' + + try: + check_in_time_str = record.check_in_time.strftime('%I:%M %p') if record.check_in_time else 'N/A' + except Exception as e: + logger_handler.logger.warning(f"Error formatting check_in_time for record {record_id}: {e}") + check_in_time_str = str(record.check_in_time) if record.check_in_time else 'N/A' + + # Prepare record data with safe formatting + try: + check_in_date_str = record.check_in_date.strftime('%Y-%m-%d') if record.check_in_date else 'N/A' + except Exception as e: + logger_handler.logger.debug(f"check_in_date strftime failed: {e}") + check_in_date_str = str(record.check_in_date) if record.check_in_date else 'N/A' + + try: + check_in_time_str = record.check_in_time.strftime('%I:%M %p') if record.check_in_time else 'N/A' + except Exception as e: + logger_handler.logger.debug(f"check_in_time strftime failed: {e}") + check_in_time_str = str(record.check_in_time) if record.check_in_time else 'N/A' + + record_data = { + 'id': record.id, + 'employee_id': record.employee_id, + 'location_name': record.location_name or 'Unknown', + 'check_in_date': check_in_date_str, + 'check_in_time': check_in_time_str, + 'location_accuracy': float(record.location_accuracy) if record.location_accuracy else None, + 'checked_in_address': record.address or 'No address', + 'verification_photo': record.verification_photo, + 'verification_status': record.verification_status, + 'verification_required': record.verification_required, + 'device_info': record.device_info or 'Unknown' + } + + return jsonify({ + 'success': True, + 'record': record_data + }) + + except Exception as e: + logger_handler.logger.error(f"Error in get_verification_details for record {record_id}: {e}", exc_info=True) + + return jsonify({ + 'success': False, + 'message': 'Error loading verification details' + }), 500 + +@bp.route('/verification-review/<int:record_id>', endpoint='verification_review_detail') +@login_required +def verification_review_detail(record_id): + """Review a single verification photo on a dedicated page""" + try: + # Check permissions + if session.get('role') not in ['admin', 'payroll', 'accounting']: + flash('Access denied. Only administrators, payroll, and accounting staff can review verification photos.', 'error') + return redirect(url_for('attendance.attendance_report')) + + # Get the attendance record + record = db.session.get(AttendanceData, record_id) + if record is None: + abort(404) + + # Check if this record has verification + if not record.verification_required: + flash('This record does not require verification.', 'warning') + return redirect(url_for('attendance.attendance_report')) + + # Get the QR code information for additional context + qr_code = db.session.get(QRCode, record.qr_code_id) if record.qr_code_id else None + + # Get employee name from Employee table + employee_name = None + try: + if record.employee_id: + employee = Employee.query.filter_by(id=int(record.employee_id)).first() + if employee: + employee_name = f"{employee.lastName}, {employee.firstName}" + else: + employee_name = f"Unknown (ID: {record.employee_id})" + except (ValueError, TypeError) as e: + logger_handler.logger.warning(f"Could not lookup employee name for ID {record.employee_id}: {e}") + employee_name = f"Unknown (ID: {record.employee_id})" + + # Get event type from QR code (Check In/Check Out) + location_event = qr_code.location_event if qr_code and qr_code.location_event else 'N/A' + + # Log the access for audit trail + logger_handler.logger.info( + f"User {session.get('username')} ({session.get('role')}) " + f"accessed verification review for record {record_id}" + ) + + # Format date and time for display + try: + check_in_date = record.check_in_date.strftime('%m/%d/%Y') if record.check_in_date else 'N/A' + except Exception as e: + logger_handler.logger.debug(f"check_in_date strftime failed: {e}") + check_in_date = str(record.check_in_date) if record.check_in_date else 'N/A' + + try: + check_in_time = record.check_in_time.strftime('%I:%M %p') if record.check_in_time else 'N/A' + except Exception as e: + logger_handler.logger.debug(f"check_in_time strftime failed: {e}") + check_in_time = str(record.check_in_time) if record.check_in_time else 'N/A' + + return render_template('verification_review_detail.html', + record=record, + qr_code=qr_code, + check_in_date=check_in_date, + check_in_time=check_in_time, + employee_name=employee_name, + location_event=location_event) + + except Exception as e: + logger_handler.logger.error(f"Error loading verification review detail: {e}") + flash('Error loading verification details.', 'error') + return redirect(url_for('attendance.attendance_report')) + +@bp.route('/api/attendance/stats', endpoint='attendance_stats_api') +@admin_required +def attendance_stats_api(): + """API endpoint for attendance statistics""" + try: + # Daily stats for the last 7 days + daily_stats = db.session.execute(text(""" + SELECT + check_in_date, + COUNT(*) as checkins, + COUNT(DISTINCT employee_id) as unique_employees + FROM attendance_data + WHERE check_in_date >= CURRENT_DATE - INTERVAL '7 days' + GROUP BY check_in_date + ORDER BY check_in_date DESC + """)).fetchall() + + # Location stats + location_stats = db.session.execute(text(""" + SELECT + location_name, + COUNT(*) as total_checkins, + COUNT(DISTINCT employee_id) as unique_employees + FROM attendance_data + GROUP BY location_name + ORDER BY total_checkins DESC + LIMIT 10 + """)).fetchall() + + # Peak hours + hourly_stats = db.session.execute(text(""" + SELECT + EXTRACT(hour FROM check_in_time) as hour, + COUNT(*) as checkins + FROM attendance_data + WHERE check_in_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY EXTRACT(hour FROM check_in_time) + ORDER BY hour + """)).fetchall() + + return jsonify({ + 'daily_stats': [{'date': str(row[0]), 'checkins': row[1], 'employees': row[2]} for row in daily_stats], + 'location_stats': [{'location': row[0], 'checkins': row[1], 'employees': row[2]} for row in location_stats], + 'hourly_stats': [{'hour': int(row[0]), 'checkins': row[1]} for row in hourly_stats] + }) + + except Exception as e: + logger_handler.logger.error(f"Error fetching attendance stats: {e}", exc_info=True) + return jsonify({'error': 'Failed to fetch attendance statistics'}), 500 diff --git a/single_checkin_calculator.py b/single_checkin_calculator.py new file mode 100644 index 0000000..64e4139 --- /dev/null +++ b/single_checkin_calculator.py @@ -0,0 +1,479 @@ +#!/usr/bin/env python3 +""" +Safe Enhanced Single Check-in Working Hours Calculator with SP/PW Support +======================================================================== + +This version maintains full backward compatibility while adding SP/PW support. +It gracefully handles missing data and falls back to standard calculation when needed. +""" + +from datetime import datetime, timedelta, time +from typing import List, Dict, Optional, Tuple, Any +from dataclasses import dataclass +import math +import re +from logger_handler import log_database_operations + + +def parse_employee_id_for_work_type(employee_id: str) -> Tuple[str, str]: + """Parse employee ID to extract base ID and work type (supports SP, PW, PT)""" + if not employee_id: + return str(employee_id), 'regular' + + employee_id_clean = str(employee_id).strip().upper() + + # Check for SP (Special Project) + sp_pattern = r'^(\d+)\s*SP$' + sp_match = re.match(sp_pattern, employee_id_clean) + if sp_match: + return sp_match.group(1), 'SP' + + # Check for PW (Periodic Work) + pw_pattern = r'^(\d+)\s*PW$' + pw_match = re.match(pw_pattern, employee_id_clean) + if pw_match: + return pw_match.group(1), 'PW' + + # Check for PT (Part-Time) + pt_pattern = r'^(\d+)\s*PT$' + pt_match = re.match(pt_pattern, employee_id_clean) + if pt_match: + return pt_match.group(1), 'PT' + + # Default to regular work + return employee_id_clean, 'regular' + +def round_time_to_quarter_hour(minutes: float) -> float: + """ + Round time to nearest quarter hour based on 7.5-minute increments + + Rules: + - 0:00-0:07 → 0:00 + - 0:08-0:22 → 0:15 + - 0:23-0:37 → 0:30 + - 0:38-0:52 → 0:45 + - 0:53-1:07 → 1:00 + """ + if minutes < 0: + return 0.0 + + # Get minutes within the hour + total_minutes = minutes + hours = int(total_minutes // 60) + minutes_in_hour = total_minutes % 60 + + # Determine rounding + if minutes_in_hour <= 7: + rounded_in_hour = 0 + elif minutes_in_hour <= 22: + rounded_in_hour = 15 + elif minutes_in_hour <= 37: + rounded_in_hour = 30 + elif minutes_in_hour <= 52: + rounded_in_hour = 45 + else: # 53-59 + hours += 1 + rounded_in_hour = 0 + + return hours * 60 + rounded_in_hour + + +def convert_minutes_to_base100(minutes: float) -> float: + """Convert minutes to base-100 hours (each hour = 100 units)""" + if minutes < 0: + return 0.0 + + decimal_hours = minutes / 60.0 + whole_hours = int(decimal_hours) + fractional_hours = decimal_hours - whole_hours + base100_fraction = fractional_hours * 100 + + return whole_hours + (base100_fraction / 100) + + +def round_base100_hours(base100_hours: float) -> float: + """ + Round base-100 hours to nearest quarter using 12.5 thresholds + + Examples: + - 4.12 → 4.00 + - 4.18 → 4.25 + - 8.02 → 8.00 + """ + if base100_hours < 0: + return 0.0 + + whole_hours = int(base100_hours) + fractional_part = (base100_hours - whole_hours) * 100 + + # Round to nearest quarter + if fractional_part < 12.5: + rounded_fraction = 0 + elif fractional_part < 37.5: + rounded_fraction = 25 + elif fractional_part < 62.5: + rounded_fraction = 50 + elif fractional_part < 87.5: + rounded_fraction = 75 + else: + whole_hours += 1 + rounded_fraction = 0 + + return round(whole_hours + (rounded_fraction / 100), 2) + +class SingleCheckInCalculator: + """Enhanced calculator for single check-in attendance systems with SP/PW support""" + + def __init__(self, max_work_period_hours: float = 12.0, min_break_minutes: int = 30): + self.max_work_period_hours = max_work_period_hours + self.min_break_minutes = min_break_minutes + + @log_database_operations('single_checkin_hours_calculation_sp_pw') + def calculate_employee_hours(self, employee_id: str, start_date: datetime, end_date: datetime, + attendance_records: List[Dict]) -> Dict[str, Any]: + """ + Calculate working hours for an employee with SP/PW support and robust error handling + """ + try: + print(f"🔍 Calculating hours for employee {employee_id} with SP/PW support") + + # Parse base employee ID and work type + base_employee_id, _ = parse_employee_id_for_work_type(employee_id) + + # Filter and categorize records + records_by_type = {'regular': [], 'SP': [], 'PW': [], 'PT': []} + + for record in attendance_records: + try: + # Extract record data safely + if hasattr(record, '__dict__'): + record_emp_id = str(getattr(record, 'employee_id', '')).strip() + record_date = getattr(record, 'check_in_date', None) + record_time = getattr(record, 'check_in_time', None) + location = getattr(record, 'location_name', 'Unknown Location') + record_id = getattr(record, 'id', 0) + else: + record_emp_id = str(record.get('employee_id', '')).strip() + record_date = record.get('check_in_date') + record_time = record.get('check_in_time') + location = record.get('location_name', 'Unknown Location') + record_id = record.get('id', 0) + + # Skip invalid records + if not record_emp_id or record_date is None or record_time is None: + continue + + # Parse work type from this record + record_base_id, work_type = parse_employee_id_for_work_type(record_emp_id) + + # Only include records for this base employee + if record_base_id == base_employee_id: + # Create a simple record dict for processing + processed_record = { + 'id': record_id, + 'employee_id': record_emp_id, + 'check_in_date': record_date, + 'check_in_time': record_time, + 'location_name': location, + 'work_type': work_type, + 'action_description': getattr(record, 'action_description', '') if hasattr(record, '__dict__') else record.get('action_description', ''), + 'timestamp': datetime.combine(record_date, record_time) if record_date and record_time else datetime.now() + } + records_by_type[work_type].append(processed_record) + + except Exception as record_error: + print(f"⚠️ Error processing record: {record_error}") + continue + + total_records = sum(len(records_by_type[wt]) for wt in records_by_type) + print(f"📊 Found {total_records} records - Regular: {len(records_by_type['regular'])}, SP: {len(records_by_type['SP'])}, PW: {len(records_by_type['PW'])}") + + # Calculate hours for each work type + daily_hours = {} + weekly_totals = [] + current_date = start_date + current_week_hours = {'regular': [], 'SP': [], 'PW': [], 'PT': []} + + while current_date <= end_date: + date_key = current_date.strftime('%Y-%m-%d') + + # Calculate hours for each work type on this day + hours_by_type = {} + is_miss_punch_by_type = {} + + for work_type in ['regular', 'SP', 'PW', 'PT']: + day_records = [r for r in records_by_type[work_type] + if r['check_in_date'] == current_date.date()] + hours, is_miss_punch = self._calculate_daily_hours_from_records(day_records) + hours_by_type[work_type] = hours + is_miss_punch_by_type[work_type] = is_miss_punch + + # Store daily data with SP/PW/PT support + total_day_hours = sum(hours_by_type.values()) + daily_hours[date_key] = { + 'total_minutes': int(total_day_hours * 60), + 'total_hours': total_day_hours, + 'regular_hours': hours_by_type['regular'], + 'sp_hours': hours_by_type['SP'], + 'pw_hours': hours_by_type['PW'], + 'pt_hours': hours_by_type['PT'], + 'is_miss_punch': any(is_miss_punch_by_type.values()), + 'records_count': sum(len([r for r in records_by_type[wt] if r['check_in_date'] == current_date.date()]) + for wt in ['regular', 'SP', 'PW', 'PT']), + 'miss_punch_details': { + 'regular': is_miss_punch_by_type['regular'], + 'SP': is_miss_punch_by_type['SP'], + 'PW': is_miss_punch_by_type['PW'], + 'PT': is_miss_punch_by_type['PT'] + } + } + + # Add to weekly calculation + for work_type in ['regular', 'SP', 'PW', 'PT']: + current_week_hours[work_type].append(max(0, hours_by_type[work_type])) + + # Check if end of week or end of period + if current_date.weekday() == 6 or current_date == end_date: + week_regular_total = sum(current_week_hours['regular']) + week_sp_total = sum(current_week_hours['SP']) + week_pw_total = sum(current_week_hours['PW']) + week_pt_total = sum(current_week_hours['PT']) + + # Round the weekly total + week_total_raw = week_regular_total + week_sp_total + week_pw_total + week_pt_total + week_total_rounded = round_base100_hours(week_total_raw) + + # Only regular hours count toward overtime + week_regular_hours = min(week_regular_total, 40.0) + week_overtime_hours = max(0, week_regular_total - 40.0) + + # Round regular, overtime, and special hours + week_regular_hours_rounded = round_base100_hours(week_regular_hours) + week_overtime_hours_rounded = round_base100_hours(week_overtime_hours) + week_sp_hours_rounded = round_base100_hours(week_sp_total) + week_pw_hours_rounded = round_base100_hours(week_pw_total) + week_pt_hours_rounded = round_base100_hours(week_pt_total) + + weekly_totals.append({ + 'total_hours': week_total_rounded, + 'regular_hours': week_regular_hours_rounded, + 'overtime_hours': week_overtime_hours_rounded, + 'sp_hours': week_sp_hours_rounded, + 'pw_hours': week_pw_hours_rounded, + 'pt_hours': week_pt_hours_rounded, + 'total_minutes': int(week_total_rounded * 60), + 'regular_minutes': int(week_regular_hours_rounded * 60), + 'overtime_minutes': int(week_overtime_hours_rounded * 60), + 'sp_minutes': int(week_sp_hours_rounded * 60), + 'pw_minutes': int(week_pw_hours_rounded * 60), + 'pt_minutes': int(week_pt_hours_rounded * 60) + }) + + current_week_hours = {'regular': [], 'SP': [], 'PW': [], 'PT': []} + + current_date += timedelta(days=1) + + # Calculate grand totals + grand_total_raw = sum(week['total_hours'] for week in weekly_totals) + grand_regular_raw = sum(week['regular_hours'] for week in weekly_totals) + grand_overtime_raw = sum(week['overtime_hours'] for week in weekly_totals) + grand_sp_raw = sum(week['sp_hours'] for week in weekly_totals) + grand_pw_raw = sum(week['pw_hours'] for week in weekly_totals) + grand_pt_raw = sum(week.get('pt_hours', 0) for week in weekly_totals) + + # Round grand totals + grand_total_hours = round_base100_hours(grand_total_raw) + grand_regular_hours = round_base100_hours(grand_regular_raw) + grand_overtime_hours = round_base100_hours(grand_overtime_raw) + grand_sp_hours = round_base100_hours(grand_sp_raw) + grand_pw_hours = round_base100_hours(grand_pw_raw) + grand_pt_hours = round_base100_hours(grand_pt_raw) + + print(f"✅ Employee {employee_id}: Total: {grand_total_hours:.2f}h (Regular: {grand_regular_hours:.2f}h, OT: {grand_overtime_hours:.2f}h, SP: {grand_sp_hours:.2f}h, PW: {grand_pw_hours:.2f}h, PT: {grand_pt_hours:.2f}h)") + + result = { + 'employee_id': employee_id, + 'base_employee_id': base_employee_id, + 'start_date': start_date.strftime('%Y-%m-%d'), + 'end_date': end_date.strftime('%Y-%m-%d'), + 'include_travel_time': True, + 'daily_hours': daily_hours, + 'weekly_hours': weekly_totals, + 'grand_totals': { + 'total_hours': grand_total_hours, + 'regular_hours': grand_regular_hours, + 'overtime_hours': grand_overtime_hours, + 'sp_hours': grand_sp_hours, + 'pw_hours': grand_pw_hours, + 'pt_hours': grand_pt_hours, + 'total_minutes': int(grand_total_hours * 60), + 'regular_minutes': int(grand_regular_hours * 60), + 'overtime_minutes': int(grand_overtime_hours * 60), + 'sp_minutes': int(grand_sp_hours * 60), + 'pw_minutes': int(grand_pw_hours * 60), + 'pt_minutes': int(grand_pt_hours * 60) + } + } + + return result + + except Exception as e: + print(f"❌ Error calculating working hours for employee {employee_id}: {e}") + import traceback + print(f"❌ Traceback: {traceback.format_exc()}") + return self._empty_result(employee_id, start_date, end_date) + + def _calculate_daily_hours_from_records(self, day_records: List[Dict]) -> Tuple[float, bool]: + """Calculate hours for a single day from processed records""" + if not day_records: + return 0.0, False + + # Sort records by timestamp + try: + sorted_records = sorted(day_records, key=lambda r: r['timestamp']) + except Exception as e: + print(f"⚠️ Error sorting records: {e}") + return 0.0, True + + # Single record = miss punch + if len(sorted_records) == 1: + return 0.0, True + + # NEW: Check if all records are IN or all OUT using action_description + if len(sorted_records) >= 2: + record_types = [] + for record in sorted_records: + action_desc = record.get('action_description', '').lower() + if 'out' in action_desc or 'checkout' in action_desc: + record_types.append('OUT') + else: + record_types.append('IN') + + # If all are IN or all are OUT, return 0 hours with miss punch + if len(set(record_types)) == 1: + print(f"⚠️ All {len(sorted_records)} records are {record_types[0]} - 0 working hours") + return 0.0, True + + # Calculate complete pairs only + num_complete_pairs = len(sorted_records) // 2 + total_hours = 0.0 + total_rounded_minutes = 0.0 # NEW: Track rounded minutes for Daily Total + + for i in range(0, num_complete_pairs * 2, 2): + try: + start_time = sorted_records[i]['timestamp'] + end_time = sorted_records[i + 1]['timestamp'] + + # Calculate exact hours (for Hours/Building column) + duration = end_time - start_time + exact_minutes = duration.total_seconds() / 60.0 + hours = exact_minutes / 60.0 + total_hours += hours + + # NEW: Round this pair's time and accumulate for Daily Total + pair_rounded_minutes = round_time_to_quarter_hour(exact_minutes) + total_rounded_minutes += pair_rounded_minutes + + except Exception as e: + print(f"⚠️ Error calculating pair hours: {e}") + return 0.0, True + + # NEW: Convert rounded minutes to base-100 and round + base100_hours = convert_minutes_to_base100(total_rounded_minutes) + rounded_total_hours = round_base100_hours(base100_hours) + + # Odd number of records = miss punch + is_miss_punch = (len(sorted_records) % 2 != 0) + + # NEW: Return rounded hours instead of exact hours for Daily Total + return rounded_total_hours, is_miss_punch + + def _empty_result(self, employee_id: str, start_date: datetime, end_date: datetime) -> Dict[str, Any]: + """Return empty result structure with SP/PW/PT support""" + base_employee_id, _ = parse_employee_id_for_work_type(employee_id) + + return { + 'employee_id': employee_id, + 'base_employee_id': base_employee_id, + 'start_date': start_date.strftime('%Y-%m-%d'), + 'end_date': end_date.strftime('%Y-%m-%d'), + 'include_travel_time': True, + 'daily_hours': {}, + 'weekly_hours': [], + 'grand_totals': { + 'total_hours': 0.0, + 'regular_hours': 0.0, + 'overtime_hours': 0.0, + 'sp_hours': 0.0, + 'pw_hours': 0.0, + 'pt_hours': 0.0, + 'total_minutes': 0, + 'regular_minutes': 0, + 'overtime_minutes': 0, + 'sp_minutes': 0, + 'pw_minutes': 0, + 'pt_minutes': 0 + } + } + + def calculate_all_employees_hours(self, start_date: datetime, end_date: datetime, + attendance_records: List[Dict]) -> Dict[str, Any]: + """Calculate working hours for all employees with robust error handling""" + try: + print(f"🚀 Starting calculation for all employees with SP/PW support") + + # Get unique base employee IDs safely + base_employee_ids = set() + for record in attendance_records: + try: + if hasattr(record, '__dict__'): + employee_id = str(getattr(record, 'employee_id', '')).strip() + else: + employee_id = str(record.get('employee_id', '')).strip() + + if employee_id: + base_id, _ = parse_employee_id_for_work_type(employee_id) + if base_id: + base_employee_ids.add(base_id) + + except Exception as e: + print(f"⚠️ Error processing employee ID: {e}") + continue + + print(f"👥 Found {len(base_employee_ids)} unique base employees") + + results = {} + for base_emp_id in sorted(base_employee_ids): + try: + print(f"\n🔄 Processing base employee {base_emp_id}") + results[base_emp_id] = self.calculate_employee_hours( + base_emp_id, start_date, end_date, attendance_records + ) + except Exception as e: + print(f"❌ Error processing employee {base_emp_id}: {e}") + results[base_emp_id] = self._empty_result(base_emp_id, start_date, end_date) + continue + + return { + 'calculation_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'period_start': start_date.strftime('%Y-%m-%d'), + 'period_end': end_date.strftime('%Y-%m-%d'), + 'include_travel_time': True, + 'employee_count': len(base_employee_ids), + 'employees': results + } + + except Exception as e: + print(f"❌ Error calculating hours for all employees: {e}") + import traceback + print(f"❌ Traceback: {traceback.format_exc()}") + + # Return minimal safe result + return { + 'calculation_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'period_start': start_date.strftime('%Y-%m-%d'), + 'period_end': end_date.strftime('%Y-%m-%d'), + 'include_travel_time': True, + 'employee_count': 0, + 'employees': {} + } \ No newline at end of file diff --git a/static/css/admin_logs.css b/static/css/admin_logs.css new file mode 100644 index 0000000..cbde946 --- /dev/null +++ b/static/css/admin_logs.css @@ -0,0 +1,830 @@ +/* Admin Logs Styles - Enhanced version */ +.logs-page { + padding: var(--spacing-6); + max-width: 1400px; + margin: 0 auto; +} + +.logs-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: var(--spacing-8); + padding-bottom: var(--spacing-6); + border-bottom: 2px solid var(--gray-200); +} + +.header-content h1 { + font-size: var(--font-size-3xl); + font-weight: 700; + color: var(--gray-900); + margin-bottom: var(--spacing-2); + display: flex; + align-items: center; + gap: var(--spacing-3); +} + +.header-content p { + color: var(--gray-600); + font-size: var(--font-size-lg); +} + +.back-button { + display: inline-flex; + align-items: center; + gap: var(--spacing-2); + color: var(--primary-color); + text-decoration: none; + font-size: var(--font-size-sm); + margin-bottom: var(--spacing-4); + transition: var(--transition); +} + +.back-button:hover { + color: var(--primary-hover); +} + +.header-actions { + display: flex; + gap: var(--spacing-3); +} + +/* Log Statistics */ +.log-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--spacing-6); + margin-bottom: var(--spacing-8); +} + +.stat-card { + background: var(--white); + padding: var(--spacing-6); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); + display: flex; + align-items: center; + gap: var(--spacing-4); + transition: var(--transition); +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.stat-icon { + width: 60px; + height: 60px; + border-radius: var(--radius-lg); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-xl); + color: var(--white); + background: linear-gradient( + 135deg, + var(--primary-color), + var(--primary-hover) + ); +} + +.stat-icon.security { + background: linear-gradient(135deg, #f59e0b, #b45309); +} + +.stat-icon.errors { + background: linear-gradient(135deg, #ef4444, #b91c1c); +} + +.stat-icon.users { + background: linear-gradient(135deg, #10b981, #059669); +} + +.stat-info h3 { + font-size: var(--font-size-2xl); + font-weight: 700; + color: var(--gray-900); + margin-bottom: var(--spacing-1); +} + +.stat-info p { + color: var(--gray-500); + font-size: var(--font-size-sm); +} + +/* Log Controls */ +.log-controls { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-6); + padding: var(--spacing-4); + background: var(--white); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); +} + +.search-filters { + display: flex; + align-items: center; + gap: var(--spacing-4); + flex: 1; +} + +.search-box { + position: relative; + flex: 1; + max-width: 400px; +} + +.search-box input { + width: 100%; + padding: var(--spacing-3) var(--spacing-3) var(--spacing-3) var(--spacing-10); + border: 1px solid var(--gray-300); + border-radius: var(--radius); + font-size: var(--font-size-sm); + transition: var(--transition); +} + +.search-box input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.search-box i { + position: absolute; + left: var(--spacing-3); + top: 50%; + transform: translateY(-50%); + color: var(--gray-400); +} + +.filter-group { + display: flex; + gap: var(--spacing-3); +} + +.filter-select { + padding: var(--spacing-2) var(--spacing-3); + border: 1px solid var(--gray-300); + border-radius: var(--radius); + font-size: var(--font-size-sm); + background: var(--white); + transition: var(--transition); +} + +.filter-select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +/* Logs Table */ +.logs-table-container { + background: var(--white); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + overflow: hidden; + margin-bottom: var(--spacing-6); +} + +.logs-table { + width: 100%; + border-collapse: collapse; +} + +.logs-table th, +.logs-table td { + padding: var(--spacing-3) var(--spacing-4); + text-align: left; + border-bottom: 1px solid var(--gray-200); +} + +.logs-table th { + background: var(--gray-50); + font-weight: 600; + color: var(--gray-700); + font-size: var(--font-size-sm); + position: sticky; + top: 0; + z-index: 10; +} + +.logs-table tr:hover { + background: var(--gray-50); +} + +.logs-table tr.severity-high { + border-left: 4px solid #ef4444; +} + +.logs-table tr.severity-medium { + border-left: 4px solid #f59e0b; +} + +.logs-table tr.severity-low { + border-left: 4px solid #10b981; +} + +.logs-table tr.severity-info { + border-left: 4px solid #3b82f6; +} + +.timestamp { + font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; + font-size: var(--font-size-xs); + color: var(--gray-600); + white-space: nowrap; + min-width: 150px; +} + +.event-type { + font-weight: 600; + color: var(--gray-900); + font-size: var(--font-size-sm); + min-width: 120px; +} + +.category-badge, +.severity-badge { + padding: var(--spacing-1) var(--spacing-2); + border-radius: var(--radius); + font-size: var(--font-size-xs); + font-weight: 600; + text-transform: uppercase; + display: inline-block; +} + +.category-badge.security { + background: #fef3c7; + color: #92400e; +} + +.category-badge.database { + background: #dbeafe; + color: #1e40af; +} + +.category-badge.user_activity { + background: #d1fae5; + color: #065f46; +} + +.category-badge.system { + background: #f3e8ff; + color: #7c3aed; +} + +.severity-badge.high { + background: #ef4444; + color: var(--white); +} + +.severity-badge.medium { + background: #f59e0b; + color: var(--white); +} + +.severity-badge.low { + background: #10b981; + color: var(--white); +} + +.severity-badge.info { + background: #3b82f6; + color: var(--white); +} + +.description { + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--font-size-sm); +} + +.description:hover { + overflow: visible; + white-space: normal; + word-break: break-word; +} + +.username { + font-weight: 500; + color: var(--gray-700); + min-width: 100px; +} + +.ip-address { + font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; + font-size: var(--font-size-xs); + color: var(--gray-500); + min-width: 120px; +} + +/* Loading and Empty States */ +.loading-state, +.empty-state { + text-align: center; + padding: var(--spacing-12); + color: var(--gray-500); +} + +.loading-state i { + font-size: var(--font-size-2xl); + margin-bottom: var(--spacing-4); + color: var(--primary-color); + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.empty-state i { + font-size: 4rem; + margin-bottom: var(--spacing-4); + color: var(--gray-300); +} + +.empty-state h3 { + font-size: var(--font-size-xl); + color: var(--gray-700); + margin-bottom: var(--spacing-2); +} + +/* Pagination */ +.pagination-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-4); + background: var(--white); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); +} + +.pagination-info { + color: var(--gray-600); + font-size: var(--font-size-sm); +} + +.pagination-controls { + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.pagination-controls button { + min-width: 80px; + padding: var(--spacing-2) var(--spacing-3); + border: 1px solid var(--gray-300); + background: var(--white); + color: var(--gray-700); + border-radius: var(--radius); + cursor: pointer; + transition: var(--transition); + font-size: var(--font-size-sm); +} + +.pagination-controls button:hover:not(:disabled) { + background: var(--gray-50); + border-color: var(--primary-color); +} + +.pagination-controls button:disabled { + opacity: 0.5; + cursor: not-allowed; + background: var(--gray-100); +} + +/* Page number buttons */ +.page-number { + min-width: 35px !important; + margin: 0 2px; + padding: var(--spacing-1) var(--spacing-2) !important; + border: 1px solid var(--gray-300); + background: var(--white); + color: var(--gray-700); +} + +.page-number:hover:not(.active) { + background: var(--gray-50); + border-color: var(--primary-color); +} + +.page-number.active { + background: var(--primary-color) !important; + color: var(--white) !important; + border-color: var(--primary-color) !important; +} + +.pagination-ellipsis { + padding: var(--spacing-1) var(--spacing-2); + color: var(--gray-500); + font-size: var(--font-size-sm); +} + +.pagination-numbers { + display: flex; + align-items: center; + gap: var(--spacing-1); +} + +/* Modal Styles */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); +} + +.modal-content { + background: var(--white); + border-radius: var(--radius-lg); + max-width: 500px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + box-shadow: var(--shadow-xl); + animation: modalSlideIn 0.3s ease-out; +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-50px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-6); + border-bottom: 1px solid var(--gray-200); +} + +.modal-header h3 { + margin: 0; + display: flex; + align-items: center; + gap: var(--spacing-2); + color: var(--gray-900); + font-size: var(--font-size-lg); +} + +.modal-close { + background: none; + border: none; + font-size: var(--font-size-xl); + cursor: pointer; + color: var(--gray-400); + padding: var(--spacing-1); + border-radius: var(--radius); + transition: var(--transition); +} + +.modal-close:hover { + color: var(--gray-600); + background: var(--gray-100); +} + +.modal-body { + padding: var(--spacing-6); +} + +.modal-body .form-group { + margin-bottom: var(--spacing-4); +} + +.modal-body label { + display: block; + margin-bottom: var(--spacing-2); + font-weight: 600; + color: var(--gray-700); +} + +.modal-body .form-control { + width: 100%; + padding: var(--spacing-3); + border: 1px solid var(--gray-300); + border-radius: var(--radius); + font-size: var(--font-size-sm); + transition: var(--transition); +} + +.modal-body .form-control:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: var(--spacing-3); + padding: var(--spacing-6); + border-top: 1px solid var(--gray-200); + background: var(--gray-50); +} + +.warning-note { + background: #fef3c7; + border: 1px solid #f59e0b; + border-radius: var(--radius); + padding: var(--spacing-4); + margin-top: var(--spacing-4); + display: flex; + align-items: flex-start; + gap: var(--spacing-3); +} + +.warning-note i { + color: #f59e0b; + margin-top: 2px; + flex-shrink: 0; +} + +.warning-note strong { + color: #92400e; +} + +/* Button Enhancements */ +.btn { + display: inline-flex; + align-items: center; + gap: var(--spacing-2); + padding: var(--spacing-2) var(--spacing-4); + border: none; + border-radius: var(--radius); + font-size: var(--font-size-sm); + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: var(--transition); +} + +.btn-primary { + background: var(--primary-color); + color: var(--white); +} + +.btn-primary:hover { + background: var(--primary-hover); + transform: translateY(-1px); +} + +.btn-secondary { + background: var(--gray-500); + color: var(--white); +} + +.btn-secondary:hover { + background: var(--gray-600); + transform: translateY(-1px); +} + +.btn-warning { + background: #f59e0b; + color: var(--white); +} + +.btn-warning:hover { + background: #d97706; + transform: translateY(-1px); +} + +.btn-danger { + background: #ef4444; + color: var(--white); +} + +.btn-danger:hover { + background: #dc2626; + transform: translateY(-1px); +} + +.btn-sm { + padding: var(--spacing-1) var(--spacing-3); + font-size: var(--font-size-xs); +} + +/* Responsive Design */ +@media (max-width: 768px) { + .logs-page { + padding: var(--spacing-4); + } + + .logs-header { + flex-direction: column; + gap: var(--spacing-4); + } + + .header-actions { + width: 100%; + justify-content: stretch; + } + + .header-actions .btn { + flex: 1; + } + + .log-stats { + grid-template-columns: 1fr; + } + + .log-controls { + flex-direction: column; + gap: var(--spacing-4); + } + + .search-filters { + flex-direction: column; + width: 100%; + } + + .filter-group { + width: 100%; + } + + .filter-select { + width: 100%; + } + + .logs-table-container { + overflow-x: auto; + } + + .logs-table { + min-width: 800px; + } + + .logs-table th, + .logs-table td { + padding: var(--spacing-2) var(--spacing-3); + } + + .pagination-wrapper { + flex-direction: column; + gap: var(--spacing-3); + } + + .pagination-controls { + width: 100%; + justify-content: center; + } +} + +@media (max-width: 480px) { + .stat-card { + flex-direction: column; + text-align: center; + gap: var(--spacing-3); + } + + .stat-icon { + width: 50px; + height: 50px; + } + + .modal-content { + width: 95%; + margin: var(--spacing-4); + } + + .modal-footer { + flex-direction: column; + } + + .modal-footer .btn { + width: 100%; + justify-content: center; + } + + .logs-table { + font-size: var(--font-size-xs); + } + + .description { + max-width: 150px; + } +} + +/* Enhanced Animations */ +.stat-card { + animation: fadeInUp 0.6s ease-out; +} + +.stat-card:nth-child(1) { + animation-delay: 0.1s; +} +.stat-card:nth-child(2) { + animation-delay: 0.2s; +} +.stat-card:nth-child(3) { + animation-delay: 0.3s; +} +.stat-card:nth-child(4) { + animation-delay: 0.4s; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.logs-table tbody tr { + animation: fadeIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* Focus and accessibility improvements */ +.btn:focus, +.filter-select:focus, +.search-box input:focus { + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} + +/* Print styles */ +@media print { + .logs-header .header-actions, + .log-controls, + .pagination-wrapper, + .modal { + display: none !important; + } + + .logs-page { + padding: 0; + max-width: none; + } + + .logs-table { + font-size: 10px; + } + + .logs-table th, + .logs-table td { + padding: 4px; + } +} + +/* Modal error states */ +.modal-error { + background-color: #f8d7da; + color: #721c24; + padding: 1rem; + border-radius: 0.375rem; + margin-bottom: 1rem; + border: 1px solid #f5c6cb; +} + +.modal-error i { + margin-right: 0.5rem; +} + +/* Debug info for development */ +.debug-info { + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + padding: 0.75rem; + margin-top: 1rem; + font-family: monospace; + font-size: 0.875rem; + display: none; /* Show only in debug mode */ +} diff --git a/static/css/attendance.css b/static/css/attendance.css new file mode 100644 index 0000000..e48078b --- /dev/null +++ b/static/css/attendance.css @@ -0,0 +1,1749 @@ +/** + * Attendance Report Page Styles + * static/css/attendance.css + */ + +/* Page Container */ +.attendance-page { + max-width: 1600px; + margin: 0 auto; + padding: var(--spacing-6); + min-height: 100vh; + background: var(--gray-50); +} + +/* Header Section */ +.attendance-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: var(--spacing-8); + padding: var(--spacing-6); + background: var(--white); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); + position: relative; + overflow: hidden; +} + +.attendance-header::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, #059669, #047857); +} + +.header-content h1 { + font-size: var(--font-size-3xl); + font-weight: 700; + color: var(--gray-900); + margin-bottom: var(--spacing-2); + display: flex; + align-items: center; + gap: var(--spacing-3); +} + +.header-content h1 i { + color: var(--success-color); +} + +.header-content p { + color: var(--gray-500); + font-size: var(--font-size-lg); + margin: 0; +} + +.header-actions { + display: flex; + gap: var(--spacing-3); + flex-wrap: wrap; +} + +/* Statistics Section */ +.stats-section { + margin-bottom: var(--spacing-8); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: var(--spacing-6); +} + +.stat-card { + background: var(--white); + padding: var(--spacing-6); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); + display: flex; + align-items: center; + gap: var(--spacing-4); + transition: var(--transition); + border: 1px solid var(--gray-200); + position: relative; + overflow: hidden; +} + +.stat-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; +} + +.stat-card.primary::before { + background: linear-gradient( + 90deg, + var(--primary-color), + var(--primary-hover) + ); +} + +.stat-card.success::before { + background: linear-gradient(90deg, var(--success-color), #047857); +} + +.stat-card.info::before { + background: linear-gradient(90deg, var(--info-color), #0369a1); +} + +.stat-card.warning::before { + background: linear-gradient(90deg, var(--warning-color), #b45309); +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.stat-icon { + width: 60px; + height: 60px; + border-radius: var(--radius-lg); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + color: var(--white); + flex-shrink: 0; +} + +.stat-card.primary .stat-icon { + background: linear-gradient( + 135deg, + var(--primary-color), + var(--primary-hover) + ); +} + +.stat-card.success .stat-icon { + background: linear-gradient(135deg, var(--success-color), #047857); +} + +.stat-card.info .stat-icon { + background: linear-gradient(135deg, var(--info-color), #0369a1); +} + +.stat-card.warning .stat-icon { + background: linear-gradient(135deg, var(--warning-color), #b45309); +} + +.stat-content { + flex: 1; +} + +.stat-content h3 { + font-size: var(--font-size-3xl); + font-weight: 700; + color: var(--gray-900); + margin-bottom: var(--spacing-1); + line-height: 1; +} + +.stat-content p { + color: var(--gray-600); + font-size: var(--font-size-base); + margin-bottom: var(--spacing-1); + font-weight: 500; +} + +.stat-trend { + font-size: var(--font-size-sm); + color: var(--gray-500); + font-style: italic; +} + +/* Filters Section */ +.filters-section { + margin-bottom: var(--spacing-8); +} + +.filters-card { + background: var(--white); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); + overflow: hidden; +} + +.filters-header { + padding: var(--spacing-6); + border-bottom: 1px solid var(--gray-200); + background: var(--gray-50); +} + +.filters-header h3 { + font-size: var(--font-size-xl); + font-weight: 600; + color: var(--gray-900); + margin: 0; + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.filters-header h3 i { + color: var(--primary-color); +} + +.filters-form { + padding: var(--spacing-6); +} + +.filter-row { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: var(--spacing-4); + align-items: end; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: var(--spacing-2); + min-width: 0; +} + +.filter-group label { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--gray-700); + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.filter-group label i { + color: var(--primary-color); + font-size: var(--font-size-xs); +} + +.filter-group input, +.filter-group select { + padding: 0.625rem 0.875rem; /* Reduced padding for compact layout */ + border: 2px solid var(--gray-200); + border-radius: var(--radius-lg); + font-size: 0.875rem; /* Fixed font size */ + background-color: var(--white); + transition: var(--transition); + width: 100%; + box-sizing: border-box; /* Ensure padding is included in width */ +} + +.filter-group input:focus, +.filter-group select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.filter-actions { + grid-column: 1 / -1; /* Span all columns */ + display: flex; + gap: var(--spacing-3); + justify-content: flex-start; + align-items: end; + margin-top: 0.5rem; +} + +/* Table Section */ +.attendance-table-section { + background: var(--white); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); + overflow: hidden; + margin-bottom: var(--spacing-8); +} + +.table-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-6); + border-bottom: 1px solid var(--gray-200); + background: var(--gray-50); +} + +.table-header h3 { + font-size: var(--font-size-xl); + font-weight: 600; + color: var(--gray-900); + margin: 0; + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.table-header h3 i { + color: var(--primary-color); +} + +.table-controls { + display: flex; + align-items: center; + gap: var(--spacing-4); +} + +.entries-per-page { + display: flex; + align-items: center; + gap: var(--spacing-2); + font-size: var(--font-size-sm); + color: var(--gray-600); +} + +.entries-per-page select { + padding: var(--spacing-2) var(--spacing-3); + border: 1px solid var(--gray-300); + border-radius: var(--radius); + font-size: var(--font-size-sm); + background-color: var(--white); +} + +.table-container { + overflow-x: auto; + max-height: 70vh; + position: relative; +} + +/* Table Styles */ +.attendance-table { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-sm); +} + +.attendance-table thead { + background: var(--gray-100); + position: sticky; + top: 0; + z-index: 10; +} + +.attendance-table th { + padding: var(--spacing-4) var(--spacing-3); + text-align: left; + font-weight: 600; + color: var(--gray-700); + border-bottom: 2px solid var(--gray-200); + cursor: pointer; + transition: var(--transition); + white-space: nowrap; + user-select: none; +} + +.attendance-table th:hover { + background: var(--gray-200); +} + +.attendance-table th i { + margin-left: var(--spacing-1); + color: var(--gray-400); + font-size: var(--font-size-xs); +} + +.attendance-table td { + padding: var(--spacing-4) var(--spacing-3); + border-bottom: 1px solid var(--gray-200); + vertical-align: middle; +} + +.attendance-table tbody tr { + transition: var(--transition); +} + +.attendance-table tbody tr:hover { + background: var(--gray-50); +} + +/* Table Cell Content */ +.modified-record { + background-color: #fff3cd !important; + border-left: 3px solid #ffc107 !important; +} + +/* Make sure there's no animation that causes flashing */ +.modified-record * { + animation: none !important; +} + +/* Remove any existing animations on table rows */ +tr.modified-record { + animation: none !important; + transition: none !important; +} + +.employee-info { + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.employee-id { + font-weight: 600; + color: var(--gray-900); + font-family: "Courier New", monospace; + font-size: var(--font-size-sm); + background: var(--gray-100); + padding: var(--spacing-1) var(--spacing-2); + border-radius: var(--radius); +} + +.employee-name { + display: flex; + align-items: center; + gap: var(--spacing-2); + color: var(--gray-700); + font-weight: 500; +} + +.employee-name i { + color: var(--primary-color); + font-size: var(--font-size-sm); +} + +.employee-name span { + font-weight: 500; +} + +.location-info { + display: flex; + align-items: center; + gap: var(--spacing-2); + color: var(--gray-700); +} + +.location-info i { + color: var(--primary-color); + font-size: var(--font-size-xs); +} + +.event-info { + color: var(--gray-600); + font-style: italic; +} + +.date-info { + font-family: "Courier New", monospace; + color: var(--gray-900); + font-weight: 500; +} + +.time-info { + font-family: "Courier New", monospace; + color: var(--gray-900); + font-weight: 600; + background: var(--primary-light); + padding: var(--spacing-1) var(--spacing-2); + border-radius: var(--radius); + display: inline-block; +} + +.device-info { + display: flex; + align-items: center; + gap: var(--spacing-2); + color: var(--gray-600); + font-size: var(--font-size-xs); +} + +.device-info i { + color: var(--info-color); +} + +/* Status Badge */ +.status-badge { + display: inline-flex; + align-items: center; + gap: var(--spacing-1); + padding: var(--spacing-1) var(--spacing-3); + border-radius: var(--radius-full); + font-size: var(--font-size-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.status-badge.present { + background: var(--success-light); + color: var(--success-color); + border: 1px solid rgba(5, 150, 105, 0.3); +} + +.status-badge.absent { + background: var(--danger-light); + color: var(--danger-color); + border: 1px solid rgba(220, 38, 38, 0.3); +} + +.status-badge i { + font-size: var(--font-size-xs); +} + +/* Action Buttons */ +.record-actions { + display: flex; + gap: var(--spacing-1); +} + +.action-btn { + width: 32px; + height: 32px; + border: none; + border-radius: var(--radius); + cursor: pointer; + transition: var(--transition); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-xs); +} + +.btn-view { + background: var(--info-light); + color: var(--info-color); +} + +.btn-view:hover { + background: var(--info-color); + color: var(--white); +} + +.btn-edit { + background: var(--warning-light); + color: var(--warning-color); +} + +.btn-edit:hover { + background: var(--warning-color); + color: var(--white); +} + +.btn-delete { + background: var(--danger-light); + color: var(--danger-color); +} + +.btn-delete:hover { + background: var(--danger-color); + color: var(--white); +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: var(--spacing-20); + color: var(--gray-500); +} + +.empty-icon { + font-size: 4rem; + color: var(--gray-300); + margin-bottom: var(--spacing-4); +} + +.empty-state h3 { + font-size: var(--font-size-xl); + color: var(--gray-700); + margin-bottom: var(--spacing-2); +} + +.empty-state p { + font-size: var(--font-size-base); + margin-bottom: var(--spacing-6); + max-width: 400px; + margin-left: auto; + margin-right: auto; +} + +/* Pagination */ +.pagination-container { + padding: var(--spacing-6); + border-top: 1px solid var(--gray-200); + background: var(--gray-50); +} + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: var(--spacing-2); +} + +.pagination-btn { + padding: var(--spacing-2) var(--spacing-3); + border: 1px solid var(--gray-300); + background: var(--white); + color: var(--gray-700); + border-radius: var(--radius); + cursor: pointer; + transition: var(--transition); + font-size: var(--font-size-sm); + min-width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; +} + +.pagination-btn:hover:not(:disabled) { + background: var(--primary-color); + color: var(--white); + border-color: var(--primary-color); +} + +.pagination-btn.active { + background: var(--primary-color); + color: var(--white); + border-color: var(--primary-color); +} + +.pagination-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pagination-info { + margin: 0 var(--spacing-4); + font-size: var(--font-size-sm); + color: var(--gray-600); +} + +/* Charts Section */ +.charts-section { + margin-bottom: var(--spacing-8); +} + +.charts-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + gap: var(--spacing-6); +} + +.chart-card { + background: var(--white); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); + overflow: hidden; +} + +.chart-header { + padding: var(--spacing-6); + border-bottom: 1px solid var(--gray-200); + background: var(--gray-50); +} + +.chart-header h3 { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--gray-900); + margin: 0; + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.chart-header h3 i { + color: var(--primary-color); +} + +.chart-container { + padding: var(--spacing-6); + height: 300px; + position: relative; +} + +/* Modal Styles */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 99999 !important; + backdrop-filter: blur(4px); +} + +.modal-content { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--white); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-xl); + width: 90%; + max-width: 600px; + max-height: 80vh; + z-index: 100000 !important; + overflow: hidden; +} + +.modal-header { + padding: var(--spacing-6); + border-bottom: 1px solid var(--gray-200); + display: flex; + justify-content: space-between; + align-items: center; + background: var(--gray-50); +} + +.modal-header h3 { + margin: 0; + font-size: var(--font-size-xl); + font-weight: 600; + color: var(--gray-900); +} + +.modal-close { + background: none; + border: none; + font-size: var(--font-size-xl); + cursor: pointer; + color: var(--gray-500); + padding: var(--spacing-2); + border-radius: var(--radius); + transition: var(--transition); +} + +.modal-close:hover { + background: var(--gray-200); + color: var(--gray-700); +} + +.modal-body { + padding: var(--spacing-6); + max-height: 60vh; + overflow-y: auto; +} + +.modal-footer { + padding: var(--spacing-6); + border-top: 1px solid var(--gray-200); + display: flex; + justify-content: flex-end; + gap: var(--spacing-3); + background: var(--gray-50); +} + +.address-info { + display: flex; + align-items: flex-start; + gap: var(--spacing-2); + color: var(--gray-700); + font-size: var(--font-size-sm); + max-width: 250px; +} + +.address-info i { + color: var(--primary-color); + font-size: var(--font-size-xs); + margin-top: 2px; + flex-shrink: 0; +} + +.address-info span { + line-height: 1.4; + word-break: break-word; +} + +.address-info.qr-address i { + color: var(--success-color); +} + +.address-info.checkin-address i { + color: var(--info-color); +} + +/* GPS Accuracy badges */ +.accuracy-info { + display: flex; + align-items: center; + justify-content: center; +} + +.accuracy-badge { + display: inline-flex; + align-items: center; + gap: var(--spacing-1); + padding: var(--spacing-1) var(--spacing-3); + border-radius: var(--radius-full); + font-size: var(--font-size-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.025em; + flex-direction: column; + text-align: center; + min-width: 80px; +} + +.accuracy-badge small { + font-size: 0.6rem; + font-weight: 500; + margin-top: 2px; + opacity: 0.8; +} + +.accuracy-badge.accuracy-high { + background: var(--success-light); + color: var(--success-color); + border: 1px solid rgba(5, 150, 105, 0.3); +} + +.accuracy-badge.accuracy-medium { + background: var(--warning-light); + color: var(--warning-color); + border: 1px solid rgba(245, 158, 11, 0.3); +} + +.accuracy-badge.accuracy-low { + background: var(--danger-light); + color: var(--danger-color); + border: 1px solid rgba(220, 38, 38, 0.3); +} + +.accuracy-badge.accuracy-unknown { + background: var(--gray-100); + color: var(--gray-500); + border: 1px solid rgba(107, 114, 128, 0.3); +} + +/* Filter indicator */ +.filter-indicator { + background: var(--primary-light); + color: var(--primary-color); + font-size: var(--font-size-xs); + padding: var(--spacing-1) var(--spacing-2); + border-radius: var(--radius); + margin-left: var(--spacing-2); + font-weight: 500; +} + +/* Enhanced action buttons */ +.btn-map { + background: var(--success-light); + color: var(--success-color); +} + +.btn-map:hover { + background: var(--success-color); + color: var(--white); +} + +.btn-map:disabled { + background: var(--gray-100); + color: var(--gray-400); + cursor: not-allowed; +} + +/* Modal enhancements */ +.modal-large .modal-content { + max-width: 800px; + width: 90%; +} + +.record-details-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: var(--spacing-6); +} + +.detail-section { + background: var(--gray-50); + padding: var(--spacing-4); + border-radius: var(--radius-lg); + border: 1px solid var(--gray-200); +} + +.detail-section h4 { + color: var(--gray-800); + font-size: var(--font-size-base); + font-weight: 600; + margin-bottom: var(--spacing-3); + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.detail-section h4 i { + color: var(--primary-color); + font-size: var(--font-size-sm); +} + +.detail-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: var(--spacing-2) 0; + border-bottom: 1px solid var(--gray-200); + gap: var(--spacing-3); +} + +.detail-item:last-child { + border-bottom: none; +} + +.detail-item strong { + color: var(--gray-700); + font-weight: 500; + flex-shrink: 0; + min-width: 120px; +} + +.detail-item span { + color: var(--gray-900); + text-align: right; + word-break: break-word; +} + +.filter-group input[type="date"] { + appearance: none; + -webkit-appearance: none; + position: relative; +} + +.filter-group input[type="date"]::-webkit-calendar-picker-indicator { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='currentColor'%3e%3cpath fill-rule='evenodd' d='M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z' clip-rule='evenodd'/%3e%3c/svg%3e"); + background-size: 16px; + background-repeat: no-repeat; + background-position: center; + cursor: pointer; +} + +/* Enhanced table responsive design */ +@media (max-width: 1200px) { + .attendance-table th:nth-child(10), + .attendance-table td:nth-child(10), + .attendance-table th:nth-child(11), + .attendance-table td:nth-child(11) { + display: none; + } +} + +@media (max-width: 768px) { + .address-info { + max-width: 150px; + } + + .accuracy-badge { + min-width: 60px; + font-size: 0.625rem; + } + + .accuracy-badge small { + display: none; + } + + .filter-row { + grid-template-columns: 1fr; + } + + .filter-actions { + flex-direction: column; + } + + .filter-actions .btn { + width: 100%; + } + + .record-details-grid { + grid-template-columns: 1fr; + } + + /* Hide address columns on mobile */ + .attendance-table th:nth-child(7), + .attendance-table td:nth-child(7), + .attendance-table th:nth-child(8), + .attendance-table td:nth-child(8), + .attendance-table th:nth-child(9), + .attendance-table td:nth-child(9) { + display: none; + } +} + +/* Enhanced empty state */ +.empty-state .btn { + margin-top: var(--spacing-4); +} + +/* Location map placeholder styles */ +#locationMap { + background: var(--gray-50); + border: 2px dashed var(--gray-300); + border-radius: var(--radius-lg); + display: flex; + align-items: center; + justify-content: center; + color: var(--gray-600); +} + +.location-accuracy-info { + display: flex; + align-items: center; + justify-content: center; +} + +.location-accuracy-badge { + display: inline-flex; + align-items: center; + gap: var(--spacing-1); + padding: var(--spacing-1) var(--spacing-3); + border-radius: var(--radius-full); + font-size: var(--font-size-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.025em; + flex-direction: column; + text-align: center; + min-width: 80px; +} + +.location-accuracy-badge small { + font-size: 0.6rem; + font-weight: 500; + margin-top: 2px; + opacity: 0.8; +} + +/* Location accuracy level colors - 2-level system based on 0.5 mile threshold */ +.location-accuracy-badge.accuracy-accurate { + background: #dcfce7; + color: #059669; + border: 1px solid rgba(5, 150, 105, 0.3); +} + +.location-accuracy-badge.accuracy-inaccurate { + background: #fee2e2; + color: #dc2626; + border: 1px solid rgba(220, 38, 38, 0.3); +} + +.location-accuracy-badge.accuracy-unknown { + background: var(--gray-100); + color: var(--gray-500); + border: 1px solid rgba(107, 114, 128, 0.3); +} + +/* Distance ruler icon styling */ +.location-accuracy-badge .fa-ruler { + font-size: var(--font-size-xs); +} + +/* Enhanced table header for location accuracy */ +.attendance-table th:nth-child(9) { + min-width: 120px; + text-align: center; +} + +/* Responsive adjustments for location accuracy column */ +@media (max-width: 1200px) { + .location-accuracy-badge { + min-width: 70px; + font-size: 0.625rem; + } + + .location-accuracy-badge small { + display: none; + } +} + +@media (max-width: 768px) { + /* Hide location accuracy column on mobile to save space */ + .attendance-table th:nth-child(9), + .attendance-table td:nth-child(9) { + display: none; + } +} + +/* Enhanced modal details for location accuracy */ +.location-accuracy-details { + background: var(--gray-50); + padding: var(--spacing-4); + border-radius: var(--radius-lg); + margin: var(--spacing-3) 0; +} + +.location-accuracy-details h5 { + color: var(--gray-800); + margin-bottom: var(--spacing-2); + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.location-accuracy-details .fa-ruler { + color: var(--primary-color); +} + +.accuracy-comparison { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-4); + margin-top: var(--spacing-3); +} + +.accuracy-comparison .location-point { + text-align: center; + padding: var(--spacing-3); + background: var(--white); + border-radius: var(--radius); + border: 1px solid var(--gray-200); +} + +.accuracy-comparison .location-point i { + font-size: 1.5rem; + margin-bottom: var(--spacing-2); +} + +.accuracy-comparison .qr-point i { + color: var(--success-color); +} + +.accuracy-comparison .checkin-point i { + color: var(--info-color); +} + +.distance-display { + text-align: center; + margin: var(--spacing-4) 0; + padding: var(--spacing-4); + background: linear-gradient( + 135deg, + var(--primary-light), + var(--success-light) + ); + border-radius: var(--radius-lg); + border: 2px dashed var(--primary-color); +} + +.distance-display .distance-value { + font-size: var(--font-size-2xl); + font-weight: 700; + color: var(--primary-color); + margin-bottom: var(--spacing-1); +} + +.distance-display .distance-label { + color: var(--gray-600); + font-weight: 500; +} + +/* Print styles for location accuracy */ +@media print { + .location-accuracy-badge { + background: transparent !important; + border: 1px solid #ccc !important; + color: #000 !important; + } + + .location-accuracy-badge small { + display: inline; + } +} + +/* Print styles for new columns */ +@media print { + .address-info, + .accuracy-info { + font-size: 0.7rem; + } + + .accuracy-badge { + background: transparent !important; + border: 1px solid #ccc !important; + color: #000 !important; + } + + .btn-map { + display: none; + } +} + +/* Responsive Design */ +@media (max-width: 768px) { + .attendance-page { + padding: var(--spacing-4); + } + + .attendance-header { + flex-direction: column; + gap: var(--spacing-4); + } + + .header-actions { + width: 100%; + justify-content: stretch; + } + + .header-actions .btn { + flex: 1; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .filter-row { + grid-template-columns: 1fr; + } + + .filter-actions { + grid-column: 1; + justify-content: stretch; + } + + .filter-actions .btn { + flex: 1; + } + + .table-header { + flex-direction: column; + gap: var(--spacing-3); + align-items: stretch; + } + + .table-controls { + justify-content: space-between; + } + + .charts-grid { + grid-template-columns: 1fr; + } + + .modal-content { + width: 95%; + margin: var(--spacing-4); + } + + .record-actions { + flex-direction: column; + } +} + +@media (max-width: 480px) { + .attendance-table th, + .attendance-table td { + padding: var(--spacing-2); + font-size: var(--font-size-xs); + } + + .employee-id { + font-size: var(--font-size-xs); + } + + .time-info { + font-size: var(--font-size-xs); + } + + .stat-icon { + width: 50px; + height: 50px; + font-size: 1.25rem; + } + + .stat-content h3 { + font-size: var(--font-size-2xl); + } +} + +/* Print Styles */ +@media print { + .attendance-page { + background: white; + box-shadow: none; + } + + .attendance-header, + .filters-section, + .charts-section { + display: none; + } + + .attendance-table-section { + box-shadow: none; + border: 1px solid #ccc; + } + + .action-btn { + display: none; + } + + .pagination-container { + display: none; + } +} +/* Delete button styling */ +.btn-delete { + background: var(--danger-light); + color: var(--danger-color); + border: 1px solid rgba(220, 38, 38, 0.2); +} + +.btn-delete:hover { + background: var(--danger-color); + color: var(--white); + border-color: var(--danger-color); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(220, 38, 38, 0.3); +} + +/* Enhanced action buttons container */ +.record-actions { + display: flex; + gap: var(--spacing-2); + justify-content: center; +} + +/* Action button improvements */ +.action-btn { + width: 36px; + height: 36px; + border: none; + border-radius: var(--radius); + cursor: pointer; + transition: all var(--transition); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-sm); + border: 1px solid transparent; +} + +.action-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.action-btn:active { + transform: translateY(0); +} + +/* Form styling for edit page */ +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +@media (max-width: 768px) { + .form-row { + grid-template-columns: 1fr; + } + + .record-actions { + flex-direction: column; + gap: var(--spacing-1); + } + + .action-btn { + width: 100%; + height: 32px; + } +} + +/* ============================================ + VERIFICATION REVIEW STYLES + ============================================ */ + +/* Review Needed Badge */ +.badge-review-needed { + background: #fef3c7 !important; + color: #92400e !important; + border: 1px solid rgba(146, 64, 14, 0.3) !important; + transition: all 0.3s ease; +} + +.badge-review-needed:hover { + background: #fde68a !important; + transform: scale(1.05); + box-shadow: 0 4px 8px rgba(146, 64, 14, 0.2); +} + +/* Verified Badge */ +.badge-verified { + background: #d1fae5 !important; + color: #065f46 !important; + border: 1px solid rgba(5, 150, 105, 0.3) !important; +} + +/* Rejected Badge */ +.badge-rejected { + background: #fee2e2 !important; + color: #991b1b !important; + border: 1px solid rgba(220, 38, 38, 0.3) !important; +} + +/* Review Button in Actions Column */ +.btn-review { + background: #fef3c7; + color: #92400e; + border: 1px solid rgba(146, 64, 14, 0.2); +} + +.btn-review:hover { + background: #92400e; + color: #ffffff; + border-color: #92400e; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(146, 64, 14, 0.3); +} + +/* Verification Photo Modal Specific Styles */ +#verificationPhotoModal { + z-index: 99999 !important; +} + +.verification-modal-content { + max-width: 900px; + width: 95%; +} + +.verification-photo-container { + text-align: center; + margin: 1.5rem 0; +} + +.verification-photo-large { + max-width: 100%; + max-height: 500px; + border-radius: 0.5rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.verification-details-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin: 1.5rem 0; +} + +.verification-detail-card { + background: var(--gray-50, #f9fafb); + padding: 1rem; + border-radius: 0.5rem; + border: 1px solid var(--gray-200, #e5e7eb); +} + +.verification-detail-card h4 { + font-size: 0.875rem; + color: var(--gray-600, #6b7280); + margin: 0 0 0.5rem 0; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.verification-detail-card p { + font-size: 1rem; + color: var(--gray-900, #0f172a); + margin: 0; + font-weight: 600; +} + +.verification-actions { + display: flex; + gap: 1rem; + justify-content: center; + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--gray-200, #e5e7eb); +} + +.btn-approve { + background: #10b981; + color: white; + padding: 0.75rem 2rem; + border: none; + border-radius: 0.5rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.btn-approve:hover { + background: #059669; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); +} + +.btn-reject { + background: #ef4444; + color: white; + padding: 0.75rem 2rem; + border: none; + border-radius: 0.5rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.btn-reject:hover { + background: #dc2626; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3); +} + +.verification-status-pending { + background: #fef3c7; + color: #92400e; + padding: 0.5rem 1rem; + border-radius: 9999px; + font-size: 0.875rem; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +/* Loading State */ +.verification-loading { + text-align: center; + padding: 3rem; + color: var(--gray-600, #6b7280); +} + +.verification-loading i { + font-size: 2rem; + margin-bottom: 1rem; +} + +/* Responsive Design for Verification Modal */ +@media (max-width: 768px) { + .verification-modal-content { + width: 98%; + margin: 0.5rem; + } + + .verification-photo-large { + max-height: 300px; + } + + .verification-details-grid { + grid-template-columns: 1fr; + } + + .verification-actions { + flex-direction: column; + } + + .btn-approve, + .btn-reject { + width: 100%; + justify-content: center; + } +} + +/* Employee Filter Autocomplete Styles */ +.autocomplete-container-filter { + position: relative; +} + +.autocomplete-results-filter { + position: fixed; /* CHANGED from absolute to fixed */ + background: white; + border: 1px solid #ddd; + border-top: none; + border-radius: 0 0 4px 4px; + max-height: 250px; + overflow-y: auto; + z-index: 9999; /* INCREASED z-index */ + display: none; + box-shadow: 0 4px 8px rgba(0,0,0,0.15); + margin-top: 0; +} + +.autocomplete-results-filter.show { + display: block; +} + +.autocomplete-item-filter { + padding: 0.75rem; + cursor: pointer; + border-bottom: 1px solid #f0f0f0; + transition: background-color 0.2s; +} + +.autocomplete-item-filter:hover { + background-color: #f8f9fa; +} + +.autocomplete-item-filter:last-child { + border-bottom: none; +} + +.employee-info-filter { + display: flex; + justify-content: space-between; + align-items: center; +} + +.employee-name-filter { + font-weight: 600; + color: #333; +} + +.employee-id-filter { + color: #666; + font-size: 0.9rem; +} + +.clear-employee-filter { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + background: #dc3545; + color: white; + border: none; + border-radius: 50%; + width: 24px; + height: 24px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s; + padding: 0; +} + +.clear-employee-filter:hover { + background: #c82333; +} + +.clear-employee-filter i { + font-size: 0.75rem; +} + +.filter-input { + padding-right: 35px !important; +} + +/* ─── Multi-Employee Chip Filter ─────────────────────────────── */ +.employee-chips-wrapper { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-height: 38px; + padding: 4px 36px 4px 8px; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + cursor: text; + position: relative; +} + +.employee-chips-wrapper:focus-within { + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); +} + +.employee-chip { + display: inline-flex; + align-items: center; + gap: 5px; + background: #eef2ff; + color: #4338ca; + border: 1px solid #c7d2fe; + border-radius: 4px; + padding: 2px 6px 2px 8px; + font-size: 0.825rem; + font-weight: 500; + white-space: nowrap; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; +} + +.employee-chip-remove { + background: none; + border: none; + cursor: pointer; + color: #6366f1; + padding: 0; + line-height: 1; + font-size: 0.75rem; + display: flex; + align-items: center; + border-radius: 2px; + transition: color 0.15s; + flex-shrink: 0; +} + +.employee-chip-remove:hover { + color: #dc2626; +} + +.employee-chip-input { + border: none; + outline: none; + font-size: 0.875rem; + background: transparent; + flex: 1; + min-width: 120px; + padding: 2px 0; + color: #1f2937; +} + +.employee-chip-input::placeholder { + color: #9ca3af; +} + +.employee-chips-clear-all { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + background: #dc3545; + color: white; + border: none; + border-radius: 50%; + width: 22px; + height: 22px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s; + padding: 0; +} + +.employee-chips-clear-all:hover { + background: #c82333; +} + +.employee-chips-clear-all i { + font-size: 0.7rem; +} +/* ─── End Multi-Employee Chip Filter ─────────────────────────── */ \ No newline at end of file diff --git a/static/css/attendance_fullscreen.css b/static/css/attendance_fullscreen.css new file mode 100644 index 0000000..f4f200f --- /dev/null +++ b/static/css/attendance_fullscreen.css @@ -0,0 +1,642 @@ +/** + * Attendance Fullscreen CSS + * Optimized for iPad and tablet viewing + * static/css/attendance_fullscreen.css + */ + +/* =========================== + FULLSCREEN TOGGLE BUTTON + =========================== */ +.fullscreen-toggle-btn { + position: fixed; + top: 20px; + right: 20px; + width: 50px; + height: 50px; + background: linear-gradient(135deg, #2563eb, #1d4ed8); + color: white; + border: none; + border-radius: 12px; + cursor: pointer; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; + box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.fullscreen-toggle-btn:hover { + background: linear-gradient(135deg, #1d4ed8, #1e40af); + transform: scale(1.05); + box-shadow: 0 6px 16px rgba(37, 99, 235, 0.4); +} + +.fullscreen-toggle-btn:active { + transform: scale(0.95); +} + +/* =========================== + FULLSCREEN MODE BASE + =========================== */ +.fullscreen-mode { + position: fixed !important; + top: 0 !important; + left: 0 !important; + width: 100vw !important; + height: 100vh !important; + max-width: 100vw !important; + margin: 0 !important; + background: #f8fafc !important; + z-index: 9998 !important; + overflow: auto !important; + padding: 20px !important; + box-sizing: border-box !important; +} + +/* =========================== + HIDE UI ELEMENTS IN FULLSCREEN + =========================== */ + +/* Hide sidebar and overlays */ +.fullscreen-mode ~ #sidebar, +.fullscreen-mode ~ .sidebar-overlay, +body.fullscreen-active #sidebar, +body.fullscreen-active .sidebar-overlay { + display: none !important; +} + +/* Adjust main wrapper */ +body.fullscreen-active .main-wrapper { + margin-left: 0 !important; + width: 100% !important; +} + +/* Hide header completely */ +.fullscreen-mode .attendance-header, +.fullscreen-mode > .attendance-header { + display: none !important; + visibility: hidden !important; + height: 0 !important; + overflow: hidden !important; + margin: 0 !important; + padding: 0 !important; +} + +/* Hide ALL stats sections with various possible class names */ +.fullscreen-mode .stats-section, +.fullscreen-mode > .stats-section, +.fullscreen-mode .stats-overview, +.fullscreen-mode > .stats-overview, +.fullscreen-mode .statistics-section, +.fullscreen-mode > .statistics-section, +.fullscreen-mode [class*="stat"][class*="section"], +.fullscreen-mode div[class*="stat-"] { + display: none !important; + visibility: hidden !important; + height: 0 !important; + overflow: hidden !important; + margin: 0 !important; + padding: 0 !important; +} + +/* Exception: Keep stat cards inside table section visible */ +.fullscreen-mode .attendance-table-section .stat-card, +.fullscreen-mode .table-section .stat-card { + display: flex !important; + visibility: visible !important; + height: auto !important; +} + +/* =========================== + COMPACT FILTERS IN FULLSCREEN + =========================== */ +.fullscreen-mode .filters-section { + margin-bottom: 1rem !important; + margin-top: 0 !important; +} + +.fullscreen-mode .filters-card { + border-radius: 8px !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important; +} + +.fullscreen-mode .filters-header { + padding: 0.75rem 1.5rem !important; + background: #f8fafc !important; + border-bottom: 1px solid #e2e8f0 !important; +} + +.fullscreen-mode .filters-header h3 { + font-size: 1rem !important; + font-weight: 600 !important; + margin: 0 !important; +} + +.fullscreen-mode .filters-form { + padding: 1rem 1.5rem !important; +} + +.fullscreen-mode .filter-row { + display: grid !important; + grid-template-columns: repeat(5, 1fr) !important; + gap: 0.75rem !important; + align-items: end !important; + margin-bottom: 0 !important; +} + +.fullscreen-mode .filter-group { + display: flex !important; + flex-direction: column !important; + gap: 0.25rem !important; + min-width: 0 !important; +} + +.fullscreen-mode .filter-group label { + font-size: 0.75rem !important; + margin-bottom: 0.25rem !important; + font-weight: 600 !important; + white-space: nowrap !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + color: #374151 !important; +} + +.fullscreen-mode .filter-group input, +.fullscreen-mode .filter-group select { + padding: 0.5rem 0.75rem !important; + font-size: 0.875rem !important; + height: 38px !important; + border: 2px solid #e2e8f0 !important; + border-radius: 6px !important; + width: 100% !important; + box-sizing: border-box !important; + background: white !important; +} + +.fullscreen-mode .filter-group input:focus, +.fullscreen-mode .filter-group select:focus { + border-color: #2563eb !important; + outline: none !important; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1) !important; +} + +.fullscreen-mode .filter-actions { + grid-column: 1 / -1 !important; + display: flex !important; + gap: 0.75rem !important; + justify-content: flex-start !important; + margin-top: 0.5rem !important; +} + +.fullscreen-mode .filter-actions .btn { + padding: 0.5rem 1rem !important; + font-size: 0.875rem !important; + height: 38px !important; + display: inline-flex !important; + align-items: center !important; + gap: 0.5rem !important; +} + +/* =========================== + TABLE SECTION IN FULLSCREEN + =========================== */ +.fullscreen-mode .attendance-table-section { + margin-bottom: 0 !important; + margin-top: 0 !important; + background: white !important; + border-radius: 8px !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important; + overflow: hidden !important; +} + +.fullscreen-mode .table-header { + padding: 0.75rem 1.5rem !important; + background: #f8fafc !important; + border-bottom: 1px solid #e2e8f0 !important; + display: flex !important; + justify-content: space-between !important; + align-items: center !important; +} + +.fullscreen-mode .table-header h3 { + font-size: 1rem !important; + font-weight: 600 !important; + margin: 0 !important; +} + +.fullscreen-mode .table-controls { + display: flex !important; + align-items: center !important; + gap: 0.5rem !important; +} + +.fullscreen-mode .table-container { + max-height: calc(100vh - 280px) !important; + overflow-y: auto !important; + overflow-x: auto !important; +} + +.fullscreen-mode .attendance-table { + font-size: 0.8125rem !important; + width: 100% !important; +} + +.fullscreen-mode .attendance-table th, +.fullscreen-mode .attendance-table td { + padding: 0.625rem 0.5rem !important; + white-space: nowrap !important; +} + +.fullscreen-mode .attendance-table th { + position: sticky !important; + top: 0 !important; + background: #f8fafc !important; + z-index: 10 !important; + font-size: 0.75rem !important; + font-weight: 600 !important; + text-transform: uppercase !important; + letter-spacing: 0.025em !important; +} + +/* =========================== + TABLE COLUMN OPTIMIZATION + =========================== */ + +/* Prevent text overlap - allow wrapping for long content */ +.fullscreen-mode .attendance-table td { + white-space: normal !important; + word-wrap: break-word !important; + word-break: break-word !important; + max-width: 200px !important; + overflow: hidden !important; +} + +/* Keep headers on single line */ +.fullscreen-mode .attendance-table th { + white-space: nowrap !important; +} + +/* Specific column width optimizations */ +.fullscreen-mode .attendance-table th:nth-child(1), +.fullscreen-mode .attendance-table td:nth-child(1) { + /* # column */ + width: 40px !important; + min-width: 40px !important; + max-width: 40px !important; + white-space: nowrap !important; +} + +.fullscreen-mode .attendance-table th:nth-child(2), +.fullscreen-mode .attendance-table td:nth-child(2) { + /* Employee ID */ + width: 80px !important; + min-width: 80px !important; + max-width: 80px !important; + white-space: nowrap !important; +} + +.fullscreen-mode .attendance-table th:nth-child(3), +.fullscreen-mode .attendance-table td:nth-child(3) { + /* Employee Name */ + width: 120px !important; + min-width: 120px !important; + max-width: 120px !important; +} + +.fullscreen-mode .attendance-table th:nth-child(4), +.fullscreen-mode .attendance-table td:nth-child(4) { + /* Location */ + width: 150px !important; + min-width: 150px !important; + max-width: 150px !important; +} + +.fullscreen-mode .attendance-table th:nth-child(5), +.fullscreen-mode .attendance-table td:nth-child(5) { + /* Event */ + width: 80px !important; + min-width: 80px !important; + max-width: 80px !important; + white-space: nowrap !important; +} + +.fullscreen-mode .attendance-table th:nth-child(6), +.fullscreen-mode .attendance-table td:nth-child(6) { + /* Date */ + width: 100px !important; + min-width: 100px !important; + max-width: 100px !important; + white-space: nowrap !important; +} + +.fullscreen-mode .attendance-table th:nth-child(7), +.fullscreen-mode .attendance-table td:nth-child(7) { + /* Time */ + width: 80px !important; + min-width: 80px !important; + max-width: 80px !important; + white-space: nowrap !important; +} + +.fullscreen-mode .attendance-table th:nth-child(8), +.fullscreen-mode .attendance-table td:nth-child(8) { + /* QR Address */ + width: 180px !important; + min-width: 180px !important; + max-width: 180px !important; +} + +.fullscreen-mode .attendance-table th:nth-child(9), +.fullscreen-mode .attendance-table td:nth-child(9) { + /* Check-in Address */ + width: 180px !important; + min-width: 180px !important; + max-width: 180px !important; +} + +.fullscreen-mode .attendance-table th:nth-child(10), +.fullscreen-mode .attendance-table td:nth-child(10) { + /* Location Accuracy */ + width: 100px !important; + min-width: 100px !important; + max-width: 100px !important; + white-space: nowrap !important; + text-align: center !important; +} + +.fullscreen-mode .attendance-table th:nth-child(11), +.fullscreen-mode .attendance-table td:nth-child(11) { + /* Device */ + width: 100px !important; + min-width: 100px !important; + max-width: 100px !important; +} + +.fullscreen-mode .attendance-table th:nth-child(12), +.fullscreen-mode .attendance-table td:nth-child(12) { + /* Actions */ + width: 80px !important; + min-width: 80px !important; + max-width: 80px !important; + white-space: nowrap !important; + text-align: center !important; +} + +/* Address columns - allow text to wrap within container */ +.fullscreen-mode .address-column, +.fullscreen-mode .attendance-table td:nth-child(8), +.fullscreen-mode .attendance-table td:nth-child(9) { + font-size: 0.75rem !important; + line-height: 1.3 !important; + padding: 0.5rem 0.375rem !important; +} + +/* Location accuracy badge styling */ +.fullscreen-mode .location-accuracy-badge, +.fullscreen-mode .accuracy-badge { + display: inline-block !important; + padding: 0.25rem 0.5rem !important; + border-radius: 4px !important; + font-size: 0.7rem !important; + white-space: nowrap !important; +} + +/* Device info */ +.fullscreen-mode .device-info { + font-size: 0.75rem !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + display: block !important; +} + +/* Employee info styling */ +.fullscreen-mode .employee-id, +.fullscreen-mode .employee-name { + font-size: 0.8125rem !important; + font-weight: 500 !important; +} + +/* Location and event info */ +.fullscreen-mode .location-info, +.fullscreen-mode .event-info { + font-size: 0.75rem !important; + display: flex !important; + align-items: center !important; + gap: 0.25rem !important; +} + +.fullscreen-mode .location-info i, +.fullscreen-mode .event-info i { + flex-shrink: 0 !important; + font-size: 0.7rem !important; +} + +/* Address info containers */ +.fullscreen-mode .address-info { + display: flex !important; + align-items: flex-start !important; + gap: 0.25rem !important; + word-break: break-word !important; +} + +.fullscreen-mode .address-info i { + flex-shrink: 0 !important; + margin-top: 0.125rem !important; + font-size: 0.7rem !important; +} + +/* Time display */ +.fullscreen-mode .time-info { + font-size: 0.75rem !important; + padding: 0.25rem 0.5rem !important; + background: #f1f5f9 !important; + border-radius: 4px !important; + display: inline-block !important; +} + +/* Action buttons */ +.fullscreen-mode .record-actions { + display: flex !important; + gap: 0.25rem !important; + justify-content: center !important; + align-items: center !important; +} + +.fullscreen-mode .action-btn { + width: 28px !important; + height: 28px !important; + font-size: 0.7rem !important; + padding: 0 !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; +} + +/* Table layout */ +.fullscreen-mode .attendance-table { + table-layout: fixed !important; + width: 100% !important; +} + +/* Ensure table container allows horizontal scroll if needed */ +.fullscreen-mode .table-container { + overflow-x: auto !important; + -webkit-overflow-scrolling: touch !important; +} + +/* =========================== + RESPONSIVE BREAKPOINTS + =========================== */ + +/* iPad Landscape */ +@media (min-width: 768px) and (max-width: 1024px) and (orientation: landscape) { + .fullscreen-mode { + padding: 12px !important; + } + + .fullscreen-mode .filter-row { + grid-template-columns: repeat(3, 1fr) !important; + gap: 0.5rem !important; + } + + .fullscreen-mode .filter-actions { + grid-column: 1 / -1 !important; + } + + .fullscreen-mode .attendance-table { + font-size: 0.7rem !important; + } + + .fullscreen-mode .attendance-table th, + .fullscreen-mode .attendance-table td { + padding: 0.5rem 0.375rem !important; + } + + /* Adjust column widths for iPad landscape */ + .fullscreen-mode .attendance-table th:nth-child(8), + .fullscreen-mode .attendance-table td:nth-child(8), + .fullscreen-mode .attendance-table th:nth-child(9), + .fullscreen-mode .attendance-table td:nth-child(9) { + width: 150px !important; + min-width: 150px !important; + max-width: 150px !important; + } +} + +/* iPad Portrait */ +@media (min-width: 768px) and (max-width: 1024px) and (orientation: portrait) { + .fullscreen-mode { + padding: 12px !important; + } + + .fullscreen-mode .filter-row { + grid-template-columns: repeat(2, 1fr) !important; + gap: 0.5rem !important; + } + + .fullscreen-mode .filter-actions { + grid-column: 1 / -1 !important; + } + + .fullscreen-mode .attendance-table { + font-size: 0.7rem !important; + } + + /* Hide less important columns on iPad portrait */ + .fullscreen-mode .attendance-table th:nth-child(11), + .fullscreen-mode .attendance-table td:nth-child(11) { + display: none !important; + } + + /* Adjust remaining column widths */ + .fullscreen-mode .attendance-table th:nth-child(8), + .fullscreen-mode .attendance-table td:nth-child(8), + .fullscreen-mode .attendance-table th:nth-child(9), + .fullscreen-mode .attendance-table td:nth-child(9) { + width: 140px !important; + min-width: 140px !important; + max-width: 140px !important; + } +} + +/* Mobile */ +@media (max-width: 767px) { + .fullscreen-toggle-btn { + width: 45px !important; + height: 45px !important; + top: 15px !important; + right: 15px !important; + } + + .fullscreen-mode { + padding: 10px !important; + } + + .fullscreen-mode .filter-row { + grid-template-columns: 1fr !important; + } + + /* Show only essential columns on mobile */ + .fullscreen-mode .attendance-table th:nth-child(8), + .fullscreen-mode .attendance-table td:nth-child(8), + .fullscreen-mode .attendance-table th:nth-child(9), + .fullscreen-mode .attendance-table td:nth-child(9), + .fullscreen-mode .attendance-table th:nth-child(10), + .fullscreen-mode .attendance-table td:nth-child(10), + .fullscreen-mode .attendance-table th:nth-child(11), + .fullscreen-mode .attendance-table td:nth-child(11) { + display: none !important; + } +} + +/* =========================== + SCROLLBAR STYLING + =========================== */ +.fullscreen-mode::-webkit-scrollbar { + width: 10px; +} + +.fullscreen-mode::-webkit-scrollbar-track { + background: #f1f5f9; + border-radius: 5px; +} + +.fullscreen-mode::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 5px; +} + +.fullscreen-mode::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} + +/* =========================== + OVERSCROLL PREVENTION + =========================== */ +.fullscreen-mode { + overscroll-behavior: none !important; + -webkit-overflow-scrolling: touch !important; +} + +body.fullscreen-active { + overscroll-behavior: none !important; + overflow: hidden !important; +} + +/* =========================== + PRINT STYLES + =========================== */ +@media print { + .fullscreen-toggle-btn { + display: none !important; + } + + .fullscreen-mode { + position: static !important; + width: 100% !important; + height: auto !important; + padding: 0 !important; + } +} \ No newline at end of file diff --git a/static/css/auth.css b/static/css/auth.css new file mode 100644 index 0000000..6de5616 --- /dev/null +++ b/static/css/auth.css @@ -0,0 +1,287 @@ +/** + * Authentication page specific styles + * static/css/auth.css + * + * This file contains all authentication-related styles for login/register pages + */ + +/* Authentication Container - Center the form */ +.auth-container { + min-height: 100vh !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + background: linear-gradient(135deg, var(--primary-color), var(--primary-hover)) !important; + padding: 2rem 1rem !important; + margin: 0 !important; +} + +/* Authentication Card - Modern centered design */ +.auth-card { + background: var(--white) !important; + border-radius: var(--radius-2xl) !important; + box-shadow: var(--shadow-xl) !important; + padding: 3rem !important; + width: 100% !important; + max-width: 420px !important; + position: relative !important; + overflow: hidden !important; +} + +.auth-card::before { + content: "" !important; + position: absolute !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + height: 4px !important; + background: linear-gradient(90deg, var(--primary-color), var(--primary-hover)) !important; +} + +/* Authentication Header */ +.auth-header { + text-align: center !important; + margin-bottom: 2.5rem !important; +} + +.auth-logo { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: 80px !important; + height: 80px !important; + background: linear-gradient(135deg, var(--primary-color), var(--primary-hover)) !important; + border-radius: 50% !important; + margin-bottom: 1.5rem !important; + box-shadow: var(--shadow-lg) !important; +} + +.auth-logo i { + font-size: 2.5rem !important; + color: var(--white) !important; +} + +.auth-header h1 { + font-size: 2.25rem !important; + font-weight: 700 !important; + color: var(--gray-900) !important; + margin-bottom: 0.5rem !important; +} + +.auth-header p { + color: var(--gray-500) !important; + font-size: 1.125rem !important; + margin: 0 !important; +} + +/* Authentication Form */ +.auth-form { + margin-bottom: 0 !important; +} + +.auth-form .form-group { + margin-bottom: 2rem !important; + position: relative !important; +} + +.auth-form .form-group label { + display: flex !important; + align-items: center !important; + gap: 0.75rem !important; + font-weight: 600 !important; + color: var(--gray-700) !important; + margin-bottom: 0.75rem !important; + font-size: 0.925rem !important; +} + +.auth-form .form-group label i { + width: 20px !important; + text-align: center !important; + color: var(--primary-color) !important; +} + +.auth-form .form-group input { + width: 100% !important; + padding: 1rem !important; + border: 2px solid var(--gray-200) !important; + border-radius: var(--radius-xl) !important; + font-size: 1rem !important; + transition: var(--transition) !important; + background-color: var(--white) !important; +} + +.auth-form .form-group input:focus { + outline: none !important; + border-color: var(--primary-color) !important; + box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.1) !important; + transform: translateY(-1px) !important; +} + +.auth-form .form-group.focused label { + color: var(--primary-color) !important; +} + +/* Button Styles */ +.auth-form .btn { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + gap: 0.75rem !important; + padding: 1rem 2rem !important; + font-size: 1rem !important; + font-weight: 600 !important; + border: none !important; + border-radius: var(--radius-xl) !important; + cursor: pointer !important; + transition: var(--transition) !important; + text-decoration: none !important; + min-height: 52px !important; +} + +.auth-form .btn-primary { + background: linear-gradient(135deg, var(--primary-color), var(--primary-hover)) !important; + color: var(--white) !important; + box-shadow: var(--shadow) !important; +} + +.auth-form .btn-primary:hover { + transform: translateY(-2px) !important; + box-shadow: var(--shadow-lg) !important; +} + +.auth-form .btn-primary:active { + transform: translateY(0) !important; +} + +.auth-form .btn-full { + width: 100% !important; +} + +.auth-form .btn:disabled { + opacity: 0.7 !important; + cursor: not-allowed !important; + transform: none !important; +} + +/* Remove Auth Footer (Register Link) */ +.auth-footer { + display: none !important; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .auth-container { + padding: 1rem !important; + } + + .auth-card { + padding: 2rem !important; + max-width: 100% !important; + } + + .auth-header h1 { + font-size: 2rem !important; + } + + .auth-logo { + width: 70px !important; + height: 70px !important; + } + + .auth-logo i { + font-size: 2rem !important; + } +} + +@media (max-width: 480px) { + .auth-card { + padding: 1.5rem !important; + } + + .auth-header h1 { + font-size: 1.75rem !important; + } + + .auth-form .form-group { + margin-bottom: 1.5rem !important; + } +} + +/* Loading Animation */ +.fa-spinner { + animation: spin 1s linear infinite !important; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* Focus Accessibility */ +.auth-form .btn:focus, +.auth-form .form-group input:focus { + outline: 2px solid var(--primary-color) !important; + outline-offset: 2px !important; +} + +/* High Contrast Mode Support */ +@media (prefers-contrast: high) { + .auth-card { + border: 2px solid var(--gray-900) !important; + } + + .auth-form .form-group input { + border-width: 3px !important; + } +} + +/* Reduced Motion Support */ +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + + .auth-form .btn-primary:hover, + .auth-form .form-group input:focus { + transform: none !important; + } +} + +/* Turnstile Integration */ +.turnstile-container { + display: flex !important; + justify-content: center !important; + margin: 1.5rem 0 !important; +} + +.turnstile-container .cf-turnstile { + transform: scale(0.95) !important; + transform-origin: center !important; +} + +/* Responsive Turnstile */ +@media screen and (max-width: 480px) { + .turnstile-container .cf-turnstile { + transform: scale(0.85) !important; + } +} + +/* Form validation enhancement for Turnstile */ +.auth-form .form-group.turnstile-error { + border: 2px solid var(--error-color) !important; + border-radius: var(--radius-lg) !important; + padding: 1rem !important; + background-color: rgba(239, 68, 68, 0.05) !important; +} + +/* Dark theme support */ +.turnstile-container .cf-turnstile[data-theme="dark"] { + background-color: var(--gray-800) !important; + border-radius: var(--radius-lg) !important; +} \ No newline at end of file diff --git a/static/css/dashboard.css b/static/css/dashboard.css new file mode 100644 index 0000000..25c1574 --- /dev/null +++ b/static/css/dashboard.css @@ -0,0 +1,1580 @@ +/** + * Dashboard-specific styles + * static/css/dashboard.css + */ + +/* Dashboard Header */ +.header-stats { + display: flex; + align-items: center; +} + +.header-stats-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + min-width: 400px; +} + +@media (min-width: 1200px) { + .header-stats-grid { + grid-template-columns: repeat(4, 1fr); + min-width: 500px; + } +} + +.header-stat-card { + background: rgba(255, 255, 255, 0.95); + padding: 1rem; + border-radius: 0.5rem; + border: 1px solid #e2e8f0; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; + gap: 0.75rem; + transition: all 0.2s ease-in-out; + backdrop-filter: blur(10px); + position: relative; + overflow: hidden; +} + +.header-stat-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; +} + +.header-stat-card.primary::before { + background: linear-gradient(90deg, #2563eb, #1d4ed8); +} + +.header-stat-card.success::before { + background: linear-gradient(90deg, #10b981, #047857); +} + +.header-stat-card.danger::before { + background: linear-gradient(90deg, #ef4444, #dc2626); +} + +.header-stat-card.info::before { + background: linear-gradient(90deg, #8b5cf6, #7c3aed); +} + +.header-stat-card:hover { + transform: translateY(-1px); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.15); +} + +.header-stat-icon { + width: 36px; + height: 36px; + border-radius: 0.375rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; + color: #ffffff; + flex-shrink: 0; +} + +.header-stat-card.primary .header-stat-icon { + background: linear-gradient(135deg, #2563eb, #1d4ed8); +} + +.header-stat-card.success .header-stat-icon { + background: linear-gradient(135deg, #10b981, #047857); +} + +.header-stat-card.danger .header-stat-icon { + background: linear-gradient(135deg, #ef4444, #dc2626); +} + +.header-stat-card.info .header-stat-icon { + background: linear-gradient(135deg, #8b5cf6, #7c3aed); +} + +.header-stat-content h3 { + font-size: 1.25rem; + font-weight: 700; + color: #0f172a; + margin-bottom: 0.125rem; + line-height: 1; +} + +.header-stat-content p { + color: #64748b; + font-size: 0.75rem; + margin: 0; + line-height: 1.2; +} + +/* Update existing dashboard header to accommodate new layout */ +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 2rem; + background: linear-gradient(135deg, #a0aff1 0%, #ebdef8 100%); + border-radius: 0.75rem; + color: white; + margin-bottom: 2rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + flex-wrap: wrap; + gap: 2rem; +} + +.dashboard-content-section { + margin-bottom: var(--spacing-8); +} + +/* Responsive Design for Header */ +@media (max-width: 1024px) { + .dashboard-header { + flex-direction: column; + text-align: center; + gap: 1.5rem; + } + + .header-stats-grid { + grid-template-columns: repeat(2, 1fr); + min-width: 300px; + } +} + +@media (max-width: 768px) { + .dashboard-header { + padding: 1.5rem; + } + + .header-stats-grid { + grid-template-columns: 1fr; + min-width: 250px; + } + + .header-stat-card { + padding: 0.75rem; + } + + .header-stat-content h3 { + font-size: 1.125rem; + } + + .header-stat-content p { + font-size: 0.6875rem; + } +} + +.welcome-section h1 { + font-size: var(--font-size-3xl); + font-weight: 700; + color: var(--gray-900); + margin-bottom: var(--spacing-2); +} + +.welcome-section p { + color: var(--gray-500); + font-size: var(--font-size-lg); +} + +.user-info-badge { + display: flex; + gap: var(--spacing-4); + flex-wrap: wrap; + margin-top: var(--spacing-4); +} + +.role-indicator { + display: inline-flex; + align-items: center; + gap: var(--spacing-2); + padding: var(--spacing-2) var(--spacing-4); + border-radius: var(--radius-lg); + font-size: var(--font-size-sm); + font-weight: 600; + text-transform: capitalize; +} + +.role-indicator.admin { + background: rgba(251, 191, 36, 0.1); + color: #d97706; + border: 1px solid rgba(251, 191, 36, 0.3); +} + +.role-indicator.staff { + background: rgba(100, 116, 139, 0.1); + color: var(--gray-600); + border: 1px solid rgba(100, 116, 139, 0.3); +} + +.last-login { + display: inline-flex; + align-items: center; + gap: var(--spacing-2); + padding: var(--spacing-2) var(--spacing-4); + background: rgba(37, 99, 235, 0.1); + color: var(--primary-color); + border-radius: var(--radius-lg); + font-size: var(--font-size-sm); + border: 1px solid rgba(37, 99, 235, 0.3); +} + +.quick-actions { + display: flex; + gap: var(--spacing-3); + flex-wrap: wrap; +} + +/* Statistics Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--spacing-6); + margin-bottom: var(--spacing-8); +} + +.stat-card { + background: var(--white); + padding: var(--spacing-6); + border-radius: var(--radius-2xl); + box-shadow: var(--shadow); + display: flex; + align-items: center; + gap: var(--spacing-4); + transition: var(--transition); + border: 1px solid var(--gray-200); + position: relative; + overflow: hidden; +} + +.stat-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient( + 90deg, + var(--primary-color), + var(--primary-hover) + ); +} + +.stat-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-xl); +} + +.stat-icon { + width: 70px; + height: 70px; + border-radius: var(--radius-xl); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.75rem; + color: var(--white); + flex-shrink: 0; +} + +.stat-card.primary .stat-icon { + background: linear-gradient( + 135deg, + var(--primary-color), + var(--primary-hover) + ); +} + +.stat-card.success .stat-icon { + background: linear-gradient(135deg, var(--success-color), #047857); +} + +.stat-card.warning .stat-icon { + background: linear-gradient(135deg, var(--warning-color), #b45309); +} + +.stat-card.danger .stat-icon { + background: linear-gradient(135deg, var(--danger-color), #b91c1c); +} + +.stat-content h3 { + font-size: var(--font-size-3xl); + font-weight: 700; + color: var(--gray-900); + margin-bottom: var(--spacing-1); +} + +.stat-content p { + color: var(--gray-500); + font-size: var(--font-size-sm); + margin: 0; +} + +/* Section Headers */ +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-6); + padding: var(--spacing-4) 0; +} + +.section-header h2 { + font-size: var(--font-size-2xl); + font-weight: 700; + color: var(--gray-900); + margin: 0; +} + +.section-controls { + display: flex; + gap: var(--spacing-3); + align-items: center; + flex-wrap: wrap; +} + +.search-box { + position: relative; + display: flex; + align-items: center; +} + +.search-box i { + position: absolute; + left: var(--spacing-3); + color: var(--gray-400); + z-index: 1; +} + +.search-box input { + padding: var(--spacing-3) var(--spacing-3) var(--spacing-3) var(--spacing-10); + border: 2px solid var(--gray-200); + border-radius: var(--radius-lg); + font-size: var(--font-size-sm); + width: 300px; + transition: var(--transition); +} + +.search-box input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.filter-select { + padding: var(--spacing-3) var(--spacing-4); + border: 2px solid var(--gray-200); + border-radius: var(--radius-lg); + font-size: var(--font-size-sm); + background-color: var(--white); + cursor: pointer; + transition: var(--transition); + min-width: 150px; +} + +.filter-select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +/* QR Codes Grid */ +.qr-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: var(--spacing-6); + margin-bottom: var(--spacing-8); +} + +/* Enhanced QR Item Styling */ +.qr-item { + border: 1px solid var(--gray-200); + border-radius: var(--radius-xl); + background: var(--white); + transition: var(--transition); + overflow: hidden; + opacity: 1; + transform: translateY(0); + cursor: pointer; +} + +.qr-item:hover { + border-color: var(--primary-color); + box-shadow: 0 4px 12px rgba(37, 99, 235, 0.1); + transform: translateY(-2px); +} + +.qr-item[data-status="inactive"] { + opacity: 0.7; + background: linear-gradient(135deg, var(--white), #f8fafc); +} + +.qr-item[data-status="inactive"]:hover { + opacity: 0.9; +} + +.qr-item.expanded { + border-color: var(--primary-color); + box-shadow: 0 8px 25px rgba(37, 99, 235, 0.15); +} + +.qr-item-header { + padding: var(--spacing-4); + display: flex; + align-items: flex-start; + gap: var(--spacing-4); +} + +.qr-code-preview { + width: 80px; + height: 80px; + flex-shrink: 0; + border-radius: var(--radius-lg); + overflow: hidden; + border: 2px solid var(--gray-200); + transition: var(--transition); +} + +.qr-code-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.qr-item:hover .qr-code-preview { + border-color: var(--primary-color); +} + +.qr-item-info { + flex: 1; + min-width: 0; +} + +.qr-item-title { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--gray-900); + margin-bottom: var(--spacing-2); + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.qr-item-meta { + display: flex; + flex-direction: column; + gap: var(--spacing-1); +} + +.qr-item-meta span { + display: flex; + align-items: center; + gap: var(--spacing-2); + font-size: var(--font-size-xs); + color: var(--gray-500); +} + +.qr-status { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border-radius: var(--radius); + font-size: 0.75rem; + font-weight: 600; + transition: var(--transition); +} + +.qr-status.active { + background: rgba(5, 150, 105, 0.1); + color: var(--success-color); + border: 1px solid rgba(5, 150, 105, 0.2); +} + +.qr-status.inactive { + background: rgba(220, 38, 38, 0.1); + color: var(--danger-color); + border: 1px solid rgba(220, 38, 38, 0.2); +} + +.qr-item-actions { + padding: 0 var(--spacing-4) var(--spacing-4); +} + +.quick-actions { + display: flex; + gap: var(--spacing-2); + flex-wrap: wrap; +} + +.action-btn { + padding: var(--spacing-2); + border: 1px solid transparent; + border-radius: var(--radius); + background: transparent; + cursor: pointer; + transition: var(--transition); + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + text-decoration: none; +} + +.btn-activate { + background: rgba(5, 150, 105, 0.1); + color: var(--success-color); + border-color: rgba(5, 150, 105, 0.2); +} + +.btn-activate:hover { + background: var(--success-color); + color: var(--white); +} + +.btn-deactivate { + background: rgba(245, 158, 11, 0.1); + color: var(--warning-color); + border-color: rgba(245, 158, 11, 0.2); +} + +.btn-deactivate:hover { + background: var(--warning-color); + color: var(--white); +} + +.btn-download { + background: rgba(37, 99, 235, 0.1); + color: var(--primary-color); + border-color: rgba(37, 99, 235, 0.2); +} + +.btn-download:hover { + background: var(--primary-color); + color: var(--white); +} + +.btn-edit { + background: rgba(107, 114, 128, 0.1); + color: var(--gray-600); + border-color: rgba(107, 114, 128, 0.2); +} + +.btn-edit:hover { + background: var(--gray-600); + color: var(--white); +} + +.btn-delete { + background: rgba(220, 38, 38, 0.1); + color: var(--danger-color); + border-color: rgba(220, 38, 38, 0.2); +} + +.btn-delete:hover { + background: var(--danger-color); + color: var(--white); +} + +/* Expanded QR Item Details */ +.qr-item-details { + padding: var(--spacing-4); + border-top: 1px solid var(--gray-200); + background: var(--gray-50); + display: none; +} + +.qr-item.expanded .qr-item-details { + display: block; + animation: slideDown 0.3s ease-out; +} + +.qr-details-content { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-6); +} + +.qr-details-info { + display: flex; + flex-direction: column; + gap: var(--spacing-3); +} + +.info-item { + display: flex; + flex-direction: column; + gap: var(--spacing-1); +} + +.info-item .label { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--gray-500); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.info-item .value { + font-size: var(--font-size-sm); + color: var(--gray-900); +} + +.qr-details-preview { + display: flex; + justify-content: center; + align-items: center; +} + +.qr-details-image { + width: 150px; + height: 150px; + border-radius: var(--radius-lg); + border: 2px solid var(--gray-200); + overflow: hidden; + cursor: pointer; + transition: var(--transition); +} + +.qr-details-image:hover { + border-color: var(--primary-color); + transform: scale(1.02); +} + +.qr-details-image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.details-actions { + display: flex; + gap: var(--spacing-3); + margin-top: var(--spacing-4); + flex-wrap: wrap; +} + +.details-actions .btn { + flex: 1; + min-width: 150px; +} + +/* Bulk Actions Bar */ +.bulk-actions-bar { + position: fixed; + bottom: var(--spacing-4); + left: 50%; + transform: translateX(-50%) translateY(100px); + background: var(--white); + border: 1px solid var(--gray-200); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-xl); + padding: var(--spacing-4) var(--spacing-6); + display: flex; + align-items: center; + gap: var(--spacing-4); + z-index: var(--z-dropdown); + opacity: 0; + transition: all 0.3s ease-out; +} + +.bulk-actions-bar.show { + transform: translateX(-50%) translateY(0); + opacity: 1; +} + +.bulk-actions-info { + font-size: var(--font-size-sm); + color: var(--gray-600); +} + +.bulk-actions-buttons { + display: flex; + gap: var(--spacing-2); +} + +/* Status loading animation */ +.status-loading { + opacity: 0.6; + pointer-events: none; +} + +.status-loading i { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* Results counter */ +.results-counter { + font-size: var(--font-size-sm); + color: var(--gray-600); + padding: var(--spacing-2) var(--spacing-4); + background: var(--gray-50); + border-radius: var(--radius); + border: 1px solid var(--gray-200); +} + +/* Empty States */ +.empty-state { + text-align: center; + padding: var(--spacing-16) var(--spacing-8); + background: var(--white); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); +} + +.empty-icon { + width: 80px; + height: 80px; + margin: 0 auto var(--spacing-6); + background: var(--gray-100); + border-radius: var(--radius-full); + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + color: var(--gray-400); +} + +.empty-state h3 { + font-size: var(--font-size-xl); + color: var(--gray-700); + margin-bottom: var(--spacing-3); +} + +.empty-state p { + color: var(--gray-500); + margin-bottom: var(--spacing-6); +} + +.search-empty-state { + text-align: center; + padding: 3rem 2rem; + background: var(--white); + border-radius: var(--radius-xl); + border: 2px dashed var(--gray-200); + margin: var(--spacing-6) 0; +} + +/* QR Modal Enhancements */ +.qr-modal { + max-width: 600px; +} + +.qr-modal-image { + text-align: center; + padding: var(--spacing-6); +} + +.qr-modal-image img { + max-width: 100%; + height: auto; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); +} + +/* Animation classes */ +.fade-in { + animation: fadeInUp 0.3s ease-out; +} + +.fade-out { + animation: fadeOutDown 0.3s ease-out; +} + +.animate-in { + animation: slideUp 0.3s ease-out; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeOutDown { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-20px); + } +} + +@keyframes slideDown { + from { + opacity: 0; + max-height: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + max-height: 500px; + transform: translateY(0); + } +} + +/* Responsive Design for Dashboard */ +@media (max-width: 1024px) { + .qr-grid { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + } + + .qr-details-content { + grid-template-columns: 1fr; + gap: var(--spacing-4); + } + + .search-box input { + width: 250px; + } +} + +@media (max-width: 768px) { + .dashboard-header { + flex-direction: column; + gap: var(--spacing-4); + text-align: center; + } + + .stats-grid { + grid-template-columns: 1fr; + gap: var(--spacing-4); + } + + .section-header { + flex-direction: column; + gap: var(--spacing-4); + align-items: stretch; + } + + .section-controls { + justify-content: center; + } + + .search-box input { + width: 100%; + max-width: 300px; + } + + .qr-grid { + grid-template-columns: 1fr; + } + + .qr-item-header { + flex-direction: column; + text-align: center; + } + + .quick-actions { + justify-content: center; + } + + .details-actions { + flex-direction: column; + } + + .details-actions .btn { + min-width: auto; + } + + .bulk-actions-bar { + left: var(--spacing-3); + right: var(--spacing-3); + transform: translateY(100px); + flex-direction: column; + gap: var(--spacing-3); + } + + .bulk-actions-bar.show { + transform: translateY(0); + } + + .bulk-actions-buttons { + width: 100%; + justify-content: center; + } +} + +@media (max-width: 480px) { + .qr-code-preview { + width: 60px; + height: 60px; + } + + .qr-item-title { + font-size: var(--font-size-base); + } + + .action-btn { + width: 32px; + height: 32px; + padding: var(--spacing-1); + } + + .qr-details-image { + width: 120px; + height: 120px; + } + + .user-info-badge { + flex-direction: column; + align-items: center; + } +} + +/* Enhanced QR Item Animations and Interactions */ +.qr-item { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + overflow: hidden; + position: relative; +} + +.qr-item-details { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-in-out; + padding: 0 var(--spacing-4); +} + +.qr-item.expanded .qr-item-details { + max-height: 500px; + padding: var(--spacing-4); + border-top: 1px solid var(--gray-200); + margin-top: var(--spacing-4); +} + +/* QR Item Actions */ +.qr-item-actions { + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.qr-checkbox { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: var(--primary-color); +} + +.toggle-expand-btn { + background: none; + border: none; + color: var(--gray-500); + cursor: pointer; + padding: var(--spacing-2); + border-radius: var(--radius-md); + transition: var(--transition); + display: flex; + align-items: center; + justify-content: center; +} + +.toggle-expand-btn:hover { + background: var(--gray-100); + color: var(--primary-color); +} + +.toggle-expand-btn i { + transition: transform 0.3s ease; +} + +/* Detail Grid */ +.detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: var(--spacing-3); + margin-bottom: var(--spacing-4); +} + +.detail-item { + display: flex; + flex-direction: column; + gap: var(--spacing-1); +} + +.detail-item label { + font-weight: 600; + color: var(--gray-700); + font-size: var(--font-size-sm); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.detail-item span { + color: var(--gray-900); + font-size: var(--font-size-sm); + word-break: break-word; +} + +.qr-url { + font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; + background: var(--gray-100); + padding: var(--spacing-1) var(--spacing-2); + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); +} + +/* QR Item Footer */ +.qr-item-footer { + border-top: 1px solid var(--gray-200); + padding-top: var(--spacing-3); + margin-top: var(--spacing-3); +} + +.qr-actions { + display: flex; + gap: var(--spacing-2); + flex-wrap: wrap; +} + +.qr-actions .btn { + font-size: var(--font-size-xs); + padding: 0.375rem 0.75rem; + border-radius: var(--radius-md); +} + +/* Status and Project Badges */ +.qr-item-meta { + display: flex; + align-items: center; + gap: var(--spacing-3); + margin-top: var(--spacing-2); + flex-wrap: wrap; +} + +.qr-status, +.qr-project { + display: flex; + align-items: center; + gap: var(--spacing-1); + font-size: var(--font-size-xs); + padding: 0.25rem 0.5rem; + border-radius: var(--radius-full); + font-weight: 500; +} + +.qr-status.active { + background: rgba(16, 185, 129, 0.1); + color: #047857; +} + +.qr-status.inactive { + background: rgba(239, 68, 68, 0.1); + color: #dc2626; +} + +.qr-project { + background: rgba(37, 99, 235, 0.1); + color: var(--primary-color); +} + +/* Animation classes */ +.fade-in { + opacity: 1; + transform: translateY(0); +} + +.fade-out { + opacity: 0; + transform: translateY(-10px); +} + +/* Empty state for search */ +.search-empty-state { + grid-column: 1 / -1; + text-align: center; + padding: 3rem 1.5rem; + color: var(--gray-600); +} + +.search-empty-state .empty-icon { + width: 80px; + height: 80px; + margin: 0 auto 1.5rem; + background: var(--gray-100); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + color: var(--gray-400); +} + +.search-empty-state h3 { + font-size: 1.5rem; + font-weight: 600; + color: var(--gray-900); + margin-bottom: 0.75rem; +} + +.search-empty-state p { + font-size: 1rem; + margin-bottom: 1.5rem; + max-width: 400px; + margin-left: auto; + margin-right: auto; +} + +/* Bulk Actions Bar */ +.bulk-actions-bar { + background: linear-gradient(135deg, #fef3c7, #fde68a); + border: 1px solid #f59e0b; + border-radius: var(--radius-lg); + padding: var(--spacing-4); + margin-bottom: var(--spacing-4); + display: none; + align-items: center; + justify-content: space-between; + gap: var(--spacing-4); +} + +.bulk-actions-info { + display: flex; + align-items: center; + gap: var(--spacing-2); + font-weight: 600; + color: #92400e; +} + +.bulk-actions-buttons { + display: flex; + gap: var(--spacing-2); +} + +/* Modal Styles */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + align-items: center; + justify-content: center; + animation: fadeIn 0.3s ease; +} + +.modal-content { + background: var(--white); + border-radius: var(--radius-xl); + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + max-width: 90vw; + max-height: 90vh; + overflow: auto; + animation: slideIn 0.3s ease; +} + +.modal-header { + padding: var(--spacing-6); + border-bottom: 1px solid var(--gray-200); + display: flex; + align-items: center; + justify-content: space-between; +} + +.modal-header h3 { + margin: 0; + font-size: var(--font-size-xl); + font-weight: 600; + color: var(--gray-900); +} + +.modal-close { + background: none; + border: none; + font-size: var(--font-size-xl); + color: var(--gray-500); + cursor: pointer; + padding: var(--spacing-2); + border-radius: var(--radius-md); + transition: var(--transition); +} + +.modal-close:hover { + background: var(--gray-100); + color: var(--gray-700); +} + +.modal-body { + padding: var(--spacing-6); + text-align: center; +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-50px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Responsive Design */ +@media (max-width: 768px) { + .detail-grid { + grid-template-columns: 1fr; + } + + .qr-actions { + flex-direction: column; + } + + .qr-actions .btn { + width: 100%; + justify-content: center; + } + + .bulk-actions-bar { + flex-direction: column; + text-align: center; + } + + .bulk-actions-buttons { + width: 100%; + justify-content: center; + } + + .modal-content { + margin: var(--spacing-4); + max-width: calc(100vw - 2rem); + } +} + +/* Enhanced QR Action Buttons */ +.qr-action-btn.copy { + background: linear-gradient(135deg, #10b981, #059669); + color: white; +} + +.qr-action-btn.copy:hover { + background: linear-gradient(135deg, #059669, #047857); + transform: translateY(-1px); +} + +.qr-action-btn.link { + background: linear-gradient(135deg, #3b82f6, #2563eb); + color: white; +} + +.qr-action-btn.link:hover { + background: linear-gradient(135deg, #2563eb, #1d4ed8); + transform: translateY(-1px); +} + +/* Check-ins Counter Styling */ +.qr-checkins-count { + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.2); + border-radius: var(--radius-md); + padding: 0.25rem 0.5rem; + font-weight: 500; + color: #1e40af; +} + +.qr-checkins-count i { + color: #3b82f6; +} + +/* Responsive Action Buttons */ +@media (max-width: 768px) { + .qr-actions-compact { + flex-wrap: wrap; + gap: 0.25rem; + } + + .qr-action-btn { + min-width: 32px; + height: 32px; + font-size: 0.75rem; + } +} + +/* Enhanced QR Actions Layout */ +.qr-actions-compact { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; +} + +.qr-card-actions { + display: flex; + gap: 0.5rem; + justify-content: center; + flex-wrap: wrap; + padding: var(--spacing-3); + border-top: 1px solid var(--gray-200); + background: var(--gray-50); +} + +/* ============================================ + QR Code Search Section + ============================================ */ +.search-section { + background: var(--white); + border: 1px solid var(--gray-200); + border-radius: var(--radius-xl); + padding: var(--spacing-6); + margin-bottom: var(--spacing-6); +} + +.search-form { + width: 100%; +} + +.search-inputs { + display: flex; + gap: var(--spacing-4); + align-items: flex-end; + flex-wrap: wrap; +} + +.search-input-group { + display: flex; + flex-direction: column; + gap: var(--spacing-2); + flex: 1; + min-width: 250px; + max-width: 400px; +} + +.search-input-group label { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--gray-700); + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.search-input, +.search-select { + padding: 0.75rem 1rem; + border: 1px solid var(--gray-300); + border-radius: var(--radius-lg); + font-size: var(--font-size-base); + transition: var(--transition); + width: 100%; + box-sizing: border-box; +} + +.search-input:focus, +.search-select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.search-actions { + display: flex; + align-items: flex-end; + flex-shrink: 0; +} + +.search-actions .btn { + min-width: 120px; + white-space: nowrap; +} + +/* Responsive Design */ +@media (max-width: 992px) { + .search-input-group { + min-width: 200px; + } +} + +@media (max-width: 768px) { + .search-inputs { + flex-direction: column; + align-items: stretch; + } + + .search-input-group { + max-width: 100%; + min-width: 100%; + } + + .search-actions { + width: 100%; + } + + .search-actions .btn { + width: 100%; + } +} + +/* ============================================ + Search Results Categorization Styles + ============================================ */ + +/* Overall Search Results Header */ +.search-results-header { + background: linear-gradient(135deg, #f0f9ff, #dbeafe); + border: 1px solid #3b82f6; + border-radius: var(--radius-xl); + padding: var(--spacing-6); + margin-bottom: var(--spacing-6); +} + +.search-results-title { + font-size: var(--font-size-2xl); + font-weight: 700; + color: #1e40af; + margin: 0 0 var(--spacing-2) 0; + display: flex; + align-items: center; + gap: var(--spacing-3); +} + +.search-results-summary { + display: flex; + align-items: center; + gap: var(--spacing-4); + flex-wrap: wrap; + color: #1e40af; + font-size: var(--font-size-base); +} + +.result-count { + font-weight: 600; + font-size: var(--font-size-lg); +} + +.project-count { + color: #3b82f6; + font-weight: 500; +} + +/* Project Section in Search Results */ +.search-results-project-section { + background: var(--white); + border: 1px solid var(--gray-200); + border-radius: var(--radius-xl); + padding: var(--spacing-5); + margin-bottom: var(--spacing-5); + transition: var(--transition); +} + +.search-results-project-section:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + border-color: var(--primary-color); +} + +.search-results-project-section.unassigned { + border-color: #f59e0b; + background: linear-gradient(135deg, #fffbeb, #fef3c7); +} + +.search-project-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-4); + padding-bottom: var(--spacing-3); + border-bottom: 2px solid var(--gray-200); +} + +.search-project-info { + display: flex; + align-items: center; + gap: var(--spacing-3); +} + +.search-project-icon { + width: 48px; + height: 48px; + background: linear-gradient(135deg, var(--primary-color), #1d4ed8); + border-radius: var(--radius-lg); + display: flex; + align-items: center; + justify-content: center; + color: var(--white); + font-size: 1.25rem; + flex-shrink: 0; +} + +.search-project-icon.unassigned-icon { + background: linear-gradient(135deg, #f59e0b, #d97706); +} + +.search-project-name { + font-size: var(--font-size-lg); + font-weight: 700; + color: var(--gray-900); + margin: 0; +} + +.search-project-description { + font-size: var(--font-size-sm); + color: var(--gray-600); + margin: 0.25rem 0 0 0; +} + +.search-project-count { + background: var(--primary-color); + color: var(--white); + padding: 0.5rem 1rem; + border-radius: var(--radius-full); + font-size: var(--font-size-sm); + font-weight: 600; + white-space: nowrap; +} + +.search-results-project-section.unassigned .search-project-count { + background: #f59e0b; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .search-project-header { + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-3); + } + + .search-project-count { + align-self: stretch; + text-align: center; + } + + .search-results-summary { + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-2); + } +} \ No newline at end of file diff --git a/static/css/employees.css b/static/css/employees.css new file mode 100644 index 0000000..3e96745 --- /dev/null +++ b/static/css/employees.css @@ -0,0 +1,823 @@ +/** + * Employee Management Page Styles + * static/css/employees.css + */ + +/* Main Container */ +.employees-page { + max-width: 1600px; + margin: 0 auto; + padding: 2rem; + min-height: 100vh; + background: #f8fafc; +} + +/* Sidebar Layout Compatibility */ +body.has-sidebar .employees-page { + margin-left: 0; + padding-left: 0; +} + +.main-wrapper .employees-page { + padding: 2rem; + max-width: 1600px; + margin: 0 auto; + min-height: 100vh; + background: #f8fafc; +} + +/* Page Header */ +.employees-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 2rem; + padding: 1.5rem; + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + position: relative; + overflow: hidden; +} + +.employees-header::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, #2563eb, #1d4ed8); +} + +.header-navigation { + margin-bottom: 1rem; +} + +.back-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: #f1f5f9; + color: #475569; + text-decoration: none; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + border: 1px solid #cbd5e1; + transition: all 0.2s ease-in-out; +} + +.back-button:hover { + background: #e2e8f0; + color: #334155; + transform: translateX(-2px); + text-decoration: none; +} + +.header-content h1 { + font-size: 1.875rem; + font-weight: 700; + color: #0f172a; + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.header-content h1 i { + color: #2563eb; +} + +.header-description { + color: #64748b; + font-size: 1rem; + margin: 0; +} + +.header-actions { + display: flex; + gap: 1rem; +} + +/* Statistics Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.stat-card { + background: #ffffff; + padding: 1.5rem; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; + gap: 1rem; + transition: all 0.2s ease-in-out; +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: 0 10px 25px 0 rgba(0, 0, 0, 0.1); +} + +.stat-card.search-results { + border: 2px solid #fbbf24; + background: #fefbf2; +} + +.stat-icon { + width: 50px; + height: 50px; + background: linear-gradient(135deg, #2563eb, #1d4ed8); + color: #ffffff; + border-radius: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; + flex-shrink: 0; +} + +.stat-card.search-results .stat-icon { + background: linear-gradient(135deg, #f59e0b, #d97706); +} + +.stat-info h3 { + font-size: 1.75rem; + font-weight: 700; + color: #0f172a; + margin-bottom: 0.25rem; +} + +.stat-info p { + color: #64748b; + font-size: 0.875rem; + margin: 0; +} + +/* Search Section */ +.search-section { + background: #ffffff; + padding: 1.5rem; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + margin-bottom: 2rem; +} + +.search-form { + display: flex; + justify-content: center; +} + +.search-input-group { + position: relative; + display: flex; + max-width: 600px; + width: 100%; +} + +.search-input { + flex: 1; + padding: 0.75rem 1rem; + border: 2px solid #e2e8f0; + border-radius: 0.5rem 0 0 0.5rem; + font-size: 1rem; + background: #ffffff; + transition: border-color 0.2s ease-in-out; +} + +.search-input:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.search-btn { + padding: 0.75rem 1.25rem; + background: #2563eb; + color: #ffffff; + border: none; + cursor: pointer; + font-size: 1rem; + transition: background-color 0.2s ease-in-out; +} + +.clear-search-btn { + padding: 0.75rem 1.25rem; + background: #dc2626; + color: #ffffff; + text-decoration: none; + border-radius: 0 0.5rem 0.5rem 0; + display: flex; + align-items: center; + transition: background-color 0.2s ease-in-out; +} + +.search-btn { + border-radius: 0 0.5rem 0.5rem 0; +} + +.search-input-group:has(.clear-search-btn) .search-btn { + border-radius: 0; +} + +.search-btn:hover { + background: #1d4ed8; +} + +.clear-search-btn:hover { + background: #b91c1c; +} + +/* Employee Table Container */ +.employees-table-container { + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.table-header { + padding: 1.5rem; + border-bottom: 1px solid #e2e8f0; + display: flex; + justify-content: space-between; + align-items: center; + background: #f8fafc; +} + +.table-header h2 { + font-size: 1.25rem; + font-weight: 600; + color: #0f172a; + margin: 0; +} + +.table-info { + color: #64748b; + font-size: 0.875rem; +} + +/* Table Styles */ +.table-responsive { + overflow-x: auto; +} + +.employees-table { + width: 100%; + border-collapse: collapse; +} + +.employees-table thead { + background: #f8fafc; +} + +.employees-table th { + padding: 1rem; + text-align: left; + font-weight: 600; + color: #374151; + border-bottom: 1px solid #e2e8f0; + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.employees-table tbody tr { + transition: background-color 0.2s ease-in-out; +} + +.employees-table tbody tr:hover { + background: #f8fafc; +} + +.employees-table td { + padding: 1rem; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; +} + +/* Table Cell Specific Styles */ +.row-number { + font-weight: 500; + color: #64748b; + width: 60px; +} + +.employee-id .id-badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.75rem; + background: #dbeafe; + color: #1e40af; + border-radius: 0.375rem; + font-weight: 600; + font-size: 0.875rem; +} + +.employee-name { + min-width: 200px; +} + +.name-container { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.avatar { + width: 40px; + height: 40px; + background: linear-gradient(135deg, #2563eb, #1d4ed8); + color: #ffffff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.avatar i { + font-size: 1.25rem; +} + +.name-details h4 { + font-weight: 600; + color: #0f172a; + margin: 0 0 0.25rem 0; +} + +.name-details p { + font-size: 0.875rem; + color: #64748b; + margin: 0; +} + +.employee-title .title-badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.75rem; + background: #ecfdf5; + color: #065f46; + border-radius: 0.375rem; + font-size: 0.75rem; + font-weight: 500; +} + +.employee-title .no-title { + color: #9ca3af; + font-style: italic; + font-size: 0.875rem; +} + +.contract .contract-badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.75rem; + background: #fef3c7; + color: #92400e; + border-radius: 0.375rem; + font-size: 0.75rem; + font-weight: 500; +} + +/* Action Buttons */ +.actions { + width: 140px; +} + +.action-buttons { + display: flex; + gap: 0.5rem; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; + border: none; + cursor: pointer; + transition: all 0.2s ease-in-out; +} + +.btn-sm { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; +} + +.btn-primary { + background: #2563eb; + color: #ffffff; +} + +.btn-primary:hover { + background: #1d4ed8; +} + +.btn-info { + background: #0891b2; + color: #ffffff; +} + +.btn-info:hover { + background: #0e7490; +} + +.btn-warning { + background: #f59e0b; + color: #ffffff; +} + +.btn-warning:hover { + background: #d97706; +} + +.btn-danger { + background: #dc2626; + color: #ffffff; +} + +.btn-danger:hover { + background: #b91c1c; +} + +.btn-secondary { + background: #6b7280; + color: #ffffff; +} + +.btn-secondary:hover { + background: #4b5563; +} + +/* Pagination */ +.pagination-container { + padding: 1.5rem; + border-top: 1px solid #e2e8f0; + background: #f8fafc; +} + +.pagination-nav { + display: flex; + justify-content: center; +} + +.pagination { + display: flex; + list-style: none; + margin: 0; + padding: 0; + gap: 0.25rem; +} + +.pagination-link { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + color: #374151; + text-decoration: none; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + transition: all 0.2s ease-in-out; +} + +.pagination-link:hover { + background: #f3f4f6; + border-color: #9ca3af; +} + +.pagination-link.current { + background: #2563eb; + color: #ffffff; + border-color: #2563eb; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 4rem 2rem; + color: #6b7280; +} + +.empty-icon { + font-size: 4rem; + color: #d1d5db; + margin-bottom: 1rem; +} + +.empty-state h3 { + font-size: 1.25rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.5rem; +} + +.empty-state p { + font-size: 1rem; + margin: 0; +} + +.empty-state a { + color: #2563eb; + text-decoration: none; + font-weight: 500; +} + +.empty-state a:hover { + text-decoration: underline; +} + +/* Modal Styles */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + align-items: center; + justify-content: center; +} + +.modal-content { + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + width: 90%; + max-width: 500px; + animation: modalSlideIn 0.3s ease-out; +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-20px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid #e2e8f0; +} + +.modal-header h3 { + display: flex; + align-items: center; + gap: 0.5rem; + color: #dc2626; + font-size: 1.125rem; + font-weight: 600; + margin: 0; +} + +.modal-header h3 i { + color: #f59e0b; +} + +.close-modal { + background: none; + border: none; + font-size: 1.25rem; + color: #6b7280; + cursor: pointer; + padding: 0.25rem; + border-radius: 0.25rem; + transition: color 0.2s ease-in-out; +} + +.close-modal:hover { + color: #374151; + background: #f3f4f6; +} + +.modal-body { + padding: 1.5rem; +} + +.modal-body p { + margin-bottom: 1rem; + color: #374151; +} + +.modal-body p:last-child { + margin-bottom: 0; +} + +.warning-text { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 0.375rem; + color: #b91c1c; + font-size: 0.875rem; +} + +.warning-text i { + color: #f59e0b; +} + +.modal-footer { + display: flex; + gap: 0.75rem; + justify-content: flex-end; + padding: 1.5rem; + border-top: 1px solid #e2e8f0; + background: #f8fafc; +} + +/* Search Highlighting */ +mark { + background: #fef08a; + color: #854d0e; + padding: 0.125rem 0.25rem; + border-radius: 0.125rem; + font-weight: 600; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .employees-page { + padding: 1rem; + } + + .employees-header { + flex-direction: column; + gap: 1rem; + text-align: center; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .table-header { + flex-direction: column; + gap: 0.5rem; + text-align: center; + } + + .employees-table { + font-size: 0.875rem; + } + + .employees-table th, + .employees-table td { + padding: 0.75rem 0.5rem; + } + + .name-container { + flex-direction: column; + text-align: center; + gap: 0.5rem; + } + + .action-buttons { + flex-direction: column; + gap: 0.25rem; + } + + .modal-content { + width: 95%; + margin: 1rem; + } + + .modal-footer { + flex-direction: column; + } +} + +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.6); + z-index: 9999; + display: none; + justify-content: center; + align-items: center; + opacity: 0; + transition: opacity 0.3s ease-in-out; +} + +.modal.show { + opacity: 1; +} + +.modal-content { + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), + 0 10px 10px -5px rgba(0, 0, 0, 0.04); + max-width: 500px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + transform: translateY(-20px); + transition: transform 0.3s ease-in-out; +} + +.modal.show .modal-content { + transform: translateY(0); +} + +.modal-header { + padding: 1.5rem 1.5rem 1rem; + border-bottom: 1px solid #e5e7eb; + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h3 { + margin: 0; + color: #dc2626; + font-size: 1.25rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.modal-body { + padding: 1.5rem; +} + +.modal-footer { + padding: 1rem 1.5rem; + border-top: 1px solid #e5e7eb; + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + +.close-modal { + background: none; + border: none; + color: #6b7280; + cursor: pointer; + padding: 0.25rem; + border-radius: 0.375rem; + transition: all 0.2s ease-in-out; +} + +.close-modal:hover { + color: #374151; + background: #f3f4f6; +} + +.warning-text { + color: #dc2626; + font-size: 0.875rem; + margin-top: 0.75rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Enhanced button styles for better interaction feedback */ +.btn-danger:hover { + background-color: #b91c1c; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(220, 38, 38, 0.3); +} + +.delete-btn:hover { + background-color: #b91c1c !important; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(220, 38, 38, 0.3); +} + +/* Loading state for delete button */ +.btn-danger:disabled { + background-color: #9ca3af; + cursor: not-allowed; + transform: none; + box-shadow: none; +} diff --git a/static/css/export_configuration.css b/static/css/export_configuration.css new file mode 100644 index 0000000..2330c78 --- /dev/null +++ b/static/css/export_configuration.css @@ -0,0 +1,897 @@ +/* Export Configuration Styles */ +.export-config-page { + min-height: 100vh; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + padding: 2rem 0; +} + +/* Header Section */ +.export-header { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + border-radius: 15px; + padding: 1.5rem 2rem; + margin: 0 auto 2rem; + max-width: 1200px; + margin-left: auto; + margin-right: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1rem; +} + +.header-content { + flex: 1; + min-width: 300px; +} + +.header-title-section { + display: flex; + align-items: center; + gap: 1.25rem; + flex-wrap: nowrap; +} + +.header-title-text { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.header-content h1 { + color: #2d3748; + font-size: 1.75rem; + font-weight: 700; + margin: 0; + display: flex; + align-items: center; + gap: 0.75rem; + line-height: 1.2; +} + +.header-content h1 i { + color: #48bb78; + font-size: 1.6rem; +} + +.header-content p { + color: #718096; + font-size: 0.9rem; + margin: 0; + line-height: 1.3; +} + +.header-actions { + display: flex; + gap: 1rem; + align-items: center; +} + +/* Applied Filters Section */ +.applied-filters-section { + max-width: 1200px; + margin: 0 auto 2rem; +} + +.filters-card { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + border-radius: 15px; + padding: 1.5rem; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); +} + +.filters-header { + display: flex; + align-items: center; + margin-bottom: 1rem; +} + +.filters-header h3 { + color: #2d3748; + font-size: 1.4rem; + font-weight: 600; + margin: 0; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.filters-header i { + color: #4299e1; +} + +.filters-display { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.filter-tag { + background: linear-gradient(135deg, #4299e1, #3182ce); + color: white; + padding: 0.5rem 1rem; + border-radius: 25px; + font-size: 0.9rem; + font-weight: 500; + display: flex; + align-items: center; + gap: 0.5rem; + box-shadow: 0 2px 8px rgba(66, 153, 225, 0.3); +} + +/* Export Configuration Section */ +.export-config-section { + max-width: 1200px; + margin: 0 auto; +} + +.config-card { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + border-radius: 15px; + padding: 2rem; + margin-bottom: 2rem; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); +} + +.config-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + flex-wrap: wrap; + gap: 1rem; +} + +.config-header h3 { + color: #2d3748; + font-size: 1.6rem; + font-weight: 600; + margin: 0; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.config-header i { + color: #48bb78; +} + +.config-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +/* Columns Grid */ +.columns-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: 1.5rem; +} + +.column-item { + background: linear-gradient(135deg, #f7fafc, #edf2f7); + border: 2px solid #e2e8f0; + border-radius: 12px; + padding: 1.5rem; + transition: all 0.3s ease; +} + +.column-item:hover { + border-color: #4299e1; + box-shadow: 0 4px 16px rgba(66, 153, 225, 0.15); + transform: translateY(-2px); +} + +.column-checkbox { + margin-bottom: 1rem; +} + +.column-checkbox input[type="checkbox"] { + display: none; +} + +.column-checkbox label { + display: flex; + align-items: center; + gap: 0.75rem; + cursor: pointer; + font-weight: 600; + color: #2d3748; + font-size: 1.1rem; + padding: 0.75rem; + border-radius: 8px; + transition: all 0.3s ease; +} + +.column-checkbox label i { + width: 20px; + height: 20px; + border: 2px solid #cbd5e0; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + color: transparent; + transition: all 0.3s ease; +} + +.column-checkbox input[type="checkbox"]:checked + label { + background: linear-gradient(135deg, #48bb78, #38a169); + color: white; +} + +.column-checkbox input[type="checkbox"]:checked + label i { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.5); + color: white; +} + +.column-name-input { + transition: all 0.3s ease; +} + +.column-name-input label { + display: block; + font-weight: 500; + color: #4a5568; + margin-bottom: 0.5rem; + font-size: 0.9rem; +} + +.column-name-input input { + width: 100%; + padding: 0.75rem; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 1rem; + background: white; + transition: all 0.3s ease; +} + +.column-name-input input:focus { + outline: none; + border-color: #4299e1; + box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.1); +} + +/* Preview Section */ +.preview-section { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + border-radius: 15px; + padding: 2rem; + margin-bottom: 2rem; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); +} + +.preview-header h3 { + color: #2d3748; + font-size: 1.6rem; + font-weight: 600; + margin: 0 0 1.5rem 0; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.preview-header i { + color: #ed8936; +} + +.preview-table-container { + overflow-x: auto; + border-radius: 10px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); +} + +.preview-table { + width: 100%; + border-collapse: collapse; + background: white; + font-size: 0.9rem; +} + +.preview-table th { + background: linear-gradient(135deg, #4299e1, #3182ce); + color: white; + padding: 1rem; + text-align: left; + font-weight: 600; + border-bottom: 2px solid #2b6cb0; + white-space: nowrap; +} + +.preview-table td { + padding: 0.75rem 1rem; + border-bottom: 1px solid #e2e8f0; + color: #4a5568; +} + +.preview-placeholder { + text-align: center; + padding: 3rem; + color: #a0aec0; + font-style: italic; + font-size: 1.1rem; +} + +/* Submit Section */ +.submit-section { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + border-radius: 15px; + padding: 2rem; + text-align: center; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); +} + +.submit-actions { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.submit-note { + color: #718096; + font-size: 0.95rem; + margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.submit-note i { + color: #4299e1; +} + +/* Button Styles */ +.btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.65rem 1.25rem; + border: none; + border-radius: 8px; + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: all 0.3s ease; + font-size: 0.95rem; + white-space: nowrap; +} + +.btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); +} + +.btn-success { + background: linear-gradient(135deg, #48bb78, #38a169); + color: white; +} + +.btn-success:hover { + background: linear-gradient(135deg, #38a169, #2f855a); +} + +.btn-secondary { + background: linear-gradient(135deg, #a0aec0, #718096); + color: white; +} + +.btn-secondary:hover { + background: linear-gradient(135deg, #718096, #4a5568); +} + +.btn-outline { + background: transparent; + color: #4299e1; + border: 2px solid #4299e1; +} + +.btn-outline:hover { + background: #4299e1; + color: white; +} + +.btn-sm { + padding: 0.5rem 1rem; + font-size: 0.85rem; +} + +.btn-lg { + padding: 0.75rem 1.5rem; + font-size: 1rem; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .export-config-page { + padding: 1rem; + } + + .export-header { + flex-direction: column; + align-items: stretch; + } + + .header-title-section { + flex-wrap: wrap; + gap: 1rem; + } + + .header-content h1 { + font-size: 1.5rem; + } + + .header-content h1 i { + font-size: 1.3rem; + } + + .header-content p { + font-size: 0.85rem; + } + + .header-actions { + width: 100%; + } + + .header-actions .btn { + width: 100%; + justify-content: center; + } + + .columns-grid { + grid-template-columns: 1fr; + gap: 1rem; + } + + .config-header { + flex-direction: column; + align-items: flex-start; + } + + .config-actions { + width: 100%; + justify-content: center; + } + + .config-card, + .preview-section, + .submit-section { + padding: 1.5rem; + } +} + +@media (max-width: 480px) { + .filters-display { + flex-direction: column; + } + + .config-actions { + flex-direction: column; + width: 100%; + } + + .btn { + width: 100%; + justify-content: center; + } + + .submit-actions { + width: 100%; + } +} + +/* Instructions Card */ +.instructions-card { + background: linear-gradient(135deg, #e3f2fd, #f3e5f5); + border: 2px solid #4fc3f7; + border-radius: 12px; + padding: 1.5rem; + margin-bottom: 2rem; + box-shadow: 0 4px 12px rgba(79, 195, 247, 0.15); +} + +.instructions-content h4 { + color: #1565c0; + font-size: 1.2rem; + font-weight: 600; + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.instructions-content ul { + list-style: none; + padding: 0; + margin: 0; +} + +.instructions-content li { + padding: 0.5rem 0; + color: #424242; + font-size: 0.95rem; + display: flex; + align-items: flex-start; + gap: 0.75rem; +} + +.instructions-content li::before { + content: "✓"; + color: #4caf50; + font-weight: bold; + font-size: 1.1rem; + margin-top: 0.1rem; +} + +.instructions-content .highlight { + background: linear-gradient(135deg, #ffeb3b, #ffc107); + padding: 0.2rem 0.5rem; + border-radius: 4px; + font-weight: 600; + color: #f57c00; +} + +/* Columns Section */ +.columns-section { + margin-bottom: 2rem; +} + +.columns-section h4 { + color: #2d3748; + font-size: 1.4rem; + font-weight: 600; + margin-bottom: 1.5rem; + display: flex; + align-items: center; + gap: 0.75rem; +} + +/* Selected Columns Section */ +.selected-columns-section { + background: linear-gradient(135deg, #f8f9fa, #e9ecef); + border: 2px dashed #6c757d; + border-radius: 12px; + padding: 2rem; + margin-top: 2rem; + transition: all 0.3s ease; +} + +.selected-columns-section.has-columns { + border-color: #28a745; + border-style: solid; + background: linear-gradient(135deg, #f8fff8, #e8f5e8); +} + +.selected-columns-section h4 { + color: #495057; + font-size: 1.3rem; + font-weight: 600; + margin-bottom: 1.5rem; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.drag-hint { + font-size: 0.85rem; + color: #6c757d; + font-weight: normal; + font-style: italic; +} + +/* Selected Columns List */ +.selected-columns-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + min-height: 60px; +} + +.selected-column-item { + background: white; + border: 2px solid #e2e8f0; + border-radius: 8px; + padding: 1rem; + cursor: move; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.selected-column-item:hover { + border-color: #4299e1; + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(66, 153, 225, 0.15); +} + +.selected-column-item.sortable-drag { + opacity: 0.8; + transform: rotate(5deg); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + z-index: 1000; +} + +.selected-column-item.sortable-ghost { + opacity: 0.4; + background: #f1f5f9; +} + +.selected-column-item.sortable-chosen { + border-color: #3b82f6; + background: #eff6ff; +} + +.selected-column-info { + display: flex; + align-items: center; + gap: 1rem; + flex: 1; +} + +.column-drag-handle { + color: #9ca3af; + font-size: 1.2rem; + cursor: grab; + padding: 0.5rem; + border-radius: 4px; + transition: all 0.2s ease; +} + +.column-drag-handle:hover { + color: #4b5563; + background: #f3f4f6; +} + +.column-drag-handle:active { + cursor: grabbing; +} + +.selected-column-details { + flex: 1; +} + +.selected-column-name { + font-weight: 600; + color: #1f2937; + font-size: 1rem; + margin-bottom: 0.25rem; +} + +.selected-column-export-name { + font-size: 0.85rem; + color: #6b7280; + font-style: italic; +} + +.column-order-number { + background: linear-gradient(135deg, #4f46e5, #7c3aed); + color: white; + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 0.9rem; + box-shadow: 0 2px 8px rgba(79, 70, 229, 0.3); +} + +/* Empty state for selected columns */ +.selected-columns-empty { + text-align: center; + padding: 2rem; + color: #6b7280; + font-style: italic; +} + +.selected-columns-empty i { + font-size: 2rem; + margin-bottom: 1rem; + color: #9ca3af; +} + +/* Enhanced column items in grid */ +.column-item.selected { + border-color: #10b981; + background: linear-gradient(135deg, #f0fdf4, #ecfdf5); + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(16, 185, 129, 0.15); +} + +.column-item.selected .column-checkbox label { + background: linear-gradient(135deg, #10b981, #059669); + color: white; +} + +/* Responsive design for drag & drop */ +@media (max-width: 768px) { + .selected-columns-section { + padding: 1rem; + } + + .selected-column-item { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .selected-column-info { + width: 100%; + flex-direction: column; + align-items: flex-start; + } + + .column-order-number { + align-self: flex-end; + } + + .drag-hint { + display: none; + } +} + +/* Animation for smooth transitions */ +.selected-columns-list .selected-column-item { + animation: slideInUp 0.3s ease-out; +} + +@keyframes slideInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Sortable.js additional styles */ +.sortable-fallback { + display: none; +} + +/* Enhanced preview table to show column order */ +.preview-table thead th { + position: relative; +} + +.preview-table thead th::before { + content: attr(data-order); + position: absolute; + top: -8px; + right: -8px; + background: #4f46e5; + color: white; + font-size: 0.7rem; + font-weight: 600; + width: 18px; + height: 18px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +/* Collapsible Sections */ +.collapsible-content { + max-height: 5000px; + overflow: hidden; + transition: max-height 0.5s ease-in-out, opacity 0.3s ease-in-out; + opacity: 1; +} + +.collapsible-content.collapsed { + max-height: 0; + opacity: 0; +} + +.toggle-icon { + transition: transform 0.3s ease; + margin-left: 10px; + font-size: 0.9em; + color: #6c757d; +} + +.toggle-icon.rotated { + transform: rotate(-180deg); +} + +.config-header[onclick], +.selected-columns-section h4[onclick] { + transition: background-color 0.2s ease; + border-radius: 8px; + padding: 10px; + margin: -10px; + user-select: none; +} + +.config-header[onclick]:hover, +.selected-columns-section h4[onclick]:hover { + background-color: rgba(0, 0, 0, 0.02); +} + +.config-header h3 { + display: flex; + align-items: center; + width: 100%; + justify-content: space-between; +} + +.selected-columns-section h4 { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; +} + +/* Edit checkbox styles */ +.export-name-controls { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.input-with-checkbox { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.input-with-checkbox input[type="text"] { + flex: 1; + background-color: #f8f9fa; + cursor: not-allowed; +} + +.input-with-checkbox input[type="text"]:not([readonly]) { + background-color: white; + cursor: text; +} + +.edit-checkbox-label { + display: flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; + padding: 0.5rem; + border-radius: 4px; + transition: background-color 0.2s; + user-select: none; +} + +.edit-checkbox-label:hover { + background-color: #e9ecef; +} + +.edit-checkbox-label input[type="checkbox"] { + cursor: pointer; +} + +.edit-checkbox-label i { + color: #6c757d; + font-size: 0.9rem; +} + +.edit-checkbox-label input[type="checkbox"]:checked + i { + color: #0d6efd; +} + +.column-name-input input[type="text"][readonly] { + opacity: 0.7; +} \ No newline at end of file diff --git a/static/css/projects.css b/static/css/projects.css new file mode 100644 index 0000000..bb120ce --- /dev/null +++ b/static/css/projects.css @@ -0,0 +1,554 @@ +/** + * Projects Page Dedicated CSS + * static/css/projects.css + * + * This file contains all necessary styles for the projects management page + * ensuring it works independently with proper layout and responsive design. + */ + +/* Sidebar Layout Compatibility Fixes */ +body.has-sidebar .projects-page { + margin-left: 0; /* Let the base template handle sidebar spacing */ + padding-left: 0; /* Remove conflicts with sidebar layout */ +} + +/* Ensure proper content area for authenticated layout */ +.main-wrapper .projects-page { + padding: 2rem; + max-width: 1600px; + margin: 0 auto; + min-height: 100vh; + background: #f8fafc; +} + +/* Fix header spacing in sidebar layout */ +body.has-sidebar .projects-header { + margin-top: 0; /* Remove any unwanted top margin */ +} + +/* Projects Page Container */ +.projects-page { + max-width: 1600px; + margin: 0 auto; + padding: 2rem; + min-height: 100vh; + background: #f8fafc; +} + +/* Page Header */ +.projects-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 2rem; + padding: 1.5rem; + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); + position: relative; + overflow: hidden; +} + +.projects-header::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, #2563eb, #1d4ed8); +} + +.header-navigation { + margin-bottom: 1rem; +} + +.back-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: #f1f5f9; + color: #475569; + text-decoration: none; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + border: 1px solid #cbd5e1; + transition: all 0.2s ease-in-out; +} + +.back-button:hover { + background: #e2e8f0; + color: #334155; + transform: translateX(-2px); + text-decoration: none; +} + +.back-button i { + font-size: 0.875rem; +} + +.header-content h1 { + font-size: 1.875rem; + font-weight: 700; + color: #0f172a; + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.header-content h1 i { + color: #2563eb; +} + +.header-content p { + color: #64748b; + font-size: 1.125rem; + margin: 0; +} + +.header-actions { + display: flex; + gap: 0.75rem; +} + +/* Project Statistics Grid */ +.project-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.stat-card { + background: #ffffff; + padding: 1.5rem; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); + display: flex; + align-items: center; + gap: 1rem; + transition: all 0.2s ease-in-out; + border: 1px solid #e2e8f0; + position: relative; + overflow: hidden; +} + +.stat-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, #2563eb, #1d4ed8); +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.stat-card.active::before { + background: linear-gradient(90deg, #10b981, #047857); +} + +.stat-icon { + width: 50px; + height: 50px; + border-radius: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; + color: #ffffff; + background: linear-gradient(135deg, #2563eb, #1d4ed8); +} + +.stat-icon.active { + background: linear-gradient(135deg, #10b981, #047857); +} + +.stat-info h3 { + font-size: 1.75rem; + font-weight: 700; + color: #0f172a; + margin-bottom: 0.25rem; +} + +.stat-info p { + color: #64748b; + font-size: 0.875rem; + margin: 0; +} + +/* Content Section */ +.content-section { + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); + overflow: hidden; +} + +.section-header { + padding: 1.5rem; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; +} + +.section-header h2 { + font-size: 1.5rem; + font-weight: 600; + color: #0f172a; + margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Projects Grid */ +.projects-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 1.5rem; + padding: 1.5rem; +} + +.project-card { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 0.75rem; + padding: 1.5rem; + transition: all 0.2s ease-in-out; + position: relative; + overflow: hidden; +} + +.project-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, #2563eb, #1d4ed8); +} + +.project-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + border-color: #2563eb; +} + +.project-card.inactive { + opacity: 0.7; + background: #f8fafc; +} + +.project-card.inactive::before { + background: linear-gradient(90deg, #6b7280, #4b5563); +} + +.project-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} + +.project-info h3 { + font-size: 1.25rem; + font-weight: 600; + color: #0f172a; + margin-bottom: 0.5rem; + line-height: 1.4; +} + +.project-description { + color: #64748b; + font-size: 0.875rem; + line-height: 1.5; + margin: 0; +} + +.project-status { + flex-shrink: 0; +} + +/* Status Badges */ +.status-badge { + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.status-badge.active { + background: rgba(16, 185, 129, 0.1); + color: #047857; + border: 1px solid rgba(16, 185, 129, 0.2); +} + +.status-badge.inactive { + background: rgba(107, 114, 128, 0.1); + color: #4b5563; + border: 1px solid rgba(107, 114, 128, 0.2); +} + +/* Project Stats */ +.project-stats-section { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: #f8fafc; + border-radius: 0.5rem; + border: 1px solid #e2e8f0; +} + +.stat-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: #64748b; +} + +.stat-item i { + width: 16px; + color: #2563eb; + text-align: center; +} + +.stat-item span { + font-weight: 500; + color: #374151; +} + +/* Project Actions */ +.project-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + flex-wrap: wrap; +} + +.project-actions .btn { + font-size: 0.875rem; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + text-decoration: none; + border: none; + cursor: pointer; + transition: all 0.2s ease-in-out; + display: inline-flex; + align-items: center; + gap: 0.375rem; +} + +.project-actions .btn-secondary { + background: #f1f5f9; + color: #475569; + border: 1px solid #cbd5e1; +} + +.project-actions .btn-secondary:hover { + background: #e2e8f0; + color: #334155; +} + +.project-actions .btn-warning { + background: #fbbf24; + color: #92400e; + border: 1px solid #f59e0b; +} + +.project-actions .btn-warning:hover { + background: #f59e0b; + color: #78350f; +} + +.project-actions .btn-success { + background: #10b981; + color: #ffffff; + border: 1px solid #059669; +} + +.project-actions .btn-success:hover { + background: #059669; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 3rem 1.5rem; + color: #64748b; +} + +.empty-icon { + width: 80px; + height: 80px; + margin: 0 auto 1.5rem; + background: #f1f5f9; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + color: #94a3b8; +} + +.empty-state h3 { + font-size: 1.5rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.75rem; +} + +.empty-state p { + font-size: 1rem; + margin-bottom: 1.5rem; + max-width: 400px; + margin-left: auto; + margin-right: auto; +} + +.empty-state .btn { + background: #2563eb; + color: #ffffff; + padding: 0.75rem 1.5rem; + border-radius: 0.5rem; + text-decoration: none; + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 0.5rem; + transition: all 0.2s ease-in-out; + border: none; +} + +.empty-state .btn:hover { + background: #1d4ed8; + transform: translateY(-1px); +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .projects-grid { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; + } + + .project-stats { + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + } +} + +@media (max-width: 768px) { + .projects-page { + padding: 1rem; + } + + .projects-header { + flex-direction: column; + align-items: stretch; + gap: 1rem; + text-align: center; + } + + .header-navigation { + text-align: left; + margin-bottom: 0.5rem; + } + + .back-button { + display: inline-flex; + justify-content: flex-start; + } + + .header-actions { + justify-content: center; + } + + .projects-grid { + grid-template-columns: 1fr; + padding: 1rem; + } + + .project-stats { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; + } + + .project-header { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + } + + .project-status { + align-self: flex-start; + } + + .project-actions { + justify-content: stretch; + } + + .project-actions .btn { + flex: 1; + justify-content: center; + } + + .stat-card { + padding: 1rem; + } + + .stat-icon { + width: 40px; + height: 40px; + font-size: 1rem; + } + + .stat-info h3 { + font-size: 1.5rem; + } +} + +@media (max-width: 480px) { + .projects-page { + padding: 0.75rem; + } + + .projects-header { + padding: 1rem; + } + + .header-content h1 { + font-size: 1.5rem; + } + + .header-content p { + font-size: 1rem; + } + + .project-stats { + grid-template-columns: 1fr; + } + + .project-card { + padding: 1rem; + } + + .project-stats-section { + padding: 0.75rem; + } + + .stat-item { + font-size: 0.8125rem; + } +} \ No newline at end of file diff --git a/static/css/qr_destination.css b/static/css/qr_destination.css new file mode 100644 index 0000000..9d162e8 --- /dev/null +++ b/static/css/qr_destination.css @@ -0,0 +1,974 @@ +/* QR Destination Page Styles - Light Mode Only */ +:root { + /* Color Palette - Light Mode Only */ + --primary-color: #2563eb; + --primary-hover: #1d4ed8; + --success-color: #059669; + --warning-color: #d97706; + --danger-color: #dc2626; + --info-color: #0891b2; + + /* Neutral Colors - Light Theme Fixed */ + --white: #ffffff; + --gray-50: #f8fafc; + --gray-100: #f1f5f9; + --gray-200: #e2e8f0; + --gray-300: #cbd5e1; + --gray-400: #94a3b8; + --gray-500: #64748b; + --gray-600: #475569; + --gray-700: #334155; + --gray-800: #1e293b; + --gray-900: #0f172a; + + /* Spacing and Layout */ + --radius-sm: 0.25rem; + --radius: 0.375rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + --radius-2xl: 1.5rem; + + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + + --transition: all 0.15s ease-in-out; +} + +/* Force Light Mode - Override any dark mode preferences */ +* { + color-scheme: light only !important; +} + +/* Reset and Base */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: linear-gradient(135deg, var(--primary-color), var(--primary-hover)) !important; + min-height: 100vh; + color: var(--gray-900) !important; + line-height: 1.6; + /* Force light mode */ + color-scheme: light !important; +} + +/* Container */ +.destination-container { + max-width: 600px; + margin: 0 auto; + padding: 2rem 1rem; + min-height: 100vh; + display: flex; + flex-direction: column; + gap: 2rem; +} + +/* Header Section */ +.destination-header { + text-align: center; + color: var(--white) !important; + margin-bottom: 1rem; +} + +.header-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 80px; + height: 80px; + background: rgba(255, 255, 255, 0.2) !important; + border-radius: 50%; + font-size: 2rem; + margin-bottom: 1rem; + backdrop-filter: blur(10px); + border: 2px solid rgba(255, 255, 255, 0.3) !important; + color: var(--white) !important; +} + +.destination-header h1 { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 0.5rem; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + color: var(--white) !important; +} + +.location-info { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + font-size: 1.125rem; + opacity: 0.9; + color: var(--white) !important; +} + +/* Card Styles - Force Light Theme */ +.info-card, +.checkin-card, +.success-card { + background: var(--white) !important; + border-radius: var(--radius-2xl); + box-shadow: var(--shadow-xl); + padding: 2rem; + width: 100%; + color: var(--gray-900) !important; +} + +.info-card { + background: rgba(255, 255, 255, 0.95) !important; + backdrop-filter: blur(10px); +} + +/* Header Styles for Cards */ +.info-header, +.checkin-header, +.success-header { + text-align: center; + margin-bottom: 2rem; +} + +.info-header h2, +.checkin-header h2, +.success-header h2 { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + font-size: 1.75rem; + font-weight: 700; + color: var(--gray-900) !important; + margin-bottom: 0.5rem; +} + +.info-header h2 i, +.checkin-header h2 i, +.success-header h2 i { + color: var(--primary-color) !important; +} + +.info-header p, +.checkin-header p { + color: var(--gray-600) !important; + font-size: 1rem; + line-height: 1.5; +} + +/* Info Details */ +.info-details { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.detail-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + background: var(--gray-50) !important; + border-radius: var(--radius-lg); + transition: var(--transition); + border: 1px solid var(--gray-200) !important; +} + +.detail-item:hover { + background: var(--gray-100) !important; + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.detail-icon { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + background: var(--primary-color) !important; + color: var(--white) !important; + border-radius: var(--radius-lg); + font-size: 1.25rem; + flex-shrink: 0; +} + +.detail-content { + flex: 1; +} + +.detail-label { + font-weight: 600; + color: var(--gray-900) !important; + margin-bottom: 0.25rem; + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.detail-value { + color: var(--gray-700) !important; + font-size: 1.125rem; + font-weight: 500; +} + +/* Form Styles - Light Theme */ +.checkin-form { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-group label { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + color: var(--gray-900) !important; + font-size: 1rem; +} + +.form-group label i { + color: var(--primary-color) !important; +} + +.form-group input { + padding: 1rem 1.25rem; + border: 2px solid var(--gray-300) !important; + border-radius: var(--radius-lg); + font-size: 1.125rem; + transition: var(--transition); + background: var(--white) !important; + color: var(--gray-900) !important; +} + +.form-group input:focus { + outline: none; + border-color: var(--primary-color) !important; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1) !important; +} + +.form-group input::placeholder { + color: var(--gray-500) !important; +} + +.form-help { + font-size: 0.875rem; + color: var(--gray-600) !important; + margin-top: 0.25rem; +} + +.form-actions { + display: flex; + justify-content: center; + align-items: center; + margin-top: 1rem; +} + +/* Button Styles - Light Theme */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 1rem 2rem; + border: none; + border-radius: var(--radius-lg); + font-size: 1.125rem; + font-weight: 600; + text-decoration: none; + transition: var(--transition); + cursor: pointer; + min-height: 3rem; +} + +.btn-primary { + background: var(--primary-color) !important; + color: var(--white) !important; +} + +.btn-primary:hover { + background: var(--primary-hover) !important; + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.btn-secondary { + background: var(--gray-500) !important; + color: var(--white) !important; +} + +.btn-secondary:hover { + background: var(--gray-600) !important; + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none !important; +} + +.btn-content { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.btn-loader { + display: none; + align-items: center; + gap: 0.5rem; +} + +.btn-loader i { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Success Card Styles - Light Theme */ +.success-card { + text-align: center; + background: var(--white) !important; +} + +.success-icon { + display: none; /* Change back to 'inline-flex' to display icon */ + align-items: center; + justify-content: center; + width: 50px; + height: 50px; + background: var(--success-color) !important; + color: var(--white) !important; + border-radius: 50%; + font-size: 1.5rem; + margin-bottom: 1.5rem; + animation: successPulse 1s ease-out; +} + +@keyframes successPulse { + 0% { + transform: scale(0); + opacity: 0; + } + 50% { + transform: scale(1.1); + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.success-card h2 { + font-size: 2rem; + font-weight: 700; + color: var(--success-color) !important; + margin-bottom: 1.5rem; +} + +.success-details { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 2rem; + text-align: left; +} + +.success-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background: var(--gray-50) !important; + border-radius: var(--radius); + border-left: 4px solid var(--success-color) !important; +} + +.success-item strong { + color: var(--gray-900) !important; + font-weight: 600; +} + +.success-item span { + color: var(--gray-700) !important; + font-weight: 500; +} + +.success-actions { + display: flex; + justify-content: center; + gap: 1rem; +} + +/* Status Message Styles - Light Theme */ +.status-message { + padding: 1rem 1.25rem; + border-radius: var(--radius-lg); + font-weight: 500; + margin: 1rem 0; + border-left: 4px solid transparent; + background: var(--white) !important; +} + +.status-message.success { + background: var(--gray-50) !important; + color: var(--success-color) !important; + border-left-color: var(--success-color) !important; +} + +.status-message.error { + background: var(--gray-50) !important; + color: var(--danger-color) !important; + border-left-color: var(--danger-color) !important; +} + +.status-message.warning { + background: var(--gray-50) !important; + color: var(--warning-color) !important; + border-left-color: var(--warning-color) !important; +} + +.status-message.info { + background: var(--gray-50) !important; + color: var(--info-color) !important; + border-left-color: var(--info-color) !important; +} + +/* Footer Styles - Light Theme */ +.destination-footer { + text-align: center; + color: rgba(255, 255, 255, 0.8) !important; + font-size: 0.875rem; + margin-top: auto; +} + +.destination-footer p { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + color: rgba(255, 255, 255, 0.9) !important; +} + +.destination-footer i { + color: rgba(255, 255, 255, 0.7) !important; +} + +.destination-footer small { + opacity: 0.7; + color: rgba(255, 255, 255, 0.6) !important; +} + +/* Loading Overlay - Light Theme */ +.loading-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.95) !important; + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.loading-spinner { + text-align: center; + color: var(--primary-color) !important; +} + +.loading-spinner i { + font-size: 3rem; + margin-bottom: 1rem; + color: var(--primary-color) !important; +} + +.loading-spinner p { + font-size: 1.125rem; + font-weight: 500; + color: var(--gray-700) !important; +} + +/* Language Selector Styles - Light Theme Enhanced */ +.language-selector { + position: absolute; + top: 1rem; + right: 1rem; + z-index: 1000; +} + +.language-toggle { + background: rgba(255, 255, 255, 0.2) !important; + border: 2px solid rgba(255, 255, 255, 0.3) !important; + border-radius: 50px; + padding: 0.5rem 1rem; + color: white !important; + cursor: pointer; + transition: all 0.2s ease; + backdrop-filter: blur(10px); + font-size: 0.875rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.5rem; + outline: none; +} + +.language-toggle:hover { + background: rgba(255, 255, 255, 0.3) !important; + border-color: rgba(255, 255, 255, 0.5) !important; + transform: translateY(-1px); +} + +.language-toggle:active { + background: rgba(255, 255, 255, 0.4) !important; + transform: translateY(0); +} + +.language-toggle:focus { + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.3) !important; +} + +.language-toggle i { + font-size: 1rem; + color: white !important; +} + +/* Smooth transitions - SIMPLIFIED */ +.fade-transition { + transition: opacity 0.15s ease; +} + +.fade-transition.active { + opacity: 1; +} + +/* Additional Location Tracking Styles - Light Theme */ +.location-status { + background: var(--gray-50) !important; + border: 1px solid var(--gray-200) !important; + border-radius: 8px; + padding: 12px 16px; + margin: 15px 0; + font-size: 14px; + display: none; + align-items: center; + gap: 8px; + color: var(--gray-700) !important; +} + +.location-status.loading { + background: #e0f2fe !important; + border-color: #0284c7 !important; + color: #0c4a6e !important; +} + +.location-status.success { + background: #dcfce7 !important; + border-color: #059669 !important; + color: #14532d !important; +} + +.location-status.error { + background: #fee2e2 !important; + border-color: #dc2626 !important; + color: #7f1d1d !important; +} + +.location-info { + background: var(--gray-50) !important; + border: 1px solid var(--gray-200) !important; + border-radius: 8px; + padding: 15px; + margin: 15px 0; + display: none; + font-size: 13px; + color: var(--gray-700) !important; +} + +.location-info h4 { + margin: 0 0 10px 0; + font-size: 14px; + color: var(--gray-900) !important; +} + +.coord-row { + display: flex; + justify-content: space-between; + padding: 4px 0; + border-bottom: 1px solid var(--gray-200) !important; + color: var(--gray-700) !important; +} + +.coord-row:last-child { + border-bottom: none; +} + +.coord-value { + font-family: 'Courier New', monospace; + font-weight: bold; + color: var(--gray-900) !important; +} + +.location-controls { + text-align: center; + margin: 10px 0; +} + +.btn-small { + background: var(--gray-500) !important; + color: white !important; + border: none; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + margin: 0 4px; + transition: background-color 0.2s; +} + +.btn-small:hover { + background: var(--gray-600) !important; +} + +/* Mobile responsive adjustments */ +@media (max-width: 768px) { + .language-selector { + position: relative; + top: 0; + right: 0; + text-align: center; + margin-bottom: 1rem; + } + + .destination-container { + padding: 1rem 0.5rem; + gap: 1.5rem; + } + + .destination-header h1 { + font-size: 2rem; + } + + .header-icon { + width: 60px; + height: 60px; + font-size: 1.5rem; + } + + .info-card, + .checkin-card, + .success-card { + margin: 0 0.5rem; + padding: 1.5rem; + } + + .detail-item { + flex-direction: column; + text-align: center; + gap: 0.75rem; + } + + .detail-icon { + align-self: center; + } + + .success-details { + text-align: center; + } + + .success-item { + flex-direction: column; + gap: 0.5rem; + text-align: center; + } +} + +@media (max-width: 480px) { + .destination-container { + padding: 0.5rem; + } + + .destination-header h1 { + font-size: 1.75rem; + } + + .form-group input { + font-size: 1rem; + padding: 0.875rem; + } + + .btn { + padding: 1rem 1.5rem; + font-size: 1rem; + } + + .success-icon { + width: 80px; + height: 80px; + font-size: 2.5rem; + } +} + +/* Accessibility - High Contrast Mode */ +@media (prefers-contrast: high) { + .info-card, + .checkin-card, + .success-card { + border: 2px solid var(--gray-900) !important; + } + + .detail-item { + border: 1px solid var(--gray-900) !important; + } + + .form-group input { + border: 2px solid var(--gray-900) !important; + } + + .btn-primary { + background: var(--gray-900) !important; + border: 2px solid var(--white) !important; + } +} + +/* Reduced Motion */ +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + + .success-icon { + animation: none; + } + + .btn:hover, + .detail-item:hover { + transform: none; + } +} + +/* Print Styles */ +@media print { + body { + background: white !important; + color: black !important; + } + + .destination-container { + max-width: none; + padding: 1rem; + } + + .info-card, + .checkin-card, + .success-card { + box-shadow: none; + border: 1px solid #000 !important; + break-inside: avoid; + } + + .btn, + .loading-overlay, + .language-selector { + display: none !important; + } + + .destination-header { + color: black !important; + } + + .header-icon { + background: white !important; + border: 2px solid black !important; + color: black !important; + } +} + +/* Override any dark mode preferences - IMPORTANT */ +@media (prefers-color-scheme: dark) { + :root { + /* Keep light theme variables even when system prefers dark */ + --white: #ffffff !important; + --gray-50: #f8fafc !important; + --gray-100: #f1f5f9 !important; + --gray-200: #e2e8f0 !important; + --gray-300: #cbd5e1 !important; + --gray-400: #94a3b8 !important; + --gray-500: #64748b !important; + --gray-600: #475569 !important; + --gray-700: #334155 !important; + --gray-800: #1e293b !important; + --gray-900: #0f172a !important; + } + + /* Force light mode styles */ + body { + background: linear-gradient(135deg, var(--primary-color), var(--primary-hover)) !important; + color: var(--gray-900) !important; + } +} + +/* Force override any dark theme attributes */ +[data-theme="dark"] { + /* Reset to light theme */ + --white: #ffffff !important; + --gray-50: #f8fafc !important; + --gray-100: #f1f5f9 !important; + --gray-200: #e2e8f0 !important; + --gray-300: #cbd5e1 !important; + --gray-400: #94a3b8 !important; + --gray-500: #64748b !important; + --gray-600: #475569 !important; + --gray-700: #334155 !important; + --gray-800: #1e293b !important; + --gray-900: #0f172a !important; +} + +/* Location Services Warning Banner */ +.location-warning-banner { + background: linear-gradient(135deg, #ff6b6b, #ee5a24); + color: white; + padding: 15px; + margin-bottom: 20px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(238, 90, 36, 0.3); + animation: slideDown 0.3s ease-out; +} + +.warning-content { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.warning-icon { + font-size: 24px; + color: #fff; + margin-top: 2px; +} + +.warning-text { + flex: 1; +} + +.warning-message { + display: block; + font-size: 14px; + line-height: 1.4; + margin-bottom: 12px; +} + +.warning-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.warning-retry-btn, +.warning-dismiss-btn { + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + padding: 8px 12px; + border-radius: 5px; + cursor: pointer; + font-size: 12px; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 5px; +} + +.warning-retry-btn:hover, +.warning-dismiss-btn:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-1px); +} + +/* Blocked Form Styling */ +.location-blocked { + opacity: 0.6; + pointer-events: none !important; + position: relative; + user-select: none; +} + +.location-blocked::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 107, 107, 0.1); + border-radius: 8px; + z-index: 1; + pointer-events: none; +} + +/* Specifically block submit buttons when location is blocked */ +button[data-location-blocked="true"], +input[data-location-blocked="true"] { + pointer-events: none !important; + cursor: not-allowed !important; + opacity: 0.5 !important; + background-color: #ccc !important; + border-color: #999 !important; +} + +/* Override any hover effects on blocked elements */ +button[data-location-blocked="true"]:hover, +button[data-location-blocked="true"]:focus, +button[data-location-blocked="true"]:active { + pointer-events: none !important; + cursor: not-allowed !important; + opacity: 0.5 !important; + transform: none !important; + background-color: #ccc !important; +} + +/* Animation for warning banner */ +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Mobile responsive adjustments */ +@media (max-width: 768px) { + .warning-actions { + flex-direction: column; + } + + .warning-retry-btn, + .warning-dismiss-btn { + width: 100%; + justify-content: center; + } +} \ No newline at end of file diff --git a/static/css/statistics.css b/static/css/statistics.css new file mode 100644 index 0000000..216e7fd --- /dev/null +++ b/static/css/statistics.css @@ -0,0 +1,774 @@ +/* Statistics Page Styles - static/css/statistics.css */ + +:root { + --primary-color: #3b82f6; + --primary-hover: #2563eb; + --success-color: #10b981; + --warning-color: #f59e0b; + --danger-color: #ef4444; + --info-color: #06b6d4; + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-300: #d1d5db; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-600: #4b5563; + --gray-700: #374151; + --gray-800: #1f2937; + --gray-900: #111827; + --white: #ffffff; + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --transition: all 0.2s ease-in-out; + --radius: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + --radius-2xl: 1.5rem; + --spacing-2: 0.5rem; + --spacing-3: 0.75rem; + --spacing-4: 1rem; + --spacing-6: 1.5rem; + --spacing-8: 2rem; + --font-size-sm: 0.875rem; + --font-size-base: 1rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.25rem; + --font-size-2xl: 1.5rem; + --font-size-3xl: 2rem; +} + +/* Collapsible Sections */ +.section-header { + background: linear-gradient(135deg, var(--primary-color), var(--primary-hover)); + color: var(--white); + padding: var(--spacing-4) var(--spacing-6); + border-radius: var(--radius-xl); + margin-bottom: var(--spacing-6); + cursor: pointer; + transition: var(--transition); + user-select: none; +} + +.section-header:hover { + background: linear-gradient(135deg, var(--primary-hover), #1d4ed8); + transform: translateY(-1px); + box-shadow: var(--shadow-lg); +} + +.section-header h2 { + margin: 0; + font-size: var(--font-size-xl); + font-weight: 600; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-3); +} + +.toggle-icon { + transition: transform 0.3s ease; + font-size: var(--font-size-base); +} + +.toggle-icon.rotated { + transform: rotate(180deg); +} + +.section-content { + max-height: none; + opacity: 1; + transition: all 0.3s ease; + overflow: hidden; +} + +.section-content.collapsed { + max-height: 0; + opacity: 0; + margin-bottom: 0; + padding-top: 0; + padding-bottom: 0; +} + +/* Chart No Data Message */ +.no-data-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--gray-400); + text-align: center; +} + +.no-data-message i { + font-size: 3rem; + margin-bottom: var(--spacing-4); + color: var(--gray-300); +} + +.no-data-message p { + margin: 0; + font-size: var(--font-size-lg); + color: var(--gray-500); +} + +/* Collapsible Table Headers */ +.table-header.collapsible { + background: linear-gradient(135deg, var(--gray-100), var(--gray-200)); + color: var(--gray-700); + cursor: pointer; + transition: var(--transition); + user-select: none; + border-radius: var(--radius-xl) var(--radius-xl) 0 0; +} + +.table-header.collapsible:hover { + background: linear-gradient(135deg, var(--gray-200), var(--gray-300)); + color: var(--gray-800); +} + +.table-header.collapsible h3 { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.table-header.collapsible .toggle-icon { + margin-left: auto; + margin-right: var(--spacing-4); +} + +/* Update table card structure for collapsible */ +.table-card { + background: var(--white); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); + overflow: hidden; + border: 1px solid var(--gray-200); + margin-bottom: var(--spacing-6); +} + +/* Ensure section content collapses properly */ +.section-content.collapsed { + max-height: 0 !important; + opacity: 0 !important; + margin-bottom: 0 !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + overflow: hidden !important; +} + +/* Fix the chevron animation */ +.toggle-icon { + transition: transform 0.3s ease !important; + font-size: var(--font-size-base); + display: inline-block; +} + +.toggle-icon.rotated { + transform: rotate(180deg) !important; +} +.statistics-page { + padding: var(--spacing-6); + background: var(--gray-50); + min-height: 100vh; +} + +/* Header Section */ +.statistics-header { + background: linear-gradient(135deg, var(--white) 0%, var(--gray-50) 100%); + border-radius: var(--radius-2xl); + padding: var(--spacing-8); + margin-bottom: var(--spacing-8); + box-shadow: var(--shadow); + display: flex; + justify-content: space-between; + align-items: center; + border: 1px solid var(--gray-200); +} + +.header-content h1 { + font-size: var(--font-size-3xl); + font-weight: 700; + color: var(--gray-900); + margin-bottom: var(--spacing-2); + display: flex; + align-items: center; + gap: var(--spacing-3); +} + +.header-content h1 i { + color: var(--primary-color); + font-size: 2.5rem; +} + +.header-content p { + color: var(--gray-600); + font-size: var(--font-size-lg); + margin: 0; +} + +.header-actions { + display: flex; + gap: var(--spacing-3); + flex-wrap: wrap; +} + +/* Filters Section */ +.filters-section { + margin-bottom: var(--spacing-8); +} + +.filters-card { + background: var(--white); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); + overflow: hidden; + border: 1px solid var(--gray-200); +} + +.filters-header { + background: linear-gradient(135deg, var(--primary-color), var(--primary-hover)); + color: var(--white); + padding: var(--spacing-6); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.filters-header h3 { + margin: 0; + font-size: var(--font-size-lg); + font-weight: 600; + display: flex; + align-items: center; + gap: var(--spacing-3); +} + +.filters-form { + padding: var(--spacing-6); +} + +.filter-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: var(--spacing-6); + margin-bottom: var(--spacing-6); +} + +.filter-group { + display: flex; + flex-direction: column; + gap: var(--spacing-2); +} + +.filter-group label { + font-weight: 600; + color: var(--gray-700); + font-size: var(--font-size-sm); + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.filter-group label i { + color: var(--primary-color); + width: 16px; +} + +.filter-group input, +.filter-group select { + padding: var(--spacing-3); + border: 2px solid var(--gray-200); + border-radius: var(--radius); + font-size: var(--font-size-base); + transition: var(--transition); + background: var(--white); +} + +.filter-group input:focus, +.filter-group select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.filter-actions { + display: flex; + gap: var(--spacing-3); + flex-wrap: wrap; +} + +/* Statistics Overview */ +.stats-overview { + margin-bottom: var(--spacing-8); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: var(--spacing-6); +} + +.stat-card { + background: var(--white); + border-radius: var(--radius-xl); + padding: var(--spacing-6); + box-shadow: var(--shadow); + display: flex; + align-items: center; + gap: var(--spacing-4); + transition: var(--transition); + border: 1px solid var(--gray-200); + position: relative; + overflow: hidden; +} + +.stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; +} + +.stat-card.primary::before { background: var(--primary-color); } +.stat-card.success::before { background: var(--success-color); } +.stat-card.warning::before { background: var(--warning-color); } +.stat-card.info::before { background: var(--info-color); } + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.stat-icon { + width: 60px; + height: 60px; + border-radius: var(--radius-lg); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + color: var(--white); + flex-shrink: 0; +} + +.stat-card.primary .stat-icon { background: var(--primary-color); } +.stat-card.success .stat-icon { background: var(--success-color); } +.stat-card.warning .stat-icon { background: var(--warning-color); } +.stat-card.info .stat-icon { background: var(--info-color); } + +.stat-content { + flex: 1; +} + +.stat-content h3 { + font-size: var(--font-size-2xl); + font-weight: 700; + color: var(--gray-900); + margin: 0 0 var(--spacing-2) 0; +} + +.stat-content p { + color: var(--gray-600); + font-size: var(--font-size-base); + font-weight: 600; + margin: 0 0 var(--spacing-2) 0; +} + +.stat-change { + display: flex; + align-items: center; + gap: var(--spacing-2); + font-size: var(--font-size-sm); + color: var(--gray-500); +} + +/* Charts Section */ +.charts-section { + margin-bottom: var(--spacing-8); +} + +.charts-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + gap: var(--spacing-6); +} + +.chart-card { + background: var(--white); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); + overflow: hidden; + border: 1px solid var(--gray-200); +} + +.chart-card.full-width { + grid-column: 1 / -1; +} + +.chart-header { + background: var(--gray-50); + padding: var(--spacing-6); + border-bottom: 1px solid var(--gray-200); +} + +.chart-header h3 { + margin: 0; + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--gray-900); + display: flex; + align-items: center; + gap: var(--spacing-3); +} + +.chart-header h3 i { + color: var(--primary-color); +} + +.chart-container { + padding: var(--spacing-6); + height: 350px; + position: relative; +} + +.chart-container.compact { + height: 250px; +} + +/* Tables Section */ +.tables-section { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.table-card { + background: var(--white); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); + overflow: hidden; + border: 1px solid var(--gray-200); +} + +.table-header { + background: var(--gray-50); + padding: var(--spacing-6); + border-bottom: 1px solid var(--gray-200); + display: flex; + justify-content: space-between; + align-items: center; +} + +.table-header h3 { + margin: 0; + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--gray-900); + display: flex; + align-items: center; + gap: var(--spacing-3); +} + +.table-header h3 i { + color: var(--primary-color); +} + +.table-count { + background: var(--primary-color); + color: var(--white); + padding: 0.25rem 0.75rem; + border-radius: var(--radius); + font-size: var(--font-size-sm); + font-weight: 600; +} + +.table-container { + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-sm); +} + +.data-table th { + background: var(--gray-100); + color: var(--gray-700); + padding: var(--spacing-4); + text-align: left; + font-weight: 600; + border-bottom: 2px solid var(--gray-200); + white-space: nowrap; +} + +.data-table td { + padding: var(--spacing-4); + border-bottom: 1px solid var(--gray-200); + color: var(--gray-700); + vertical-align: middle; +} + +.data-table tbody tr:hover { + background: var(--gray-50); +} + +/* Metric Badges */ +.metric-badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.75rem; + border-radius: var(--radius); + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--white); +} + +.metric-badge.primary { background: var(--primary-color); } +.metric-badge.success { background: var(--success-color); } +.metric-badge.info { background: var(--info-color); } +.metric-badge.warning { background: var(--warning-color); } + +/* Progress Bars */ +.progress-bar { + position: relative; + background: var(--gray-200); + border-radius: var(--radius); + height: 24px; + overflow: hidden; + min-width: 100px; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--success-color), #059669); + transition: width 0.3s ease; +} + +.progress-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--gray-700); + text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8); +} + +/* Special Elements */ +.ip-address { + background: var(--gray-100); + padding: 0.25rem 0.5rem; + border-radius: var(--radius); + font-family: 'Monaco', 'Menlo', monospace; + font-size: var(--font-size-sm); +} + +.user-info strong { + display: block; + color: var(--gray-900); +} + +.user-info small { + color: var(--gray-500); + font-size: 0.75rem; +} + +.activity-score .score { + padding: 0.25rem 0.5rem; + border-radius: var(--radius); + font-size: var(--font-size-sm); + font-weight: 600; + text-transform: uppercase; +} + +.activity-score .score.high { + background: var(--success-color); + color: var(--white); +} + +.activity-score .score.medium { + background: var(--warning-color); + color: var(--white); +} + +.activity-score .score.low { + background: var(--gray-400); + color: var(--white); +} + +.performance-indicator .perf { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: var(--radius); + font-size: var(--font-size-sm); + font-weight: 600; + text-transform: capitalize; + margin-bottom: 0.25rem; +} + +.performance-indicator .perf.excellent { + background: var(--success-color); + color: var(--white); +} + +.performance-indicator .perf.good { + background: var(--info-color); + color: var(--white); +} + +.performance-indicator .perf.fair { + background: var(--warning-color); + color: var(--white); +} + +.performance-indicator .perf.poor { + background: var(--danger-color); + color: var(--white); +} + +.performance-indicator small { + display: block; + color: var(--gray-500); + font-size: 0.75rem; +} + +/* Button Styles */ +.btn { + display: inline-flex; + align-items: center; + gap: var(--spacing-2); + padding: var(--spacing-3) var(--spacing-6); + border: none; + border-radius: var(--radius); + font-size: var(--font-size-base); + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: var(--transition); + white-space: nowrap; +} + +.btn-primary { + background: var(--primary-color); + color: var(--white); +} + +.btn-primary:hover { + background: var(--primary-hover); + transform: translateY(-1px); +} + +.btn-success { + background: var(--success-color); + color: var(--white); +} + +.btn-success:hover { + background: #059669; + transform: translateY(-1px); +} + +.btn-secondary { + background: var(--gray-600); + color: var(--white); +} + +.btn-secondary:hover { + background: var(--gray-700); + transform: translateY(-1px); +} + +.btn-outline { + background: transparent; + color: var(--gray-600); + border: 2px solid var(--gray-300); +} + +.btn-outline:hover { + background: var(--gray-50); + border-color: var(--gray-400); + color: var(--gray-700); +} + +/* Responsive Design */ +@media (max-width: 768px) { + .statistics-page { + padding: var(--spacing-4); + } + + .statistics-header { + flex-direction: column; + align-items: stretch; + gap: var(--spacing-4); + text-align: center; + } + + .header-actions { + justify-content: center; + } + + .filter-row { + grid-template-columns: 1fr; + gap: var(--spacing-4); + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .charts-grid { + grid-template-columns: 1fr; + } + + .chart-container { + height: 250px; + } + + .data-table { + font-size: 0.75rem; + } + + .data-table th, + .data-table td { + padding: var(--spacing-2); + } +} + +@media (max-width: 480px) { + .header-content h1 { + font-size: var(--font-size-2xl); + } + + .stat-card { + flex-direction: column; + text-align: center; + } + + .stat-icon { + margin-bottom: var(--spacing-2); + } +} + +/* Print Styles */ +@media print { + .statistics-page { + background: white; + } + + .header-actions, + .filters-section { + display: none; + } + + .chart-card, + .table-card { + break-inside: avoid; + margin-bottom: var(--spacing-4); + } +} \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..12b8705 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,1499 @@ +/* CSS Variables - Light Mode Only */ +:root { + /* Primary color palette */ + --primary-color: #2563eb; + --primary-hover: #1d4ed8; + --secondary-color: #64748b; + --success-color: #10b981; + --warning-color: #f59e0b; + --danger-color: #ef4444; + --info-color: #0891b2; + + /* Neutral colors - Light mode fixed */ + --white: #ffffff; + --gray-50: #f8fafc; + --gray-100: #f1f5f9; + --gray-200: #e2e8f0; + --gray-300: #cbd5e1; + --gray-400: #94a3b8; + --gray-500: #64748b; + --gray-600: #475569; + --gray-700: #334155; + --gray-800: #1e293b; + --gray-900: #0f172a; + + /* Layout variables */ + --sidebar-width: 280px; + --sidebar-collapsed-width: 64px; + --header-height: 64px; + + /* Existing spacing and other variables */ + --spacing-1: 0.25rem; + --spacing-2: 0.5rem; + --spacing-3: 0.75rem; + --spacing-4: 1rem; + --spacing-5: 1.25rem; + --spacing-6: 1.5rem; + --spacing-8: 2rem; + --spacing-12: 3rem; + + --font-size-xs: 0.75rem; + --font-size-sm: 0.875rem; + --font-size-base: 1rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.25rem; + --font-size-2xl: 1.5rem; + --font-size-3xl: 1.875rem; + --font-size-4xl: 2.25rem; + + --radius: 0.375rem; + --radius-lg: 0.5rem; + --radius-xl: 0.75rem; + --radius-2xl: 1rem; + --radius-full: 9999px; + + --shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + + --transition: all 0.2s ease-in-out; + --transition-slow: all 0.3s ease-in-out; + + --z-dropdown: 1000; + --z-modal: 1050; + --z-sidebar: 1100; + --z-overlay: 1200; +} + +/* Force Light Mode Only */ +* { + color-scheme: light only !important; +} + +/* Reset and Base Styles */ +html { + scroll-behavior: smooth; +} + +body { + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + sans-serif; + line-height: 1.6; + color: var(--gray-700); + background-color: var(--gray-50); + min-height: 100vh; + margin: 0; + padding: 0; +} + +/* App Layout */ +.app-layout { + display: flex; + min-height: 100vh; + background-color: var(--gray-50); +} + +/* Sidebar Styles */ +.sidebar { + width: var(--sidebar-width); + background: linear-gradient( + 180deg, + var(--primary-color), + var(--primary-hover) + ); + color: var(--white); + position: fixed; + left: 0; + top: 0; + height: 100vh; + z-index: var(--z-sidebar); + display: flex; + flex-direction: column; + transition: var(--transition-slow); + box-shadow: var(--shadow-xl); +} + +.sidebar.collapsed { + width: var(--sidebar-collapsed-width); +} + +/* Sidebar Brand */ +.sidebar-brand { + padding: var(--spacing-6) var(--spacing-4); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.brand-content { + display: flex; + align-items: center; + gap: var(--spacing-3); + font-size: var(--font-size-xl); + font-weight: 700; +} + +.brand-content i { + font-size: 1.5rem; +} + +.sidebar.collapsed .brand-text { + display: none; +} + +/* Sidebar Menu */ +.sidebar-menu { + flex: 1; + display: flex; + flex-direction: column; + padding: var(--spacing-4) 0; +} + +.menu-section { + flex: 1; +} + +.menu-items { + display: flex; + flex-direction: column; + gap: var(--spacing-1); + padding: 0 var(--spacing-3); +} + +.menu-item { + display: flex; + align-items: center; + gap: var(--spacing-3); + padding: var(--spacing-3) var(--spacing-4); + color: var(--white); + text-decoration: none; + border-radius: var(--radius-lg); + transition: var(--transition); + position: relative; + border: none; + background: none; + font-size: var(--font-size-base); + cursor: pointer; + width: 100%; +} + +.menu-item:hover { + background-color: rgba(255, 255, 255, 0.1); + transform: translateX(4px); +} + +.menu-item.active, +.menu-item:focus { + background-color: rgba(255, 255, 255, 0.2); + transform: translateX(4px); +} + +.menu-item.logout:hover { + background-color: var(--danger-color); +} + +.menu-item i { + width: 20px; + text-align: center; + flex-shrink: 0; +} + +.sidebar.collapsed .menu-text { + display: none; +} + +.sidebar.collapsed .menu-item { + justify-content: center; + padding: var(--spacing-3); +} + +/* Sidebar Bottom */ +.sidebar-bottom { + border-top: 1px solid rgba(255, 255, 255, 0.1); + padding-top: var(--spacing-4); +} + +/* Sidebar Toggle Button */ +.sidebar-toggle { + position: absolute; + right: -12px; + top: 50%; + transform: translateY(-50%); + width: 24px; + height: 24px; + background: var(--white); + border: 1px solid var(--gray-200); + border-radius: var(--radius-full); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--gray-600); + font-size: 0.75rem; + transition: var(--transition); + z-index: 10; +} + +.sidebar-toggle:hover { + background: var(--gray-50); + color: var(--primary-color); +} + +.sidebar.collapsed .sidebar-toggle i { + transform: rotate(180deg); +} + +/* Sidebar Overlay for Mobile */ +.sidebar-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: calc(var(--z-sidebar) - 1); +} + +/* Main Wrapper */ +.main-wrapper { + flex: 1; + margin-left: var(--sidebar-width); + display: flex; + flex-direction: column; + min-height: 100vh; + transition: var(--transition-slow); +} + +.sidebar.collapsed + .sidebar-overlay + .main-wrapper { + margin-left: var(--sidebar-collapsed-width); +} + +/* Top Header */ +.top-header { + height: var(--header-height); + background: var(--white); + border-bottom: 1px solid var(--gray-200); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--spacing-6); + position: sticky; + top: 0; + z-index: 100; + box-shadow: var(--shadow); +} + +.header-left { + display: flex; + align-items: center; + gap: var(--spacing-4); +} + +.mobile-menu-btn { + display: none; + flex-direction: column; + gap: 4px; + background: none; + border: none; + cursor: pointer; + padding: var(--spacing-2); +} + +.hamburger-line { + width: 24px; + height: 2px; + background: var(--gray-700); + transition: var(--transition); +} + +.page-title { + font-size: var(--font-size-xl); + font-weight: 600; + color: var(--gray-900); + margin: 0; +} + +.header-right { + display: flex; + align-items: center; + gap: var(--spacing-4); +} + +.user-info { + display: flex; + flex-direction: column; + align-items: flex-end; + font-size: var(--font-size-sm); +} + +.user-name { + font-weight: 600; + color: var(--gray-900); +} + +.user-role { + color: var(--gray-500); + font-size: var(--font-size-xs); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Main Content */ +.main-content { + flex: 1; + padding: var(--spacing-8) 0; +} + +/* Keep all existing component styles (buttons, cards, forms, etc.) */ + +/* Typography */ +h1, +h2, +h3, +h4, +h5, +h6 { + color: var(--gray-900); + font-weight: 600; + line-height: 1.25; + margin-bottom: var(--spacing-4); +} + +h1 { + font-size: var(--font-size-4xl); +} +h2 { + font-size: var(--font-size-3xl); +} +h3 { + font-size: var(--font-size-2xl); +} +h4 { + font-size: var(--font-size-xl); +} +h5 { + font-size: var(--font-size-lg); +} +h6 { + font-size: var(--font-size-base); +} + +p { + margin-bottom: var(--spacing-4); +} + +a { + color: var(--primary-color); + text-decoration: none; + transition: var(--transition); +} + +a:hover { + color: var(--primary-hover); + text-decoration: underline; +} + +/* Layout */ +.container { + max-width: 1600px; + margin: 0 auto; + padding: 0 var(--spacing-4); +} + +/* Keep all existing component styles from original CSS */ + +/* Cards */ +.card { + background: var(--white); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); + overflow: hidden; + transition: var(--transition); +} + +.card:hover { + box-shadow: var(--shadow-lg); +} + +.card-header { + padding: var(--spacing-6); + border-bottom: 1px solid var(--gray-200); + background: var(--gray-50); +} + +.card-body { + padding: var(--spacing-6); +} + +.card-footer { + padding: var(--spacing-6); + border-top: 1px solid var(--gray-200); + background: var(--gray-50); +} + +/* Enhanced Dashboard Styling Fixes */ +.search-box input { + padding: var(--spacing-3) var(--spacing-3) var(--spacing-3) var(--spacing-10); + border: 2px solid var(--gray-200); + border-radius: var(--radius-lg); + font-size: var(--font-size-sm); + width: 320px; + height: 44px; + transition: var(--transition); +} + +.filter-select { + padding: var(--spacing-3) var(--spacing-4); + border: 2px solid var(--gray-200); + border-radius: var(--radius-lg); + font-size: var(--font-size-sm); + background-color: var(--white); + cursor: pointer; + transition: var(--transition); + min-width: 150px; + height: 44px; +} + +.results-counter { + padding: var(--spacing-3) var(--spacing-4); + background: var(--gray-100); + border: 2px solid var(--gray-200); + border-radius: var(--radius-lg); + font-size: var(--font-size-sm); + color: var(--gray-600); + font-weight: 500; + height: 44px; + display: flex; + align-items: center; +} + +/* Standardized Action Buttons */ +.action-btn { + padding: var(--spacing-2); + border: 1px solid transparent; + border-radius: var(--radius); + background: transparent; + cursor: pointer; + transition: var(--transition); + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + text-decoration: none; + font-size: var(--font-size-sm); +} + +.action-btn i { + font-size: 14px; +} + +/* QR Card Actions Container */ +.quick-actions { + display: flex; + gap: var(--spacing-2); + flex-wrap: wrap; + align-items: center; +} + +/* Standard Button Heights */ +.btn { + height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-2); + padding: 0 var(--spacing-6); + font-size: var(--font-size-sm); + font-weight: 500; + border-radius: var(--radius-lg); + border: 2px solid transparent; + cursor: pointer; + transition: var(--transition); + text-decoration: none; + line-height: 1; + white-space: nowrap; +} + +.btn-outline { + background: var(--white); + border-color: var(--gray-200); + color: var(--gray-700); + height: 44px; +} + +.btn-outline:hover { + background: var(--gray-50); + border-color: var(--primary-color); + color: var(--primary-color); +} + +/* Section Controls Alignment */ +.section-controls { + display: flex; + gap: var(--spacing-3); + align-items: center; + flex-wrap: wrap; +} + +.section-controls > * { + height: 44px; +} + +.btn-primary { + background: var(--primary-color); + color: var(--white); +} + +.btn-primary:hover { + background: var(--primary-hover); +} + +.btn-secondary { + background: var(--secondary-color); + color: var(--white); +} + +.btn-success { + background: var(--success-color); + color: var(--white); +} + +.btn-warning { + background: var(--warning-color); + color: var(--white); +} + +.btn-danger { + background: var(--danger-color); + color: var(--white); +} + +.btn-info { + background: var(--info-color); + color: var(--white); +} + +/* Forms */ +.form-group { + margin-bottom: var(--spacing-6); +} + +.form-label { + display: block; + font-weight: 500; + color: var(--gray-700); + margin-bottom: var(--spacing-2); + font-size: var(--font-size-sm); +} + +.form-control { + width: 100%; + padding: var(--spacing-3) var(--spacing-4); + font-size: var(--font-size-base); + border: 1px solid var(--gray-300); + border-radius: var(--radius-lg); + background: var(--white); + transition: var(--transition); +} + +.form-control:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +/* Flash Messages */ +.flash-messages { + position: fixed; + top: var(--spacing-4); + right: var(--spacing-4); + z-index: var(--z-modal); + max-width: 400px; +} + +.alert { + display: flex; + align-items: center; + gap: var(--spacing-3); + padding: var(--spacing-4); + margin-bottom: var(--spacing-3); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + animation: slideInRight 0.3s ease-out; +} + +.alert-success { + background: var(--success-color); + color: var(--white); +} + +.alert-error { + background: var(--danger-color); + color: var(--white); +} + +.alert-info { + background: var(--info-color); + color: var(--white); +} + +.alert-warning { + background: var(--warning-color); + color: var(--white); +} + +.alert-close { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: var(--font-size-lg); + margin-left: auto; +} + +/* Project Manager Permissions Section Styles */ +.permissions-section { + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 20px; + margin: 20px 0; +} + +.permissions-section .section-header { + margin-bottom: 20px; +} + +.permissions-section .section-header h3 { + color: #495057; + font-size: 1.2rem; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 10px; +} + +.permissions-section .section-description { + color: #6c757d; + font-size: 0.9rem; + margin: 0; +} + +.checkbox-group { + max-height: 250px; + overflow-y: auto; + border: 1px solid #ced4da; + border-radius: 6px; + padding: 15px; + background: #ffffff; +} + +.checkbox-label { + display: flex; + align-items: center; + padding: 8px 12px; + margin: 4px 0; + cursor: pointer; + border-radius: 4px; + transition: background-color 0.2s; +} + +.checkbox-label:hover { + background-color: #f1f3f5; +} + +.checkbox-input { + margin-right: 12px; + width: 18px; + height: 18px; + cursor: pointer; +} + +.checkbox-text { + font-size: 0.95rem; + color: #495057; +} + +.checkbox-label input:checked + .checkbox-text { + font-weight: 600; + color: #0056b3; +} + +/* Footer */ +.footer { + background-color: var(--gray-800); + color: var(--gray-300); + text-align: center; + padding: var(--spacing-6) 0; + margin-top: auto; +} + +.footer-content { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: var(--spacing-4); +} + +.footer-links { + display: flex; + gap: var(--spacing-6); +} + +.footer-link { + color: var(--gray-400); + font-size: var(--font-size-sm); +} + +.footer-link:hover { + color: var(--white); +} + +/* Animations */ +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.fade-in { + animation: fadeIn 0.3s ease-out; +} + +.fade-out { + animation: fadeOut 0.3s ease-out; +} + +.slide-up { + animation: slideUp 0.3s ease-out; +} + +/* Utilities */ +.text-left { + text-align: left; +} +.text-center { + text-align: center; +} +.text-right { + text-align: right; +} + +.text-primary { + color: var(--primary-color); +} +.text-secondary { + color: var(--secondary-color); +} +.text-success { + color: var(--success-color); +} +.text-warning { + color: var(--warning-color); +} +.text-danger { + color: var(--danger-color); +} +.text-info { + color: var(--info-color); +} +.text-muted { + color: var(--gray-500); +} + +.bg-primary { + background-color: var(--primary-color); +} +.bg-secondary { + background-color: var(--secondary-color); +} +.bg-success { + background-color: var(--success-color); +} +.bg-warning { + background-color: var(--warning-color); +} +.bg-danger { + background-color: var(--danger-color); +} +.bg-info { + background-color: var(--info-color); +} + +.d-none { + display: none; +} +.d-block { + display: block; +} +.d-flex { + display: flex; +} +.d-grid { + display: grid; +} + +.flex-column { + flex-direction: column; +} +.flex-wrap { + flex-wrap: wrap; +} +.justify-content-center { + justify-content: center; +} +.justify-content-between { + justify-content: space-between; +} +.align-items-center { + align-items: center; +} + +.mb-0 { + margin-bottom: 0; +} +.mb-1 { + margin-bottom: var(--spacing-1); +} +.mb-2 { + margin-bottom: var(--spacing-2); +} +.mb-3 { + margin-bottom: var(--spacing-3); +} +.mb-4 { + margin-bottom: var(--spacing-4); +} +.mb-5 { + margin-bottom: var(--spacing-5); +} +.mb-6 { + margin-bottom: var(--spacing-6); +} + +.mt-0 { + margin-top: 0; +} +.mt-1 { + margin-top: var(--spacing-1); +} +.mt-2 { + margin-top: var(--spacing-2); +} +.mt-3 { + margin-top: var(--spacing-3); +} +.mt-4 { + margin-top: var(--spacing-4); +} +.mt-5 { + margin-top: var(--spacing-5); +} +.mt-6 { + margin-top: var(--spacing-6); +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .sidebar { + width: var(--sidebar-collapsed-width); + } + + .sidebar .menu-text, + .sidebar .brand-text { + display: none; + } + + .main-wrapper { + margin-left: var(--sidebar-collapsed-width); + } +} + +@media (max-width: 768px) { + .sidebar { + width: var(--sidebar-width); + transform: translateX(-100%); + } + + .sidebar.mobile-open { + transform: translateX(0); + } + + .sidebar-overlay.active { + display: block; + } + + .main-wrapper { + margin-left: 0; + } + + .mobile-menu-btn { + display: flex; + } + + .sidebar-toggle { + display: none; + } + + .mobile-menu-btn.active .hamburger-line:nth-child(1) { + transform: rotate(45deg) translate(5px, 5px); + } + + .mobile-menu-btn.active .hamburger-line:nth-child(2) { + opacity: 0; + } + + .mobile-menu-btn.active .hamburger-line:nth-child(3) { + transform: rotate(-45deg) translate(7px, -6px); + } + + .container { + padding: 0 var(--spacing-3); + } + + .flash-messages { + left: var(--spacing-3); + right: var(--spacing-3); + max-width: none; + } + + .header-right .user-info { + display: none; + } + + .page-title { + font-size: var(--font-size-lg); + } + + h1 { + font-size: var(--font-size-3xl); + } + h2 { + font-size: var(--font-size-2xl); + } + h3 { + font-size: var(--font-size-xl); + } +} + +@media (max-width: 480px) { + .container { + padding: 0 var(--spacing-2); + } + + .main-content { + padding: var(--spacing-4) 0; + } + + .card-body, + .card-header, + .card-footer { + padding: var(--spacing-4); + } + + .top-header { + padding: 0 var(--spacing-3); + } + + .page-title { + font-size: var(--font-size-base); + } + + .btn { + padding: var(--spacing-2) var(--spacing-4); + font-size: var(--font-size-xs); + } +} + +/* Print Styles */ +@media print { + .sidebar, + .top-header, + .footer, + .btn, + .flash-messages { + display: none !important; + } + + .main-wrapper { + margin-left: 0; + } + + .main-content { + padding: 0; + } + + .card { + box-shadow: none; + border: 1px solid var(--gray-300); + } +} + +/* Legacy Navigation Styles - Hidden by default but available for non-authenticated pages */ +.navbar { + display: none; +} + +.nav-container, +.nav-brand, +.nav-menu, +.nav-link, +.nav-toggle { + display: none; +} + +/* Show legacy navbar only for non-authenticated pages */ +body:not(.app-layout) .navbar { + display: block; + background: linear-gradient( + 135deg, + var(--primary-color), + var(--primary-hover) + ); + color: var(--white); + padding: var(--spacing-4) 0; + box-shadow: var(--shadow-md); + position: sticky; + top: 0; + z-index: var(--z-dropdown); +} + +body:not(.app-layout) .nav-container { + display: flex; + max-width: 1200px; + margin: 0 auto; + padding: 0 var(--spacing-4); + justify-content: space-between; + align-items: center; +} + +body:not(.app-layout) .nav-brand { + display: flex; + align-items: center; + gap: var(--spacing-3); + font-size: var(--font-size-xl); + font-weight: 700; + color: var(--white); +} + +body:not(.app-layout) .nav-menu { + display: flex; + gap: var(--spacing-6); + align-items: center; +} + +body:not(.app-layout) .nav-link { + display: flex; + color: var(--white); + padding: var(--spacing-2) var(--spacing-4); + border-radius: var(--radius); + transition: var(--transition); + align-items: center; + gap: var(--spacing-2); +} + +body:not(.app-layout) .nav-link:hover { + background-color: rgba(255, 255, 255, 0.1); + text-decoration: none; +} + +body:not(.app-layout) .nav-link.logout:hover { + background-color: var(--danger-color); +} + +/* Non-authenticated layout adjustments */ +body:not(.has-sidebar) { + display: flex; + flex-direction: column; +} + +body:not(.has-sidebar) .main-content { + flex: 1; + padding: var(--spacing-8) 0; +} + +/* Dropdown Styles */ +.dropdown { + position: relative; + display: inline-block; +} + +.dropdown-trigger { + background: none; + border: none; + cursor: pointer; + display: flex; + align-items: center; + gap: var(--spacing-2); + padding: var(--spacing-2); + border-radius: var(--radius); + transition: var(--transition); +} + +.dropdown-trigger:hover { + background-color: var(--gray-100); +} + +.dropdown-menu { + position: absolute; + top: 100%; + right: 0; + background: var(--white); + border: 1px solid var(--gray-200); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + min-width: 200px; + z-index: var(--z-dropdown); + opacity: 0; + visibility: hidden; + transform: translateY(-10px); + transition: var(--transition); +} + +.dropdown-menu.show { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.dropdown-item { + display: flex; + align-items: center; + gap: var(--spacing-3); + width: 100%; + padding: var(--spacing-3) var(--spacing-4); + color: var(--gray-700); + text-decoration: none; + border: none; + background: none; + cursor: pointer; + font-size: var(--font-size-sm); + transition: var(--transition); +} + +.dropdown-item:hover { + background-color: var(--gray-50); + color: var(--gray-900); +} + +.dropdown-item:first-child { + border-top-left-radius: var(--radius-lg); + border-top-right-radius: var(--radius-lg); +} + +.dropdown-item:last-child { + border-bottom-left-radius: var(--radius-lg); + border-bottom-right-radius: var(--radius-lg); +} + +.dropdown-divider { + height: 1px; + background-color: var(--gray-200); + margin: var(--spacing-2) 0; +} + +/* Modal Styles */ +.modal { + position: fixed; + inset: 0; + z-index: var(--z-modal); + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + opacity: 0; + visibility: hidden; + transition: var(--transition); +} + +.modal.show { + opacity: 1; + visibility: visible; +} + +.modal-content { + background: var(--white); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-xl); + width: 90%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + transform: scale(0.95); + transition: var(--transition); +} + +.modal.show .modal-content { + transform: scale(1); +} + +.modal-header { + padding: var(--spacing-6); + border-bottom: 1px solid var(--gray-200); + display: flex; + align-items: center; + justify-content: space-between; +} + +.modal-title { + font-size: var(--font-size-xl); + font-weight: 600; + color: var(--gray-900); + margin: 0; +} + +.modal-close { + background: none; + border: none; + font-size: var(--font-size-xl); + color: var(--gray-400); + cursor: pointer; + padding: var(--spacing-1); + border-radius: var(--radius); + transition: var(--transition); +} + +.modal-close:hover { + background-color: var(--gray-100); + color: var(--gray-600); +} + +.modal-body { + padding: var(--spacing-6); +} + +.modal-footer { + padding: var(--spacing-6); + border-top: 1px solid var(--gray-200); + display: flex; + gap: var(--spacing-3); + justify-content: flex-end; +} + +/* Step-by-step Permission Selection Styles */ +.permission-step { + background: #ffffff; + border: 2px solid #e5e7eb; + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + transition: all 0.3s ease; +} + +.permission-step:hover { + border-color: #3b82f6; + box-shadow: 0 4px 6px rgba(59, 130, 246, 0.1); +} + +.step-header { + display: flex; + align-items: center; + gap: 15px; + margin-bottom: 20px; +} + +.step-number { + width: 40px; + height: 40px; + background: linear-gradient(135deg, #3b82f6, #2563eb); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + font-weight: bold; + flex-shrink: 0; +} + +.step-info h4 { + margin: 0; + color: #1f2937; + font-size: 1.1rem; +} + +.step-info p { + margin: 4px 0 0 0; + color: #6b7280; + font-size: 0.9rem; +} + +.step-footer { + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid #e5e7eb; +} + +.selection-summary { + display: flex; + align-items: center; + gap: 8px; + padding: 10px; + background: #f9fafb; + border-radius: 6px; + font-size: 0.95rem; + color: #374151; +} + +.selection-summary i { + font-size: 1.1rem; + color: #6b7280; +} + +.selection-summary strong { + color: #1f2937; +} + +.location-loading, +.location-empty { + padding: 30px; + text-align: center; + color: #6b7280; +} + +.location-loading { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.location-loading i { + font-size: 2rem; + color: #3b82f6; +} + +.location-empty i { + font-size: 2.5rem; + color: #9ca3af; + margin-bottom: 10px; +} + +.location-empty p { + margin: 10px 0 5px 0; + color: #4b5563; +} + +.location-empty small { + color: #6b7280; +} + +.permissions-overview { + background: linear-gradient(135deg, #f0f9ff, #e0f2fe); + border: 2px solid #3b82f6; + border-radius: 8px; + padding: 20px; + margin-top: 20px; +} + +.overview-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 15px; + color: #1e40af; +} + +.overview-header i { + font-size: 1.5rem; +} + +.overview-header h4 { + margin: 0; + font-size: 1.1rem; +} + +.overview-content { + display: flex; + flex-direction: column; + gap: 10px; +} + +.overview-item { + display: flex; + gap: 10px; + padding: 8px; + background: white; + border-radius: 6px; + font-size: 0.95rem; +} + +.overview-item strong { + color: #1f2937; + min-width: 90px; +} + +.overview-item span { + color: #374151; + flex: 1; +} + +.project-qr-count { + margin-left: auto; + padding: 2px 8px; + background: #e0f2fe; + color: #0369a1; + border-radius: 12px; + font-size: 0.85rem; + font-weight: 500; +} + +.project-qr-count i { + margin-right: 4px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .step-header { + flex-direction: column; + align-items: flex-start; + } + + .step-number { + width: 35px; + height: 35px; + font-size: 1rem; + } + + .overview-item { + flex-direction: column; + gap: 5px; + } + + .overview-item strong { + min-width: auto; + } +} \ No newline at end of file diff --git a/static/css/theme-gov.css b/static/css/theme-gov.css new file mode 100644 index 0000000..d2ccf0a --- /dev/null +++ b/static/css/theme-gov.css @@ -0,0 +1,121 @@ +/** + * theme-gov.css + * ============= + * UI theme overrides for GOV Services, Inc. (qr.govservicesinc.com) + * + * HOW IT WORKS + * ------------ + * Loaded AFTER style.css but BEFORE page-specific CSS (e.g. auth.css). + * Overrides CSS custom properties in :root so every component that uses + * those variables (sidebar, buttons, login page, links, focus rings) + * adopts the new palette automatically. + * + * Brand text is handled directly in templates via {{ COMPANY_NAME }} — + * no CSS tricks needed. + * + * TO CUSTOMISE COLORS + * ------------------- + * Change --primary-color / --primary-hover below. + * All gradients, buttons, focus rings, sidebar, and login page pick + * up the new values automatically via CSS variable inheritance. + * + * ADDING A THIRD THEME (future) + * ----------------------------- + * Create static/css/theme-{name}.css and set THEME_NAME={name} in + * that instance's .env. No code changes required. + */ + +/* ------------------------------------------------------------------ */ +/* 1. COLOR PALETTE — GOV Services green */ +/* ------------------------------------------------------------------ */ +:root { + /* Primary brand color: bright institutional green */ + --primary-color: #16a34a; + --primary-hover: #15803d; + + /* Accent used for links, focus rings, active states */ + --info-color: #16a34a; +} + +/* ------------------------------------------------------------------ */ +/* 2. SIDEBAR GRADIENT */ +/* Re-declared to consume the new variables above. */ +/* ------------------------------------------------------------------ */ +.sidebar { + background: linear-gradient( + 180deg, + var(--primary-color), + var(--primary-hover) + ); +} + +/* ------------------------------------------------------------------ */ +/* 3. LOGIN PAGE — auth.css loads after this file (via extra_head) so */ +/* we must use !important to win the cascade on rules that auth.css */ +/* also declares with !important. */ +/* ------------------------------------------------------------------ */ + +/* Login background gradient */ +.auth-container { + background: linear-gradient( + 135deg, + var(--primary-color), + var(--primary-hover) + ) !important; +} + +/* Logo circle gradient */ +.auth-logo { + background: linear-gradient( + 135deg, + var(--primary-color), + var(--primary-hover) + ) !important; +} + +/* Card top accent stripe */ +.auth-card::before { + background: linear-gradient( + 90deg, + var(--primary-color), + var(--primary-hover) + ) !important; +} + +/* Sign In button gradient */ +.auth-form .btn-primary { + background: linear-gradient( + 135deg, + var(--primary-color), + var(--primary-hover) + ) !important; +} + +/* Input focus border colour */ +.auth-form .form-group input:focus { + border-color: var(--primary-color) !important; + /* Override the hardcoded blue rgba(37,99,235) in auth.css */ + box-shadow: 0 0 0 4px rgba(22, 163, 74, 0.15) !important; +} + +/* Focused label colour */ +.auth-form .form-group.focused label { + color: var(--primary-color) !important; +} + +/* Label icon colour */ +.auth-form .form-group label i { + color: var(--primary-color) !important; +} + +/* Focus accessibility outline */ +.auth-form .btn:focus, +.auth-form .form-group input:focus { + outline-color: var(--primary-color) !important; +} + +/* ------------------------------------------------------------------ */ +/* 4. FOOTER COPYRIGHT */ +/* Handled dynamically in templates via {{ CURRENT_YEAR }} and */ +/* {{ COMPANY_NAME }} — no CSS override needed here. */ +/* ------------------------------------------------------------------ */ diff --git a/static/css/time_attendance.css b/static/css/time_attendance.css new file mode 100644 index 0000000..b181c2d --- /dev/null +++ b/static/css/time_attendance.css @@ -0,0 +1,1037 @@ +/** + * Time Attendance Page Styles + * static/css/time_attendance.css + */ + +/* Main Container */ +.time-attendance-page { + max-width: 1600px; + margin: 0 auto; + padding: 2rem; + min-height: 100vh; + background: #f8fafc; +} + +/* Sidebar Layout Compatibility */ +body.has-sidebar .time-attendance-page { + margin-left: 0; + padding-left: 0; +} + +.main-wrapper .time-attendance-page { + padding: 2rem; + max-width: 1600px; + margin: 0 auto; + min-height: 100vh; + background: #f8fafc; +} + +/* Page Header */ +.time-attendance-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 2rem; + padding: 1.5rem; + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + position: relative; + overflow: hidden; +} + +.time-attendance-header::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, #8b5cf6, #7c3aed); +} + +.header-navigation { + margin-bottom: 1rem; +} + +.back-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: #f1f5f9; + color: #475569; + text-decoration: none; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + border: 1px solid #cbd5e1; + transition: all 0.2s ease-in-out; +} + +.back-button:hover { + background: #e2e8f0; + color: #334155; + transform: translateX(-2px); + text-decoration: none; +} + +.header-content h1 { + font-size: 1.875rem; + font-weight: 700; + color: #0f172a; + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.header-content h1 i { + color: #8b5cf6; +} + +.header-description { + color: #64748b; + font-size: 1rem; + margin: 0; +} + +.header-actions { + display: flex; + gap: 1rem; +} + +/* Statistics Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.stat-card { + background: #ffffff; + padding: 1.5rem; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; + gap: 1rem; + transition: all 0.2s ease-in-out; +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: 0 10px 25px 0 rgba(0, 0, 0, 0.1); +} + +.stat-icon { + width: 60px; + height: 60px; + border-radius: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + color: #ffffff; + flex-shrink: 0; +} + +.stat-icon.records { + background: linear-gradient(135deg, #8b5cf6, #7c3aed); +} + +.stat-icon.employees { + background: linear-gradient(135deg, #10b981, #059669); +} + +.stat-icon.locations { + background: linear-gradient(135deg, #f59e0b, #d97706); +} + +.stat-icon.imports { + background: linear-gradient(135deg, #3b82f6, #2563eb); +} + +.stat-info h3 { + font-size: 2rem; + font-weight: 700; + color: #0f172a; + margin: 0; +} + +.stat-info p { + color: #64748b; + font-size: 0.875rem; + margin: 0; + font-weight: 500; +} + +/* Import Section */ +.import-section { + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + margin-bottom: 2rem; + overflow: hidden; +} + +.import-header { + background: #f8fafc; + padding: 1.5rem; + border-bottom: 1px solid #e2e8f0; + position: relative; +} + +.import-header::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, #3b82f6, #2563eb); +} + +.import-header h2 { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 1.25rem; + font-weight: 600; + color: #0f172a; + margin: 0; +} + +.import-header h2 i { + color: #3b82f6; +} + +.import-body { + padding: 2rem; +} + +/* File Upload Area */ +.file-upload-area { + border: 2px dashed #cbd5e1; + border-radius: 0.75rem; + padding: 3rem 2rem; + text-align: center; + background: #f8fafc; + transition: all 0.2s ease-in-out; + cursor: pointer; + position: relative; +} + +.file-upload-area:hover { + border-color: #8b5cf6; + background: #faf5ff; +} + +.file-upload-area.dragover { + border-color: #8b5cf6; + background: #faf5ff; + transform: scale(1.02); +} + +.upload-icon { + width: 80px; + height: 80px; + margin: 0 auto 1rem; + background: linear-gradient(135deg, #8b5cf6, #7c3aed); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + font-size: 2rem; +} + +.upload-text h3 { + font-size: 1.25rem; + font-weight: 600; + color: #0f172a; + margin-bottom: 0.5rem; +} + +.upload-text p { + color: #64748b; + font-size: 0.875rem; + margin-bottom: 1.5rem; +} + +.file-input { + display: none; +} + +/* Form Styles */ +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-label { + display: block; + font-weight: 600; + color: #374151; + margin-bottom: 0.5rem; + font-size: 0.875rem; +} + +.form-label.required::after { + content: " *"; + color: #dc2626; +} + +.form-input { + width: 100%; + padding: 0.75rem 1rem; + border: 2px solid #e2e8f0; + border-radius: 0.5rem; + font-size: 1rem; + transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out; + background: #ffffff; +} + +.form-input:focus { + outline: none; + border-color: #8b5cf6; + box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1); +} + +.form-select { + width: 100%; + padding: 0.75rem 1rem; + border: 2px solid #e2e8f0; + border-radius: 0.5rem; + font-size: 1rem; + background: #ffffff; + transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out; +} + +.form-select:focus { + outline: none; + border-color: #8b5cf6; + box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1); +} + +/* Filter Section */ +.filter-section { + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + margin-bottom: 2rem; + overflow: hidden; +} + +.filter-header { + background: #f8fafc; + padding: 1rem 1.5rem; + border-bottom: 1px solid #e2e8f0; + display: flex; + justify-content: between; + align-items: center; +} + +.filter-header h3 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + font-weight: 600; + color: #0f172a; + margin: 0; +} + +.filter-toggle { + background: none; + border: none; + color: #64748b; + cursor: pointer; + font-size: 1rem; + transition: color 0.2s ease-in-out; +} + +.filter-toggle:hover { + color: #0f172a; +} + +.filter-body { + padding: 1.5rem; +} + +.filter-form { + display: grid; + grid-template-columns: repeat(5, 1fr); /* 5 equal columns */ + gap: 1rem; + align-items: end; +} + +.form-group { + display: flex; + flex-direction: column; + min-width: 0; /* Allow shrinking */ +} + +.form-input, +.form-select { + width: 100%; /* Full width of parent */ + padding: 0.625rem 0.875rem; + font-size: 0.875rem; + box-sizing: border-box; +} + +/* Make buttons span full width */ +.form-actions { + grid-column: 1 / -1; /* Span all columns */ + display: flex; + gap: 0.75rem; + margin-top: 0.5rem; +} + +/* Responsive */ +@media (max-width: 1200px) { + .filter-form { + grid-template-columns: repeat(3, 1fr); /* 2 columns on medium screens */ + } +} + +@media (max-width: 768px) { + .filter-form { + grid-template-columns: 1fr; /* 1 column on mobile */ + } + + .form-actions { + flex-direction: column; + } +} + +/* Records Table */ +.records-section { + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.records-header { + background: #f8fafc; + padding: 1.5rem; + border-bottom: 1px solid #e2e8f0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.records-header h2 { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 1.25rem; + font-weight: 600; + color: #0f172a; + margin: 0; +} + +.records-header h2 i { + color: #8b5cf6; +} + +.records-meta { + color: #64748b; + font-size: 0.875rem; +} + +.records-table { + width: 100%; + border-collapse: collapse; +} + +.records-table th { + background: #f8fafc; + padding: 1rem; + text-align: left; + font-weight: 600; + color: #374151; + font-size: 0.875rem; + border-bottom: 1px solid #e2e8f0; +} + +.records-table td { + padding: 1rem; + border-bottom: 1px solid #f1f5f9; + font-size: 0.875rem; +} + +.records-table tr:hover { + background: #f8fafc; +} + +.employee-cell { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.employee-avatar { + width: 32px; + height: 32px; + background: linear-gradient(135deg, #8b5cf6, #7c3aed); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + font-size: 0.75rem; + font-weight: 600; + flex-shrink: 0; +} + +.employee-info { + flex: 1; +} + +.employee-name { + font-weight: 600; + color: #0f172a; + margin-bottom: 0.125rem; +} + +.employee-id { + color: #64748b; + font-size: 0.75rem; +} + +.action-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} + +.action-badge.check-in { + background: #dcfce7; + color: #166534; +} + +.action-badge.check-out { + background: #fef3c7; + color: #92400e; +} + +.action-badge.break { + background: #dbeafe; + color: #1e40af; +} + +.datetime-cell { + color: #374151; + font-family: monospace; + font-size: 0.8125rem; +} + +.location-cell { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.platform-cell { + color: #64748b; + font-size: 0.8125rem; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.actions-cell { + text-align: right; +} + +.action-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border: none; + border-radius: 0.375rem; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease-in-out; +} + +.action-btn.view { + background: #e0e7ff; + color: #3730a3; +} + +.action-btn.view:hover { + background: #c7d2fe; +} + +.action-btn.delete { + background: #fee2e2; + color: #991b1b; +} + +.action-btn.delete:hover { + background: #fecaca; +} + +/* Enhanced action buttons */ +.action-btn.btn-info { + background: #4299e1; + color: white; +} + +.action-btn.btn-info:hover { + background: #3182ce; +} + +.action-btn.btn-delete { + background: #f56565; + color: white; +} + +.action-btn.btn-delete:hover { + background: #e53e3e; +} + +/* Progress indicator animation */ +@keyframes spin { + to { transform: rotate(360deg); } +} + +.spinner { + animation: spin 0.8s linear infinite; +} + +/* Pagination */ +.pagination-section { + padding: 1.5rem; + background: #f8fafc; + border-top: 1px solid #e2e8f0; + display: flex; + justify-content: between; + align-items: center; +} + +.pagination-info { + color: #64748b; + font-size: 0.875rem; +} + +.pagination-controls { + display: flex; + gap: 0.5rem; +} + +.pagination-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.5rem 0.75rem; + border: 1px solid #e2e8f0; + border-radius: 0.375rem; + background: #ffffff; + color: #374151; + font-size: 0.875rem; + text-decoration: none; + transition: all 0.2s ease-in-out; +} + +.pagination-btn:hover:not(.disabled) { + background: #f8fafc; + border-color: #cbd5e1; + text-decoration: none; +} + +.pagination-btn.active { + background: #8b5cf6; + border-color: #8b5cf6; + color: #ffffff; +} + +.pagination-btn.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 4rem 2rem; +} + +.empty-icon { + width: 120px; + height: 120px; + margin: 0 auto 1.5rem; + background: #f1f5f9; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #64748b; + font-size: 3rem; +} + +.empty-state h3 { + font-size: 1.5rem; + font-weight: 600; + color: #0f172a; + margin-bottom: 0.5rem; +} + +.empty-state p { + color: #64748b; + font-size: 1rem; + margin-bottom: 2rem; + max-width: 400px; + margin-left: auto; + margin-right: auto; +} + +/* Import Results */ +.import-results { + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.import-results.success { + border-left: 4px solid #10b981; +} + +.import-results.error { + border-left: 4px solid #ef4444; +} + +.import-results.warning { + border-left: 4px solid #f59e0b; +} + +.results-header { + padding: 1.5rem; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; +} + +.results-header h2 { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 1.25rem; + font-weight: 600; + margin: 0; +} + +.results-body { + padding: 2rem; +} + +.results-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.summary-item { + text-align: center; + padding: 1rem; + border-radius: 0.5rem; + background: #f8fafc; +} + +.summary-item.success { + background: #dcfce7; +} + +.summary-item.error { + background: #fee2e2; +} + +.summary-item .number { + font-size: 2rem; + font-weight: 700; + margin-bottom: 0.25rem; +} + +.summary-item.success .number { + color: #166534; +} + +.summary-item.error .number { + color: #991b1b; +} + +.summary-item .label { + color: #64748b; + font-size: 0.875rem; + font-weight: 500; +} + +.error-list { + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 0.5rem; + padding: 1rem; +} + +.error-list h4 { + color: #991b1b; + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.75rem; +} + +.error-list ul { + list-style: none; + padding: 0; + margin: 0; +} + +.error-list li { + color: #dc2626; + font-size: 0.875rem; + padding: 0.25rem 0; + border-bottom: 1px solid #fecaca; +} + +.error-list li:last-child { + border-bottom: none; +} + +/* Button Styles */ +.btn { + padding: 0.75rem 1.25rem; + font-size: 0.875rem; + line-height: 1.25rem; /* Fixed line height */ + height: 2.75rem; /* Fixed height: 44px */ + border-radius: 0.5rem; + border: none; + font-weight: 600; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + transition: all 0.2s ease-in-out; + text-decoration: none; + white-space: nowrap; + box-sizing: border-box; /* CRITICAL */ +} + +.btn-primary { + background: linear-gradient(135deg, #8b5cf6, #7c3aed); + color: #ffffff; + border: 2px solid transparent; +} + +.btn-primary:hover { + background: #7c3aed; + transform: translateY(-1px); + text-decoration: none; + color: #ffffff; +} + +.btn-secondary { + background: #64748b; + color: #ffffff; + border: 2px solid transparent; +} + +.btn-secondary:hover { + background: #475569; + transform: translateY(-1px); + text-decoration: none; + color: #ffffff; +} + +.btn-success { + background: #10b981; + color: #ffffff; +} + +.btn-success:hover { + background: #059669; + transform: translateY(-1px); + text-decoration: none; + color: #ffffff; +} + +.btn-warning { + background: #f59e0b; + color: #ffffff; +} + +.btn-warning:hover { + background: #d97706; + transform: translateY(-1px); + text-decoration: none; + color: #ffffff; +} + +.btn-danger { + background: #ef4444; + color: #ffffff; +} + +.btn-danger:hover { + background: #dc2626; + transform: translateY(-1px); + text-decoration: none; + color: #ffffff; +} + +.btn-outline { + background: #ffffff; + border: 2px solid #e2e8f0; + color: #374151; +} + +.btn-outline:hover { + background: #f8fafc; + border-color: #cbd5e1; + text-decoration: none; +} + +.btn-sm { + padding: 0.5rem 1rem; + font-size: 0.8125rem; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .stats-grid { + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + } + + .form-grid { + grid-template-columns: 1fr; + } + + .filter-form { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .time-attendance-page { + padding: 1rem; + } + + .time-attendance-header { + flex-direction: column; + align-items: stretch; + gap: 1rem; + text-align: center; + } + + .header-navigation { + text-align: left; + margin-bottom: 0.5rem; + } + + .header-actions { + justify-content: center; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .records-table { + font-size: 0.75rem; + } + + .records-table th, + .records-table td { + padding: 0.75rem 0.5rem; + } + + .employee-cell { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .pagination-section { + flex-direction: column; + gap: 1rem; + text-align: center; + } +} + +@media (max-width: 480px) { + .time-attendance-page { + padding: 0.75rem; + } + + .time-attendance-header { + padding: 1rem; + } + + .header-content h1 { + font-size: 1.5rem; + } + + .stat-card { + padding: 1rem; + } + + .stat-icon { + width: 48px; + height: 48px; + font-size: 1.25rem; + } + + .stat-info h3 { + font-size: 1.5rem; + } + + .import-body, + .results-body { + padding: 1rem; + } + + .file-upload-area { + padding: 2rem 1rem; + } + + .upload-icon { + width: 60px; + height: 60px; + font-size: 1.5rem; + } +} + +.btn, +button.btn, +a.btn { + height: 2.75rem !important; + min-height: 2.75rem !important; + max-height: 2.75rem !important; + line-height: 1.25rem !important; + box-sizing: border-box !important; +} \ No newline at end of file diff --git a/static/css/users.css b/static/css/users.css new file mode 100644 index 0000000..42e4f43 --- /dev/null +++ b/static/css/users.css @@ -0,0 +1,723 @@ +/** + * Users Page Dedicated CSS + * static/css/users-page.css + * + * This file contains all necessary styles for the users management page + * ensuring it works independently of other CSS files. + */ + +/* Users Page Container */ +.users-page { + max-width: 1600px; + margin: 0 auto; + padding: 2rem; + min-height: 100vh; + background: #f8fafc; +} + +/* Page Header */ +.users-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 2rem; + padding: 1.5rem; + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); + position: relative; + overflow: hidden; +} + +.users-header::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, #059669, #047857); +} + +.header-content h1 { + font-size: 1.875rem; + font-weight: 700; + color: #0f172a; + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.header-content h1 i { + color: #059669; +} + +.header-content p { + color: #64748b; + font-size: 1.125rem; + margin: 0; +} + +.header-actions { + display: flex; + gap: 0.75rem; +} + +/* User Statistics Grid */ +.user-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.stat-card { + background: #ffffff; + padding: 1.5rem; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); + display: flex; + align-items: center; + gap: 1rem; + transition: all 0.2s ease-in-out; + border: 1px solid #e2e8f0; + position: relative; + overflow: hidden; +} + +.stat-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; +} + +.stat-card.admin::before { + background: linear-gradient(90deg, #fbbf24, #f59e0b); +} + +.stat-card.staff::before { + background: linear-gradient(90deg, #64748b, #475569); +} + +.stat-card.payroll::before { + background: linear-gradient(90deg, #17a2b8, #138496); +} + +.stat-card.accounting::before { + background: linear-gradient(90deg, #059669, #047857); +} + +.stat-card.project-manager::before { + background: linear-gradient(90deg, #6f42c1, #5a2d91); +} + +.stat-card.active::before { + background: linear-gradient(90deg, #10b981, #047857); +} + +.stat-card.inactive::before { + background: linear-gradient(90deg, #ef4444, #b91c1c); +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +.stat-icon { + width: 50px; + height: 50px; + border-radius: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; + color: #ffffff; + flex-shrink: 0; +} + +.stat-icon.admin { + background: linear-gradient(135deg, #fbbf24, #f59e0b); +} + +.stat-icon.staff { + background: linear-gradient(135deg, #64748b, #475569); +} + +.stat-icon.payroll { + background: linear-gradient(135deg, #17a2b8, #138496); +} + +.stat-icon.accounting { + background: linear-gradient(135deg, #059669, #047857); +} + +.stat-icon.project-manager { + background: linear-gradient(135deg, #6f42c1, #5a2d91); +} + +.stat-icon.active { + background: linear-gradient(135deg, #10b981, #047857); +} + +.stat-icon.inactive { + background: linear-gradient(135deg, #ef4444, #b91c1c); +} + +.stat-info h3 { + font-size: 1.75rem; + font-weight: 700; + color: #0f172a; + margin-bottom: 0.25rem; + margin-top: 0; +} + +.stat-info p { + color: #64748b; + font-size: 0.875rem; + margin: 0; +} + +/* Users Controls */ +.users-controls { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding: 1.5rem; + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); + flex-wrap: wrap; + gap: 1rem; +} + +.search-filters { + display: flex; + gap: 1rem; + align-items: center; + flex-wrap: wrap; +} + +.search-box { + position: relative; + display: flex; + align-items: center; +} + +.search-box i { + position: absolute; + left: 0.75rem; + color: #94a3b8; + z-index: 1; +} + +.search-box input { + padding: 0.75rem 0.75rem 0.75rem 2.5rem; + border: 2px solid #e2e8f0; + border-radius: 0.5rem; + font-size: 0.875rem; + width: 300px; + transition: all 0.2s ease-in-out; + background: #ffffff; +} + +.search-box input:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.filter-group { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.filter-select { + padding: 0.75rem 1rem; + border: 2px solid #e2e8f0; + border-radius: 0.5rem; + font-size: 0.875rem; + background-color: #ffffff; + cursor: pointer; + transition: all 0.2s ease-in-out; + min-width: 140px; +} + +.filter-select:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.view-options { + display: flex; + gap: 0.75rem; +} + +/* Users Table Container */ +.users-table-container { + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); + overflow: hidden; +} + +.users-table { + width: 100%; + border-collapse: collapse; +} + +.users-table th { + background-color: #f8fafc; + padding: 1rem; + text-align: left; + font-weight: 600; + color: #334155; + font-size: 0.875rem; + border-bottom: 1px solid #e2e8f0; + position: sticky; + top: 0; + z-index: 10; +} + +.users-table th:first-child { + width: 50px; + text-align: center; +} + +.users-table td { + padding: 1rem; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; +} + +.users-table tr.user-row { + transition: all 0.2s ease-in-out; + cursor: pointer; +} + +.users-table tr.user-row:hover { + background-color: #f8fafc; +} + +.users-table tr.user-row:last-child td { + border-bottom: none; +} + +/* User Information */ +.user-info { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.user-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: linear-gradient(135deg, #2563eb, #1d4ed8); + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 0.875rem; + flex-shrink: 0; +} + +.user-details h4 { + font-size: 0.875rem; + font-weight: 600; + color: #0f172a; + margin: 0 0 0.25rem 0; +} + +.user-details p { + font-size: 0.75rem; + color: #64748b; + margin: 0; +} + +/* Contact Information */ +.user-contact { + font-size: 0.875rem; +} + +.contact-email { + display: flex; + align-items: center; + gap: 0.5rem; + color: #334155; +} + +.contact-email i { + color: #64748b; + font-size: 0.75rem; +} + +/* Role Badges */ +.user-role { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.user-role.admin { + background-color: rgba(251, 191, 36, 0.1); + color: #d97706; + border: 1px solid rgba(251, 191, 36, 0.3); +} + +.user-role.staff { + background-color: rgba(100, 116, 139, 0.1); + color: #64748b; + border: 1px solid rgba(100, 116, 139, 0.3); +} + +.user-role.payroll { + background-color: rgba(23, 162, 184, 0.1); + color: #138496; + border: 1px solid rgba(23, 162, 184, 0.3); +} + +.user-role.accounting { + background-color: rgba(5, 150, 105, 0.1); + color: #047857; + border: 1px solid rgba(5, 150, 105, 0.3); +} + +.user-role.project_manager { + background-color: rgba(111, 66, 193, 0.1); + color: #5a2d91; + border: 1px solid rgba(111, 66, 193, 0.3); +} + +/* Status Badges */ +.user-status { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 600; +} + +.user-status.active { + background-color: rgba(5, 150, 105, 0.1); + color: #10b981; + border: 1px solid rgba(5, 150, 105, 0.2); +} + +.user-status.inactive { + background-color: rgba(220, 38, 38, 0.1); + color: #ef4444; + border: 1px solid rgba(220, 38, 38, 0.2); +} + +/* QR Code Count */ +.user-qr-count { + text-align: center; +} + +.qr-stats { + display: flex; + flex-direction: column; + align-items: center; +} + +.qr-count { + font-size: 1.25rem; + font-weight: 700; + color: #2563eb; +} + +.qr-stats small { + font-size: 0.75rem; + color: #64748b; + margin-top: 0.25rem; +} + +/* Date Information */ +.date-info { + display: flex; + flex-direction: column; + font-size: 0.875rem; +} + +.date-main { + font-weight: 500; + color: #0f172a; +} + +.date-time { + font-size: 0.75rem; + color: #64748b; + margin-top: 0.125rem; +} + +.never-logged-in { + color: #94a3b8; + font-style: italic; + font-size: 0.875rem; +} + +/* Action Buttons */ +.user-actions { + display: flex; + justify-content: center; +} + +.action-buttons { + display: flex; + gap: 0.5rem; + align-items: center; +} + +/* Button Styles */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border: 1px solid transparent; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: all 0.2s ease-in-out; + white-space: nowrap; +} + +.btn:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.1); +} + +.btn-sm { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; +} + +.btn-primary { + background-color: #2563eb; + color: #ffffff; + border-color: #2563eb; +} + +.btn-primary:hover { + background-color: #1d4ed8; + border-color: #1d4ed8; + text-decoration: none; + color: #ffffff; +} + +.btn-secondary { + background-color: #64748b; + color: #ffffff; + border-color: #64748b; +} + +.btn-secondary:hover { + background-color: #475569; + border-color: #475569; + text-decoration: none; + color: #ffffff; +} + +.btn-success { + background-color: #10b981; + color: #ffffff; + border-color: #10b981; +} + +.btn-success:hover { + background-color: #047857; + border-color: #047857; + text-decoration: none; + color: #ffffff; +} + +.btn-warning { + background-color: #f59e0b; + color: #ffffff; + border-color: #f59e0b; +} + +.btn-warning:hover { + background-color: #d97706; + border-color: #d97706; + text-decoration: none; + color: #ffffff; +} + +.btn-danger { + background-color: #ef4444; + color: #ffffff; + border-color: #ef4444; +} + +.btn-danger:hover { + background-color: #dc2626; + border-color: #dc2626; + text-decoration: none; + color: #ffffff; +} + +.btn:disabled, +.btn[disabled] { + opacity: 0.5; + cursor: not-allowed; +} + +.btn:disabled:hover, +.btn[disabled]:hover { + transform: none; +} + +/* No Users Message */ +.no-users-message { + display: flex; + justify-content: center; + align-items: center; + padding: 3rem; + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); +} + +.no-users-content { + text-align: center; + max-width: 400px; +} + +.no-users-content i { + font-size: 3rem; + color: #94a3b8; + margin-bottom: 1rem; +} + +.no-users-content h3 { + font-size: 1.25rem; + color: #334155; + margin-bottom: 0.75rem; +} + +.no-users-content p { + color: #64748b; + margin-bottom: 1.5rem; +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .users-page { + padding: 1rem; + } + + .user-stats { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; + } + + .search-box input { + width: 250px; + } +} + +@media (max-width: 768px) { + .users-header { + flex-direction: column; + gap: 1rem; + text-align: center; + } + + .user-stats { + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + } + + .users-controls { + flex-direction: column; + align-items: stretch; + gap: 1rem; + } + + .search-filters { + justify-content: center; + flex-wrap: wrap; + } + + .search-box input { + width: 100%; + max-width: 300px; + } + + .filter-group { + justify-content: center; + } + + /* Hide less important columns on mobile */ + .users-table th:nth-child(6), + .users-table td:nth-child(6), + .users-table th:nth-child(7), + .users-table td:nth-child(7), + .users-table th:nth-child(8), + .users-table td:nth-child(8) { + display: none; + } +} + +@media (max-width: 480px) { + .user-stats { + grid-template-columns: 1fr; + } + + .users-table { + font-size: 0.75rem; + } + + .users-table th, + .users-table td { + padding: 0.5rem; + } + + .user-avatar { + width: 32px; + height: 32px; + font-size: 0.75rem; + } + + .user-details h4 { + font-size: 0.75rem; + } + + .user-details p { + font-size: 0.625rem; + } + + /* Hide more columns on very small screens */ + .users-table th:nth-child(5), + .users-table td:nth-child(5) { + display: none; + } + + .action-buttons { + flex-direction: column; + gap: 0.25rem; + } +} diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..66280b0b118562525fa995d0f0ef6f1d0894736f GIT binary patch literal 15406 zcmeHO36LCB8SWJY4=@QK*Y3>pF*BQFL9T!i39#AO>0^#9t|&r*Q4k1)h!{`-sgWZ> z1riA%$?QzO?%5;|mm7+^G$Kbdq{I`1LQ+HsSLDilZT$Y(?b)}zJv}{}O%;VqRo6TJ z<GuI4|NZ~}-Uvd2u%9q;q<~ZsF8sV8d|eO(N$M?68YKt}bw?iA*WN4$&y5j;N$3L* zK=jrlsq4p17(a%km3BKVoo{EPOKxu+b6_bGY6^nH&d67nI+WdA9nn4Xl``^*Qd;_H zS605i7QCI3e(b=NVYA9>b~-YpE30f?+7az3r6cXta99~J)zzWws=#NJt(G1>wv<tx ztkh+czvWs5%-FymOGgyYc)!w*_)dxEmGsczUD;?4UynJN#X~=0#11HBl{KzD^PE0b zM!tn_1HShbr6X9m-d|T*Uck58X}OpagwHwc#4qOXu>pOu%6w`sYN3O%KDXLgX+F)n zm6CK+H#o3Pngc#tjZ6Wa)2+001~bGqr;So-=gaQ=0*n#(3mE6q_@2R<l<8AHrb<&S zEw;wgW6u<frjQ%9dq&ItSc8W-{&y_R$VWK+0Jp459rqPWmn2h*Rq#^#9*m`wR_@?n z_jF~HISwq+2GFj+Fg1QF*Cx=M@UY<hm3ha7zQp%odOS~MqQ&&+5~qzB(n$m4sUy1E z%1EtFyOS~_e!ZnfFE{o0f}*CJN%h^Jfib_z)5bdSd^_L*tuNDJ=UKXPs#94?%fIM{ z#a>EF^Kv!T$bqG_sD=+KKz5gBLh;4wxUqcOaOO$RNcdXuk$(EL{H#`KWHH&4U}<?a z?MKMbO6;9~*%{>xtj&w1_Qptg@9B|ChI`Cq9hjB3^sj%f9(BslrT37mVjr83&n+ww zQHF~LD=l6NIs^2bLzhzQpLO}x#zRc4X}Y81F)w!w2YuTRf8y4)JZ-e?wDeFb?cXk} zMSq*J8@i))Z+Kuv<aF0wFP~lgG@^TKBXSMVu+s7kd_Cn$8EH-T3>?%$_%Tg98uPlz zjgv1!CLSmJR(t4cn1}6ro0F&Ws$TMo)#%t#R$lAi@Jg}1uagcT`p7@$)u&vSk=N(j zM~w)|1L-~NrT()S^XSG69lF(OZ~6w|#5`T>Zg-b4Pd5dnkG^x<@B_+OrH5(Z?>YEP zJ=_5sq2Ipn;1}&%mujJy15+(!Y9v!z-okU;@HpRd19<e{0XphFHymFk-3311^TPu= z-CpuMLlE|Z{qukyEO7VOdL+Tqbn^Kr!WUrg{J{?%dhIdj-!D2a?v!*h>0&<|X#dMf z4?7P!=6XLk$i#hgcDrf1%PcXz1vFms!-YA#kcTt%$TZB&PMRO*gM9D6+42_7m?w*R z<N~ib=W+1)9OlSMhEkRxUJJfU!0`ZZJ&5_c2Wi35B0u8$lMSD58yhvJH%?rV91~yM z7HXopTpe}=kMEO^r?be`!`E3x<VKA9M%w?*S5(Je<j3QJm~g<}j6IKiWT%yXdddLp z0ZmMM(Jv*MMw_a1C+yD6rWRjc>|LipHoF(BQ&()8AiC{Ko)7SKr_+~DY5!Sz>{&~n z__3W*mRd&iw@jbZ!|JeO%w)I`vi}NCZ$KWprH~jqj;HJ7nJ#^ofp#GgI=sJ5zMARH zA1#_TqT&-dZSJ(Z_tWwV1L(PQAZ&q|ZaAlG>G8)+Eqa4frj*Vh(0|o7<a79XcRtr5 z?2kS6gdc5cC)>;o+gEO|jEV1>iKgS+?M#a=Vp{aq?s~p#r^Va+XhWxLvfIK@9){18 z?zW8h7SaKHJ>{)bbB?Jo(_QbBX>NRNGpvD?5-)f9S4kTnckkHA#^c@X*b7UT9;^7w ze0!l?oDA9<{l*U8Y7NOY59gJ)^yWvIp$xDQdiO;0TAU4Mxna6nTCnEQ0N>utM=2ZK zRx-q++^}9{%+Z;U|4n9c!ZCgA4NQ}-XIk?IUba(LTD;0l&s`?F*^8D}9m%?7#C{JR zHd~r<x1~j&E$@GIU`&HxI}=UKkg2ME2=M`VU*;q4UMvV!>&TJtbuFyL`&jq2&<PiK zb%c(2$URoR3_s|8ic5IG2C1XD$W=M}nA+4<KBHZ_1$E>DZNnKeCy2ZS;lryBYoDMo zH9QM8=S<Acca|E`$+i(zEiHTk>0d9H&+0z8GeB_?glL7C_&!Z>5)5ab|8m%)`F3Rl z;w`bF5jmcvBAKElpAFq}uC2@GTG$)QjM!HlMvATD4nw}$8SHf1rz!DUIH@tow9qwH zN`8?15$KJzc2;>CI)viPIM1M4Nx!08XlrUChDz<?QP3@`Xsr8`!79k-A`Xi<$2mCj z-XdMc>szmUiDIHD@gDM9C>{o%=PC5tfjDU{KV?eI^SBBdL#ifUhUj9RKlb9)s}8Z{ z=dGj?Ctndh#18?xnfk%De#O=zhYpF6App@CY8%ooG3UN<Abt#pzkLW>_l#T)v3(;P zrMP6J9d{O`v{==i8v=_}LC9AJ?^i&cD|bE>KHTSLUzuWurY_!oK5RCU6Zo)*WxC=& zi1)!KPE;j43=Kq^;%2l52hn%)<CHPCc1nC?dG8%ggQ<#V0p2?%myw^Y<_ir2`-t<) zC&Jf%GcfNM1N7#LbU#4!O<kS{-PA8P&KUbr+7HYK&&pM?llEc^4TxRD!Tam=@Q!)@ zfb<jbQ<l55d~>Zh8pii0rZ$H6g4aQydH7kF$CY*Q4w+|qcy4}5<Ch5`#jL^G`kFYf z4`Ab8HHe8JR&=+v90<L)gyQKnX>;-b-tWEX_(KjNvNQ%eC9e(&k8yrbXrC~7h%8sY zNY^1&aX#d7TTt5Qhy5!3xuA+NAu9X(s(4);@a1z5KIVP-Z137t7^*J=|38vNynRDG zv`I$bk7Q`<L~dc?pwSpl;JL5Rf{ugUI;~!R%^4!@e2xGwFIDhyvQfQG*~OBf^LSs0 z#zApQitSg2bDagfw0Z#ZFvZUto`R<3ySlV=XRR2<0DkEGfEK1$k*6(b>v*eB!#^H? z4`YD6w1dv<pnOxm9O=5WcxBmM3^eC`bW&oGe`nyPNj56*c7n$pZa)3Wkb##=h#kA} zRV!O+_+;?z8^3YWgPi`3;?vb|I`wpy0lVjUH@sh&-jM+R!W`z+!BZt|p!+uH$^rNI zez@r@0`67yocR<Bg<QYkkJq&yvdUW&3jp7qvPt7vG%Yd;_mazNosa!=iFfHy>@U0` zs_$neL#L3Q>c{2c*Cn@P|G~$w%#MlW^TdmqrHhwge+AMJI1l#Ny7(yFg%3r)QJfEN zg=QkA^ag0w^m}NWShEki@J7UocT-oURn>3wo(N}e+1J2ZAlMNvTUyh#WY2ivs;6!N zz1vDOU4XlvyRnvkg?;sJ&{>Bw=gsohL20@C8tL+4GUOF=Zm?AG`*tc~VU6zS{X|MF z;9LxT=dzUabIkX-mKsSe#9hRUA>Msg8(R*0bHUVuze;y3MRnX^tW7+c?#b!voId{0 z#rk-O?tZGp|LDyTy(uzbub3V9ke1&VVUFo6BK*~8|G(GM`CL%P$rPtI^~Py*Z%H5N zo646)91?AX6G!j0O*h>0jrgbv9lQqey$dqGyXuz_H=6Aeeh`#?4l+Y`RCI3`6sF#O zoF1+E2FKB{*pK&;T~mt&$$e*g=rmIkpR?1kjg}#=hyORP?;fb?`B4im-%d6)-sYSN zo%gQ8pKppd*wU1o<Z*cA=?ugD$jz3~yoDK)_b_A99^6+Tod{n_S<TdOQ@!Bots@%+ z^BL%zC;I_$u3zPFX86&em>BG<i<mxn6Yl-CGEG`x;*JUF6}W?W74<#1e_ge>ZFEgL z$Bzzc4{MO|cYNoYJAZK|tR~&+hm+dK#>4&0YSU=m1{$*|R^!0rVHeq{*b7XXwAIui zmpZ<Q(;g(vVLuh1Q?O6h1?3&%++acX1i^16#Z#Ekyxr8~PLfYI480}8+s(~*d-=%1 zA@~%Xh!0{eSJmND?7$uF&D}F<zb_$PAtNU41vhjiCAvBo^rJbqb>%MfdE3e;@j)GY zD(2%34m$D~%zN;d9p28;ouCg|z6ri=>>s8Yf6DLuDaG-z2D@-4Kb;5m%a{EYLs}73 zX8QVVw-KlF!^^jo^_da>Z&8h{@Y^da9sMTmF?U#MFrSWWJj6P$gpFB_GY1+2y(LMB z%u9xOp7}iORm}fKcu(C$adEzl&zq@<$3VAkD<<W$`TA=4qOK$Y>-avMgZ_W-Oa+#k zGHJ3W?NsxB@Gjz^zBsEVOI9kXLw<I5Cgl##4qRo~SFG`7Yw&s~TX463aXs&{9FkTl z_5@_(W%&D&Q|C?*LzsiNCmV~n_i?xJ<?=fxocDwI^Y^pxR`oIZF9(k+Xr39-**Ir+ zLVtR`i^)$JH3BlW4r}y+Up%0E4jR&>^~5h|oTW^3!`{RU@us&0bNdd?*w-mu%ln07 z6S0(hD{KkeLqr$&`CfQ~f%hkh?|Z{R>w@<xbT1HeuD$xvzQr2kEq&51{J#J(g~o4p zCJt$4(?e5X%iTh6xFBEa5nuE4K?x`3et-CEcYyzz_9Qok?YPgG@25As`1PtQpOLB9 zOH7-LH)hQr+D3G(t;g5H_Sp`bsE4WIY`=R#dj9~v>WWWcypI<Wvgi9VFCJ^vk^hN1 z+&^NzKQh&po#1^d-chZ?{nh2XOu6w`@KIsg1eY6%^W$yoP~w|z+U_zl#*cxW_ATh5 zpW(kh7h8$&u`4}nA_tCaTbwyp)7vIaE~>^w5L4I&-j@|qvgqL07ikVQqu-o6G?%C7 zeWX<NUaPzxdf(wf&%maHe(Lk3hQ%Lp_!w$hm>x>up5p<gMSf#y;S%zB+yUK&_|%Q~ u@6|Py-gFu61ZG*fJYzUzeW?7p;kxnGkiH;m<%a#N{MihAHUmSOf&T%I0h`SL literal 0 HcmV?d00001 diff --git a/static/images/favicon.ico b/static/images/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..66280b0b118562525fa995d0f0ef6f1d0894736f GIT binary patch literal 15406 zcmeHO36LCB8SWJY4=@QK*Y3>pF*BQFL9T!i39#AO>0^#9t|&r*Q4k1)h!{`-sgWZ> z1riA%$?QzO?%5;|mm7+^G$Kbdq{I`1LQ+HsSLDilZT$Y(?b)}zJv}{}O%;VqRo6TJ z<GuI4|NZ~}-Uvd2u%9q;q<~ZsF8sV8d|eO(N$M?68YKt}bw?iA*WN4$&y5j;N$3L* zK=jrlsq4p17(a%km3BKVoo{EPOKxu+b6_bGY6^nH&d67nI+WdA9nn4Xl``^*Qd;_H zS605i7QCI3e(b=NVYA9>b~-YpE30f?+7az3r6cXta99~J)zzWws=#NJt(G1>wv<tx ztkh+czvWs5%-FymOGgyYc)!w*_)dxEmGsczUD;?4UynJN#X~=0#11HBl{KzD^PE0b zM!tn_1HShbr6X9m-d|T*Uck58X}OpagwHwc#4qOXu>pOu%6w`sYN3O%KDXLgX+F)n zm6CK+H#o3Pngc#tjZ6Wa)2+001~bGqr;So-=gaQ=0*n#(3mE6q_@2R<l<8AHrb<&S zEw;wgW6u<frjQ%9dq&ItSc8W-{&y_R$VWK+0Jp459rqPWmn2h*Rq#^#9*m`wR_@?n z_jF~HISwq+2GFj+Fg1QF*Cx=M@UY<hm3ha7zQp%odOS~MqQ&&+5~qzB(n$m4sUy1E z%1EtFyOS~_e!ZnfFE{o0f}*CJN%h^Jfib_z)5bdSd^_L*tuNDJ=UKXPs#94?%fIM{ z#a>EF^Kv!T$bqG_sD=+KKz5gBLh;4wxUqcOaOO$RNcdXuk$(EL{H#`KWHH&4U}<?a z?MKMbO6;9~*%{>xtj&w1_Qptg@9B|ChI`Cq9hjB3^sj%f9(BslrT37mVjr83&n+ww zQHF~LD=l6NIs^2bLzhzQpLO}x#zRc4X}Y81F)w!w2YuTRf8y4)JZ-e?wDeFb?cXk} zMSq*J8@i))Z+Kuv<aF0wFP~lgG@^TKBXSMVu+s7kd_Cn$8EH-T3>?%$_%Tg98uPlz zjgv1!CLSmJR(t4cn1}6ro0F&Ws$TMo)#%t#R$lAi@Jg}1uagcT`p7@$)u&vSk=N(j zM~w)|1L-~NrT()S^XSG69lF(OZ~6w|#5`T>Zg-b4Pd5dnkG^x<@B_+OrH5(Z?>YEP zJ=_5sq2Ipn;1}&%mujJy15+(!Y9v!z-okU;@HpRd19<e{0XphFHymFk-3311^TPu= z-CpuMLlE|Z{qukyEO7VOdL+Tqbn^Kr!WUrg{J{?%dhIdj-!D2a?v!*h>0&<|X#dMf z4?7P!=6XLk$i#hgcDrf1%PcXz1vFms!-YA#kcTt%$TZB&PMRO*gM9D6+42_7m?w*R z<N~ib=W+1)9OlSMhEkRxUJJfU!0`ZZJ&5_c2Wi35B0u8$lMSD58yhvJH%?rV91~yM z7HXopTpe}=kMEO^r?be`!`E3x<VKA9M%w?*S5(Je<j3QJm~g<}j6IKiWT%yXdddLp z0ZmMM(Jv*MMw_a1C+yD6rWRjc>|LipHoF(BQ&()8AiC{Ko)7SKr_+~DY5!Sz>{&~n z__3W*mRd&iw@jbZ!|JeO%w)I`vi}NCZ$KWprH~jqj;HJ7nJ#^ofp#GgI=sJ5zMARH zA1#_TqT&-dZSJ(Z_tWwV1L(PQAZ&q|ZaAlG>G8)+Eqa4frj*Vh(0|o7<a79XcRtr5 z?2kS6gdc5cC)>;o+gEO|jEV1>iKgS+?M#a=Vp{aq?s~p#r^Va+XhWxLvfIK@9){18 z?zW8h7SaKHJ>{)bbB?Jo(_QbBX>NRNGpvD?5-)f9S4kTnckkHA#^c@X*b7UT9;^7w ze0!l?oDA9<{l*U8Y7NOY59gJ)^yWvIp$xDQdiO;0TAU4Mxna6nTCnEQ0N>utM=2ZK zRx-q++^}9{%+Z;U|4n9c!ZCgA4NQ}-XIk?IUba(LTD;0l&s`?F*^8D}9m%?7#C{JR zHd~r<x1~j&E$@GIU`&HxI}=UKkg2ME2=M`VU*;q4UMvV!>&TJtbuFyL`&jq2&<PiK zb%c(2$URoR3_s|8ic5IG2C1XD$W=M}nA+4<KBHZ_1$E>DZNnKeCy2ZS;lryBYoDMo zH9QM8=S<Acca|E`$+i(zEiHTk>0d9H&+0z8GeB_?glL7C_&!Z>5)5ab|8m%)`F3Rl z;w`bF5jmcvBAKElpAFq}uC2@GTG$)QjM!HlMvATD4nw}$8SHf1rz!DUIH@tow9qwH zN`8?15$KJzc2;>CI)viPIM1M4Nx!08XlrUChDz<?QP3@`Xsr8`!79k-A`Xi<$2mCj z-XdMc>szmUiDIHD@gDM9C>{o%=PC5tfjDU{KV?eI^SBBdL#ifUhUj9RKlb9)s}8Z{ z=dGj?Ctndh#18?xnfk%De#O=zhYpF6App@CY8%ooG3UN<Abt#pzkLW>_l#T)v3(;P zrMP6J9d{O`v{==i8v=_}LC9AJ?^i&cD|bE>KHTSLUzuWurY_!oK5RCU6Zo)*WxC=& zi1)!KPE;j43=Kq^;%2l52hn%)<CHPCc1nC?dG8%ggQ<#V0p2?%myw^Y<_ir2`-t<) zC&Jf%GcfNM1N7#LbU#4!O<kS{-PA8P&KUbr+7HYK&&pM?llEc^4TxRD!Tam=@Q!)@ zfb<jbQ<l55d~>Zh8pii0rZ$H6g4aQydH7kF$CY*Q4w+|qcy4}5<Ch5`#jL^G`kFYf z4`Ab8HHe8JR&=+v90<L)gyQKnX>;-b-tWEX_(KjNvNQ%eC9e(&k8yrbXrC~7h%8sY zNY^1&aX#d7TTt5Qhy5!3xuA+NAu9X(s(4);@a1z5KIVP-Z137t7^*J=|38vNynRDG zv`I$bk7Q`<L~dc?pwSpl;JL5Rf{ugUI;~!R%^4!@e2xGwFIDhyvQfQG*~OBf^LSs0 z#zApQitSg2bDagfw0Z#ZFvZUto`R<3ySlV=XRR2<0DkEGfEK1$k*6(b>v*eB!#^H? z4`YD6w1dv<pnOxm9O=5WcxBmM3^eC`bW&oGe`nyPNj56*c7n$pZa)3Wkb##=h#kA} zRV!O+_+;?z8^3YWgPi`3;?vb|I`wpy0lVjUH@sh&-jM+R!W`z+!BZt|p!+uH$^rNI zez@r@0`67yocR<Bg<QYkkJq&yvdUW&3jp7qvPt7vG%Yd;_mazNosa!=iFfHy>@U0` zs_$neL#L3Q>c{2c*Cn@P|G~$w%#MlW^TdmqrHhwge+AMJI1l#Ny7(yFg%3r)QJfEN zg=QkA^ag0w^m}NWShEki@J7UocT-oURn>3wo(N}e+1J2ZAlMNvTUyh#WY2ivs;6!N zz1vDOU4XlvyRnvkg?;sJ&{>Bw=gsohL20@C8tL+4GUOF=Zm?AG`*tc~VU6zS{X|MF z;9LxT=dzUabIkX-mKsSe#9hRUA>Msg8(R*0bHUVuze;y3MRnX^tW7+c?#b!voId{0 z#rk-O?tZGp|LDyTy(uzbub3V9ke1&VVUFo6BK*~8|G(GM`CL%P$rPtI^~Py*Z%H5N zo646)91?AX6G!j0O*h>0jrgbv9lQqey$dqGyXuz_H=6Aeeh`#?4l+Y`RCI3`6sF#O zoF1+E2FKB{*pK&;T~mt&$$e*g=rmIkpR?1kjg}#=hyORP?;fb?`B4im-%d6)-sYSN zo%gQ8pKppd*wU1o<Z*cA=?ugD$jz3~yoDK)_b_A99^6+Tod{n_S<TdOQ@!Bots@%+ z^BL%zC;I_$u3zPFX86&em>BG<i<mxn6Yl-CGEG`x;*JUF6}W?W74<#1e_ge>ZFEgL z$Bzzc4{MO|cYNoYJAZK|tR~&+hm+dK#>4&0YSU=m1{$*|R^!0rVHeq{*b7XXwAIui zmpZ<Q(;g(vVLuh1Q?O6h1?3&%++acX1i^16#Z#Ekyxr8~PLfYI480}8+s(~*d-=%1 zA@~%Xh!0{eSJmND?7$uF&D}F<zb_$PAtNU41vhjiCAvBo^rJbqb>%MfdE3e;@j)GY zD(2%34m$D~%zN;d9p28;ouCg|z6ri=>>s8Yf6DLuDaG-z2D@-4Kb;5m%a{EYLs}73 zX8QVVw-KlF!^^jo^_da>Z&8h{@Y^da9sMTmF?U#MFrSWWJj6P$gpFB_GY1+2y(LMB z%u9xOp7}iORm}fKcu(C$adEzl&zq@<$3VAkD<<W$`TA=4qOK$Y>-avMgZ_W-Oa+#k zGHJ3W?NsxB@Gjz^zBsEVOI9kXLw<I5Cgl##4qRo~SFG`7Yw&s~TX463aXs&{9FkTl z_5@_(W%&D&Q|C?*LzsiNCmV~n_i?xJ<?=fxocDwI^Y^pxR`oIZF9(k+Xr39-**Ir+ zLVtR`i^)$JH3BlW4r}y+Up%0E4jR&>^~5h|oTW^3!`{RU@us&0bNdd?*w-mu%ln07 z6S0(hD{KkeLqr$&`CfQ~f%hkh?|Z{R>w@<xbT1HeuD$xvzQr2kEq&51{J#J(g~o4p zCJt$4(?e5X%iTh6xFBEa5nuE4K?x`3et-CEcYyzz_9Qok?YPgG@25As`1PtQpOLB9 zOH7-LH)hQr+D3G(t;g5H_Sp`bsE4WIY`=R#dj9~v>WWWcypI<Wvgi9VFCJ^vk^hN1 z+&^NzKQh&po#1^d-chZ?{nh2XOu6w`@KIsg1eY6%^W$yoP~w|z+U_zl#*cxW_ATh5 zpW(kh7h8$&u`4}nA_tCaTbwyp)7vIaE~>^w5L4I&-j@|qvgqL07ikVQqu-o6G?%C7 zeWX<NUaPzxdf(wf&%maHe(Lk3hQ%Lp_!w$hm>x>up5p<gMSf#y;S%zB+yUK&_|%Q~ u@6|Py-gFu61ZG*fJYzUzeW?7p;kxnGkiH;m<%a#N{MihAHUmSOf&T%I0h`SL literal 0 HcmV?d00001 diff --git a/static/js/android_location_handler.js b/static/js/android_location_handler.js new file mode 100644 index 0000000..521ed30 --- /dev/null +++ b/static/js/android_location_handler.js @@ -0,0 +1,445 @@ +/** + * Android Location Fix - GPS + IP Geolocation Only + * File: static/js/android_location_handler.js + * + * This version includes only precise location methods: + * 1. Progressive GPS fallback (4 attempts) + * 2. IP-based geolocation (3 services) - 5-50km accuracy + * No timezone analysis (removed 100km accuracy method) + */ + +// Android-specific geolocation configuration +const ANDROID_LOCATION_CONFIG = { + // Primary attempt - High accuracy with reasonable timeout + highAccuracy: { + enableHighAccuracy: true, + timeout: 15000, + maximumAge: 60000 + }, + + // Fallback attempt - Network-based location + networkBased: { + enableHighAccuracy: false, + timeout: 20000, + maximumAge: 300000 + }, + + // Final attempt - Any available location + anyLocation: { + enableHighAccuracy: false, + timeout: 30000, + maximumAge: 600000 + } +}; + +// Global variables for location state +let locationAttemptInProgress = false; +let currentLocationMethod = ''; + +// Enhanced location request with GPS + IP fallback only +function requestAndroidEnhancedLocation() { + console.log("📱 Starting Android location request (GPS + IP methods only)..."); + + if (locationAttemptInProgress) { + console.log("📍 Location attempt already in progress, skipping"); + return; + } + + if (typeof locationRequestActive !== 'undefined' && locationRequestActive) { + console.log("📍 Location request already active, skipping"); + return; + } + + if (!navigator.geolocation) { + console.log("❌ Geolocation not supported, trying IP-based location"); + attemptIPBasedLocation(); + return; + } + + locationAttemptInProgress = true; + + // Set active flags + if (typeof locationRequestActive !== 'undefined') { + locationRequestActive = true; + } + if (typeof locationCaptureActive !== 'undefined') { + locationCaptureActive = true; + } + + console.log("📱 Attempting GPS-based location sequence..."); + attemptAndroidLocationSequence(); +} + +// GPS-based sequence (Steps 1-4) +function attemptAndroidLocationSequence() { + console.log("🔄 Step 1/5: High Accuracy GPS"); + currentLocationMethod = 'gps_high_accuracy'; + + navigator.geolocation.getCurrentPosition( + (position) => { + console.log("✅ GPS high-accuracy success!"); + handleAndroidLocationSuccess(position, "gps_high_accuracy"); + }, + (error) => { + console.log(`❌ High accuracy failed (${error.message}), trying network-based...`); + attemptNetworkBasedLocation(); + }, + ANDROID_LOCATION_CONFIG.highAccuracy + ); +} + +function attemptNetworkBasedLocation() { + console.log("🔄 Step 2/5: Network-based GPS"); + currentLocationMethod = 'network'; + + navigator.geolocation.getCurrentPosition( + (position) => { + console.log("✅ Network-based GPS success!"); + handleAndroidLocationSuccess(position, "network"); + }, + (error) => { + console.log(`❌ Network-based failed (${error.message}), trying any location...`); + attemptAnyAvailableLocation(); + }, + ANDROID_LOCATION_CONFIG.networkBased + ); +} + +function attemptAnyAvailableLocation() { + console.log("🔄 Step 3/5: Any available GPS"); + currentLocationMethod = 'any'; + + navigator.geolocation.getCurrentPosition( + (position) => { + console.log("✅ Any-location GPS success!"); + handleAndroidLocationSuccess(position, "any"); + }, + (error) => { + console.log(`❌ Any location failed (${error.message}), trying watchPosition...`); + attemptWatchPosition(); + }, + ANDROID_LOCATION_CONFIG.anyLocation + ); +} + +function attemptWatchPosition() { + console.log("🔄 Step 4/5: Watch Position (persistent)"); + currentLocationMethod = 'watch'; + + let watchId = null; + let watchTimeout = null; + + watchTimeout = setTimeout(() => { + if (watchId !== null) { + navigator.geolocation.clearWatch(watchId); + } + console.log("❌ Watch position timed out, trying IP-based location..."); + attemptIPBasedLocation(); + }, 25000); + + watchId = navigator.geolocation.watchPosition( + (position) => { + console.log("✅ Watch position success!"); + navigator.geolocation.clearWatch(watchId); + clearTimeout(watchTimeout); + handleAndroidLocationSuccess(position, "watch"); + }, + (error) => { + console.log(`❌ Watch position error: ${error.message}`); + }, + { + enableHighAccuracy: false, + timeout: 20000, + maximumAge: 0 + } + ); +} + +// IP-based geolocation (Step 5) - Final fallback +function attemptIPBasedLocation() { + console.log("🔄 Step 5/5: IP-based Geolocation (Final Fallback)"); + currentLocationMethod = 'ip_geolocation'; + + // Try multiple IP geolocation services for better accuracy + const ipLocationServices = [ + { + url: 'https://ipinfo.io/json', + parseResponse: (data) => { + if (data.loc) { + const [lat, lng] = data.loc.split(','); + return { + lat: parseFloat(lat), + lng: parseFloat(lng), + city: data.city, + region: data.region, + country: data.country, + accuracy: data.city ? 15000 : 50000 // Better accuracy if city is available + }; + } + return null; + } + }, + { + url: 'https://ipapi.co/json/', + parseResponse: (data) => ({ + lat: data.latitude, + lng: data.longitude, + city: data.city, + region: data.region, + country: data.country_name, + accuracy: data.city ? 10000 : 50000 // Better accuracy if city is available + }) + }, + ]; + + let serviceIndex = 0; + + function tryNextIPService() { + if (serviceIndex >= ipLocationServices.length) { + console.log("❌ All IP geolocation services failed - location detection complete"); + handleAndroidLocationError("All GPS and IP geolocation methods failed"); + return; + } + + const service = ipLocationServices[serviceIndex]; + console.log(`🌐 Trying IP geolocation service ${serviceIndex + 1}: ${service.url}`); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + fetch(service.url, { + method: 'GET', + signal: controller.signal, + headers: { + 'Accept': 'application/json' + } + }) + .then(response => response.json()) + .then(data => { + clearTimeout(timeoutId); + console.log(`🌐 IP service ${serviceIndex + 1} response:`, data); + + const parsed = service.parseResponse(data); + + if (parsed && parsed.lat && parsed.lng && !isNaN(parsed.lat) && !isNaN(parsed.lng)) { + // Validate coordinates are reasonable + if (parsed.lat >= -90 && parsed.lat <= 90 && parsed.lng >= -180 && parsed.lng <= 180) { + console.log(`✅ IP-based location success: ${parsed.lat}, ${parsed.lng}`); + console.log(`📊 Location: ${parsed.city}, ${parsed.region}, ${parsed.country}`); + console.log(`📊 Estimated accuracy: ${parsed.accuracy}m (~${Math.round(parsed.accuracy/1000)}km)`); + + const ipLocationData = { + coords: { + latitude: parsed.lat, + longitude: parsed.lng, + accuracy: parsed.accuracy, + altitude: null + }, + locationInfo: { + city: parsed.city, + region: parsed.region, + country: parsed.country, + source: `IP Service ${serviceIndex + 1}`, + serviceUrl: service.url + } + }; + + handleAndroidLocationSuccess(ipLocationData, "ip_geolocation"); + return; + } + } + + console.log(`❌ Invalid or missing coordinates from service ${serviceIndex + 1}, trying next...`); + serviceIndex++; + tryNextIPService(); + }) + .catch(error => { + clearTimeout(timeoutId); + if (error.name === 'AbortError') { + console.log(`❌ IP service ${serviceIndex + 1} timed out (10s), trying next...`); + } else { + console.log(`❌ IP service ${serviceIndex + 1} failed: ${error.message}, trying next...`); + } + serviceIndex++; + tryNextIPService(); + }); + } + + tryNextIPService(); +} + +// Enhanced success handler for GPS and IP location methods +function handleAndroidLocationSuccess(position, source) { + console.log(`✅ Android location obtained successfully via ${source}`); + + let locationData; + + if (source === "ip_geolocation") { + // Handle IP-based location + locationData = { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + accuracy: position.coords.accuracy, + altitude: position.coords.altitude, + timestamp: new Date(), + source: source, + address: null, + locationInfo: position.locationInfo || null + }; + + console.log(`📍 IP-estimated coordinates: ${position.coords.latitude}, ${position.coords.longitude}`); + console.log(`📊 IP-estimated accuracy: ${position.coords.accuracy}m (~${Math.round(position.coords.accuracy/1000)}km)`); + if (position.locationInfo) { + console.log(`🏢 Location info: ${position.locationInfo.city}, ${position.locationInfo.region}`); + } + } else { + // Handle GPS-based location + locationData = { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + accuracy: position.coords.accuracy, + altitude: position.coords.altitude, + timestamp: new Date(), + source: source, + address: null, + }; + + console.log(`📍 GPS coordinates: ${position.coords.latitude}, ${position.coords.longitude}`); + console.log(`📊 GPS accuracy: ${position.coords.accuracy}m`); + } + + // Update global location variables + if (typeof userLocation !== 'undefined') { + Object.assign(userLocation, locationData); + console.log("📍 Updated userLocation with location data"); + + // Trigger reverse geocoding if we have coordinates but no address + if (typeof reverseGeocode === 'function' && locationData.latitude && locationData.longitude && !locationData.address) { + reverseGeocode(locationData.latitude, locationData.longitude); + } + } + + if (typeof currentUserLocation !== 'undefined') { + Object.assign(currentUserLocation, locationData); + console.log("📍 Updated currentUserLocation with location data"); + + // Trigger enhanced reverse geocoding if available + if (typeof reverseGeocodeEnhanced === 'function' && locationData.latitude && locationData.longitude && !locationData.address) { + reverseGeocodeEnhanced(locationData.latitude, locationData.longitude); + } + } + + // Clear active flags + if (typeof locationRequestActive !== 'undefined') { + locationRequestActive = false; + } + if (typeof locationCaptureActive !== 'undefined') { + locationCaptureActive = false; + } + locationAttemptInProgress = false; + + // Console logging + console.log(`📊 LOCATION SUCCESS LOG:`, { + method: source, + success: true, + coordinates: `${locationData.latitude},${locationData.longitude}`, + accuracy: `${locationData.accuracy}m`, + accuracyKm: `~${Math.round(locationData.accuracy/1000)}km`, + locationInfo: locationData.locationInfo, + timestamp: new Date().toISOString() + }); +} + +function handleAndroidLocationError(errorMessage) { + console.log(`❌ All Android location methods failed: ${errorMessage}`); + + // Update global location variables to indicate manual source + if (typeof userLocation !== 'undefined') { + userLocation.source = "manual"; + } + if (typeof currentUserLocation !== 'undefined') { + currentUserLocation.source = "manual"; + } + + // Clear active flags + if (typeof locationRequestActive !== 'undefined') { + locationRequestActive = false; + } + if (typeof locationCaptureActive !== 'undefined') { + locationCaptureActive = false; + } + locationAttemptInProgress = false; + + console.log(`📊 LOCATION ERROR LOG:`, { + error: errorMessage, + method: currentLocationMethod, + finalResult: 'manual_entry_required', + gpsAttempts: 4, + ipAttempts: 3, + userAgent: navigator.userAgent, + timestamp: new Date().toISOString() + }); +} + +// Device detection functions +function isAndroidDevice() { + const userAgent = navigator.userAgent.toLowerCase(); + return userAgent.includes('android'); +} + +function isAndroidChrome() { + const userAgent = navigator.userAgent.toLowerCase(); + return userAgent.includes('android') && userAgent.includes('chrome') && !userAgent.includes('edg'); +} + +// Main initialization function +function initializeAndroidLocation() { + console.log("📱 Initializing Android location services (GPS + IP only)..."); + + if (isAndroidDevice()) { + console.log("📱 Android device detected, using GPS + IP location methods (5 steps)"); + requestAndroidEnhancedLocation(); + } else { + console.log("📱 Non-Android device, using standard location request"); + + if (typeof requestLocationData === 'function') { + requestLocationData(); + } else if (typeof requestEnhancedLocation === 'function') { + requestEnhancedLocation(); + } + } +} + +// Override standard location initialization for Android devices +document.addEventListener('DOMContentLoaded', function() { + setTimeout(() => { + if (isAndroidDevice()) { + console.log("📱 Android detected - overriding with GPS + IP location handler"); + + if (typeof initializeLocation === 'function') { + window.initializeLocation = function() { + console.log("📱 Using GPS + IP Android location initialization"); + initializeAndroidLocation(); + }; + } + + if (typeof requestEnhancedLocation === 'function') { + window.requestEnhancedLocation = function() { + console.log("📱 Using GPS + IP Android location request"); + initializeAndroidLocation(); + }; + } + } + }, 100); +}); + +// Export functions +if (typeof window !== 'undefined') { + window.AndroidLocationHandler = { + requestAndroidEnhancedLocation, + isAndroidDevice, + isAndroidChrome, + initializeAndroidLocation, + attemptIPBasedLocation + }; +} \ No newline at end of file diff --git a/static/js/attendance_fullscreen.js b/static/js/attendance_fullscreen.js new file mode 100644 index 0000000..28edcd6 --- /dev/null +++ b/static/js/attendance_fullscreen.js @@ -0,0 +1,471 @@ +/** + * Attendance Fullscreen JavaScript + * Handles fullscreen toggle and optimization for iPad viewing + * static/js/attendance_fullscreen.js + */ + +// Fullscreen state management +let isFullscreen = false; + +// Touch event handlers +let touchStartY = 0; +let touchStartX = 0; + +/** + * Toggle fullscreen mode for attendance report + */ +function toggleFullscreen() { + const container = document.getElementById('attendanceReportContainer'); + const icon = document.getElementById('fullscreenIcon'); + const body = document.body; + + if (!container || !icon) { + console.error('Fullscreen elements not found'); + return; + } + + isFullscreen = !isFullscreen; + + if (isFullscreen) { + enterFullscreen(container, icon, body); + } else { + exitFullscreen(container, icon, body); + } + + // Log fullscreen action + logFullscreenAction(isFullscreen ? 'enter' : 'exit'); +} + +/** + * Prevent swipe gestures from interfering with fullscreen on touch devices + */ +function preventSwipeGestures(container, enable) { + if (enable) { + // Prevent pull-to-refresh and other touch gestures + container.addEventListener('touchstart', handleTouchStart, { passive: false }); + container.addEventListener('touchmove', handleTouchMove, { passive: false }); + container.addEventListener('touchend', handleTouchEnd, { passive: false }); + + // Prevent overscroll + document.body.style.overscrollBehavior = 'none'; + container.style.overscrollBehavior = 'none'; + + console.log('Swipe gestures prevented for fullscreen mode'); + } else { + // Re-enable normal touch behavior + container.removeEventListener('touchstart', handleTouchStart); + container.removeEventListener('touchmove', handleTouchMove); + container.removeEventListener('touchend', handleTouchEnd); + + // Restore overscroll + document.body.style.overscrollBehavior = ''; + container.style.overscrollBehavior = ''; + + console.log('Swipe gestures re-enabled'); + } +} + +function handleTouchStart(e) { + touchStartY = e.touches[0].clientY; + touchStartX = e.touches[0].clientX; +} + +function handleTouchMove(e) { + if (!isFullscreen) return; + + const touchY = e.touches[0].clientY; + const touchX = e.touches[0].clientX; + const deltaY = touchY - touchStartY; + const deltaX = touchX - touchStartX; + + const container = document.getElementById('attendanceReportContainer'); + const scrollTop = container.scrollTop; + const scrollHeight = container.scrollHeight; + const clientHeight = container.clientHeight; + const isAtTop = scrollTop === 0; + const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1; + + // Prevent pull-down-to-refresh when at top + if (isAtTop && deltaY > 0) { + e.preventDefault(); + return false; + } + + // Prevent overscroll at bottom + if (isAtBottom && deltaY < 0) { + e.preventDefault(); + return false; + } + + // Allow normal scrolling within the container +} + +function handleTouchEnd(e) { + touchStartY = 0; + touchStartX = 0; +} + +/** + * Enter fullscreen mode + */ +function enterFullscreen(container, icon, body) { + // Add fullscreen classes + container.classList.add('fullscreen-mode'); + body.classList.add('fullscreen-active'); + + // Change icon + icon.classList.remove('fa-expand'); + icon.classList.add('fa-compress'); + + // Update button title + const button = document.getElementById('fullscreenToggle'); + if (button) { + button.setAttribute('title', 'Exit Fullscreen'); + } + + // Detect if device is touch-enabled (iPad/tablet) + const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0; + + // Only use native fullscreen API on non-touch devices + // This prevents swipe-down gesture from exiting fullscreen on iPad + if (!isTouchDevice) { + // Try to use native fullscreen API for desktop browsers + if (container.requestFullscreen) { + container.requestFullscreen().catch(err => { + console.log('Native fullscreen not available, using CSS fullscreen'); + }); + } else if (container.webkitRequestFullscreen) { + container.webkitRequestFullscreen().catch(err => { + console.log('Native fullscreen not available, using CSS fullscreen'); + }); + } else if (container.mozRequestFullScreen) { + container.mozRequestFullScreen().catch(err => { + console.log('Native fullscreen not available, using CSS fullscreen'); + }); + } else if (container.msRequestFullscreen) { + container.msRequestFullscreen().catch(err => { + console.log('Native fullscreen not available, using CSS fullscreen'); + }); + } + } else { + console.log('Touch device detected - using CSS-only fullscreen to prevent swipe-down exit'); + } + + // Adjust table layout for better viewing + adjustTableForFullscreen(true); + + // Save fullscreen preference + saveFullscreenPreference(true); + + // Prevent default touch behaviors that might interfere + preventSwipeGestures(container, true); +} + +/** + * Exit fullscreen mode + */ +function exitFullscreen(container, icon, body) { + // Remove fullscreen classes + container.classList.remove('fullscreen-mode'); + body.classList.remove('fullscreen-active'); + + // Change icon back + icon.classList.remove('fa-compress'); + icon.classList.add('fa-expand'); + + // Update button title + const button = document.getElementById('fullscreenToggle'); + if (button) { + button.setAttribute('title', 'Toggle Fullscreen'); + } + + // Exit native fullscreen if active (only for desktop) + const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0; + + if (!isTouchDevice) { + if (document.exitFullscreen) { + document.exitFullscreen().catch(err => { + console.log('Native fullscreen exit not needed'); + }); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen().catch(err => { + console.log('Native fullscreen exit not needed'); + }); + } else if (document.mozCancelFullScreen) { + document.mozCancelFullScreen().catch(err => { + console.log('Native fullscreen exit not needed'); + }); + } else if (document.msExitFullscreen) { + document.msExitFullscreen().catch(err => { + console.log('Native fullscreen exit not needed'); + }); + } + } + + // Restore table layout + adjustTableForFullscreen(false); + + // Save fullscreen preference + saveFullscreenPreference(false); + + // Re-enable default touch behaviors + preventSwipeGestures(container, false); +} + +/** + * Adjust table columns visibility based on fullscreen and device + */ +function adjustTableForFullscreen(isFullscreen) { + const table = document.getElementById('attendanceTable'); + if (!table) return; + + const viewport = { + width: window.innerWidth, + height: window.innerHeight, + orientation: window.innerWidth > window.innerHeight ? 'landscape' : 'portrait' + }; + + // Log viewport info for debugging + console.log('Adjusting table for fullscreen:', { + isFullscreen, + viewport + }); + + // Additional optimizations can be added here + // The CSS already handles most responsive adjustments +} + +/** + * Save fullscreen preference to localStorage and URL + */ +function saveFullscreenPreference(isFullscreen) { + try { + localStorage.setItem('attendance_fullscreen_preference', isFullscreen ? 'true' : 'false'); + + // Also update the hidden form field if it exists + const fullscreenInput = document.getElementById('fullscreenState'); + if (fullscreenInput) { + fullscreenInput.value = isFullscreen ? '1' : ''; + } + + console.log('Fullscreen preference saved:', isFullscreen); + } catch (e) { + console.warn('Could not save fullscreen preference:', e); + } +} + +/** + * Load fullscreen preference from localStorage + */ +function loadFullscreenPreference() { + try { + const preference = localStorage.getItem('attendance_fullscreen_preference'); + return preference === 'true'; + } catch (e) { + console.warn('Could not load fullscreen preference:', e); + return false; + } +} + +/** + * Log fullscreen action for analytics + */ +function logFullscreenAction(action) { + const logData = { + action: action, + timestamp: new Date().toISOString(), + viewport: { + width: window.innerWidth, + height: window.innerHeight, + orientation: window.innerWidth > window.innerHeight ? 'landscape' : 'portrait' + }, + userAgent: navigator.userAgent, + isIPad: /iPad/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) + }; + + console.log('Fullscreen action logged:', logData); + + // You can send this to your backend for analytics if needed + // fetch('/api/log-fullscreen', { + // method: 'POST', + // headers: { 'Content-Type': 'application/json' }, + // body: JSON.stringify(logData) + // }); +} + +/** + * Handle native fullscreen change events (only for desktop) + */ +function handleFullscreenChange() { + // Skip handling on touch devices since we're not using native fullscreen there + const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0; + if (isTouchDevice) { + return; + } + + const isNativeFullscreen = !!( + document.fullscreenElement || + document.webkitFullscreenElement || + document.mozFullScreenElement || + document.msFullscreenElement + ); + + // Sync our state with native fullscreen (desktop only) + if (!isNativeFullscreen && isFullscreen) { + // User exited native fullscreen, update our state + const container = document.getElementById('attendanceReportContainer'); + const icon = document.getElementById('fullscreenIcon'); + const body = document.body; + + if (container && icon) { + isFullscreen = false; + exitFullscreen(container, icon, body); + } + } +} + +/** + * Handle keyboard shortcuts + */ +function handleKeyboardShortcuts(event) { + // F11 or F for fullscreen toggle + if (event.key === 'F11' || (event.key === 'f' && event.ctrlKey)) { + event.preventDefault(); + toggleFullscreen(); + } + + // Escape to exit fullscreen + if (event.key === 'Escape' && isFullscreen) { + toggleFullscreen(); + } +} + +/** + * Detect iPad and adjust UI accordingly + */ +function detectAndOptimizeForIPad() { + const isIPad = /iPad/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 0); + + if (isIPad) { + console.log('iPad detected - optimizing UI'); + document.body.classList.add('ipad-device'); + + // Add iPad-specific optimizations + const container = document.getElementById('attendanceReportContainer'); + if (container) { + container.classList.add('ipad-optimized'); + } + } +} + +/** + * Restore fullscreen state from URL (CSS-only, no native fullscreen) + */ +function restoreFullscreenFromURL() { + const urlParams = new URLSearchParams(window.location.search); + const fullscreenParam = urlParams.get('fullscreen'); + + if (fullscreenParam === '1' && !isFullscreen) { + console.log('Restoring fullscreen mode from URL parameter (CSS-only)'); + + // Use CSS-only fullscreen (no native fullscreen API call) + const container = document.getElementById('attendanceReportContainer'); + const icon = document.getElementById('fullscreenIcon'); + const body = document.body; + + if (container && icon) { + // Manually set fullscreen state without calling native API + isFullscreen = true; + container.classList.add('fullscreen-mode'); + body.classList.add('fullscreen-active'); + + // Update icon + icon.classList.remove('fa-expand'); + icon.classList.add('fa-compress'); + + // Update button + const button = document.getElementById('fullscreenToggle'); + if (button) { + button.setAttribute('title', 'Exit Fullscreen'); + } + + // Apply touch gesture prevention + preventSwipeGestures(container, true); + + // Save preference + saveFullscreenPreference(true); + + console.log('Fullscreen restored successfully (CSS-only mode)'); + } + } +} + +/** + * Initialize fullscreen functionality + */ +function initializeFullscreen() { + console.log('Initializing fullscreen functionality'); + + // Detect iPad + detectAndOptimizeForIPad(); + + // Add event listeners for native fullscreen changes (desktop only) + document.addEventListener('fullscreenchange', handleFullscreenChange); + document.addEventListener('webkitfullscreenchange', handleFullscreenChange); + document.addEventListener('mozfullscreenchange', handleFullscreenChange); + document.addEventListener('MSFullscreenChange', handleFullscreenChange); + + // Add keyboard shortcuts + document.addEventListener('keydown', handleKeyboardShortcuts); + + // Intercept filter form submission to preserve fullscreen state + const filterForm = document.getElementById('filterForm'); + if (filterForm) { + filterForm.addEventListener('submit', function(e) { + const fullscreenInput = document.getElementById('fullscreenState'); + if (fullscreenInput) { + fullscreenInput.value = isFullscreen ? '1' : ''; + console.log('Filter form submitted with fullscreen state:', isFullscreen); + } + }); + } + + // Handle orientation changes + window.addEventListener('orientationchange', function() { + console.log('Orientation changed'); + if (isFullscreen) { + adjustTableForFullscreen(true); + } + }); + + // Handle window resize + let resizeTimeout; + window.addEventListener('resize', function() { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(function() { + if (isFullscreen) { + adjustTableForFullscreen(true); + } + }, 250); + }); + + // Restore fullscreen state from URL if present + // Use setTimeout to ensure DOM is fully loaded + setTimeout(function() { + restoreFullscreenFromURL(); + }, 100); + + console.log('Fullscreen functionality initialized'); +} + +// Initialize when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeFullscreen); +} else { + initializeFullscreen(); +} + +// Export functions for external use +window.toggleFullscreen = toggleFullscreen; +window.isAttendanceFullscreen = function() { return isFullscreen; }; \ No newline at end of file diff --git a/static/js/attendance_report.js b/static/js/attendance_report.js new file mode 100644 index 0000000..596abe6 --- /dev/null +++ b/static/js/attendance_report.js @@ -0,0 +1,1243 @@ +/** + * Enhanced Attendance Report JavaScript + * Handles filtering, sorting, pagination, and new location/accuracy features + */ + +// Global variables +let currentPage = 1; +let entriesPerPage = 50; +let sortColumn = -1; +let sortDirection = "asc"; +let attendanceData = []; +let filteredData = []; + +// Charts +let dailyChart = null; +let locationChart = null; + +// Initialize page when DOM is loaded +document.addEventListener("DOMContentLoaded", function () { + console.log("Enhanced Attendance Report page initialized"); + + initializeReport(); + loadAttendanceData(); + initializeCharts(); + setupEventListeners(); + initializeDateRangeFilters(); +}); + +function initializeReport() { + // Load data from table + loadTableData(); + + // Initialize pagination + updatePagination(); + + // Apply initial filters if any + applyFilters(); +} + + +function extractAccuracyValue(cell) { + const text = cell.textContent; + const match = text.match(/(\d+\.?\d*)m/); + return match ? parseFloat(match[1]) : null; +} + +function extractAccuracyLevel(cell) { + const text = cell.textContent; + if (text.includes("high")) return "high"; + if (text.includes("medium")) return "medium"; + if (text.includes("low")) return "low"; + return "unknown"; +} + +function extractCoordinates(cell) { + // This would need to be enhanced based on actual data structure + // For now, return placeholder + return "Coordinates available"; +} + +function initializeDateRangeFilters() { + const dateFromInput = document.getElementById("date_from"); + const dateToInput = document.getElementById("date_to"); + + if (dateFromInput && dateToInput) { + // Set max date to today + const today = new Date().toISOString().split("T")[0]; + dateFromInput.max = today; + dateToInput.max = today; + + // Add validation to ensure 'from' date is not after 'to' date + dateFromInput.addEventListener("change", function () { + if (dateToInput.value && this.value > dateToInput.value) { + dateToInput.value = this.value; + } + }); + + dateToInput.addEventListener("change", function () { + if (dateFromInput.value && this.value < dateFromInput.value) { + dateFromInput.value = this.value; + } + }); + } +} + +function setupEventListeners() { + // Enhanced search and filter listeners + const searchInput = document.getElementById("searchInput"); + const locationFilter = document.getElementById("location"); + const employeeFilter = document.getElementById("employee"); + + if (searchInput) { + searchInput.addEventListener("input", debounce(applyFilters, 300)); + } + + if (locationFilter) { + locationFilter.addEventListener("change", applyFilters); + } + + if (employeeFilter) { + employeeFilter.addEventListener("input", debounce(applyFilters, 300)); + } + + // Entries per page listener + const entriesSelect = document.getElementById("entriesPerPage"); + if (entriesSelect) { + entriesSelect.addEventListener("change", changeEntriesPerPage); + } + + // Modal close listeners + window.addEventListener("click", function (event) { + const recordModal = document.getElementById("recordModal"); + const mapModal = document.getElementById("mapModal"); + + if (event.target === recordModal) { + closeModal(); + } + if (event.target === mapModal) { + closeMapModal(); + } + }); + + // Keyboard shortcuts + document.addEventListener("keydown", function (event) { + if (event.key === "Escape") { + closeModal(); + closeMapModal(); + } + }); +} + +// Work-type codes that may be attached to an employee ID: +// "1234SP", "1234 PW", "PT1234", "1234C". The codes below mirror +// WORK_TYPE_CODES in utils/helpers.py and parse_employee_id_for_work_type() +// in working_hours_calculator.py — keep the three in sync. +/** + * Split an employee ID into its numeric base ID and work-type code. + * "1234" -> {baseId: "1234", workType: "regular"} + * "1234 SP" / "1234SP" / "SP1234" -> {baseId: "1234", workType: "SP"} + */ +function parseEmployeeIdWorkType(rawId) { + const id = String(rawId || "").trim().toUpperCase(); + if (!id) { + return { baseId: "", workType: "regular" }; + } + + // Any non-alphanumeric run may separate the two parts: "1234 SP", "1234.PW" + const suffixMatch = id.match(/^([0-9]+)[^0-9A-Z]*(SP|PW|PT|C)$/); + if (suffixMatch) { + return { baseId: stripLeadingZeros(suffixMatch[1]), workType: suffixMatch[2] }; + } + + const prefixMatch = id.match(/^(SP|PW|PT|C)[^0-9A-Z]*([0-9]+)$/); + if (prefixMatch) { + return { baseId: stripLeadingZeros(prefixMatch[2]), workType: prefixMatch[1] }; + } + + return { baseId: stripLeadingZeros(id), workType: "regular" }; +} + +function stripLeadingZeros(value) { + // "01234" and "1234" are the same employee — the server tolerates padding too. + return value.replace(/^0+(?=[0-9])/, ""); +} + +function applyFilters() { + const searchTerm = + document.getElementById("searchInput")?.value.toLowerCase() || ""; + const locationFilter = document.getElementById("location")?.value || ""; + // Support comma-separated multi-employee filter + const employeeFilterRaw = + document.getElementById("employee")?.value || ""; + // Parse each selected ID into base + work type so this client-side pass + // keeps the rows the server already matched. A plain ID ("1234") matches + // the regular record AND every work-type variant (1234SP / 1234PW / 1234PT / + // 1234C); an ID that already carries a code matches only that code. + // This mirrors _work_type_codes_for() in utils/helpers.py — without it the + // extra-work rows returned by the query were filtered back out here. + const employeeFilterIds = employeeFilterRaw + ? employeeFilterRaw + .split(",") + .map(function (s) { return s.trim(); }) + .filter(Boolean) + .map(parseEmployeeIdWorkType) + : []; + + filteredData = attendanceData.filter((record) => { + const matchesSearch = + !searchTerm || + record.employeeId.toLowerCase().includes(searchTerm) || + record.location.toLowerCase().includes(searchTerm) || + record.event.toLowerCase().includes(searchTerm); + + const matchesLocation = + !locationFilter || (record.location && record.location.trim() === locationFilter.trim()); + const recordEmployee = parseEmployeeIdWorkType(record.employeeId); + const matchesEmployee = + employeeFilterIds.length === 0 || + employeeFilterIds.some(function (selected) { + if (selected.baseId !== recordEmployee.baseId) { + return false; + } + // Regular selection = all work types; explicit code = that code only + return ( + selected.workType === "regular" || + selected.workType === recordEmployee.workType + ); + }); + + return matchesSearch && matchesLocation && matchesEmployee; + }); + + currentPage = 1; + updateTable(); + updatePagination(); + updateFilterStats(); +} + +function sortTable(columnIndex) { + if (sortColumn === columnIndex) { + sortDirection = sortDirection === "asc" ? "desc" : "asc"; + } else { + sortColumn = columnIndex; + sortDirection = "asc"; + } + + const sortKey = getSortKey(columnIndex); + + filteredData.sort((a, b) => { + let aVal = a[sortKey]; + let bVal = b[sortKey]; + + // Handle numeric values for accuracy + if (columnIndex === 8 && aVal !== null && bVal !== null) { + aVal = parseFloat(aVal); + bVal = parseFloat(bVal); + } + + // Handle null values + if (aVal === null || aVal === undefined) aVal = ""; + if (bVal === null || bVal === undefined) bVal = ""; + + if (typeof aVal === "string") { + aVal = aVal.toLowerCase(); + bVal = bVal.toLowerCase(); + } + + let result; + if (aVal < bVal) result = -1; + else if (aVal > bVal) result = 1; + else result = 0; + + return sortDirection === "asc" ? result : -result; + }); + + updateTable(); + updateSortIndicators(columnIndex); +} + +function getSortKey(columnIndex) { + const sortKeys = [ + "index", + "employeeId", + "location", + "event", + "date", + "time", + "qr_address", + "checked_in_address", + "accuracy", + "device", + ]; + return sortKeys[columnIndex] || "index"; +} + +function updateSortIndicators(activeColumn) { + // Update sort indicators in table headers + const headers = document.querySelectorAll(".attendance-table th"); + headers.forEach((header, index) => { + const icon = header.querySelector("i"); + if (icon) { + icon.className = "fas fa-sort"; + if (index === activeColumn) { + icon.className = + sortDirection === "asc" ? "fas fa-sort-up" : "fas fa-sort-down"; + } + } + }); +} + +function updateTable() { + const table = document.getElementById("attendanceTable"); + if (!table) return; + + const tbody = table.querySelector("tbody"); + const startIndex = (currentPage - 1) * entriesPerPage; + const endIndex = + entriesPerPage === "all" + ? filteredData.length + : startIndex + entriesPerPage; + const pageData = filteredData.slice(startIndex, endIndex); + + tbody.innerHTML = ""; + + pageData.forEach((record, index) => { + const row = createTableRow(record, startIndex + index + 1); + tbody.appendChild(row); + }); + + // Update any dynamic elements + updateFilterStats(); +} + +function changeEntriesPerPage() { + const select = document.getElementById("entriesPerPage"); + entriesPerPage = select.value === "all" ? "all" : parseInt(select.value); + currentPage = 1; + updateTable(); + updatePagination(); +} + +function updatePagination() { + const container = document.getElementById("paginationContainer"); + if (!container || entriesPerPage === "all") { + if (container) container.innerHTML = ""; + return; + } + + const totalPages = Math.ceil(filteredData.length / entriesPerPage); + + if (totalPages <= 1) { + container.innerHTML = ""; + return; + } + + let paginationHTML = '<div class="pagination">'; + + // Previous button + paginationHTML += ` + <button onclick="goToPage(${currentPage - 1})" + class="pagination-btn" + ${currentPage === 1 ? "disabled" : ""}> + <i class="fas fa-chevron-left"></i> + </button> + `; + + // Page numbers + const maxVisiblePages = 5; + let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2)); + let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); + + if (endPage - startPage + 1 < maxVisiblePages) { + startPage = Math.max(1, endPage - maxVisiblePages + 1); + } + + if (startPage > 1) { + paginationHTML += `<button onclick="goToPage(1)" class="pagination-btn">1</button>`; + if (startPage > 2) { + paginationHTML += '<span class="pagination-ellipsis">...</span>'; + } + } + + for (let i = startPage; i <= endPage; i++) { + paginationHTML += ` + <button onclick="goToPage(${i})" + class="pagination-btn ${i === currentPage ? "active" : ""}"> + ${i} + </button> + `; + } + + if (endPage < totalPages) { + if (endPage < totalPages - 1) { + paginationHTML += '<span class="pagination-ellipsis">...</span>'; + } + paginationHTML += `<button onclick="goToPage(${totalPages})" class="pagination-btn">${totalPages}</button>`; + } + + // Next button + paginationHTML += ` + <button onclick="goToPage(${currentPage + 1})" + class="pagination-btn" + ${currentPage === totalPages ? "disabled" : ""}> + <i class="fas fa-chevron-right"></i> + </button> + `; + + paginationHTML += "</div>"; + + // Add pagination info + const startRecord = (currentPage - 1) * entriesPerPage + 1; + const endRecord = Math.min(currentPage * entriesPerPage, filteredData.length); + + paginationHTML += ` + <div class="pagination-info"> + Showing ${startRecord} to ${endRecord} of ${ + filteredData.length + } entries + ${ + filteredData.length !== attendanceData.length + ? `(filtered from ${attendanceData.length} total entries)` + : "" + } + </div> + `; + + container.innerHTML = paginationHTML; +} + +function goToPage(page) { + const totalPages = Math.ceil(filteredData.length / entriesPerPage); + + if (page < 1 || page > totalPages) return; + + currentPage = page; + updateTable(); + updatePagination(); + + // Scroll to top of table + const table = document.getElementById("attendanceTable"); + if (table) { + table.scrollIntoView({ behavior: "smooth", block: "start" }); + } +} + +function updateFilterStats() { + // Update stats display if needed + const totalRecords = filteredData.length; + console.log(`Filtered records: ${totalRecords}`); +} + +// Enhanced record actions +function editRecord(recordId) { + // Check permissions before allowing edit + if (!hasEditPermission) { + alert( + "Access denied. Only administrators can edit attendance records." + ); + return; + } + + console.log(`Edit record: ${recordId}`); + // Log the action + console.log(`[LOG] User attempting to edit attendance record: ${recordId}`); + + // Redirect to edit page + window.location.href = `/attendance/${recordId}/edit`; +} + +function deleteRecord(recordId, employeeId) { + // Check permissions before allowing delete + if (!hasEditPermission) { + alert( + "Access denied. Only administrators can delete attendance records." + ); + return; + } + + console.log(`Delete record: ${recordId}`); + + // Confirmation dialog + const confirmMessage = `Are you sure you want to delete the attendance record for employee "${employeeId}"?\n\nThis action cannot be undone.`; + + if (confirm(confirmMessage)) { + console.log( + `[LOG] User confirmed deletion of attendance record: ${recordId}` + ); + + // Send delete request + fetch(`/attendance/${recordId}/delete`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + 'X-CSRF-Token': (window.qrConfig && window.qrConfig.csrfToken) || '', + }, + }) + .then((response) => response.json()) + .then((data) => { + if (data.success) { + console.log( + `[LOG] Successfully deleted attendance record: ${recordId}` + ); + alert("Attendance record deleted successfully!"); + window.location.reload(); + } else { + console.error( + `[LOG] Failed to delete attendance record: ${recordId} - ${data.message}` + ); + alert(data.message || "Error deleting record. Please try again."); + } + }) + .catch((error) => { + console.error( + `[LOG] Error during attendance record deletion: ${recordId}`, + error + ); + alert("Error deleting record. Please try again."); + }); + } +} + +function closeModal() { + const modal = document.getElementById("recordModal"); + if (modal) { + modal.style.display = "none"; + } +} + +// Chart initialization (placeholder) +function initializeCharts() { + console.log("Initializing charts..."); + // Chart implementation would go here +} + +function loadAttendanceData() { + console.log("Loading attendance data for charts..."); + // Additional data loading for charts would go here +} + +// Utility function +function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} + +// Enhanced JavaScript functions for location accuracy features + +function loadTableData() { + const table = document.getElementById("attendanceTable"); + if (table) { + const rows = table.querySelectorAll("tbody tr"); + attendanceData = Array.from(rows).map((row, index) => { + const cells = row.querySelectorAll("td"); + + // Extract verification data from the accuracy badge + const verificationData = extractVerificationData(cells[9]); + + return { + id: row.dataset.recordId, + index: index + 1, + employeeId: cells[1] ? cells[1].textContent.trim() : "", + employeeName: cells[2] ? cells[2].textContent.trim() : "", // NEW: Employee Name column + location: cells[3] ? cells[3].textContent.trim() : "", // Updated from cells[2] + event: cells[4] ? cells[4].textContent.trim() : "", // Updated from cells[3] + date: cells[5] ? cells[5].textContent.trim() : "", // Updated from cells[4] + time: cells[6] ? cells[6].textContent.trim() : "", // Updated from cells[5] + qr_address: cells[7] // Updated from cells[6] + ? cells[7].getAttribute("title") || cells[7].textContent.trim() + : "", + checked_in_address: cells[8] // Updated from cells[7] + ? cells[8].getAttribute("title") || cells[8].textContent.trim() + : "", + // FIXED: Extract location accuracy for address display logic + location_accuracy: cells[9] ? extractLocationAccuracy(cells[9]) : null, // Updated from cells[8] + accuracy_level: cells[9] // Updated from cells[8] + ? extractLocationAccuracyLevel(cells[9]) + : "unknown", + device: cells[10] // Updated from cells[9] + ? cells[10].textContent.trim() + : "", + isModified: row.classList.contains('modified-record'), + isDynamic: row.dataset.isDynamic === '1', + verification_required: verificationData.required, + verification_status: verificationData.status + }; + }); + + filteredData = [...attendanceData]; + console.log(`Loaded ${attendanceData.length} attendance records`); + + // Debug log for location accuracy data + const recordsWithAccuracy = attendanceData.filter( + (r) => r.location_accuracy !== null + ); + console.log( + `Records with location accuracy: ${recordsWithAccuracy.length}` + ); + if (recordsWithAccuracy.length > 0) { + console.log( + `Sample location accuracy values:`, + recordsWithAccuracy.slice(0, 3).map((r) => r.location_accuracy) + ); + } + } +} + +function extractLocationAccuracy(cell) { + const text = cell.textContent; + console.log(`Extracting accuracy from: "${text}"`); + + // Look for miles pattern (e.g., "0.003 mi", "1.234 mi") + const milesMatch = text.match(/(\d+\.?\d*)\s*mi/); + if (milesMatch) { + const value = parseFloat(milesMatch[1]); + console.log(`Found miles: ${value}`); + return value; + } + + // Look for specific accuracy patterns in the HTML + const accuracyMatch = text.match(/accuracy[:\s]*(\d+\.?\d*)/i); + if (accuracyMatch) { + const value = parseFloat(accuracyMatch[1]); + console.log(`Found accuracy: ${value}`); + return value; + } + + // Check for data attributes + const dataAccuracy = cell.getAttribute("data-accuracy"); + if (dataAccuracy) { + const value = parseFloat(dataAccuracy); + console.log(`Found data-accuracy: ${value}`); + return value; + } + + // Fallback: look for GPS accuracy in meters and convert to miles (approximate) + const metersMatch = text.match(/(\d+\.?\d*)\s*m/); + if (metersMatch) { + const meters = parseFloat(metersMatch[1]); + const miles = meters * 0.000621371; // Convert meters to miles (approximate) + console.log(`Found meters: ${meters}, converted to miles: ${miles}`); + return miles; + } + + console.log(`No accuracy found in: "${text}"`); + return null; +} + +function extractLocationAccuracyLevel(cell) { + // 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.3 ? "accurate" : "inaccurate"; + } + return "unknown"; +} + +function extractVerificationData(cell) { + // Extract verification status from badge classes in the HTML + if (!cell) { + console.log('extractVerificationData: No cell provided'); + return { required: false, status: null }; + } + + const badge = cell.querySelector('.location-accuracy-badge'); + if (!badge) { + console.log('extractVerificationData: No badge found in cell'); + return { required: false, status: null }; + } + + console.log('extractVerificationData: Badge classes:', badge.className); + + // Check badge classes for verification status + if (badge.classList.contains('badge-review-needed')) { + console.log('extractVerificationData: Found pending verification'); + return { required: true, status: 'pending' }; + } else if (badge.classList.contains('badge-verified')) { + console.log('extractVerificationData: Found approved verification'); + return { required: true, status: 'approved' }; + } else if (badge.classList.contains('badge-rejected')) { + console.log('extractVerificationData: Found rejected verification'); + return { required: true, status: 'rejected' }; + } + + console.log('extractVerificationData: No verification status found, standard badge'); + return { required: false, status: null }; +} + +function createTableRow(record, displayIndex) { + const row = document.createElement("tr"); + row.dataset.recordId = record.id; + + // Apply highlighting if record was modified + if (record.isModified) { + row.classList.add('modified-record'); + } + // Apply blue-border highlight for Dynamic QR records + if (record.isDynamic) { + row.classList.add('dynamic-qr-record'); + } + + // Debug logging for first few records + if (displayIndex <= 3) { + console.log(`=== CREATING ROW ${displayIndex} ===`); + console.log(`Employee: ${record.employeeId}`); + console.log(`Location accuracy: ${record.location_accuracy}`); + console.log(`Verification required: ${record.verification_required}`); + console.log(`Verification status: ${record.verification_status}`); + console.log(`QR address: ${record.qr_address}`); + console.log(`Check-in address: ${record.checked_in_address}`); + } + + // Create location accuracy badge HTML - check verification status first + let locationAccuracyBadge; + + if (record.verification_required && record.verification_status === 'pending') { + // Show Review Needed badge for pending verification - LINK to review page + locationAccuracyBadge = `<a href="/verification-review/${record.id}" + class="location-accuracy-badge badge-review-needed" + style="cursor: pointer; text-decoration: none;" + title="Click to review verification photo - Distance: ${record.location_accuracy ? record.location_accuracy.toFixed(3) : 'N/A'} miles"> + <i class="fas fa-exclamation-triangle"></i> + Review Needed + <small>(${record.location_accuracy ? record.location_accuracy.toFixed(3) : 'N/A'} mi)</small> + </a>`; + } else if (record.verification_status === 'approved') { + // Show Verified badge for approved verification + locationAccuracyBadge = `<span class="location-accuracy-badge badge-verified" + title="Verification approved - Distance: ${record.location_accuracy ? record.location_accuracy.toFixed(3) : 'N/A'} miles"> + <i class="fas fa-check-circle"></i> + Verified + <small>(${record.location_accuracy ? record.location_accuracy.toFixed(3) : 'N/A'} mi)</small> + </span>`; + } else if (record.verification_status === 'rejected') { + // Show Rejected badge for rejected verification + locationAccuracyBadge = `<span class="location-accuracy-badge badge-rejected" + title="Verification rejected - Distance: ${record.location_accuracy ? record.location_accuracy.toFixed(3) : 'N/A'} miles"> + <i class="fas fa-times-circle"></i> + Rejected + <small>(${record.location_accuracy ? record.location_accuracy.toFixed(3) : 'N/A'} mi)</small> + </span>`; + } else if (record.location_accuracy !== null) { + // Show standard location accuracy badge + locationAccuracyBadge = `<span class="location-accuracy-badge accuracy-${ + record.accuracy_level + }" + title="Distance between QR location and check-in location: ${ + record.location_accuracy + } miles - ${record.accuracy_level}"> + <i class="fas fa-ruler"></i> + ${record.location_accuracy.toFixed(3)} mi + <small>(${record.accuracy_level})</small> + </span>`; + } else { + // No accuracy data + locationAccuracyBadge = `<span class="location-accuracy-badge accuracy-unknown" title="Location accuracy could not be calculated"> + <i class="fas fa-question-circle"></i> + Unknown + </span>`; + } + + // Address display logic based on location accuracy + let addressDisplayHTML = ""; + let addressToShow = record.checked_in_address; + let addressIcon = "fas fa-location-arrow"; + let addressClass = "address-normal-accuracy"; + let addressTitle = `Check-in Address: ${record.checked_in_address}`; + + // Apply 0.5-mile threshold logic + if ( + record.location_accuracy !== null && + record.location_accuracy !== undefined + ) { + const accuracy = parseFloat(record.location_accuracy); + + if (displayIndex <= 3) { + console.log(`Applying address logic for ${record.employeeId}:`); + console.log(` Accuracy value: ${accuracy}`); + console.log(` Is <= 0.3? ${accuracy <= 0.3}`); + } + + if (!isNaN(accuracy) && accuracy <= 0.3) { + // High accuracy - use QR address + addressToShow = record.qr_address; + addressIcon = "fas fa-check-circle"; + addressClass = "address-high-accuracy"; + addressTitle = `QR Address (High Accuracy ≤ 0.5 mi): ${record.qr_address}`; + + if (displayIndex <= 3) { + console.log(` → Using QR address: ${addressToShow}`); + } + + addressDisplayHTML = ` + <i class="${addressIcon}" style="color: #059669; margin-right: 4px;" + title="High accuracy - showing QR location"></i> + <span title="${addressTitle}" class="${addressClass}"> + ${ + addressToShow.length > 45 + ? addressToShow.substring(0, 45) + "..." + : addressToShow + } + </span> + `; + } else { + // Lower accuracy - use check-in address + if (displayIndex <= 3) { + console.log(` → Using check-in address: ${addressToShow}`); + } + + addressDisplayHTML = ` + <i class="fas fa-exclamation-triangle" style="color: #f59e0b; margin-right: 4px;" + title="Lower accuracy - showing actual check-in location"></i> + <span title="${addressTitle} (Accuracy: ${accuracy.toFixed( + 3 + )} mi)" class="${addressClass}"> + ${ + addressToShow.length > 45 + ? addressToShow.substring(0, 45) + "..." + : addressToShow + } + </span> + `; + } + } else { + // No accuracy data - use check-in address + if (displayIndex <= 3) { + console.log( + ` → No accuracy data, using check-in address: ${addressToShow}` + ); + } + + addressDisplayHTML = ` + <i class="${addressIcon}"></i> + <span title="${addressTitle}" class="${addressClass}"> + ${ + addressToShow.length > 45 + ? addressToShow.substring(0, 45) + "..." + : addressToShow + } + </span> + `; + } + + row.innerHTML = ` + <td>${displayIndex}</td> + <td> + <div class="employee-info"> + <span class="employee-id">${record.employeeId}</span> + </div> + </td> + <td> + <div class="employee-name"> + <i class="fas fa-user"></i> + <span>${record.employeeName || 'Unknown'}</span> + </div> + </td> + <td> + <div class="location-info"> + <i class="fas fa-map-marker-alt"></i> + ${record.location} + </div> + </td> + <td> + <div class="event-info"> + ${record.event} + </div> + </td> + <td> + <div class="date-info"> + ${record.date} + </div> + </td> + <td> + <div class="time-info"> + ${record.time} + </div> + </td> + <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}"> + ${ + record.qr_address.length > 50 + ? record.qr_address.substring(0, 50) + "..." + : record.qr_address + } + </span> + </div> + </td> + <td> + <div class="address-info checkin-address"> + ${addressDisplayHTML} + </div> + </td> + <td> + <div class="location-accuracy-info"> + ${locationAccuracyBadge} + </div> + </td> + <td> + <div class="device-info"> + <i class="fas fa-mobile-alt"></i> + <span title="${record.device}"> + ${ + record.device.length > 20 + ? record.device.substring(0, 20) + "..." + : record.device + } + </span> + </div> + </td> + <td> + <div class="record-actions"> + ${ + record.verification_required && record.verification_status === 'pending' + ? `<a href="/verification-review/${record.id}" + class="action-btn btn-review" + title="Review Verification Photo"> + <i class="fas fa-camera"></i> + </a>` + : '' + } + ${ + hasEditPermission + ? ` + <button onclick="editRecord('${record.id}')" + class="action-btn btn-edit" + title="Edit Record"> + <i class="fas fa-edit"></i> + </button> + <button onclick="deleteRecord('${record.id}', '${record.employeeId}')" + class="action-btn btn-delete" + title="Delete Record"> + <i class="fas fa-trash"></i> + </button> + ` + : ` + <span class="text-muted" title="Admin access required"> + <i class="fas fa-lock"></i> + </span> + ` + } + </div> + </td> + `; + + return row; +} + +function getSortKey(columnIndex) { + const sortKeys = [ + "index", + "employeeId", + "location", + "event", + "date", + "time", + "qr_address", + "checked_in_address", + "location_accuracy", + "device", + ]; + return sortKeys[columnIndex] || "index"; +} + +// Enhanced sorting for location accuracy (numeric sorting) +function sortTable(columnIndex) { + if (sortColumn === columnIndex) { + sortDirection = sortDirection === "asc" ? "desc" : "asc"; + } else { + sortColumn = columnIndex; + sortDirection = "asc"; + } + + const sortKey = getSortKey(columnIndex); + + filteredData.sort((a, b) => { + let aVal = a[sortKey]; + let bVal = b[sortKey]; + + // Handle numeric values for location accuracy + if (columnIndex === 8 && aVal !== null && bVal !== null) { + aVal = parseFloat(aVal); + bVal = parseFloat(bVal); + } + + // Handle null values - put them at the end + if (aVal === null || aVal === undefined) { + return sortDirection === "asc" ? 1 : -1; + } + if (bVal === null || bVal === undefined) { + return sortDirection === "asc" ? -1 : 1; + } + + if (typeof aVal === "string") { + aVal = aVal.toLowerCase(); + bVal = bVal.toLowerCase(); + } + + let result; + if (aVal < bVal) result = -1; + else if (aVal > bVal) result = 1; + else result = 0; + + return sortDirection === "asc" ? result : -result; + }); + + updateTable(); + updateSortIndicators(columnIndex); +} + +// Enhanced statistics display for location accuracy +function updateFilterStats() { + const totalRecords = filteredData.length; + const recordsWithAccuracy = filteredData.filter( + (r) => r.location_accuracy !== null + ).length; + const avgAccuracy = + recordsWithAccuracy > 0 + ? filteredData + .filter((r) => r.location_accuracy !== null) + .reduce((sum, r) => sum + r.location_accuracy, 0) / + recordsWithAccuracy + : 0; + + console.log(`Filtered records: ${totalRecords}`); + console.log(`Records with location accuracy: ${recordsWithAccuracy}`); + console.log(`Average location accuracy: ${avgAccuracy.toFixed(3)} miles`); +} + +// Function to get accuracy level color for charts or displays +function getAccuracyLevelColor(level) { + const colors = { + accurate: "#059669", // green + inaccurate: "#dc2626", // red + unknown: "#6b7280", // gray + }; + return colors[level] || colors["unknown"]; +} + +// Enhanced export function to include location accuracy +function exportAttendanceWithAccuracy() { + // Build CSV header with location accuracy + const headers = [ + "#", + "Employee ID", + "Employee Name", + "Location", + "Event", + "Date", + "Time", + "QR Address", + "Check-in Address", + "Location Accuracy (miles)", + "Accuracy Level", + "Device", + ]; + + // Build CSV rows + const rows = filteredData.map((record, index) => [ + index + 1, + record.employeeId, + record.employeeName || "Unknown", + record.location, + record.event, + record.date, + record.time, + record.qr_address, + record.checked_in_address, + record.location_accuracy ? record.location_accuracy.toFixed(3) : "Unknown", + record.accuracy_level, + record.device, + ]); + + // Create CSV content + const csvContent = [headers, ...rows] + .map((row) => row.map((field) => `"${field}"`).join(",")) + .join("\n"); + + // Download CSV + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + link.setAttribute("href", url); + link.setAttribute( + "download", + `attendance_report_with_accuracy_${ + new Date().toISOString().split("T")[0] + }.csv` + ); + link.style.visibility = "hidden"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +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', 'accounting'].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 by ${userRole} - redirecting to configuration page`); + + // Get current filters + const currentFilters = getCurrentFilters(); + + // Build URL with current filters + const params = new URLSearchParams(); + if (currentFilters.date_from) + params.append("date_from", currentFilters.date_from); + if (currentFilters.date_to) params.append("date_to", currentFilters.date_to); + if (currentFilters.location) + params.append("location", currentFilters.location); + if (currentFilters.employee) + params.append("employee", currentFilters.employee); + if (currentFilters.project) + params.append("project", currentFilters.project); + + // Navigate to export configuration page + const configUrl = + "/export-configuration" + + (params.toString() ? "?" + params.toString() : ""); + window.location.href = configUrl; +} + +function getCurrentFilters() { + // Extract current filter values from the page + return { + date_from: document.getElementById("date_from")?.value || "", + date_to: document.getElementById("date_to")?.value || "", + location: document.getElementById("location")?.value || "", + employee: document.getElementById("employee")?.value || "", + project: document.getElementById("project")?.value || "", + }; +} + +// 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', 'accounting'].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(); + + if (filters.date_from) params.append("date_from", filters.date_from); + if (filters.date_to) params.append("date_to", filters.date_to); + if (filters.location) params.append("location", filters.location); + if (filters.employee) params.append("employee", filters.employee); + if (filters.project) params.append("project", filters.project); + params.append("export", "csv"); + + // Create a temporary link and click it to download + const downloadUrl = window.location.pathname + "?" + params.toString(); + window.open(downloadUrl, "_blank"); +} + +// Enhanced export menu (if you want to add dropdown with multiple export options) +function showExportMenu() { + // Create export options menu + const existingMenu = document.getElementById("exportMenu"); + if (existingMenu) { + existingMenu.remove(); + return; + } + + const exportBtn = document.querySelector( + 'button[onclick="exportAttendance()"]' + ); + if (!exportBtn) return; + + const menu = document.createElement("div"); + menu.id = "exportMenu"; + menu.style.cssText = ` + position: absolute; + top: 100%; + right: 0; + background: white; + border: 1px solid #e2e8f0; + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0,0,0,0.1); + z-index: 1000; + min-width: 200px; + margin-top: 5px; + `; + + menu.innerHTML = ` + <div style="padding: 0.5rem;"> + <button onclick="exportAttendance(); closeExportMenu();" + style="width: 100%; padding: 0.75rem; border: none; background: none; text-align: left; cursor: pointer; border-radius: 4px;" + onmouseover="this.style.background='#f7fafc'" + onmouseout="this.style.background='none'"> + <i class="fas fa-file-excel" style="color: #48bb78; margin-right: 0.5rem;"></i> + Excel Export (Customizable) + </button> + <button onclick="exportAttendanceCSV(); closeExportMenu();" + style="width: 100%; padding: 0.75rem; border: none; background: none; text-align: left; cursor: pointer; border-radius: 4px;" + onmouseover="this.style.background='#f7fafc'" + onmouseout="this.style.background='none'"> + <i class="fas fa-file-csv" style="color: #4299e1; margin-right: 0.5rem;"></i> + Quick CSV Export + </button> + </div> + `; + + exportBtn.parentElement.style.position = "relative"; + exportBtn.parentElement.appendChild(menu); + + // Close menu when clicking outside + setTimeout(() => { + document.addEventListener("click", function closeOnClickOutside(e) { + if (!menu.contains(e.target) && e.target !== exportBtn) { + closeExportMenu(); + document.removeEventListener("click", closeOnClickOutside); + } + }); + }, 100); +} + +function closeExportMenu() { + const menu = document.getElementById("exportMenu"); + if (menu) { + menu.remove(); + } +} + +// Initialize export functionality when page loads +document.addEventListener("DOMContentLoaded", function () { + // Update export button to use enhanced functionality + const exportBtn = document.querySelector( + 'button[onclick="exportAttendance()"]' + ); + if (exportBtn) { + // You can modify the button to show a dropdown instead + // exportBtn.onclick = showExportMenu; + // exportBtn.innerHTML = '<i class="fas fa-download"></i> Export Data <i class="fas fa-chevron-down" style="margin-left: 0.5rem;"></i>'; + } + + console.log("Enhanced export functionality initialized"); +}); \ No newline at end of file diff --git a/static/js/dashboard.js b/static/js/dashboard.js new file mode 100644 index 0000000..d3af0e9 --- /dev/null +++ b/static/js/dashboard.js @@ -0,0 +1,703 @@ +/** + * Unified Dashboard JavaScript for QR Code Management + * static/js/dashboard.js + */ + +class ProjectDashboardManager { + constructor() { + this.expandedProjects = new Set(); + this.currentModalQR = null; + this.selectedQRCodes = new Set(); + this.allExpanded = false; + this.initialize(); + } + + initialize() { + const saved = localStorage.getItem("expandedProjects"); + if (saved) { + this.expandedProjects = new Set(JSON.parse(saved)); + this.restoreProjectStates(); + } + + this.setupEventListeners(); + this.addScrollAnimations(); + } + + restoreProjectStates() { + this.expandedProjects.forEach((projectId) => { + this.expandProject(projectId, false); + }); + } + + // Setup event listeners + setupEventListeners() { + // Keyboard shortcuts + document.addEventListener("keydown", (e) => { + // ESC to close modals + if (e.key === "Escape") { + this.closeQRModal(); + this.closeImageLightbox(); + } + + // Ctrl/Cmd + F to focus search + if ((e.ctrlKey || e.metaKey) && e.key === "f") { + e.preventDefault(); + const searchInput = document.getElementById("qrSearch"); + if (searchInput) searchInput.focus(); + } + + // Delete key for bulk delete (when items selected) + if (e.key === "Delete" && this.selectedQRCodes.size > 0) { + e.preventDefault(); + this.bulkDeleteQRCodes(); + } + }); + + // Expand/collapse all toggle + const expandToggle = document.getElementById("expandAllToggle"); + if (expandToggle) { + expandToggle.addEventListener("click", () => { + this.toggleExpandAll(); + }); + } + } + + // Add scroll animations for QR items + addScrollAnimations() { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add("animate-in"); + } + }); + }, + { threshold: 0.1 } + ); + + const qrItems = document.querySelectorAll(".qr-item, .qr-card"); + qrItems.forEach((item) => observer.observe(item)); + } + + // Project Management Functions + toggleProject(projectId) { + const isExpanded = this.expandedProjects.has(projectId); + + if (isExpanded) { + this.collapseProject(projectId); + } else { + this.expandProject(projectId); + } + + this.saveExpandedState(); + } + + expandProject(projectId, animate = true) { + const projectQR = document.getElementById(`project-qr-${projectId}`); + const toggle = document.getElementById(`toggle-${projectId}`); + const header = toggle?.closest(".project-header"); + + if (projectQR && toggle) { + projectQR.classList.add("expanded"); + toggle.classList.add("expanded"); + header?.classList.add("expanded"); + this.expandedProjects.add(projectId); + + if (animate) { + setTimeout(() => { + projectQR.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + }, 200); + } + } + } + + collapseProject(projectId) { + const projectQR = document.getElementById(`project-qr-${projectId}`); + const toggle = document.getElementById(`toggle-${projectId}`); + const header = toggle?.closest(".project-header"); + + if (projectQR && toggle) { + projectQR.classList.remove("expanded"); + toggle.classList.remove("expanded"); + header?.classList.remove("expanded"); + this.expandedProjects.delete(projectId); + } + } + + saveExpandedState() { + localStorage.setItem( + "expandedProjects", + JSON.stringify([...this.expandedProjects]) + ); + } + + // QR Code Status Toggle + async toggleQRCodeStatus(qrId) { + try { + const response = await fetch(`/qr-codes/${qrId}/toggle-status`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + 'X-CSRF-Token': (window.qrConfig && window.qrConfig.csrfToken) || '', + }, + }); + + if (response.ok) { + const result = await response.json(); + if (result.success) { + this.showToast(result.message, "success"); + + // Reload page after short delay to show updated status + setTimeout(() => { + window.location.reload(); + }, 1000); + } else { + throw new Error(result.message || "Failed to toggle QR code status"); + } + } else { + throw new Error("Failed to toggle QR code status"); + } + } catch (error) { + console.error("Toggle status failed:", error); + this.showToast("Failed to update QR code status", "error"); + } + } + + // QR Modal Functions + openQRModalFromData(element) { + const qrData = { + name: element.dataset.qrName, + image: element.querySelector("img").src, + location: element.dataset.qrLocation, + address: element.dataset.qrAddress, + event: element.dataset.qrEvent, + qr_url: element.dataset.qrUrl, + }; + + this.openQRModal(qrData); + } + + openQRModal(qrData) { + const modal = document.getElementById("qrModal"); + const modalImage = document.getElementById("modalQRImage"); + const modalTitle = document.getElementById("modalTitle"); + const modalQRName = document.getElementById("modalQRName"); + const modalQRLocation = document.getElementById("modalQRLocation"); + const modalQRAddress = document.getElementById("modalQRAddress"); + const modalQREvent = document.getElementById("modalQREvent"); + const modalQRDestination = document.getElementById("modalQRDestination"); + + if (modal && modalImage && modalTitle) { + modalTitle.textContent = `QR Code: ${qrData.name}`; + modalImage.src = qrData.image; + modalImage.alt = `QR Code for ${qrData.name}`; + + if (modalQRName) modalQRName.textContent = qrData.name || "-"; + if (modalQRLocation) modalQRLocation.textContent = qrData.location || "-"; + if (modalQRAddress) modalQRAddress.textContent = qrData.address || "-"; + if (modalQREvent) + modalQREvent.textContent = qrData.event || "No event specified"; + + if (modalQRDestination && qrData.qr_url) { + const destinationUrl = `${window.location.origin}/qr/${qrData.qr_url}`; + const linkElement = modalQRDestination.querySelector("a"); + if (linkElement) { + linkElement.href = destinationUrl; + linkElement.innerHTML = ` + <i class="fas fa-external-link-alt"></i> + ${destinationUrl} + `; + } + } else if (modalQRDestination) { + modalQRDestination.innerHTML = + '<span style="color: var(--gray-500); font-style: italic;">No destination URL available</span>'; + } + + this.currentModalQR = { + name: qrData.name, + image: qrData.image, + location: qrData.location, + address: qrData.address, + event: qrData.event, + qr_url: qrData.qr_url, + destination_url: qrData.qr_url + ? `${window.location.origin}/qr/${qrData.qr_url}` + : null, + }; + + modal.style.display = "flex"; + + document.addEventListener("keydown", this.handleModalKeydown.bind(this)); + } + } + + closeQRModal() { + const modal = document.getElementById("qrModal"); + if (modal) { + modal.style.display = "none"; + this.currentModalQR = null; + + document.removeEventListener( + "keydown", + this.handleModalKeydown.bind(this) + ); + } + } + + handleModalKeydown(event) { + if (event.key === "Escape") { + this.closeQRModal(); + } + } + + // Download Functions + downloadModalQR() { + if (this.currentModalQR) { + const base64Data = this.currentModalQR.image.includes("base64,") + ? this.currentModalQR.image.split("base64,")[1] + : this.currentModalQR.image; + this.downloadQR(base64Data, this.currentModalQR.name); + } + } + + downloadQRFromCard(button) { + const qrCard = button.closest(".qr-card") || button.closest(".qr-item"); + const img = qrCard.querySelector("img"); + const qrName = + qrCard.dataset.qrName || + qrCard.querySelector(".qr-name")?.textContent || + "qr_code"; + + if (img && img.src) { + const base64Data = img.src.includes("base64,") + ? img.src.split("base64,")[1] + : img.src; + this.downloadQR(base64Data, qrName); + } + } + + downloadQR(base64Image, filename) { + try { + const base64Data = base64Image.includes("base64,") + ? base64Image.split("base64,")[1] + : base64Image; + + const link = document.createElement("a"); + link.href = `data:image/png;base64,${base64Data}`; + link.download = `${filename + .replace(/[^a-z0-9]/gi, "_") + .toLowerCase()}_qr_code.png`; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + this.showToast("QR code downloaded successfully!", "success"); + } catch (error) { + console.error("Download error:", error); + this.showToast("Failed to download QR code", "error"); + } + } + + // Copy Functions + copyModalQRData() { + if (this.currentModalQR) { + const data = `QR Code: ${this.currentModalQR.name}\nLocation: ${ + this.currentModalQR.location + }\nAddress: ${this.currentModalQR.address}\nEvent: ${ + this.currentModalQR.event + }${ + this.currentModalQR.destination_url + ? `\nQR Link: ${this.currentModalQR.destination_url}` + : "" + }`; + + navigator.clipboard + .writeText(data) + .then(() => { + this.showToast("QR code information copied to clipboard!", "success"); + }) + .catch(() => { + this.fallbackCopyText(data); + }); + } + } + + copyQRDestination() { + if (this.currentModalQR && this.currentModalQR.destination_url) { + navigator.clipboard + .writeText(this.currentModalQR.destination_url) + .then(() => { + this.showToast("QR destination link copied to clipboard!", "success"); + }) + .catch(() => { + this.showToast("Failed to copy QR link", "error"); + }); + } else { + this.showToast("No QR destination link available", "warning"); + } + } + + // FIXED: Copy QR Code URL + async copyQRUrl(qrId) { + try { + const response = await fetch(`/qr-codes/${qrId}/copy-url`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": (window.qrConfig && window.qrConfig.csrfToken) || "", + }, + }); + + const data = await response.json(); + + if (data.success && data.url) { + // Copy to clipboard + await navigator.clipboard.writeText(data.url); + this.showToast("QR code URL copied to clipboard!", "success"); + + // Log the action + console.log(`QR URL copied for ID: ${qrId}`); + } else { + this.showToast("Failed to copy URL", "error"); + } + } catch (error) { + console.error("Copy URL error:", error); + this.showToast("Failed to copy URL", "error"); + } + } + + // FIXED: Open QR Code Link + async openQRLink(qrId) { + try { + const response = await fetch(`/qr-codes/${qrId}/open-link`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": (window.qrConfig && window.qrConfig.csrfToken) || "", + }, + }); + + const data = await response.json(); + + if (data.success && data.url) { + // Open in new tab + window.open(data.url, "_blank"); + this.showToast("QR code link opened!", "success"); + + // Log the action + console.log(`QR link opened for ID: ${qrId}`); + } else { + this.showToast("Failed to open link", "error"); + } + } catch (error) { + console.error("Open link error:", error); + this.showToast("Failed to open link", "error"); + } + } + + fallbackCopyText(text) { + const textArea = document.createElement("textarea"); + textArea.value = text; + document.body.appendChild(textArea); + textArea.select(); + try { + document.execCommand("copy"); + this.showToast("QR code information copied to clipboard!", "success"); + } catch (err) { + this.showToast("Failed to copy to clipboard", "error"); + } + document.body.removeChild(textArea); + } + + // 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(); + } + } + + // QR Item Toggle functionality (for selection) + toggleQRItem(element) { + const qrId = element.dataset.qrId; + if (this.selectedQRCodes.has(qrId)) { + this.selectedQRCodes.delete(qrId); + element.classList.remove("selected"); + } else { + this.selectedQRCodes.add(qrId); + element.classList.add("selected"); + } + + // Update bulk action buttons if they exist + this.updateBulkActionButtons(); + } + + updateBulkActionButtons() { + const bulkActions = document.querySelector(".bulk-actions"); + if (bulkActions) { + bulkActions.style.display = + this.selectedQRCodes.size > 0 ? "flex" : "none"; + } + } + + // Copy QR data functionality + copyQRData(name, location, address, event) { + const data = `QR Code: ${name}\nLocation: ${location}\nAddress: ${address}\nEvent: ${event}`; + + if (navigator.clipboard) { + navigator.clipboard + .writeText(data) + .then(() => + this.showToast("QR code information copied to clipboard!", "success") + ) + .catch(() => this.fallbackCopyText(data)); + } else { + this.fallbackCopyText(data); + } + } + + // Delete QR Code + async deleteQRCode(qrId, qrName) { + if (!confirm(`Delete "${qrName}"? This cannot be undone.`)) return; + + try { + // Show loading state + const deleteBtn = document.querySelector( + `[onclick*="deleteQRCode(${qrId}"]` + ); + if (deleteBtn) { + deleteBtn.disabled = true; + deleteBtn.innerHTML = + '<i class="fas fa-spinner fa-spin"></i> Deleting...'; + } + + const response = await fetch(`/qr-codes/${qrId}/delete`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-CSRF-Token": (window.qrConfig && window.qrConfig.csrfToken) || "", + }, + }); + + if (response.ok) { + // Remove QR item from page immediately + const qrItem = document.querySelector(`[data-qr-id="${qrId}"]`); + if (qrItem) { + qrItem.style.transition = "opacity 0.3s"; + qrItem.style.opacity = "0"; + setTimeout(() => { + qrItem.remove(); + }, 300); + } + + // Show success message + this.showToast(`QR code "${qrName}" deleted successfully!`, "success"); + } else { + throw new Error(`Server error: ${response.status}`); + } + } catch (error) { + console.error("Delete error:", error); + + // Restore button if there was an error + const deleteBtn = document.querySelector( + `[onclick*="deleteQRCode(${qrId}"]` + ); + if (deleteBtn) { + deleteBtn.disabled = false; + deleteBtn.innerHTML = '<i class="fas fa-trash"></i>'; + } + + // Show error message + this.showToast("Failed to delete QR code. Please try again.", "error"); + } + } + + // Toast notification system + showToast(message, type = "info") { + const toast = document.createElement("div"); + toast.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: ${ + type === "success" + ? "#10b981" + : type === "error" + ? "#ef4444" + : type === "warning" + ? "#f59e0b" + : "#3b82f6" + }; + color: white; + padding: 12px 16px; + border-radius: 8px; + z-index: 9999; + font-weight: 500; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + transition: all 0.3s ease; + opacity: 0; + transform: translateX(100%); + `; + + toast.textContent = message; + document.body.appendChild(toast); + + // Animate in + setTimeout(() => { + toast.style.opacity = "1"; + toast.style.transform = "translateX(0)"; + }, 100); + + // Animate out + setTimeout(() => { + toast.style.opacity = "0"; + toast.style.transform = "translateX(100%)"; + setTimeout(() => { + if (document.body.contains(toast)) { + toast.remove(); + } + }, 300); + }, 3000); + } + + // Expand/collapse functionality + toggleExpandAll() { + this.allExpanded = !this.allExpanded; + const qrItems = document.querySelectorAll(".qr-item"); + const expandToggle = document.getElementById("expandAllToggle"); + + qrItems.forEach((item) => { + const details = item.querySelector(".qr-details"); + if (details) { + if (this.allExpanded) { + details.style.display = "block"; + item.classList.add("expanded"); + } else { + details.style.display = "none"; + item.classList.remove("expanded"); + } + } + }); + + if (expandToggle) { + expandToggle.innerHTML = this.allExpanded + ? '<i class="fas fa-compress-alt"></i> Collapse All' + : '<i class="fas fa-expand-alt"></i> Expand All'; + } + } +} + +// Global variable to hold dashboard manager instance +let dashboardManager; + +// Initialize dashboard when DOM is loaded +document.addEventListener("DOMContentLoaded", function () { + dashboardManager = new ProjectDashboardManager(); + + // Register all global functions for template compatibility + window.toggleProject = (projectId) => + dashboardManager.toggleProject(projectId); + window.toggleQRCodeStatus = (qrId) => + dashboardManager.toggleQRCodeStatus(qrId); + window.openQRModalFromData = (element) => + dashboardManager.openQRModalFromData(element); + window.openQRModal = (qrData) => dashboardManager.openQRModal(qrData); + 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(); + window.toggleQRItem = (element) => dashboardManager.toggleQRItem(element); + window.copyQRData = (name, location, address, event) => + dashboardManager.copyQRData(name, location, address, event); + window.deleteQRCode = (qrId, qrName) => + dashboardManager.deleteQRCode(qrId, qrName); + + // FIXED: Global functions for copy/open link functionality + window.copyQRUrl = function (qrId) { + if (dashboardManager && dashboardManager.copyQRUrl) { + dashboardManager.copyQRUrl(qrId); + } else { + console.error( + "ProjectDashboardManager not initialized or copyQRUrl method missing" + ); + } + }; + + window.openQRLink = function (qrId) { + if (dashboardManager && dashboardManager.openQRLink) { + dashboardManager.openQRLink(qrId); + } else { + console.error( + "ProjectDashboardManager not initialized or openQRLink method missing" + ); + } + }; + + console.log( + "Project Dashboard initialized successfully with copy/open link functionality" + ); + console.log("Dashboard keyboard shortcuts:"); + console.log("Ctrl/Cmd + F: Focus search"); + console.log("Escape: Close modal"); +}); diff --git a/static/js/export_configuration.js b/static/js/export_configuration.js new file mode 100644 index 0000000..2df1379 --- /dev/null +++ b/static/js/export_configuration.js @@ -0,0 +1,862 @@ +/** + * Enhanced Export Configuration JavaScript with Drag & Drop + * Handles column selection, preview updates, drag & drop reordering, and preference management + */ + +// Default column configuration +const DEFAULT_COLUMN_ORDER = [ + 'employee_id', // ID + 'device_info', // Platform + 'check_in_date', // Date + 'check_in_time', // Time + 'location_name', // Location Name + 'status', // Action Description + 'qr_address', // Event Description + 'address', // Recorded Address + 'location_accuracy' // Distance +]; + +const DEFAULT_COLUMNS = { + 'employee_id': { selected: true, name: 'ID' }, + 'location_name': { selected: true, name: 'Location Name' }, + 'status': { selected: true, name: 'Action Description' }, + 'check_in_date': { selected: true, name: 'Date' }, + 'check_in_time': { selected: true, name: 'Time' }, + 'qr_address': { selected: true, name: 'Event Description' }, + 'address': { selected: true, name: 'Recorded Address' }, + 'device_info': { selected: true, name: 'Platform' }, + 'location_accuracy': { selected: true, name: 'Distance' } +}; + +// Global variables +let availableColumns = []; +let sortableInstance = null; +let savedColumnOrder = []; + +function toggleSection(sectionId) { + try { + const section = document.getElementById(sectionId); + const icon = document.getElementById(sectionId + 'Icon'); + + if (!section) return; + + if (section.classList.contains('collapsed')) { + section.classList.remove('collapsed'); + if (icon) icon.classList.remove('rotated'); + } else { + section.classList.add('collapsed'); + if (icon) icon.classList.add('rotated'); + } + + // Save state + try { + const collapsedSections = JSON.parse(localStorage.getItem('collapsedSections') || '{}'); + collapsedSections[sectionId] = section.classList.contains('collapsed'); + localStorage.setItem('collapsedSections', JSON.stringify(collapsedSections)); + } catch (e) {} + } catch (error) { + console.error('Error toggling section:', error); + } +} + +function restoreCollapsedState() { + try { + const collapsedSections = JSON.parse(localStorage.getItem('collapsedSections') || '{}'); + + Object.keys(collapsedSections).forEach(sectionId => { + if (collapsedSections[sectionId]) { + const section = document.getElementById(sectionId); + const icon = document.getElementById(sectionId + 'Icon'); + + if (section) { + section.classList.add('collapsed'); + if (icon) icon.classList.add('rotated'); + } + } + }); + } catch (error) {} +} + +// Initialize when DOM is loaded +document.addEventListener('DOMContentLoaded', function() { + console.log('Enhanced Export Configuration with Drag & Drop initialized'); + + // Initialize available columns data + initializeColumnsData(); + + // Set up event listeners + setupEventListeners(); + + // Load saved preferences if available + loadSavedPreferences(); + + // Update preview on load + updatePreview(); + + // Initialize drag & drop + initializeDragDrop(); +}); + +function initializeColumnsData() { + try { + // Extract column data from the form + const checkboxes = document.querySelectorAll('input[name="selected_columns"]'); + availableColumns = Array.from(checkboxes).map(cb => { + const columnKey = cb.value; + const label = cb.parentElement.querySelector('label').textContent.trim(); + const nameInput = document.getElementById('name_' + columnKey); + + return { + key: columnKey, + label: label, + defaultName: nameInput ? nameInput.value : label, + enabled: cb.checked + }; + }); + + console.log('Initialized columns data:', availableColumns); + } catch (error) { + console.error('Error initializing columns data:', error); + } +} + +function setupEventListeners() { + try { + // Add change listeners to all column checkboxes + const checkboxes = document.querySelectorAll('input[name="selected_columns"]'); + checkboxes.forEach(checkbox => { + checkbox.addEventListener('change', function() { + toggleColumnName(this.value); + updateSelectedColumnsList(); + updatePreview(); + + // Add visual feedback + const columnItem = this.closest('.column-item'); + if (this.checked) { + columnItem.classList.add('selected'); + } else { + columnItem.classList.remove('selected'); + } + }); + }); + + // Add input listeners to all column name inputs + const nameInputs = document.querySelectorAll('input[id^="name_"]'); + nameInputs.forEach(input => { + input.addEventListener('input', debounce(() => { + updateSelectedColumnsList(); + updatePreview(); + }, 300)); + }); + + // Form validation before submit + const form = document.getElementById('exportForm'); + if (form) { + form.addEventListener('submit', function(e) { + if (!validateForm()) { + e.preventDefault(); + } + }); + } + } catch (error) { + console.error('Error setting up event listeners:', error); + } +} + +function toggleColumnName(columnKey) { + try { + const checkbox = document.getElementById('col_' + columnKey); + const nameGroup = document.getElementById('name_group_' + columnKey); + + if (checkbox && nameGroup) { + if (checkbox.checked) { + nameGroup.style.display = 'block'; + nameGroup.style.opacity = '0'; + setTimeout(() => { + nameGroup.style.opacity = '1'; + }, 10); + } else { + nameGroup.style.opacity = '0'; + setTimeout(() => { + nameGroup.style.display = 'none'; + }, 300); + } + } + } catch (error) { + console.error('Error toggling column name:', error); + } +} + +/** + * Toggle edit mode for column name input + * @param {string} columnKey - The column key + * @param {boolean} enableEdit - Whether to enable editing + */ +function toggleEditMode(columnKey, enableEdit) { + try { + const nameInput = document.getElementById('name_' + columnKey); + + if (nameInput) { + if (enableEdit) { + // Enable editing + nameInput.removeAttribute('readonly'); + nameInput.focus(); + nameInput.select(); + console.log(`✏️ Editing enabled for column: ${columnKey}`); + } else { + // Disable editing + nameInput.setAttribute('readonly', 'readonly'); + console.log(`🔒 Editing disabled for column: ${columnKey}`); + } + + // Update preview when name changes + updateSelectedColumnsList(); + updatePreview(); + } + } catch (error) { + console.error('Error toggling edit mode:', error); + } +} + + +function initializeDragDrop() { + try { + const selectedColumnsList = document.getElementById('selectedColumnsList'); + if (selectedColumnsList) { + sortableInstance = Sortable.create(selectedColumnsList, { + animation: 200, + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + dragClass: 'sortable-drag', + handle: '.column-drag-handle', + onStart: function(evt) { + console.log('Drag started:', evt.oldIndex); + }, + onEnd: function(evt) { + console.log('Drag ended:', evt.oldIndex, '->', evt.newIndex); + updateColumnOrderNumbers(); + updatePreview(); + + // Save the new order + savePreferences(); + } + }); + + console.log('Drag & drop initialized successfully'); + } + } catch (error) { + console.error('Error initializing drag & drop:', error); + } +} + +function updateSelectedColumnsList() { + try { + const selectedColumnsList = document.getElementById('selectedColumnsList'); + const selectedColumnsSection = document.getElementById('selectedColumnsSection'); + + if (!selectedColumnsList || !selectedColumnsSection) return; + + // Get currently selected columns + const selectedColumns = Array.from(document.querySelectorAll('input[name="selected_columns"]:checked')); + + if (selectedColumns.length === 0) { + selectedColumnsSection.style.display = 'none'; + return; + } + + selectedColumnsSection.style.display = 'block'; + selectedColumnsSection.classList.add('has-columns'); + + // Get current order if exists, otherwise use selection order + let orderedColumns = []; + if (savedColumnOrder.length > 0) { + // Use saved order, but only include currently selected columns + orderedColumns = savedColumnOrder.filter(key => + selectedColumns.some(cb => cb.value === key) + ); + // Add any newly selected columns that weren't in saved order + selectedColumns.forEach(cb => { + if (!orderedColumns.includes(cb.value)) { + orderedColumns.push(cb.value); + } + }); + } else { + orderedColumns = selectedColumns.map(cb => cb.value); + } + + // Build the selected columns list HTML + let listHTML = ''; + orderedColumns.forEach((columnKey, index) => { + const nameInput = document.getElementById('name_' + columnKey); + const columnData = availableColumns.find(col => col.key === columnKey); + const customName = nameInput ? nameInput.value : (columnData ? columnData.label : columnKey); + + listHTML += ` + <div class="selected-column-item" data-column-key="${columnKey}"> + <div class="selected-column-info"> + <div class="column-drag-handle" title="Drag to reorder"> + <i class="fas fa-grip-vertical"></i> + </div> + <div class="selected-column-details"> + <div class="selected-column-name">${columnData ? columnData.label : columnKey}</div> + <div class="selected-column-export-name">Export as: "${customName}"</div> + </div> + </div> + <div class="column-order-number">${index + 1}</div> + </div> + `; + }); + + if (listHTML === '') { + listHTML = ` + <div class="selected-columns-empty"> + <i class="fas fa-hand-point-up"></i> + <p>Select columns above to see them here for reordering</p> + </div> + `; + } + + selectedColumnsList.innerHTML = listHTML; + + // Re-initialize sortable after updating content + if (sortableInstance) { + sortableInstance.destroy(); + } + initializeDragDrop(); + + console.log(`Updated selected columns list with ${orderedColumns.length} columns`); + } catch (error) { + console.error('Error updating selected columns list:', error); + } +} + +function updateColumnOrderNumbers() { + try { + const orderNumbers = document.querySelectorAll('.column-order-number'); + orderNumbers.forEach((element, index) => { + element.textContent = index + 1; + }); + } catch (error) { + console.error('Error updating column order numbers:', error); + } +} + +function getCurrentColumnOrder() { + try { + const selectedItems = document.querySelectorAll('.selected-column-item'); + return Array.from(selectedItems).map(item => item.dataset.columnKey); + } catch (error) { + console.error('Error getting current column order:', error); + return []; + } +} + +function updateColumnOrderField() { + try { + const columnOrderField = document.getElementById('column_order'); + const currentOrder = getCurrentColumnOrder(); + if (columnOrderField) { + columnOrderField.value = JSON.stringify(currentOrder); + } + } catch (error) { + console.error('Error updating column order field:', error); + } +} + +function updatePreview() { + try { + const previewHeader = document.getElementById('previewHeader'); + const previewTable = document.querySelector('.preview-table tbody'); + + if (!previewHeader || !previewTable) { + console.warn('Preview elements not found'); + return; + } + + // Get selected columns in their current order + const currentOrder = getCurrentColumnOrder(); + + if (currentOrder.length === 0) { + // No columns selected + previewHeader.innerHTML = ''; + previewTable.innerHTML = ` + <tr> + <td colspan="100%" class="preview-placeholder"> + <i class="fas fa-exclamation-triangle"></i> + No columns selected - please select at least one column to export + </td> + </tr> + `; + updateGenerateButton(0); + return; + } + + // Build header with order numbers + let headerHTML = ''; + currentOrder.forEach((columnKey, index) => { + const nameInput = document.getElementById('name_' + columnKey); + const customName = nameInput ? nameInput.value : columnKey; + + headerHTML += `<th data-order="${index + 1}">${customName}</th>`; + }); + previewHeader.innerHTML = headerHTML; + + // Build sample data row + let sampleRowHTML = '<tr>'; + currentOrder.forEach(columnKey => { + let sampleData = getSampleData(columnKey); + + // Special handling for address column to show the logic + if (columnKey === 'address') { + sampleData = `<span title="If location accuracy ≤ 0.5 miles: shows QR address, otherwise: shows actual check-in address">123 Business St, City*</span>`; + } + + sampleRowHTML += `<td>${sampleData}</td>`; + }); + sampleRowHTML += '</tr>'; + + // Add explanation row if address column is selected + const hasAddressColumn = currentOrder.includes('address'); + if (hasAddressColumn) { + sampleRowHTML += ` + <tr style="background-color: #f8f9fa; font-size: 0.85em; color: #6c757d;"> + <td colspan="${currentOrder.length}" style="text-align: center; padding: 0.75rem; font-style: italic;"> + <i class="fas fa-info-circle"></i> + * Check-in Address: Shows QR address when location accuracy ≤ 0.5 miles, otherwise shows actual GPS address + </td> + </tr> + `; + } + + previewTable.innerHTML = sampleRowHTML; + + // Update generate button + updateGenerateButton(currentOrder.length); + + console.log(`Preview updated with ${currentOrder.length} columns in order:`, currentOrder); + } catch (error) { + console.error('Error updating preview:', error); + } +} + +function getSampleData(columnKey) { + // Return sample data for each column type + const sampleData = { + 'employee_id': 'EMP001', + 'location_name': 'Main Office', + 'status': 'Check In', + 'check_in_date': '2025-08-14', + 'check_in_time': '09:30:00', + 'qr_address': '123 Business St, City', + 'address': '123 Business St, City', + 'device_info': 'iPhone 14 Pro', + 'ip_address': '192.168.1.100', + 'user_agent': 'Mobile Safari', + 'latitude': '40.7128', + 'longitude': '-74.0060', + 'accuracy': '5.2', + 'location_accuracy': '0.003' + }; + + return sampleData[columnKey] || 'Sample Data'; +} + +function selectAllColumns() { + try { + const checkboxes = document.querySelectorAll('input[name="selected_columns"]'); + checkboxes.forEach(checkbox => { + if (!checkbox.checked) { + checkbox.checked = true; + checkbox.closest('.column-item').classList.add('selected'); + toggleColumnName(checkbox.value); + } + }); + updateSelectedColumnsList(); + updatePreview(); + + console.log('All columns selected'); + } catch (error) { + console.error('Error selecting all columns:', error); + } +} + +function deselectAllColumns() { + try { + const checkboxes = document.querySelectorAll('input[name="selected_columns"]'); + checkboxes.forEach(checkbox => { + if (checkbox.checked) { + checkbox.checked = false; + checkbox.closest('.column-item').classList.remove('selected'); + toggleColumnName(checkbox.value); + } + }); + updateSelectedColumnsList(); + updatePreview(); + + console.log('All columns deselected'); + } catch (error) { + console.error('Error deselecting all columns:', error); + } +} + +function resetToDefaults() { + try { + console.log('🔄 Resetting to default settings...'); + + // First, deselect all columns and reset to defaults + const allCheckboxes = document.querySelectorAll('input[name="selected_columns"]'); + allCheckboxes.forEach(checkbox => { + const key = checkbox.value; + const nameInput = document.getElementById('name_' + key); + const editCheckbox = document.getElementById('edit_' + key); + const columnItem = checkbox.closest('.column-item'); + + // Check if this column should be selected by default + const isDefaultSelected = DEFAULT_COLUMNS.hasOwnProperty(key) && DEFAULT_COLUMNS[key].selected; + + // Set checkbox state + checkbox.checked = isDefaultSelected; + + // Update visual state + if (isDefaultSelected) { + columnItem?.classList.add('selected'); + } else { + columnItem?.classList.remove('selected'); + } + + // Set the export name and reset to readonly + if (nameInput) { + if (DEFAULT_COLUMNS[key]) { + nameInput.value = DEFAULT_COLUMNS[key].name; + } else { + // Use the original default name from availableColumns if not in DEFAULT_COLUMNS + const columnData = availableColumns.find(col => col.key === key); + nameInput.value = columnData ? columnData.defaultName : nameInput.value; + } + + // Reset to readonly mode + nameInput.setAttribute('readonly', 'readonly'); + } + + // Uncheck edit checkbox + if (editCheckbox) { + editCheckbox.checked = false; + } + + // Toggle visibility + toggleColumnName(key); + }); + + // Set the saved order to the default order + savedColumnOrder = [...DEFAULT_COLUMN_ORDER]; + + // Update the display with the default order + updateSelectedColumnsList(); + + // Apply the default order to the selected columns list + applyDefaultOrder(); + + // Update preview + updatePreview(); + + // Clear saved preferences from localStorage + try { + localStorage.removeItem('exportPreferences'); + } catch (storageError) { + console.warn('Could not clear saved preferences:', storageError); + } + + console.log('✅ Reset to default settings complete'); + } catch (error) { + console.error('❌ Error resetting to defaults:', error); + } +} + +/** + * Apply the default column order to the selected columns list + */ +function applyDefaultOrder() { + try { + const selectedList = document.getElementById('selectedColumnsList'); + if (!selectedList) return; + + const items = Array.from(selectedList.children); + if (items.length === 0) return; + + // Sort items based on DEFAULT_COLUMN_ORDER + items.sort((a, b) => { + const keyA = a.dataset.columnKey; + const keyB = b.dataset.columnKey; + const indexA = DEFAULT_COLUMN_ORDER.indexOf(keyA); + const indexB = DEFAULT_COLUMN_ORDER.indexOf(keyB); + + // If key not in default order, put it at the end + if (indexA === -1 && indexB === -1) return 0; + if (indexA === -1) return 1; + if (indexB === -1) return -1; + + return indexA - indexB; + }); + + // Clear and re-append in sorted order + selectedList.innerHTML = ''; + items.forEach(item => selectedList.appendChild(item)); + + // Update order numbers + updateColumnOrderNumbers(); + + // Update the hidden column order field + updateColumnOrderField(); + + console.log('📋 Applied default column order'); + } catch (error) { + console.error('Error applying default order:', error); + } +} + +function updateGenerateButton(columnCount) { + try { + const generateBtn = document.getElementById('generateBtn'); + if (!generateBtn) return; + + if (columnCount === 0) { + generateBtn.disabled = true; + generateBtn.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Select columns to export'; + generateBtn.classList.add('btn-disabled'); + } else { + generateBtn.disabled = false; + generateBtn.innerHTML = `<i class="fas fa-download"></i> Generate Excel Export (${columnCount} columns)`; + generateBtn.classList.remove('btn-disabled'); + } + } catch (error) { + console.error('Error updating generate button:', error); + } +} + +function validateForm() { + try { + const selectedColumns = document.querySelectorAll('input[name="selected_columns"]:checked'); + + if (selectedColumns.length === 0) { + alert('Please select at least one column to export.'); + return false; + } + + // Validate that all selected columns have names + let hasEmptyNames = false; + selectedColumns.forEach(checkbox => { + const nameInput = document.getElementById('name_' + checkbox.value); + if (nameInput && nameInput.value.trim() === '') { + hasEmptyNames = true; + nameInput.style.borderColor = '#e53e3e'; + nameInput.focus(); + } else if (nameInput) { + nameInput.style.borderColor = '#e2e8f0'; + } + }); + + if (hasEmptyNames) { + alert('Please provide names for all selected columns.'); + return false; + } + + // Update column order field before submitting + updateColumnOrderField(); + + // Save preferences before submitting + savePreferences(); + + return true; + } catch (error) { + console.error('Error validating form:', error); + return false; + } +} + +function savePreferences() { + try { + const selectedColumns = Array.from(document.querySelectorAll('input[name="selected_columns"]:checked')) + .map(cb => cb.value); + + const columnNames = {}; + const editStates = {}; + selectedColumns.forEach(col => { + const input = document.getElementById('name_' + col); + const editCheckbox = document.getElementById('edit_' + col); + + if (input) { + columnNames[col] = input.value.trim(); + } + + if (editCheckbox) { + editStates[col] = editCheckbox.checked; + } + }); + + // Get current column order + const columnOrder = getCurrentColumnOrder(); + + const prefs = { + selected_columns: selectedColumns, + column_names: columnNames, + edit_states: editStates, + column_order: columnOrder, + timestamp: new Date().toISOString() + }; + + localStorage.setItem('exportPreferences', JSON.stringify(prefs)); + console.log('💾 Preferences saved with column order and edit states:', prefs); + + } catch (e) { + console.warn('Could not save preferences:', e); + } +} + +function loadSavedPreferences() { + try { + 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; + } + + const prefs = JSON.parse(savedPrefs); + + // Check if preferences are not too old (30 days) + const savedDate = new Date(prefs.timestamp || 0); + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + + 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; + } + + // Apply saved column selections + if (prefs.selected_columns) { + const checkboxes = document.querySelectorAll('input[name="selected_columns"]'); + checkboxes.forEach(cb => { + const shouldBeChecked = prefs.selected_columns.includes(cb.value); + if (cb.checked !== shouldBeChecked) { + cb.checked = shouldBeChecked; + const columnItem = cb.closest('.column-item'); + if (shouldBeChecked) { + columnItem?.classList.add('selected'); + } else { + columnItem?.classList.remove('selected'); + } + 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 && prefs.column_names[key]) { + input.value = prefs.column_names[key]; + } + }); + } + + // Apply saved edit states + if (prefs.edit_states) { + Object.keys(prefs.edit_states).forEach(key => { + const editCheckbox = document.getElementById('edit_' + key); + const nameInput = document.getElementById('name_' + key); + + if (editCheckbox && prefs.edit_states[key]) { + editCheckbox.checked = true; + if (nameInput) { + nameInput.removeAttribute('readonly'); + } + } else if (nameInput) { + nameInput.setAttribute('readonly', 'readonly'); + } + }); + } + + // Save column order for later use + if (prefs.column_order) { + savedColumnOrder = prefs.column_order; + } + + console.log('📋 Preferences loaded:', prefs); + + // Update preview after loading preferences + updateSelectedColumnsList(); + updatePreview(); + + } catch (e) { + console.warn('Could not load saved preferences:', e); + try { + localStorage.removeItem('exportPreferences'); + } 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(); + } + } +} + +// Utility function for debouncing +function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} + +// Global functions for template usage +window.selectAllColumns = selectAllColumns; +window.deselectAllColumns = deselectAllColumns; +window.resetToDefaults = resetToDefaults; +window.toggleColumnName = toggleColumnName; +window.toggleEditMode = toggleEditMode; +window.updateSelectedColumnsList = updateSelectedColumnsList; +window.getCurrentColumnOrder = getCurrentColumnOrder; +window.updateColumnOrderField = updateColumnOrderField; +window.savePreferences = savePreferences; + +// Add CSS for disabled button +const style = document.createElement('style'); +style.textContent = ` +.btn-disabled { + opacity: 0.6 !important; + cursor: not-allowed !important; + background: #a0aec0 !important; + pointer-events: none; +} + +.btn-disabled:hover { + transform: none !important; + box-shadow: none !important; +} +`; +document.head.appendChild(style); \ No newline at end of file diff --git a/static/js/qr_destination.js b/static/js/qr_destination.js new file mode 100644 index 0000000..6a45df8 --- /dev/null +++ b/static/js/qr_destination.js @@ -0,0 +1,1510 @@ +/** + * QR Code Destination Page JavaScript - Enhanced with Multiple Check-ins Support + * Handles staff check-in functionality with GPS location support, language switching, and 30-minute interval validation + */ + +// Global variables +let isSubmitting = false; +let currentTime = new Date(); + +// Camera verification variables +let cameraStream = null; +let capturedPhotoData = null; +let verificationAttemptData = null; + +// Location tracking variables +let userLocation = { + latitude: null, + longitude: null, + accuracy: null, + altitude: null, + timestamp: null, + source: "manual", + address: null, +}; +let locationRequestActive = false; +let locationWatchId = null; + +// BILINGUAL FUNCTIONALITY +let currentLanguage = "en"; +const translations = { + en: { + languageText: "ES", + statusMessages: { + processing: "Processing check-in...", + success: "Check-in successful!", + error: "Check-in failed. Please try again.", + duplicate: "You have already checked in today.", + tooSoon: "Please wait before checking in again.", + multipleSuccess: "Submitted successfully!", + invalidId: "Please enter a valid Employee ID.", + locationError: "Unable to get location data.", + networkError: "Network error. Please check your connection.", + }, + }, + es: { + languageText: "EN", + statusMessages: { + processing: "Procesando registro...", + success: "¡Registro exitoso!", + error: "Error en el registro. Por favor intente de nuevo.", + duplicate: "Ya se ha registrado hoy.", + tooSoon: "Por favor espere antes de registrarse nuevamente.", + multipleSuccess: "Submitted successfully!!", + invalidId: "Por favor ingrese un ID de empleado válido.", + locationError: "No se pudo obtener datos de ubicación.", + networkError: "Error de red. Verifique su conexión.", + }, + }, +}; + +// DOM Content Loaded Event (PRESERVED FROM ORIGINAL) +document.addEventListener("DOMContentLoaded", function () { + // CRITICAL: Disable form FIRST before anything else + window.locationServicesBlocked = true; + disableFormImmediately(); + // CRITICAL: Initialize systems in correct order + initializeLanguage(); + initializeLocationServicesCheck(); + initializeStaffIdPersistence(); + initializeForm(); + initializeLocation(); + startClock(); + + // Add fade-in animation to elements + setTimeout(() => { + document.querySelectorAll(".fade-transition").forEach((el) => { + el.classList.add("active"); + }); + }, 100); +}); + +// ENHANCED STAFF ID PERSISTENCE FUNCTIONALITY +function initializeStaffIdPersistence() { + // Load last staff ID from localStorage + lastStaffId = loadLastStaffId(); + if (lastStaffId) { + // Automatically fill the last staff ID + const employeeIdInput = document.getElementById("employee_id"); + if (employeeIdInput) { + employeeIdInput.value = lastStaffId; + validateEmployeeId(); + } + } +} + +function loadLastStaffId() { + try { + const saved = localStorage.getItem("qr_last_staff_id"); + const digitsOnly = (saved || "").replace(/[^0-9]/g, ""); + if (digitsOnly.length >= 2) { + return digitsOnly; + } + return null; + } catch (error) { + return null; + } +} + +function saveLastStaffId(staffId) { + try { + if (!staffId || typeof staffId !== "string" || staffId.trim().length < 2) { + return false; + } + + const cleanId = staffId.trim().toUpperCase(); + lastStaffId = cleanId; + + // Save to localStorage + localStorage.setItem("qr_last_staff_id", cleanId); + + return true; + } catch (error) { + return false; + } +} + +// ENHANCED FORM HANDLING FOR MULTIPLE CHECK-INS +function initializeForm() { + const form = document.getElementById("checkinForm"); + const submitButton = document.getElementById("submitCheckin"); + + if (form && submitButton) { + form.addEventListener("submit", handleFormSubmit); + + // Add real-time Employee ID validation + const employeeIdInput = document.getElementById("employee_id"); + if (employeeIdInput) { + // Employee ID is numeric only — strip anything else as it is typed + employeeIdInput.addEventListener("input", function () { + const digitsOnly = this.value.replace(/[^0-9]/g, ""); + if (this.value !== digitsOnly) { + this.value = digitsOnly; + } + }); + employeeIdInput.addEventListener("input", validateEmployeeId); + employeeIdInput.addEventListener("keypress", function (e) { + if (e.key === "Enter") { + e.preventDefault(); + handleFormSubmit(e); + } + }); + } + } +} + +function disableFormImmediately() { + // Find and disable submit button immediately + const submitButton = + document.getElementById("submitCheckin") || + document.getElementById("submitButton") || + document.querySelector('button[type="submit"]') || + document.querySelector(".btn-primary"); + + const employeeIdInput = document.getElementById("employee_id"); + const form = document.getElementById("checkinForm"); + + if (submitButton) { + submitButton.disabled = true; + submitButton.style.opacity = "0.5"; + submitButton.style.cursor = "not-allowed"; + submitButton.style.pointerEvents = "none"; + submitButton.setAttribute("data-location-blocked", "true"); + + // Store original content + if (!submitButton.getAttribute("data-original-content")) { + submitButton.setAttribute( + "data-original-content", + submitButton.innerHTML + ); + } + + // Show loading/checking state + submitButton.innerHTML = ` + <i class="fas fa-spinner fa-spin"></i> + <span>Checking Location Services...</span> + `; + } + + if (employeeIdInput) { + employeeIdInput.disabled = true; + employeeIdInput.style.opacity = "0.7"; + employeeIdInput.setAttribute("data-location-blocked", "true"); + employeeIdInput.placeholder = "Checking location services..."; + } + + if (form) { + form.classList.add("location-blocked"); + form.style.pointerEvents = "none"; + } + + console.log("🚫 Form DISABLED by default - Checking Location Services..."); +} + +function handleFormSubmit(event) { + event.preventDefault(); + event.stopPropagation(); + + // CRITICAL: Check if location services are blocked + if (window.locationServicesBlocked === true) { + console.log("🚫 Form submission blocked - Location Services not enabled"); + showLocationServicesBlockedMessage(); + return false; + } + + if (isSubmitting) { + return false; + } + + // STEP 1: Check Location Services FIRST + console.log("🔍 Step 1: Validating Location Services..."); + + checkLocationServicesStatus() + .then(() => { + // Location services are working, proceed with check-in + console.log("✅ Location Services validated successfully"); + proceedWithCheckin(); + }) + .catch((error) => { + // Location services are not working, block check-in + console.log("❌ Location Services validation failed:", error); + showLocationServicesBlockedMessage(); + return false; + }); + + return false; +} + +/** + * Proceed with the actual check-in process after location validation + */ +function proceedWithCheckin() { + // Double-check location services are not blocked + if (window.locationServicesBlocked === true) { + console.log("🚫 Check-in blocked - Location Services not enabled"); + showLocationServicesBlockedMessage(); + return; + } + + // ADDED: For dynamic QR codes, require a location to be selected before proceeding + var selLocField = document.getElementById("selected_location_name"); + var locationSelectCard = document.getElementById("locationSelectCard"); + var dropdown = document.getElementById("locationDropdown"); + var hasLocationSelected = (window._dynamicQRSelectedName && window._dynamicQRSelectedName.trim()) || + (selLocField && selLocField.value.trim()) || + (dropdown && dropdown.value.trim()); + + if (locationSelectCard && !hasLocationSelected) { + // No location selected — redirect employee back to Step 1 + document.getElementById("checkinFormCard").style.display = "none"; + locationSelectCard.style.display = "block"; + showLocalizedStatusMessage("invalidId", "error"); + console.log("❌ Dynamic QR: no location selected, returning to Step 1"); + return; + } + + const employeeId = document.getElementById("employee_id")?.value?.trim(); + + if (!employeeId) { + showLocalizedStatusMessage("invalidId", "error"); + return; + } + + if (employeeId.length < 2) { + showLocalizedStatusMessage("invalidId", "error"); + return; + } + + // Save the staff ID for future use + saveLastStaffId(employeeId); + + // Show processing status + showLocalizedStatusMessage("processing", "info"); + + // Submit the check-in + submitCheckin(); +} + +/** + * Show message when check-in is blocked due to location services + */ +function showLocationServicesBlockedMessage() { + const messages = { + en: "Check-in blocked: Location Services must be enabled to continue.", + es: "Registro bloqueado: Los Servicios de Ubicación deben estar habilitados para continuar.", + }; + + const currentLang = currentLanguage || "en"; + const message = messages[currentLang]; + + showCustomStatusMessage(message, "error"); +} + +// ENHANCED CHECK-IN SUBMISSION WITH MULTIPLE CHECK-IN SUPPORT +function submitCheckin() { + if (isSubmitting) { + return; + } + + isSubmitting = true; + updateSubmitButton(true); + + const employeeIdField = document.getElementById("employee_id"); + // Numeric only — mirrors the input filter, in case the value was set + // programmatically (autofill / browser restore) without an input event. + if (employeeIdField) { + employeeIdField.value = employeeIdField.value.replace(/[^0-9]/g, ""); + } + const employeeId = employeeIdField ? employeeIdField.value.trim() : ""; + + if (!employeeId) { + showLocalizedStatusMessage("invalidId", "error"); + isSubmitting = false; + updateSubmitButton(false); + return; + } + + const workTypeField = document.getElementById("work_type"); + const workType = workTypeField ? workTypeField.value.trim() : ""; + + // Prepare form data + const formData = new FormData(); + formData.append("employee_id", employeeId); + // Empty string = Regular; PW / SP / C are appended to the ID server-side + formData.append("work_type", workType); + formData.append( + "latitude", + userLocation.latitude ? userLocation.latitude.toFixed(10) : "" + ); + formData.append( + "longitude", + userLocation.longitude ? userLocation.longitude.toFixed(10) : "" + ); + formData.append("accuracy", userLocation.accuracy || ""); + formData.append("altitude", userLocation.altitude || ""); + formData.append("location_source", userLocation.source || "manual"); + formData.append("address", userLocation.address || ""); + + // ADDED: Forward the employee-selected location for dynamic QR check-in. + // Priority: window globals (set by confirmLocationSelection) → hidden field → dropdown. + var locationNameValue = (window._dynamicQRSelectedName && window._dynamicQRSelectedName.trim()) + ? window._dynamicQRSelectedName.trim() + : ""; + var locationAddrValue = (window._dynamicQRSelectedAddress && window._dynamicQRSelectedAddress.trim()) + ? window._dynamicQRSelectedAddress.trim() + : ""; + + // Fallback to hidden fields + if (!locationNameValue) { + var _hn = document.getElementById("selected_location_name"); + var _ha = document.getElementById("selected_location_address"); + if (_hn && _hn.value.trim()) { + locationNameValue = _hn.value.trim(); + locationAddrValue = (_ha && _ha.value.trim()) ? _ha.value.trim() : ""; + } + } + + // Final fallback to dropdown directly + if (!locationNameValue) { + var _dd = document.getElementById("locationDropdown"); + if (_dd && _dd.value.trim()) { + locationNameValue = _dd.value.trim(); + var _so = _dd.options[_dd.selectedIndex]; + locationAddrValue = _so ? (_so.getAttribute("data-address") || "") : ""; + } + } + + console.log("📍 [qr_destination.js] Location — name:", locationNameValue, "| addr:", locationAddrValue); + + if (locationNameValue) { + formData.append("selected_location_name", locationNameValue); + } + if (locationAddrValue) { + formData.append("selected_location_address", locationAddrValue); + } + + const currentUrl = window.location.pathname; + const checkinUrl = `${currentUrl}/checkin`; + + fetch(checkinUrl, { + method: "POST", + body: formData, + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }) + .then((response) => { + return response.json(); + }) + .then((data) => { + // Check if photo verification is required + if (data.requires_verification) { + console.log('⚠️ Photo verification required'); + verificationAttemptData = formData; // Store for retry with photo + showVerificationModal(data.distance || 0, data.threshold || 0.3); + } else { + handleCheckinResponse(data); + } + }) + .catch((error) => { + handleCheckinError(error); + }) + .finally(() => { + isSubmitting = false; + updateSubmitButton(false); + }); +} + +// ENHANCED RESPONSE HANDLING FOR MULTIPLE CHECK-INS +function handleCheckinResponse(data) { + if (data.success) { + handleCheckinSuccess(data); + } else { + const errorMsg = data.message || "Submission failed"; + + // NEW: Handle different types of check-in failures + if (errorMsg.toLowerCase().includes("already submitted")) { + showLocalizedStatusMessage("duplicate", "warning"); + } else if ( + errorMsg.toLowerCase().includes("submit again in") || + errorMsg.toLowerCase().includes("minutes") + ) { + // Handle 30-minute interval message + showCustomStatusMessage(errorMsg, "warning"); + } else { + showLocalizedStatusMessage("error", "error"); + } + } +} + +// ENHANCED SUCCESS HANDLING WITH MULTIPLE CHECK-IN INFO +function handleCheckinSuccess(data) { + console.log("✅ Success data received:", data); + + const responseData = data.data || data || {}; + const checkinCount = responseData.checkin_count_today || 1; + const checkinSequence = responseData.checkin_sequence || "Check-in"; + + // Show appropriate success message based on check-in count + if (checkinCount > 1) { + showLocalizedStatusMessage("multipleSuccess", "success"); + } else { + showLocalizedStatusMessage("success", "success"); + } + + // Hide the entire checkin-card (includes instruction header and form) + const checkinCard = document.querySelector(".checkin-card"); + if (checkinCard) { + checkinCard.style.display = "none"; + checkinCard.classList.remove("active"); + checkinCard.classList.add("hidden-after-success"); + } + + // Also hide form separately for backward compatibility (PRESERVED FROM ORIGINAL) + const form = document.getElementById("checkinForm"); + if (form) { + form.style.display = "none"; + } + + // Update success card with enhanced information + const successCard = document.getElementById("successCard"); + if (successCard) { + successCard.style.display = "block"; + successCard.classList.add("active"); + + const updateElement = (id, value) => { + const el = document.getElementById(id); + if (el) { + el.textContent = value || "N/A"; + } + }; + + // Get employee ID from form input if not in response data + const employeeIdInput = document.getElementById("employee_id"); + const employeeId = + responseData.employee_id || + (employeeIdInput + ? employeeIdInput.value.trim().toUpperCase() + : "Unknown"); + + const location = responseData.location || "Unknown Location"; + const event = + responseData.event || responseData.location_event || "Check-in"; + + // Format current date and time if not provided in response + const now = new Date(); + const checkInTime = + responseData.check_in_time || + now.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: true, + }); + const checkInDate = + responseData.check_in_date || + now.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + + // Type of work actually stored, so a wrong pick is visible right away. + // The server sends a bilingual label; fall back to the dropdown's text. + const workTypeLabel = responseData.work_type_label; + const workTypeField = document.getElementById("work_type"); + const workTypeText = workTypeLabel + ? workTypeLabel.en + " / " + workTypeLabel.es + : workTypeField && workTypeField.selectedIndex >= 0 + ? workTypeField.options[workTypeField.selectedIndex].textContent.trim() + : "Regular / Trabajo Regular"; + + // Update success card elements + updateElement("successEmployeeId", employeeId); + updateElement("successWorkType", workTypeText); + updateElement("successLocation", location); + updateElement("successEvent", event); + updateElement("successCheckInTime", checkInTime); + updateElement("successCheckInDate", checkInDate); + + // NEW: Add check-in sequence information + updateElement("successCheckinSequence", checkinSequence); + + // Update additional info if available + if (responseData.device_info) { + updateElement("successDeviceInfo", responseData.device_info); + } + + if (responseData.coordinates) { + updateElement("successCoordinates", responseData.coordinates); + } + + if (responseData.address) { + updateElement("successAddress", responseData.address); + } + + if (responseData.location_accuracy) { + updateElement( + "successLocationAccuracy", + `${responseData.location_accuracy} miles` + ); + } + + // Log successful check-in with details + console.log( + `✅ Check-in successful for Employee ID: ${employeeId}, Location: ${location}, Time: ${checkInTime}, Date: ${checkInDate}, Action: ${event}` + ); + } +} + +// NEW: Reset form for new check-in +function resetForNewCheckin() { + // Show checkin-card again (includes instruction header and form) + const checkinCard = document.querySelector(".checkin-card"); + if (checkinCard) { + checkinCard.style.display = "block"; + checkinCard.classList.add("active"); + checkinCard.classList.remove("hidden-after-success"); + } + + // Show form again (PRESERVED FROM ORIGINAL) + const form = document.getElementById("checkinForm"); + if (form) { + form.style.display = "block"; + } + + // Hide success card + const successCard = document.getElementById("successCard"); + if (successCard) { + successCard.style.display = "none"; + successCard.classList.remove("active"); + } + + // Clear previous employee ID + const employeeIdInput = document.getElementById("employee_id"); + if (employeeIdInput) { + employeeIdInput.value = ""; + employeeIdInput.focus(); + } + + // Clear status messages + clearStatusMessages(); + + // Reset location if needed + if (!userLocation.latitude || !userLocation.longitude) { + requestLocationData(); + } +} + +// NEW: Show custom status message (for interval warnings) +function showCustomStatusMessage(message, type = "info") { + const statusContainer = document.getElementById("statusMessage"); + if (statusContainer) { + statusContainer.className = `status-message ${type}`; + statusContainer.innerHTML = ` + <div class="status-content"> + <i class="fas ${getStatusIcon(type)}"></i> + <span>${message}</span> + </div> + `; + statusContainer.style.display = "block"; + + // Auto-hide after 5 seconds + setTimeout(() => { + statusContainer.style.display = "none"; + }, 5000); + } +} + +// Helper function to get appropriate icon for status type +function getStatusIcon(type) { + switch (type) { + case "success": + return "fa-check-circle"; + case "error": + return "fa-exclamation-circle"; + case "warning": + return "fa-clock"; + case "info": + default: + return "fa-info-circle"; + } +} + +// PRESERVED: All other existing functions remain unchanged +function showLocalizedStatusMessage(messageKey, type = "info") { + const message = + translations[currentLanguage].statusMessages[messageKey] || + translations["en"].statusMessages[messageKey] || + "Status update"; + + showCustomStatusMessage(message, type); +} + +function clearStatusMessages() { + const statusContainer = document.getElementById("statusMessage"); + if (statusContainer) { + statusContainer.style.display = "none"; + } +} + +function handleCheckinError(error) { + console.error("❌ Check-in submission error:", error); + showLocalizedStatusMessage("networkError", "error"); +} + +function updateSubmitButton(isLoading) { + const submitButton = document.getElementById("submitCheckin"); + if (submitButton) { + if (isLoading) { + submitButton.disabled = true; + submitButton.innerHTML = + '<i class="fas fa-spinner fa-spin"></i> <span data-en="Processing..." data-es="Procesando...">Processing...</span>'; + } else { + submitButton.disabled = false; + submitButton.innerHTML = + '<i class="fas fa-user-check"></i> <span data-en="Submit" data-es="Someter">Submit</span>'; + } + applyTranslations(); + } +} + +function validateEmployeeId() { + const employeeIdInput = document.getElementById("employee_id"); + const submitButton = document.getElementById("submitCheckin"); + + if (employeeIdInput && submitButton) { + const isValid = employeeIdInput.value.trim().length >= 2; + submitButton.disabled = !isValid || isSubmitting; + + if (isValid) { + employeeIdInput.classList.remove("invalid"); + employeeIdInput.classList.add("valid"); + } else { + employeeIdInput.classList.remove("valid"); + if (employeeIdInput.value.length > 0) { + employeeIdInput.classList.add("invalid"); + } + } + } +} + +// All location and language functions remain unchanged from original +function initializeLocation() { + // Check if Android enhanced location handler is available + if ( + typeof AndroidLocationHandler !== "undefined" && + AndroidLocationHandler.isAndroidDevice() + ) { + console.log("📱 Using Android-enhanced location initialization"); + AndroidLocationHandler.initializeAndroidLocation(); + } else { + console.log("📍 Using standard location initialization"); + requestLocationData(); + } +} + +function requestLocationData() { + if (locationRequestActive) { + console.log("📍 Location request already active, skipping"); + return; + } + + if (!navigator.geolocation) { + console.log("❌ Geolocation not supported"); + userLocation.source = "manual"; + return; + } + + locationRequestActive = true; + console.log("📍 Requesting location data..."); + + const options = { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 300000, + }; + + navigator.geolocation.getCurrentPosition( + handleLocationSuccess, + (error) => { + console.log("❌ High accuracy failed, trying low accuracy..."); + + // Simple fallback with low accuracy + const lowAccuracyOptions = { + enableHighAccuracy: false, + timeout: 15000, + maximumAge: 600000, + }; + + navigator.geolocation.getCurrentPosition( + handleLocationSuccess, + handleLocationError, + lowAccuracyOptions + ); + }, + options + ); +} + +function handleLocationSuccess(position) { + console.log("✅ Location obtained successfully"); + + userLocation = { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + accuracy: position.coords.accuracy, + altitude: position.coords.altitude, + timestamp: new Date(), + source: "gps", + address: null, + }; + + // Reverse geocode to get address + reverseGeocode(userLocation.latitude, userLocation.longitude); + + locationRequestActive = false; +} + +function handleLocationError(error) { + userLocation.source = "manual"; + locationRequestActive = false; +} + +function reverseGeocode(lat, lng) { + // The server will use Google Maps API first, then fall back to OpenStreetMap + // This provides better accuracy and address formatting + const url = "/api/reverse-geocode"; // You may want to create this endpoint + + // For now, using direct OpenStreetMap as fallback + // In production, this should go through your server API + const osmUrl = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1&zoom=18`; + + fetch(osmUrl, { + method: "GET", + headers: { + "User-Agent": "QR-Attendance-System/1.0", + }, + }) + .then((response) => response.json()) + .then((data) => { + if (data && data.display_name) { + userLocation.address = data.display_name; + } else { + userLocation.address = `${lat.toFixed(10)}, ${lng.toFixed(10)}`; + } + }) + .catch((error) => { + console.error(`❌ Reverse geocoding error:`, error); + // Fallback to coordinates if reverse geocoding fails + userLocation.address = `${lat.toFixed(10)}, ${lng.toFixed(10)}`; + }); +} + +// ENHANCED LANGUAGE FUNCTIONALITY WITH PERSISTENCE +function initializeLanguage() { + // Load saved language preference from localStorage + const savedLanguage = loadLanguagePreference(); + if (savedLanguage && savedLanguage !== currentLanguage) { + currentLanguage = savedLanguage; + } + + // Set up language toggle button event listener + const languageToggle = document.getElementById("languageToggle"); + if (languageToggle) { + languageToggle.addEventListener("click", toggleLanguage); + } + + // Apply initial translations based on loaded language + applyTranslations(); +} + +function toggleLanguage() { + // Switch between languages + const newLanguage = currentLanguage === "en" ? "es" : "en"; + currentLanguage = newLanguage; + + // Save new language preference to localStorage + saveLanguagePreference(currentLanguage); + + // Apply translations immediately + applyTranslations(); + + // Optional: Show brief confirmation message + showLanguageChangeConfirmation(); +} + +function loadLanguagePreference() { + try { + // Retrieve language preference from localStorage + const savedLanguage = localStorage.getItem("qr_staff_language"); + + // Validate saved language is supported + if (savedLanguage && translations.hasOwnProperty(savedLanguage)) { + return savedLanguage; + } else if (savedLanguage) { + // Clean up invalid preference + localStorage.removeItem("qr_staff_language"); + } + + return null; + } catch (error) { + return null; + } +} + +function saveLanguagePreference(language) { + try { + // Validate language before saving + if (!translations.hasOwnProperty(language)) { + console.error(`❌ Invalid language code: ${language}`); + return false; + } + + // Save to localStorage + localStorage.setItem("qr_staff_language", language); + return true; + } catch (error) { + return false; + } +} + +function showLanguageChangeConfirmation() { + // Brief visual feedback for language change + const languageToggle = document.getElementById("languageToggle"); + if (languageToggle) { + // Add temporary visual feedback + languageToggle.style.transform = "scale(1.05)"; + languageToggle.style.background = "rgba(255, 255, 255, 0.4)"; + + setTimeout(() => { + languageToggle.style.transform = ""; + languageToggle.style.background = ""; + }, 200); + } +} + +function applyTranslations() { + // Update language toggle button text + const languageText = document.getElementById("languageText"); + if (languageText) { + languageText.textContent = translations[currentLanguage].languageText; + } + + // Apply translations to all elements with data attributes + document.querySelectorAll(`[data-${currentLanguage}]`).forEach((element) => { + element.textContent = element.getAttribute(`data-${currentLanguage}`); + }); + + // Update any dynamic content that might have been generated after initial load + updateDynamicTranslations(); +} + +function updateDynamicTranslations() { + // Update submit button text if it exists and has been modified + const submitButton = document.getElementById("submitCheckin"); + if (submitButton && submitButton.innerHTML.includes("data-")) { + // Re-apply translations to submit button content + const spans = submitButton.querySelectorAll(`[data-${currentLanguage}]`); + spans.forEach((span) => { + span.textContent = span.getAttribute(`data-${currentLanguage}`); + }); + } +} + +function startClock() { + function updateClock() { + currentTime = new Date(); + const timeElements = document.querySelectorAll(".current-time"); + timeElements.forEach((el) => { + el.textContent = currentTime.toLocaleTimeString(); + }); + } + + updateClock(); + setInterval(updateClock, 1000); +} + +function checkLocationServicesStatus() { + // Check if geolocation is supported + if (!navigator.geolocation) { + showLocationServicesWarning("not_supported"); + blockCheckInProcess(true); + return Promise.reject("Location services not supported"); + } + + return new Promise((resolve, reject) => { + // Test location access with a quick check + const timeoutId = setTimeout(() => { + showLocationServicesWarning("timeout"); + blockCheckInProcess(true); + reject("Location services timeout"); + }, 5000); // 5 second timeout + + navigator.geolocation.getCurrentPosition( + (position) => { + // Success - location services are working + clearTimeout(timeoutId); + hideLocationServicesWarning(); + blockCheckInProcess(false); + + // Log successful location access + console.log("✅ Location Services: ENABLED and working"); + + resolve(position); + }, + (error) => { + // Error - location services may be disabled + clearTimeout(timeoutId); + blockCheckInProcess(true); + + switch (error.code) { + case error.PERMISSION_DENIED: + showLocationServicesWarning("permission_denied"); + console.log("❌ Location Services: PERMISSION DENIED"); + break; + case error.POSITION_UNAVAILABLE: + showLocationServicesWarning("position_unavailable"); + console.log("❌ Location Services: POSITION UNAVAILABLE"); + break; + case error.TIMEOUT: + showLocationServicesWarning("timeout"); + console.log("❌ Location Services: TIMEOUT"); + break; + default: + showLocationServicesWarning("unknown_error"); + console.log("❌ Location Services: UNKNOWN ERROR"); + break; + } + + reject(error); + }, + { + enableHighAccuracy: false, + timeout: 10000, + maximumAge: 30000, + } + ); + }); +} + +function blockCheckInProcess(shouldBlock) { + // Try multiple possible submit button IDs from your codebase + const submitButton = + document.getElementById("submitCheckin") || + document.getElementById("submitButton") || + document.querySelector('button[type="submit"]') || + document.querySelector(".btn-primary"); + + const employeeIdInput = document.getElementById("employee_id"); + const form = document.getElementById("checkinForm"); + + if (shouldBlock) { + // Set global blocking flag FIRST + window.locationServicesBlocked = true; + + // Block check-in process + if (submitButton) { + submitButton.disabled = true; + submitButton.style.opacity = "0.5"; + submitButton.style.cursor = "not-allowed"; + submitButton.style.pointerEvents = "none"; + + // Add data attribute to track blocking state + submitButton.setAttribute("data-location-blocked", "true"); + + // Store original button content + if (!submitButton.getAttribute("data-original-content")) { + submitButton.setAttribute( + "data-original-content", + submitButton.innerHTML + ); + } + + // Update button text to show it's blocked + submitButton.innerHTML = ` + <i class="fas fa-lock"></i> + <span>Location Required / Ubicación Requerida</span> + `; + + // Remove all event listeners by cloning + const newButton = submitButton.cloneNode(true); + submitButton.parentNode.replaceChild(newButton, submitButton); + + // Add blocking event listener + newButton.addEventListener("click", function (e) { + e.preventDefault(); + e.stopPropagation(); + showLocationServicesBlockedMessage(); + return false; + }); + } + + if (employeeIdInput) { + employeeIdInput.disabled = true; + employeeIdInput.style.opacity = "0.7"; + employeeIdInput.setAttribute("data-location-blocked", "true"); + } + + if (form) { + form.classList.add("location-blocked"); + form.style.pointerEvents = "none"; + + // Override form submission completely + form.onsubmit = function (e) { + e.preventDefault(); + e.stopPropagation(); + showLocationServicesBlockedMessage(); + return false; + }; + } + + console.log("🚫 Check-in process BLOCKED - Location Services required"); + } else { + // Clear global blocking flag FIRST + window.locationServicesBlocked = false; + + // Unblock check-in process + if (submitButton) { + submitButton.disabled = false; + submitButton.style.opacity = "1"; + submitButton.style.cursor = "pointer"; + submitButton.style.pointerEvents = "auto"; + + // Remove blocking data attribute + submitButton.removeAttribute("data-location-blocked"); + + // Restore original button content + const originalContent = submitButton.getAttribute( + "data-original-content" + ); + if (originalContent) { + submitButton.innerHTML = originalContent; + } + + // Re-attach proper event listeners + submitButton.onclick = function (e) { + e.preventDefault(); + handleFormSubmit(e); + return false; + }; + } + + if (employeeIdInput) { + employeeIdInput.disabled = false; + employeeIdInput.style.opacity = "1"; + employeeIdInput.removeAttribute("data-location-blocked"); + } + + if (form) { + form.classList.remove("location-blocked"); + form.style.pointerEvents = "auto"; + + // Restore proper form submission handler + form.onsubmit = function (e) { + e.preventDefault(); + handleFormSubmit(e); + return false; + }; + } + + console.log("✅ Check-in process UNBLOCKED - Location Services working"); + } +} + +/** + * Show location services warning banner + */ +function showLocationServicesWarning(errorType) { + // Remove existing warning if present + hideLocationServicesWarning(); + + const warningMessages = { + en: { + not_supported: + "⚠️ Location Services Not Supported<br><strong>Check-in is currently blocked.</strong><br>Your browser does not support location services required for check-in.<br><br>Los servicios de ubicación no son compatibles. El registro está bloqueado.", + permission_denied: + "⚠️ Location Access Denied<br><strong>Check-in is currently blocked.</strong><br>Please enable location access in your browser settings to continue with check-in.<br><br>Acceso a ubicación denegado. Habilite el acceso para continuar.", + position_unavailable: + "⚠️ Location Services Disabled<br><strong>Check-in is currently blocked.</strong><br>Please turn on Location Services in your device settings and refresh the page.<br><br>Servicios de ubicación deshabilitados. Active los servicios y actualice la página.", + timeout: + "⚠️ Location Services Not Responding<br><strong>Check-in is currently blocked.</strong><br>Location services may be disabled. Please check your device settings.<br><br>Los servicios de ubicación no responden. Verifique la configuración.", + unknown_error: + "⚠️ Location Services Error<br><strong>Check-in is currently blocked.</strong><br>Unable to access location services. Please check your settings and try again.<br><br>Error de servicios de ubicación. Verifique la configuración.", + }, + es: { + not_supported: + "⚠️ Servicios de Ubicación No Compatibles<br><strong>El registro está bloqueado.</strong><br>Su navegador no es compatible con los servicios de ubicación requeridos.", + permission_denied: + "⚠️ Acceso a Ubicación Denegado<br><strong>El registro está bloqueado.</strong><br>Habilite el acceso a la ubicación en la configuración de su navegador.", + position_unavailable: + "⚠️ Servicios de Ubicación Deshabilitados<br><strong>El registro está bloqueado.</strong><br>Active los Servicios de Ubicación en la configuración y actualice la página.", + timeout: + "⚠️ Servicios de Ubicación No Responden<br><strong>El registro está bloqueado.</strong><br>Los servicios pueden estar deshabilitados. Verifique la configuración.", + unknown_error: + "⚠️ Error de Servicios de Ubicación<br><strong>El registro está bloqueado.</strong><br>No se puede acceder a los servicios. Verifique la configuració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="location.reload()"> + <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); + } +} + +/** + * Hide location services warning banner + */ +function hideLocationServicesWarning() { + const existingWarning = document.getElementById("locationServicesWarning"); + if (existingWarning) { + existingWarning.remove(); + } +} + +/** + * Copy the live value / checked state of every control from one form to its + * clone. The clone is structurally identical, so controls are matched by + * position - no selector escaping needed. + */ +function preserveFormControlState(oldForm, newForm) { + const oldControls = oldForm.querySelectorAll("input, select, textarea"); + const newControls = newForm.querySelectorAll("input, select, textarea"); + const count = Math.min(oldControls.length, newControls.length); + + for (let i = 0; i < count; i++) { + const oldControl = oldControls[i]; + const newControl = newControls[i]; + if (oldControl.type === "checkbox" || oldControl.type === "radio") { + newControl.checked = oldControl.checked; + } else { + newControl.value = oldControl.value; + } + } +} + +function initializeLocationServicesCheck() { + // Initialize global blocking flag + window.locationServicesBlocked = false; + + // Check location services status when page loads and block if necessary + setTimeout(() => { + console.log("🔍 Initializing Location Services check..."); + checkLocationServicesStatus() + .then(() => { + console.log("✅ Initial Location Services check passed"); + }) + .catch(() => { + console.log( + "❌ Initial Location Services check failed - Check-in blocked" + ); + }); + }, 1000); + + // Override form initialization to ensure our handlers are used + setTimeout(() => { + const form = document.getElementById("checkinForm"); + const submitButton = + document.getElementById("submitCheckin") || + document.querySelector('button[type="submit"]'); + + if (form) { + // Remove existing event listeners by cloning + const newForm = form.cloneNode(true); + + // cloneNode(true) copies ATTRIBUTES, not live control state: a <select> + // reverts to the option carrying the `selected` attribute and typed values + // are lost. Without this the pre-selected check-out work type snapped back + // to Regular 1.5 s after load. + preserveFormControlState(form, newForm); + + form.parentNode.replaceChild(newForm, form); + + // Add our controlled event listener + newForm.addEventListener("submit", handleFormSubmit); + + // Every listener the page bound to the old controls (numeric-only ID + // filter, work-type echo, ID persistence) died with them. Tell the page + // to re-attach them to the replacement nodes. + document.dispatchEvent( + new CustomEvent("checkinFormReplaced", { detail: { form: newForm } }) + ); + } + + if (submitButton) { + // Find the new submit button after form cloning + const newSubmitButton = + document.getElementById("submitCheckin") || + document.querySelector('button[type="submit"]'); + + if (newSubmitButton) { + newSubmitButton.addEventListener("click", function (e) { + e.preventDefault(); + e.stopPropagation(); + handleFormSubmit(e); + return false; + }); + } + } + }, 1500); +} + +// =================================== +// CAMERA VERIFICATION FUNCTIONALITY +// =================================== + +function showVerificationModal(distance, threshold) { + console.log(`📸 Showing verification modal - Distance: ${distance}, Threshold: ${threshold}`); + + const modal = document.getElementById('verificationModal'); + const distanceSpan = document.getElementById('verificationDistance'); + const thresholdSpan = document.getElementById('verificationThreshold'); + + if (distanceSpan) distanceSpan.textContent = distance.toFixed(3); + if (thresholdSpan) thresholdSpan.textContent = threshold.toFixed(3); + + modal.classList.add('active'); + startCamera(); +} + +function hideVerificationModal() { + const modal = document.getElementById('verificationModal'); + modal.classList.remove('active'); + stopCamera(); + resetCameraInterface(); +} + +function startCamera() { + const video = document.getElementById('cameraVideo'); + + console.log('📸 Starting camera...'); + + const constraints = { + video: { + facingMode: 'environment', // Use back camera on mobile + width: { ideal: 1280 }, + height: { ideal: 720 } + }, + audio: false + }; + + navigator.mediaDevices.getUserMedia(constraints) + .then(stream => { + cameraStream = stream; + video.srcObject = stream; + video.style.display = 'block'; + console.log('✅ Camera started successfully'); + }) + .catch(error => { + console.error('❌ Camera error:', error); + alert('Unable to access camera. Please check permissions and try again. / No se puede acceder a la cámara.'); + hideVerificationModal(); + isSubmitting = false; + updateSubmitButton(false); + }); +} + +function stopCamera() { + if (cameraStream) { + cameraStream.getTracks().forEach(track => track.stop()); + cameraStream = null; + console.log('📸 Camera stopped'); + } +} + +function capturePhoto() { + const video = document.getElementById('cameraVideo'); + const canvas = document.getElementById('cameraCanvas'); + const capturedImage = document.getElementById('capturedPhoto'); + const captureBtn = document.getElementById('captureBtn'); + const retakeBtn = document.getElementById('retakeBtn'); + const submitBtn = document.getElementById('submitVerificationBtn'); + + // Set canvas dimensions to match video + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + // Draw video frame to canvas + const context = canvas.getContext('2d'); + context.drawImage(video, 0, 0, canvas.width, canvas.height); + + // Get photo data as base64 + capturedPhotoData = canvas.toDataURL('image/jpeg', 0.8); + + // Show captured photo + capturedImage.src = capturedPhotoData; + capturedImage.style.display = 'block'; + video.style.display = 'none'; + + // Update button visibility + captureBtn.style.display = 'none'; + retakeBtn.style.display = 'inline-flex'; + submitBtn.style.display = 'inline-flex'; + + console.log('📸 Photo captured'); +} + +function retakePhoto() { + const video = document.getElementById('cameraVideo'); + const capturedImage = document.getElementById('capturedPhoto'); + const captureBtn = document.getElementById('captureBtn'); + const retakeBtn = document.getElementById('retakeBtn'); + const submitBtn = document.getElementById('submitVerificationBtn'); + + // Reset interface + capturedImage.style.display = 'none'; + video.style.display = 'block'; + + captureBtn.style.display = 'inline-flex'; + retakeBtn.style.display = 'none'; + submitBtn.style.display = 'none'; + + capturedPhotoData = null; + + console.log('📸 Ready to retake photo'); +} + +function resetCameraInterface() { + const video = document.getElementById('cameraVideo'); + const capturedImage = document.getElementById('capturedPhoto'); + const captureBtn = document.getElementById('captureBtn'); + const retakeBtn = document.getElementById('retakeBtn'); + const submitBtn = document.getElementById('submitVerificationBtn'); + + if (capturedImage) capturedImage.style.display = 'none'; + if (video) video.style.display = 'block'; + + if (captureBtn) captureBtn.style.display = 'inline-flex'; + if (retakeBtn) retakeBtn.style.display = 'none'; + if (submitBtn) submitBtn.style.display = 'none'; + + capturedPhotoData = null; + verificationAttemptData = null; +} + +function submitWithVerification() { + if (!capturedPhotoData) { + alert('Please capture a photo first. / Por favor capture una foto primero.'); + return; + } + + console.log('📸 Submitting check-in with verification photo...'); + + const submitBtn = document.getElementById('submitVerificationBtn'); + submitBtn.disabled = true; + submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Submitting...'; + + // Use stored form data from initial attempt + if (verificationAttemptData) { + verificationAttemptData.append('verification_photo', capturedPhotoData); + + const currentUrl = window.location.pathname; + const checkinUrl = `${currentUrl}/checkin`; + + fetch(checkinUrl, { + method: "POST", + body: verificationAttemptData, + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }) + .then((response) => response.json()) + .then((data) => { + hideVerificationModal(); + + if (data.success) { + showCustomStatusMessage( + "Submission successful! Photo pending review. / ¡Envío exitoso! Foto pendiente de revisión.", + "success" + ); + handleCheckinSuccess(data); + } else { + showCustomStatusMessage( + data.message || "Submission failed / Envío fallido", + "error" + ); + } + }) + .catch((error) => { + hideVerificationModal(); + console.error('❌ Verification submit error:', error); + showLocalizedStatusMessage("networkError", "error"); + }) + .finally(() => { + isSubmitting = false; + updateSubmitButton(false); + submitBtn.disabled = false; + submitBtn.innerHTML = '<i class="fas fa-check"></i> Submit with Photo'; + }); + } +} + +function cancelVerification() { + hideVerificationModal(); + isSubmitting = false; + updateSubmitButton(false); +} + +// Initialize camera button event listeners +function initializeCameraButtons() { + const captureBtn = document.getElementById('captureBtn'); + const retakeBtn = document.getElementById('retakeBtn'); + const submitBtn = document.getElementById('submitVerificationBtn'); + const cancelBtn = document.getElementById('cancelVerificationBtn'); + + if (captureBtn) { + captureBtn.addEventListener('click', capturePhoto); + } + + if (retakeBtn) { + retakeBtn.addEventListener('click', retakePhoto); + } + + if (submitBtn) { + submitBtn.addEventListener('click', submitWithVerification); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', cancelVerification); + } + + console.log('📸 Camera verification buttons initialized'); +} + +// Call initialization when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeCameraButtons); +} else { + initializeCameraButtons(); +} \ No newline at end of file diff --git a/static/js/script.js b/static/js/script.js new file mode 100644 index 0000000..e76c163 --- /dev/null +++ b/static/js/script.js @@ -0,0 +1,613 @@ +// Enhanced JavaScript for Left Sidebar Navigation +class QRManager { + constructor() { + this.initializeApp(); + } + + initializeApp() { + this.initSidebar(); + this.initModals(); + this.initDropdowns(); + this.initActiveNavigation(); + this.initFlashMessages(); + } + + // Sidebar Management + initSidebar() { + const sidebar = document.getElementById('sidebar'); + const sidebarToggle = document.getElementById('sidebarToggle'); + const mobileMenuBtn = document.getElementById('mobileMenuBtn'); + const sidebarOverlay = document.getElementById('sidebarOverlay'); + + if (!sidebar) return; + + // Desktop sidebar toggle + if (sidebarToggle) { + sidebarToggle.addEventListener('click', () => { + this.toggleSidebar(); + }); + } + + // Mobile menu toggle + if (mobileMenuBtn) { + mobileMenuBtn.addEventListener('click', () => { + this.toggleMobileSidebar(); + }); + } + + // Close mobile sidebar when clicking overlay + if (sidebarOverlay) { + sidebarOverlay.addEventListener('click', () => { + this.closeMobileSidebar(); + }); + } + + // Close mobile sidebar when clicking menu items + const menuItems = sidebar.querySelectorAll('.menu-item'); + menuItems.forEach(item => { + item.addEventListener('click', () => { + if (window.innerWidth <= 768) { + this.closeMobileSidebar(); + } + }); + }); + + // Handle window resize + window.addEventListener('resize', () => { + this.handleResize(); + }); + + // Initialize sidebar state based on screen size + this.handleResize(); + } + + toggleSidebar() { + const sidebar = document.getElementById('sidebar'); + if (sidebar) { + sidebar.classList.toggle('collapsed'); + this.saveSidebarState(); + } + } + + toggleMobileSidebar() { + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebarOverlay'); + const mobileBtn = document.getElementById('mobileMenuBtn'); + + if (sidebar && overlay && mobileBtn) { + const isOpen = sidebar.classList.contains('mobile-open'); + + if (isOpen) { + this.closeMobileSidebar(); + } else { + this.openMobileSidebar(); + } + } + } + + openMobileSidebar() { + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebarOverlay'); + const mobileBtn = document.getElementById('mobileMenuBtn'); + + if (sidebar && overlay && mobileBtn) { + sidebar.classList.add('mobile-open'); + overlay.classList.add('active'); + mobileBtn.classList.add('active'); + document.body.style.overflow = 'hidden'; + } + } + + closeMobileSidebar() { + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebarOverlay'); + const mobileBtn = document.getElementById('mobileMenuBtn'); + + if (sidebar && overlay && mobileBtn) { + sidebar.classList.remove('mobile-open'); + overlay.classList.remove('active'); + mobileBtn.classList.remove('active'); + document.body.style.overflow = ''; + } + } + + handleResize() { + const sidebar = document.getElementById('sidebar'); + if (!sidebar) return; + + if (window.innerWidth <= 768) { + // Mobile: ensure sidebar is hidden and mobile menu is available + this.closeMobileSidebar(); + } else if (window.innerWidth <= 1024) { + // Tablet: auto-collapse sidebar + sidebar.classList.add('collapsed'); + this.closeMobileSidebar(); + } else { + // Desktop: restore saved state + this.restoreSidebarState(); + this.closeMobileSidebar(); + } + } + + saveSidebarState() { + const sidebar = document.getElementById('sidebar'); + if (sidebar && window.innerWidth > 1024) { + const isCollapsed = sidebar.classList.contains('collapsed'); + localStorage.setItem('sidebarCollapsed', isCollapsed); + } + } + + restoreSidebarState() { + const sidebar = document.getElementById('sidebar'); + if (sidebar && window.innerWidth > 1024) { + const isCollapsed = localStorage.getItem('sidebarCollapsed') === 'true'; + sidebar.classList.toggle('collapsed', isCollapsed); + } + } + + // Active Navigation Highlighting + initActiveNavigation() { + const menuItems = document.querySelectorAll('.menu-item[href]'); + const currentPath = window.location.pathname; + + menuItems.forEach(item => { + const href = item.getAttribute('href'); + if (href === currentPath || (currentPath.startsWith(href) && href !== '/')) { + item.classList.add('active'); + } else { + item.classList.remove('active'); + } + }); + } + + // Theme Management + initTheme() { + const themeToggle = document.getElementById('themeToggle'); + if (!themeToggle) return; + + // Load saved theme + const savedTheme = localStorage.getItem('theme') || 'light'; + this.setTheme(savedTheme); + + themeToggle.addEventListener('click', () => { + const currentTheme = document.body.getAttribute('data-theme') || 'light'; + const newTheme = currentTheme === 'light' ? 'dark' : 'light'; + this.setTheme(newTheme); + localStorage.setItem('theme', newTheme); + }); + } + + setTheme(theme) { + document.body.setAttribute('data-theme', theme); + const themeToggle = document.getElementById('themeToggle'); + + if (themeToggle) { + const icon = themeToggle.querySelector('i'); + const text = themeToggle.querySelector('.menu-text'); + + if (theme === 'dark') { + icon.className = 'fas fa-sun'; + if (text) text.textContent = 'Light Mode'; + } else { + icon.className = 'fas fa-moon'; + if (text) text.textContent = 'Dark Mode'; + } + } + } + + // Modal Management + initModals() { + // Close modal when clicking outside + document.addEventListener('click', (e) => { + if (e.target.classList.contains('modal')) { + this.closeModal(e.target); + } + }); + + // Close modal with close button + document.addEventListener('click', (e) => { + if (e.target.classList.contains('modal-close') || + e.target.closest('.modal-close')) { + const modal = e.target.closest('.modal'); + if (modal) { + this.closeModal(modal); + } + } + }); + + // Close modal with Escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + const openModal = document.querySelector('.modal.show'); + if (openModal) { + this.closeModal(openModal); + } + } + }); + } + + showModal(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.classList.add('show'); + document.body.style.overflow = 'hidden'; + + // Focus first focusable element + const focusableElement = modal.querySelector('input, textarea, select, button, [tabindex]:not([tabindex="-1"])'); + if (focusableElement) { + setTimeout(() => focusableElement.focus(), 100); + } + } + } + + closeModal(modal) { + if (modal) { + modal.classList.remove('show'); + document.body.style.overflow = ''; + } + } + + // Dropdown Management + initDropdowns() { + const dropdowns = document.querySelectorAll('.dropdown'); + + dropdowns.forEach(dropdown => { + const trigger = dropdown.querySelector('.dropdown-trigger'); + const menu = dropdown.querySelector('.dropdown-menu'); + + if (trigger && menu) { + trigger.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleDropdown(dropdown); + }); + } + }); + + // Close dropdowns when clicking outside + document.addEventListener('click', () => { + this.closeAllDropdowns(); + }); + + // Close dropdowns with Escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + this.closeAllDropdowns(); + } + }); + } + + toggleDropdown(dropdown) { + const menu = dropdown.querySelector('.dropdown-menu'); + const isOpen = menu.classList.contains('show'); + + this.closeAllDropdowns(); + + if (!isOpen) { + menu.classList.add('show'); + } + } + + closeAllDropdowns() { + const openMenus = document.querySelectorAll('.dropdown-menu.show'); + openMenus.forEach(menu => { + menu.classList.remove('show'); + }); + } + + // Flash Messages + initFlashMessages() { + const alerts = document.querySelectorAll('.alert'); + + alerts.forEach(alert => { + // Auto-dismiss after 5 seconds + setTimeout(() => { + this.dismissAlert(alert); + }, 5000); + + // Manual dismiss + const closeBtn = alert.querySelector('.alert-close'); + if (closeBtn) { + closeBtn.addEventListener('click', () => { + this.dismissAlert(alert); + }); + } + }); + } + + dismissAlert(alert) { + alert.style.opacity = '0'; + alert.style.transform = 'translateX(100%)'; + + setTimeout(() => { + if (alert.parentNode) { + alert.parentNode.removeChild(alert); + } + }, 300); + } + + // Download QR Code functionality + downloadQR(base64Image, filename) { + try { + const link = document.createElement("a"); + link.href = `data:image/png;base64,${base64Image}`; + link.download = `${filename.replace(/[^a-z0-9]/gi, "_").toLowerCase()}_qr_code.png`; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Show success toast + this.showToast("QR code downloaded successfully!", "success"); + } catch (error) { + this.showToast("Failed to download QR code", "error"); + console.error("Download error:", error); + } + } + + // Download from modal + downloadModalQR() { + if (window.currentModalQR) { + const base64Data = window.currentModalQR.image.split("base64,")[1]; + this.downloadQR(base64Data, window.currentModalQR.name); + } + } + + // QR Modal functionality + openQRModal(qrData, qrName) { + const modal = document.getElementById("qrModal"); + const modalTitle = document.getElementById("modalTitle"); + const modalImage = document.getElementById("modalQRImage"); + + if (modal && modalTitle && modalImage) { + modalTitle.textContent = `${qrName} - QR Code`; + modalImage.src = `data:image/png;base64,${qrData}`; + modalImage.alt = `QR Code for ${qrName}`; + + // Store current modal QR for download + window.currentModalQR = { + name: qrName, + image: `data:image/png;base64,${qrData}`, + }; + + modal.classList.add('show'); + } + } + + closeQRModal() { + const modal = document.getElementById("qrModal"); + if (modal) { + modal.classList.remove('show'); + window.currentModalQR = null; + } + } + + // Utility Methods + showToast(message, type = 'info', duration = 3000) { + const toast = document.createElement('div'); + toast.className = `alert alert-${type}`; + toast.innerHTML = ` + <i class="fas fa-info-circle"></i> + ${message} + <button class="alert-close"> + <i class="fas fa-times"></i> + </button> + `; + + const container = document.querySelector('.flash-messages') || document.body; + container.appendChild(toast); + + // Trigger animation + setTimeout(() => { + toast.classList.add('show'); + }, 10); + + // Auto dismiss + setTimeout(() => { + this.dismissAlert(toast); + }, duration); + + return toast; + } + + // Form Validation Helper + validateForm(formElement) { + const requiredFields = formElement.querySelectorAll('[required]'); + let isValid = true; + + requiredFields.forEach(field => { + if (!field.value.trim()) { + this.showFieldError(field, 'This field is required'); + isValid = false; + } else { + this.clearFieldError(field); + } + }); + + return isValid; + } + + showFieldError(field, message) { + this.clearFieldError(field); + + field.classList.add('error'); + const errorElement = document.createElement('div'); + errorElement.className = 'field-error'; + errorElement.textContent = message; + + field.parentNode.appendChild(errorElement); + } + + clearFieldError(field) { + field.classList.remove('error'); + const existingError = field.parentNode.querySelector('.field-error'); + if (existingError) { + existingError.remove(); + } + } + + // AJAX Helper + async makeRequest(url, options = {}) { + // Read the CSRF token injected by Flask into window.qrConfig + const csrfToken = (window.qrConfig && window.qrConfig.csrfToken) || ''; + const defaultOptions = { + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-Token': csrfToken + }, + ...options + }; + + try { + const response = await fetch(url, defaultOptions); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error('Request failed:', error); + this.showToast('An error occurred. Please try again.', 'error'); + throw error; + } + } +} + +// Keep existing date/time utilities +const DateTimeUtils = { + formatDate: (dateString) => { + const date = new Date(dateString); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + }, + + formatDateTime: (dateString) => { + const date = new Date(dateString); + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }, + + formatTime: (dateString) => { + const date = new Date(dateString); + return date.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + }); + }, +}; + +// Legacy Navigation Manager for backward compatibility +class NavigationManager { + constructor() { + this.initMobileMenu(); + this.initDropdowns(); + } + + initMobileMenu() { + const mobileMenuBtn = document.getElementById("mobile-menu"); + const navMenu = document.getElementById("navMenu"); + + if (mobileMenuBtn && navMenu) { + mobileMenuBtn.addEventListener("click", () => { + mobileMenuBtn.classList.toggle("active"); + navMenu.classList.toggle("active"); + }); + + const navLinks = navMenu.querySelectorAll(".nav-link"); + navLinks.forEach((link) => { + link.addEventListener("click", () => { + mobileMenuBtn.classList.remove("active"); + navMenu.classList.remove("active"); + }); + }); + } + } + + initDropdowns() { + const dropdowns = document.querySelectorAll(".dropdown"); + + dropdowns.forEach((dropdown) => { + const trigger = dropdown.querySelector(".dropdown-trigger"); + const menu = dropdown.querySelector(".dropdown-menu"); + + if (trigger && menu) { + trigger.addEventListener("click", (e) => { + e.stopPropagation(); + this.toggleDropdown(dropdown); + }); + } + }); + + document.addEventListener("click", () => { + this.closeAllDropdowns(); + }); + } + + toggleDropdown(dropdown) { + const menu = dropdown.querySelector(".dropdown-menu"); + const isOpen = menu.classList.contains("show"); + + this.closeAllDropdowns(); + + if (!isOpen) { + menu.classList.add("show"); + } + } + + closeAllDropdowns() { + const openMenus = document.querySelectorAll(".dropdown-menu.show"); + openMenus.forEach((menu) => { + menu.classList.remove("show"); + }); + } +} + +// Initialize the application +document.addEventListener('DOMContentLoaded', () => { + // Initialize main app if sidebar exists (authenticated users) + if (document.getElementById('sidebar')) { + window.qrManager = new QRManager(); + + // Make functions globally available for inline event handlers + window.downloadQR = (base64Image, filename) => { + window.qrManager.downloadQR(base64Image, filename); + }; + + window.downloadModalQR = () => { + window.qrManager.downloadModalQR(); + }; + + window.openQRModal = (qrData, qrName) => { + window.qrManager.openQRModal(qrData, qrName); + }; + + window.closeQRModal = () => { + window.qrManager.closeQRModal(); + }; + } else { + // Initialize legacy navigation for non-authenticated pages + window.navigationManager = new NavigationManager(); + } +}); + +// Export for use in other scripts +window.DateTimeUtils = DateTimeUtils; + +// Keep any existing global functions for backward compatibility +if (typeof showConfirmation === 'undefined') { + window.showConfirmation = async function(title, message, description = '') { + return new Promise((resolve) => { + const confirmed = confirm(`${title}\n\n${message}\n${description}`); + resolve(confirmed); + }); + }; +} \ No newline at end of file diff --git a/static/js/users.js b/static/js/users.js new file mode 100644 index 0000000..058bb87 --- /dev/null +++ b/static/js/users.js @@ -0,0 +1,747 @@ +/** + * Users management JavaScript functionality + * static/js/users.js + */ + +class UsersManager { + constructor() { + this.selectedUsers = new Set(); + this.init(); + } + + init() { + this.initializeSearch(); + this.initializeFilters(); + this.initializeBulkActions(); + this.setupEventListeners(); + this.initializeModals(); + } + + // Initialize search functionality + initializeSearch() { + const searchInput = document.getElementById("searchUsers"); + if (!searchInput) return; + + let searchTimeout; + searchInput.addEventListener("input", () => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + this.filterUsers(); + }, 300); + }); + } + + // Initialize filter functionality + initializeFilters() { + const filters = ["roleFilter", "statusFilter"]; + + filters.forEach((filterId) => { + const filter = document.getElementById(filterId); + if (filter) { + filter.addEventListener("change", () => { + this.filterUsers(); + }); + } + }); + } + + // Initialize bulk actions + initializeBulkActions() { + const selectAllCheckbox = document.getElementById("selectAllUsers"); + if (selectAllCheckbox) { + selectAllCheckbox.addEventListener("change", (e) => { + this.toggleSelectAll(e.target.checked); + }); + } + + // Individual checkbox handlers + const userCheckboxes = document.querySelectorAll(".user-checkbox"); + userCheckboxes.forEach((checkbox) => { + checkbox.addEventListener("change", (e) => { + this.handleUserSelection(e.target); + }); + }); + + // Bulk action buttons + this.setupBulkActionButtons(); + } + + setupBulkActionButtons() { + const bulkDeactivateBtn = document.getElementById("bulkDeactivateBtn"); + const bulkActivateBtn = document.getElementById("bulkActivateBtn"); + const bulkDeleteBtn = document.getElementById("bulkDeleteBtn"); + + if (bulkDeactivateBtn) { + bulkDeactivateBtn.addEventListener("click", () => { + this.bulkDeactivateUsers(); + }); + } + + if (bulkActivateBtn) { + bulkActivateBtn.addEventListener("click", () => { + this.bulkActivateUsers(); + }); + } + + if (bulkDeleteBtn) { + bulkDeleteBtn.addEventListener("click", () => { + this.bulkDeleteUsers(); + }); + } + } + + // Setup event listeners + setupEventListeners() { + // Keyboard shortcuts + document.addEventListener("keydown", (e) => { + // ESC to close modals + if (e.key === "Escape") { + this.closeAllModals(); + } + + // Ctrl/Cmd + F to focus search + if ((e.ctrlKey || e.metaKey) && e.key === "f") { + e.preventDefault(); + const searchInput = document.getElementById("searchUsers"); + if (searchInput) searchInput.focus(); + } + }); + + // Click outside dropdowns to close + document.addEventListener("click", (e) => { + if (!e.target.closest(".dropdown")) { + this.closeAllDropdowns(); + } + }); + } + + // Initialize modal functionality + initializeModals() { + const modals = document.querySelectorAll(".modal"); + modals.forEach((modal) => { + modal.addEventListener("click", (e) => { + if (e.target === modal) { + this.closeModal(modal); + } + }); + }); + } + + // Filter users based on search and filters + filterUsers() { + const searchTerm = + document.getElementById("searchUsers")?.value.toLowerCase() || ""; + const roleFilter = document.getElementById("roleFilter")?.value || ""; + const statusFilter = document.getElementById("statusFilter")?.value || ""; + + const userRows = document.querySelectorAll(".user-row"); + let visibleCount = 0; + + userRows.forEach((row) => { + const name = row.dataset.name?.toLowerCase() || ""; + const email = row.dataset.email?.toLowerCase() || ""; + const username = row.dataset.username?.toLowerCase() || ""; + const role = row.dataset.role || ""; + const status = row.dataset.status || ""; + + const matchesSearch = + !searchTerm || + name.includes(searchTerm) || + email.includes(searchTerm) || + username.includes(searchTerm); + + const matchesRole = !roleFilter || role === roleFilter; + const matchesStatus = !statusFilter || status === statusFilter; + + if (matchesSearch && matchesRole && matchesStatus) { + this.showUserRow(row); + visibleCount++; + } else { + this.hideUserRow(row); + } + }); + + this.updateResultsCount(visibleCount); + } + + showUserRow(row) { + row.style.display = "table-row"; + row.classList.remove("fade-out"); + row.classList.add("fade-in"); + } + + hideUserRow(row) { + row.classList.remove("fade-in"); + row.classList.add("fade-out"); + setTimeout(() => { + if (row.classList.contains("fade-out")) { + row.style.display = "none"; + } + }, 300); + } + + updateResultsCount(count) { + const counter = document.querySelector(".results-counter"); + if (counter) { + counter.textContent = `${count} users found`; + } + } + + // Dropdown management + toggleDropdown(event, button) { + event.stopPropagation(); + + const dropdown = button.closest(".dropdown"); + const menu = dropdown.querySelector(".dropdown-menu"); + + // Close all other dropdowns + this.closeAllDropdowns(); + + // Toggle current dropdown + menu.classList.toggle("show"); + } + + closeAllDropdowns() { + const openMenus = document.querySelectorAll(".dropdown-menu.show"); + openMenus.forEach((menu) => { + menu.classList.remove("show"); + }); + } + + // User Actions + async deactivateUser(userId, userName) { + const confirmed = await this.showConfirmation( + "Deactivate User", + `Are you sure you want to deactivate ${userName}?`, + "This will disable their login access but preserve their data." + ); + + if (!confirmed) return; + + try { + const response = await fetch(`/users/${userId}/delete`, { + method: "GET", + headers: { + "X-Requested-With": "XMLHttpRequest", + 'X-CSRF-Token': (window.qrConfig && window.qrConfig.csrfToken) || '', + }, + }); + + if (response.ok) { + // Update UI + this.updateUserStatus(userId, "inactive"); + window.showToast( + `User ${userName} deactivated successfully`, + "success" + ); + } else { + throw new Error("Failed to deactivate user"); + } + } catch (error) { + console.error("Deactivation error:", error); + window.showToast("Failed to deactivate user", "error"); + } + } + + async reactivateUser(userId, userName) { + try { + const response = await fetch(`/users/${userId}/reactivate`, { + method: "GET", + headers: { + "X-Requested-With": "XMLHttpRequest", + 'X-CSRF-Token': (window.qrConfig && window.qrConfig.csrfToken) || '', + }, + }); + + if (response.ok) { + this.updateUserStatus(userId, "active"); + window.showToast( + `User ${userName} reactivated successfully`, + "success" + ); + } else { + throw new Error("Failed to reactivate user"); + } + } catch (error) { + console.error("Reactivation error:", error); + window.showToast("Failed to reactivate user", "error"); + } + } + + async promoteUser(userId, userName) { + const confirmed = await this.showConfirmation( + "Promote to Admin", + `Promote ${userName} to admin?`, + "This will give them full system access including user management and system settings." + ); + + if (!confirmed) return; + + try { + const response = await fetch(`/users/${userId}/promote`, { + method: "GET", + headers: { + "X-Requested-With": "XMLHttpRequest", + 'X-CSRF-Token': (window.qrConfig && window.qrConfig.csrfToken) || '', + }, + }); + + if (response.ok) { + this.updateUserRole(userId, "admin"); + window.showToast( + `${userName} promoted to admin successfully`, + "success" + ); + } else { + throw new Error("Failed to promote user"); + } + } catch (error) { + console.error("Promotion error:", error); + window.showToast("Failed to promote user", "error"); + } + } + + async demoteUser(userId, userName) { + const confirmed = await this.showConfirmation( + "Demote from Admin", + `Demote ${userName} from admin to staff?`, + "This will remove their admin privileges and limit access to QR code management only." + ); + + if (!confirmed) return; + + try { + const response = await fetch(`/users/${userId}/demote`, { + method: "GET", + headers: { + "X-Requested-With": "XMLHttpRequest", + 'X-CSRF-Token': (window.qrConfig && window.qrConfig.csrfToken) || '', + }, + }); + + if (response.ok) { + this.updateUserRole(userId, "staff"); + window.showToast( + `${userName} demoted to staff successfully`, + "success" + ); + } else { + throw new Error("Failed to demote user"); + } + } catch (error) { + console.error("Demotion error:", error); + window.showToast("Failed to demote user", "error"); + } + } + + async permanentlyDeleteUser(userId, userName) { + const confirmed = await this.showConfirmation( + "Permanently Delete User", + `⚠️ PERMANENTLY DELETE ${userName}?`, + "This action CANNOT be undone and will permanently remove the user account and all associated QR codes.", + "danger" + ); + + if (!confirmed) return; + + try { + const response = await fetch(`/users/${userId}/permanently-delete`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + 'X-CSRF-Token': (window.qrConfig && window.qrConfig.csrfToken) || '', + }, + }); + + if (response.ok) { + // Remove user row from table + const userRow = document.querySelector(`[data-user-id="${userId}"]`); + if (userRow) { + userRow.classList.add("fade-out"); + setTimeout(() => userRow.remove(), 300); + } + + window.showToast(`User ${userName} permanently deleted`, "success"); + } else { + throw new Error("Failed to delete user"); + } + } catch (error) { + console.error("Deletion error:", error); + window.showToast("Failed to delete user", "error"); + } + } + + // Update UI after user actions + updateUserStatus(userId, newStatus) { + const userRow = document.querySelector(`[data-user-id="${userId}"]`); + if (!userRow) return; + + userRow.dataset.status = newStatus; + + const statusBadge = userRow.querySelector(".user-status"); + if (statusBadge) { + statusBadge.className = `user-status ${newStatus}`; + statusBadge.innerHTML = ` + <i class="fas ${ + newStatus === "active" ? "fa-check-circle" : "fa-times-circle" + }"></i> + ${newStatus === "active" ? "Active" : "Inactive"} + `; + } + + // Update action buttons in dropdown + this.updateUserActions(userId, newStatus); + } + + updateUserRole(userId, newRole) { + const userRow = document.querySelector(`[data-user-id="${userId}"]`); + if (!userRow) return; + + userRow.dataset.role = newRole; + + const roleBadge = userRow.querySelector(".user-role"); + if (roleBadge) { + roleBadge.className = `user-role ${newRole}`; + roleBadge.textContent = newRole; + } + + // Update action buttons + this.updateUserActions(userId, null, newRole); + } + + updateUserActions(userId, status = null, role = null) { + const userRow = document.querySelector(`[data-user-id="${userId}"]`); + if (!userRow) return; + + const currentStatus = status || userRow.dataset.status; + const currentRole = role || userRow.dataset.role; + + // Update dropdown menu items + const dropdownMenu = userRow.querySelector(".dropdown-menu"); + if (dropdownMenu) { + // This would update the dropdown items based on new status/role + // Implementation depends on your dropdown structure + } + } + + // Bulk Actions + toggleSelectAll(checked) { + const userCheckboxes = document.querySelectorAll(".user-checkbox"); + userCheckboxes.forEach((checkbox) => { + checkbox.checked = checked; + this.handleUserSelection(checkbox); + }); + } + + handleUserSelection(checkbox) { + const userId = checkbox.value; + + if (checkbox.checked) { + this.selectedUsers.add(userId); + } else { + this.selectedUsers.delete(userId); + } + + this.updateBulkActionsBar(); + this.updateSelectAllState(); + } + + updateBulkActionsBar() { + const bulkActionsBar = document.getElementById("bulkActionsBar"); + const selectedCount = document.getElementById("selectedCount"); + + if (bulkActionsBar && selectedCount) { + if (this.selectedUsers.size > 0) { + bulkActionsBar.classList.add("show"); + selectedCount.textContent = this.selectedUsers.size; + } else { + bulkActionsBar.classList.remove("show"); + } + } + } + + updateSelectAllState() { + const selectAllCheckbox = document.getElementById("selectAllUsers"); + const userCheckboxes = document.querySelectorAll(".user-checkbox"); + + if (selectAllCheckbox && userCheckboxes.length > 0) { + const checkedCount = Array.from(userCheckboxes).filter( + (cb) => cb.checked + ).length; + selectAllCheckbox.checked = checkedCount === userCheckboxes.length; + selectAllCheckbox.indeterminate = + checkedCount > 0 && checkedCount < userCheckboxes.length; + } + } + + async bulkDeactivateUsers() { + if (this.selectedUsers.size === 0) return; + + const confirmed = await this.showConfirmation( + "Bulk Deactivate Users", + `Deactivate ${this.selectedUsers.size} selected users?`, + "This will disable their login access but preserve their data." + ); + + if (!confirmed) return; + + try { + const response = await fetch("/users/bulk/deactivate", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + 'X-CSRF-Token': (window.qrConfig && window.qrConfig.csrfToken) || '', + }, + body: JSON.stringify({ + user_ids: Array.from(this.selectedUsers), + }), + }); + + const result = await response.json(); + + if (result.success) { + // Update UI for deactivated users + this.selectedUsers.forEach((userId) => { + this.updateUserStatus(userId, "inactive"); + }); + + this.clearSelection(); + window.showToast(result.message, "success"); + } else { + throw new Error(result.message); + } + } catch (error) { + console.error("Bulk deactivation error:", error); + window.showToast("Failed to deactivate users", "error"); + } + } + + async bulkActivateUsers() { + if (this.selectedUsers.size === 0) return; + + const confirmed = await this.showConfirmation( + "Bulk Activate Users", + `Activate ${this.selectedUsers.size} selected users?`, + "This will restore their login access." + ); + + if (!confirmed) return; + + try { + const response = await fetch("/users/bulk/activate", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + 'X-CSRF-Token': (window.qrConfig && window.qrConfig.csrfToken) || '', + }, + body: JSON.stringify({ + user_ids: Array.from(this.selectedUsers), + }), + }); + + const result = await response.json(); + + if (result.success) { + this.selectedUsers.forEach((userId) => { + this.updateUserStatus(userId, "active"); + }); + + this.clearSelection(); + window.showToast(result.message, "success"); + } else { + throw new Error(result.message); + } + } catch (error) { + console.error("Bulk activation error:", error); + window.showToast("Failed to activate users", "error"); + } + } + + async bulkDeleteUsers() { + if (this.selectedUsers.size === 0) return; + + const confirmed = await this.showConfirmation( + "Permanently Delete Users", + `⚠️ PERMANENTLY DELETE ${this.selectedUsers.size} selected users?`, + "This action CANNOT be undone and will permanently remove all user accounts and their associated data.", + "danger" + ); + + if (!confirmed) return; + + try { + const response = await fetch("/users/bulk/permanently-delete", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + 'X-CSRF-Token': (window.qrConfig && window.qrConfig.csrfToken) || '', + }, + body: JSON.stringify({ + user_ids: Array.from(this.selectedUsers), + }), + }); + + const result = await response.json(); + + if (result.success) { + // Remove users from table + this.selectedUsers.forEach((userId) => { + const userRow = document.querySelector(`[data-user-id="${userId}"]`); + if (userRow) { + userRow.classList.add("fade-out"); + setTimeout(() => userRow.remove(), 300); + } + }); + + this.clearSelection(); + window.showToast(result.message, "success"); + } else { + throw new Error(result.message); + } + } catch (error) { + console.error("Bulk deletion error:", error); + window.showToast("Failed to delete users", "error"); + } + } + + clearSelection() { + this.selectedUsers.clear(); + const userCheckboxes = document.querySelectorAll(".user-checkbox"); + userCheckboxes.forEach((checkbox) => { + checkbox.checked = false; + }); + this.updateBulkActionsBar(); + this.updateSelectAllState(); + } + + // Modal and confirmation dialogs + showConfirmation(title, message, details = "", type = "warning") { + return new Promise((resolve) => { + const modal = document.createElement("div"); + modal.className = "modal"; + modal.style.display = "flex"; + modal.innerHTML = ` + <div class="modal-content confirmation-modal"> + <div class="modal-header"> + <h3> + <i class="fas ${ + type === "danger" + ? "fa-exclamation-triangle text-danger" + : "fa-question-circle text-warning" + }"></i> + ${title} + </h3> + </div> + <div class="modal-body"> + <p><strong>${message}</strong></p> + ${details ? `<p class="text-muted">${details}</p>` : ""} + </div> + <div class="modal-footer"> + <button class="btn btn-secondary cancel-btn">Cancel</button> + <button class="btn btn-${ + type === "danger" ? "danger" : "warning" + } confirm-btn"> + <i class="fas fa-check"></i> Confirm + </button> + </div> + </div> + `; + + document.body.appendChild(modal); + + const cancelBtn = modal.querySelector(".cancel-btn"); + const confirmBtn = modal.querySelector(".confirm-btn"); + + const cleanup = () => modal.remove(); + + cancelBtn.addEventListener("click", () => { + cleanup(); + resolve(false); + }); + + confirmBtn.addEventListener("click", () => { + cleanup(); + resolve(true); + }); + + modal.addEventListener("click", (e) => { + if (e.target === modal) { + cleanup(); + resolve(false); + } + }); + }); + } + + closeModal(modal) { + modal.style.display = "none"; + } + + closeAllModals() { + const modals = document.querySelectorAll('.modal[style*="flex"]'); + modals.forEach((modal) => this.closeModal(modal)); + } + + // User details modal + showUserDetails(userId) { + // Implementation for showing user details modal + const modal = document.getElementById("userDetailsModal"); + if (modal) { + // Populate modal with user data + modal.style.display = "flex"; + } + } + + closeUserDetailsModal() { + const modal = document.getElementById("userDetailsModal"); + if (modal) { + modal.style.display = "none"; + } + } + + // Password reset modal + showPasswordResetModal(userId) { + const modal = document.getElementById("passwordResetModal"); + if (modal) { + modal.style.display = "flex"; + } + } + + closePasswordResetModal() { + const modal = document.getElementById("passwordResetModal"); + if (modal) { + modal.style.display = "none"; + } + } +} + +// Initialize users manager when DOM is loaded +document.addEventListener("DOMContentLoaded", function () { + window.usersManager = new UsersManager(); + + // Global functions for inline event handlers + window.toggleDropdown = (event, button) => + window.usersManager.toggleDropdown(event, button); + window.deactivateUser = (userId, userName) => + window.usersManager.deactivateUser(userId, userName); + window.reactivateUser = (userId, userName) => + window.usersManager.reactivateUser(userId, userName); + window.promoteUser = (userId, userName) => + window.usersManager.promoteUser(userId, userName); + window.demoteUser = (userId, userName) => + window.usersManager.demoteUser(userId, userName); + window.permanentlyDeleteUser = (userId, userName) => + window.usersManager.permanentlyDeleteUser(userId, userName); + window.showUserDetails = (userId) => + window.usersManager.showUserDetails(userId); + window.closeUserDetailsModal = () => + window.usersManager.closeUserDetailsModal(); + window.showPasswordResetModal = (userId) => + window.usersManager.showPasswordResetModal(userId); + window.closePasswordResetModal = () => + window.usersManager.closePasswordResetModal(); +}); diff --git a/templates/add_manual_attendance.html b/templates/add_manual_attendance.html new file mode 100644 index 0000000..c799831 --- /dev/null +++ b/templates/add_manual_attendance.html @@ -0,0 +1,503 @@ +{% extends "base_authenticated.html" %} + +{% block title %}Add Manual Attendance Record - QR Code Management{% endblock %} + +{% block extra_head %} +<style> +.manual-attendance-container { + max-width: 800px; + margin: 2rem auto; + padding: 2rem; +} + +.form-card { + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + padding: 2rem; +} + +.form-header { + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 2px solid #e0e0e0; +} + +.form-header h1 { + margin: 0 0 0.5rem 0; + color: #333; + font-size: 1.75rem; +} + +.form-header p { + margin: 0; + color: #666; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + color: #333; +} + +.form-group label .required { + color: #dc3545; +} + +.form-control { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + transition: border-color 0.3s; +} + +.form-control:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 3px rgba(0,123,255,0.1); +} + +.form-control:disabled { + background-color: #f5f5f5; + cursor: not-allowed; +} + +.autocomplete-container { + position: relative; +} + +.autocomplete-results { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: white; + border: 1px solid #ddd; + border-top: none; + border-radius: 0 0 4px 4px; + max-height: 200px; + overflow-y: auto; + z-index: 1000; + display: none; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); +} + +.autocomplete-results.show { + display: block; +} + +.autocomplete-item { + padding: 0.75rem; + cursor: pointer; + border-bottom: 1px solid #f0f0f0; +} + +.autocomplete-item:hover { + background-color: #f8f9fa; +} + +.autocomplete-item:last-child { + border-bottom: none; +} + +.employee-info { + display: flex; + justify-content: space-between; +} + +.employee-name { + font-weight: 600; + color: #333; +} + +.employee-id { + color: #666; + font-size: 0.9rem; +} + +.form-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid #e0e0e0; +} + +.btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 4px; + font-size: 1rem; + cursor: pointer; + transition: all 0.3s; + text-decoration: none; + display: inline-block; +} + +.btn-primary { + background-color: #007bff; + color: white; +} + +.btn-primary:hover { + background-color: #0056b3; +} + +.btn-secondary { + background-color: #6c757d; + color: white; +} + +.btn-secondary:hover { + background-color: #545b62; +} + +.btn i { + margin-right: 0.5rem; +} + +.loading-spinner { + display: none; + text-align: center; + padding: 1rem; + color: #007bff; +} + +.loading-spinner.show { + display: block; +} + +.alert { + padding: 1rem; + border-radius: 4px; + margin-bottom: 1rem; +} + +.alert-info { + background-color: #d1ecf1; + border: 1px solid #bee5eb; + color: #0c5460; +} + +.help-text { + font-size: 0.875rem; + color: #666; + margin-top: 0.25rem; +} +</style> +{% endblock %} + +{% block content %} +<div class="manual-attendance-container"> + <div class="form-card"> + <div class="form-header"> + <h1> + <i class="fas fa-user-plus"></i> + Add Manual Attendance Record + </h1> + <p>Create a new attendance record manually for employees who couldn't check in via QR code</p> + </div> + + <div class="alert alert-info"> + <i class="fas fa-info-circle"></i> + The system will automatically use the QR code's location address for both QR Address and Check-in Address, + with a fixed distance of 0.010 miles. + </div> + + <form id="manualAttendanceForm" method="POST" action="{{ url_for('attendance.save_manual_attendance') }}"> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> + <!-- Employee Selection with Autocomplete --> + <div class="form-group"> + <label for="employee_search"> + Employee <span class="required">*</span> + </label> + <div class="autocomplete-container"> + <input + type="text" + id="employee_search" + class="form-control" + placeholder="Search by employee name or ID..." + autocomplete="off" + required + > + <input type="hidden" id="employee_id" name="employee_id" required> + <div id="autocomplete_results" class="autocomplete-results"></div> + </div> + <div class="help-text">Start typing to search for employees by name or ID</div> + </div> + + <!-- Project Selection --> + <div class="form-group"> + <label for="project_id"> + Project <span class="required">*</span> + </label> + <select id="project_id" name="project_id" class="form-control" required> + <option value="">-- Select Project --</option> + {% for project in projects %} + <option value="{{ project.id }}">{{ project.name }}</option> + {% endfor %} + </select> + <div class="help-text">Select the project associated with this attendance record</div> + </div> + + <!-- Location Selection --> + <div class="form-group"> + <label for="location_select"> + Location <span class="required">*</span> + </label> + <select id="location_select" class="form-control" required disabled> + <option value="">-- Select Project First --</option> + </select> + <div class="help-text">Locations will be loaded based on the selected project (each location shown once)</div> + <div id="location_loading" class="loading-spinner"> + <i class="fas fa-spinner fa-spin"></i> Loading locations... + </div> + </div> + + <!-- Event Type Selection --> + <div class="form-group"> + <label for="event_type_select"> + Event Type <span class="required">*</span> + </label> + <select id="event_type_select" class="form-control" required disabled> + <option value="">-- Select Location First --</option> + </select> + <div class="help-text">Select whether this is a check-in or check-out event</div> + </div> + + <!-- Hidden field for the actual QR code ID that will be submitted --> + <input type="hidden" id="location_id" name="location_id" required> + + <!-- Date and Time --> + <div class="form-group"> + <label for="check_date"> + Date <span class="required">*</span> + </label> + <input + type="date" + id="check_date" + name="check_date" + class="form-control" + max="{{ today_date }}" + required + > + <div class="help-text">The date of the attendance record</div> + </div> + + <div class="form-group"> + <label for="check_time"> + Time <span class="required">*</span> + </label> + <input + type="time" + id="check_time" + name="check_time" + class="form-control" + required + > + <div class="help-text">The time of the attendance record</div> + </div> + + <!-- Form Actions --> + <div class="form-actions"> + <a href="{{ url_for('attendance.attendance_report') }}" class="btn btn-secondary"> + <i class="fas fa-times"></i> + Cancel + </a> + <button type="submit" class="btn btn-primary" id="submitBtn"> + <i class="fas fa-save"></i> + Save Record + </button> + </div> + </form> + </div> +</div> + +<script> +// Employee autocomplete functionality +const employeeSearch = document.getElementById('employee_search'); +const employeeIdHidden = document.getElementById('employee_id'); +const autocompleteResults = document.getElementById('autocomplete_results'); +let searchTimeout; + +employeeSearch.addEventListener('input', function() { + const searchTerm = this.value.trim(); + + if (searchTerm.length < 2) { + autocompleteResults.classList.remove('show'); + return; + } + + // Debounce search + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + fetch(`/api/search_employees?q=${encodeURIComponent(searchTerm)}`) + .then(response => response.json()) + .then(data => { + displayAutocompleteResults(data.employees); + }) + .catch(error => { + console.error('Error searching employees:', error); + }); + }, 300); +}); + +function displayAutocompleteResults(employees) { + if (employees.length === 0) { + autocompleteResults.innerHTML = '<div class="autocomplete-item">No employees found</div>'; + autocompleteResults.classList.add('show'); + return; + } + + const html = employees.map(emp => ` + <div class="autocomplete-item" onclick="selectEmployee(${emp.id}, '${emp.firstName} ${emp.lastName}')"> + <div class="employee-info"> + <span class="employee-name">${emp.lastName}, ${emp.firstName}</span> + <span class="employee-id">ID: ${emp.id}</span> + </div> + </div> + `).join(''); + + autocompleteResults.innerHTML = html; + autocompleteResults.classList.add('show'); +} + +function selectEmployee(id, name) { + employeeIdHidden.value = id; + employeeSearch.value = name; + autocompleteResults.classList.remove('show'); +} + +// Close autocomplete when clicking outside +document.addEventListener('click', function(e) { + if (!e.target.closest('.autocomplete-container')) { + autocompleteResults.classList.remove('show'); + } +}); + +// Project selection - load locations +const projectSelect = document.getElementById('project_id'); +const locationSelect = document.getElementById('location_select'); +const eventTypeSelect = document.getElementById('event_type_select'); +const locationIdHidden = document.getElementById('location_id'); +const locationLoading = document.getElementById('location_loading'); + +// Store location data for later use +let locationData = {}; + +projectSelect.addEventListener('change', function() { + const projectId = this.value; + + // Reset location and event type + locationSelect.innerHTML = '<option value="">-- Select Location --</option>'; + locationSelect.disabled = true; + eventTypeSelect.innerHTML = '<option value="">-- Select Location First --</option>'; + eventTypeSelect.disabled = true; + locationIdHidden.value = ''; + locationData = {}; + + if (!projectId) { + locationSelect.innerHTML = '<option value="">-- Select Project First --</option>'; + return; + } + + // Load locations for the selected project + locationLoading.classList.add('show'); + + fetch(`/api/get_project_locations?project_id=${projectId}`) + .then(response => response.json()) + .then(data => { + locationLoading.classList.remove('show'); + + if (data.success && data.locations.length > 0) { + locationSelect.disabled = false; + + data.locations.forEach((loc, index) => { + const locationKey = `loc_${index}`; + locationData[locationKey] = loc.qr_codes; + + const option = document.createElement('option'); + option.value = locationKey; + option.textContent = `${loc.location} - ${loc.location_address}`; + locationSelect.appendChild(option); + }); + } else { + locationSelect.innerHTML = '<option value="">No active locations found for this project</option>'; + } + }) + .catch(error => { + locationLoading.classList.remove('show'); + console.error('Error loading locations:', error); + alert('Error loading locations. Please try again.'); + }); +}); + +// Location selection - populate event type options +locationSelect.addEventListener('change', function() { + const locationKey = this.value; + + eventTypeSelect.innerHTML = ''; + eventTypeSelect.disabled = false; + locationIdHidden.value = ''; + + if (locationKey && locationData[locationKey]) { + const qrCodes = locationData[locationKey]; + + // Add available event types + if (qrCodes['Check In']) { + const option = document.createElement('option'); + option.value = qrCodes['Check In']; + option.textContent = 'Check In'; + eventTypeSelect.appendChild(option); + } + + if (qrCodes['Check Out']) { + const option = document.createElement('option'); + option.value = qrCodes['Check Out']; + option.textContent = 'Check Out'; + eventTypeSelect.appendChild(option); + } + + if (eventTypeSelect.options.length === 0) { + eventTypeSelect.innerHTML = '<option value="">No event types available</option>'; + eventTypeSelect.disabled = true; + } else { + // Set the hidden location_id to the first available option + locationIdHidden.value = eventTypeSelect.options[0].value; + } + } else { + eventTypeSelect.innerHTML = '<option value="">-- Select Location First --</option>'; + eventTypeSelect.disabled = true; + } +}); + +// Event type selection - update hidden location_id field +eventTypeSelect.addEventListener('change', function() { + locationIdHidden.value = this.value; +}); + +// Set default date to today +document.getElementById('check_date').valueAsDate = new Date(); + +// Set default time to current time +const now = new Date(); +const hours = String(now.getHours()).padStart(2, '0'); +const minutes = String(now.getMinutes()).padStart(2, '0'); +document.getElementById('check_time').value = `${hours}:${minutes}`; + +// Form submission +document.getElementById('manualAttendanceForm').addEventListener('submit', function(e) { + const submitBtn = document.getElementById('submitBtn'); + submitBtn.disabled = true; + submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Saving...'; +}); +</script> +{% endblock %} diff --git a/templates/admin_logs.html b/templates/admin_logs.html new file mode 100644 index 0000000..821c8b1 --- /dev/null +++ b/templates/admin_logs.html @@ -0,0 +1,2068 @@ +<!-- templates/admin/logs.html --> +{% extends "base_authenticated.html" %} {% block title %}System Logs - QR Code +Management{% endblock %} {% block extra_head %} +<!-- Admin Logs CSS --> +<link + rel="stylesheet" + href="{{ url_for('static', filename='css/admin_logs.css') }}" +/> +{% endblock %} {% block content %} +<div class="logs-page"> + <!-- Page Header --> + <div class="logs-header"> + <div class="header-content"> + <h1><i class="fas fa-clipboard-list"></i> System Logs</h1> + <p>Monitor system activities and security events</p> + </div> + <div class="header-actions"> + <button class="btn btn-secondary btn-sm" onclick="refreshLogs()"> + <i class="fas fa-sync-alt"></i> + Refresh + </button> + <button class="btn btn-secondary btn-sm" onclick="exportLogs()"> + <i class="fas fa-download"></i> + Export + </button> + <button class="btn btn-warning" onclick="clearOldLogs()"> + <i class="fas fa-broom"></i> + Clear Old Logs + </button> + </div> + </div> + + <!-- Log Statistics --> + <div class="log-stats"> + <div class="stat-card total"> + <div class="stat-icon total"> + <i class="fas fa-list-alt"></i> + </div> + <div class="stat-info"> + <h3 id="totalEventsCount"> + {{ log_stats.total_events if log_stats else 0 }} + </h3> + <p>Total Events</p> + </div> + </div> + + <div class="stat-card security"> + <div class="stat-icon security"> + <i class="fas fa-shield-alt"></i> + </div> + <div class="stat-info"> + <h3 id="securityEventsCount"> + {{ log_stats.security_events if log_stats else 0 }} + </h3> + <p>Security Events</p> + </div> + </div> + + <div class="stat-card auth"> + <div class="stat-icon auth"> + <i class="fas fa-user-shield"></i> + </div> + <div class="stat-info"> + <h3 id="authEventsCount"> + {{ log_stats.authentication_events if log_stats else 0 }} + </h3> + <p>Authentication</p> + </div> + </div> + + <div class="stat-card qr"> + <div class="stat-icon qr"> + <i class="fas fa-qrcode"></i> + </div> + <div class="stat-info"> + <h3 id="qrEventsCount"> + {{ log_stats.qr_management_events if log_stats else 0 }} + </h3> + <p>QR Management</p> + </div> + </div> + + <div class="stat-card database"> + <div class="stat-icon database"> + <i class="fas fa-database"></i> + </div> + <div class="stat-info"> + <h3 id="databaseErrorsCount"> + {{ log_stats.database_errors if log_stats else 0 }} + </h3> + <p>Database Errors</p> + </div> + </div> + + <div class="stat-card application"> + <div class="stat-icon application"> + <i class="fas fa-cogs"></i> + </div> + <div class="stat-info"> + <h3 id="applicationEventsCount"> + {{ log_stats.application_events if log_stats else 0 }} + </h3> + <p>Application Events</p> + </div> + </div> + </div> + + <!-- Debug Info (only visible in development) --> + {% if config.DEBUG %} + <div + class="debug-info" + style=" + background: #f3f4f6; + padding: 1rem; + border-radius: 8px; + margin-bottom: 2rem; + font-family: monospace; + font-size: 0.875rem; + " + > + <strong>Debug Info (Development Mode):</strong><br /> + Log Stats Available: {{ log_stats is not none }}<br /> + {% if log_stats %} Log Stats Keys: {{ log_stats.keys() | list }}<br /> + Total Events: {{ log_stats.total_events }}<br /> + Security Events: {{ log_stats.security_events }}<br /> + Database Errors: {{ log_stats.database_errors }}<br /> + User Activities: {{ log_stats.user_activities }}<br /> + {% endif %} + </div> + {% endif %} + + <!-- Log Controls --> + <div class="log-controls"> + <div class="search-filters"> + <div class="search-box"> + <i class="fas fa-search"></i> + <input + type="text" + id="searchLogs" + placeholder="Search logs by event type, description, or username..." + /> + </div> + + <div class="filter-group"> + <select id="categoryFilter" class="filter-select"> + <option value="">All Categories</option> + <option value="security">Security Events</option> + <option value="authentication">Authentication</option> + <option value="qr_management">QR Management</option> + <option value="database">Database Events</option> + <option value="application">Application Events</option> + <option value="system">System Events</option> + </select> + + <select id="severityFilter" class="filter-select"> + <option value="">All Severity</option> + <option value="CRITICAL">Critical</option> + <option value="ERROR">Error</option> + <option value="WARNING">Warning</option> + <option value="INFO">Info</option> + </select> + + <select id="daysFilter" class="filter-select"> + <option value="1">Last 24 Hours</option> + <option value="7" selected>Last 7 Days</option> + <option value="30">Last 30 Days</option> + <option value="90">Last 90 Days</option> + </select> + </div> + </div> + </div> + + <!-- Logs Table --> + <div class="logs-table-container"> + <div id="logsTableWrapper"> + <div class="loading-state" id="loadingState"> + <i class="fas fa-spinner fa-spin"></i> + <p>Loading log entries...</p> + </div> + + <div class="empty-state" id="emptyState" style="display: none"> + <i class="fas fa-clipboard-list"></i> + <h3>No Log Entries Found</h3> + <p>No log entries match your current filter criteria.</p> + </div> + + <table class="logs-table" id="logsTable" style="display: none"> + <thead> + <tr> + <th>Timestamp</th> + <th>Event Type</th> + <th>Category</th> + <th>Description</th> + <th>Severity</th> + <th>User</th> + <th>IP Address</th> + <th>Actions</th> + </tr> + </thead> + <tbody id="logsTableBody"> + <!-- Logs will be loaded via JavaScript --> + </tbody> + </table> + </div> + </div> + + <!-- Pagination --> + <div class="pagination-wrapper" id="paginationWrapper" style="display: none"> + <div class="pagination-info"> + <span id="paginationInfo">Showing 0 of 0 entries</span> + </div> + <div class="pagination-controls"> + <button class="btn btn-sm" id="prevPage" onclick="previousPage()"> + <i class="fas fa-chevron-left"></i> + Previous + </button> + <span class="pagination-numbers" id="pageNumbers"> + <!-- Page numbers will be inserted here --> + </span> + <button class="btn btn-sm" id="nextPage" onclick="nextPage()"> + Next + <i class="fas fa-chevron-right"></i> + </button> + </div> + </div> +</div> + +<!-- Log Details Modal --> +<div class="modal" id="logDetailsModal"> + <div class="modal-content"> + <div class="modal-header"> + <h3><i class="fas fa-info-circle"></i> Log Entry Details</h3> + <button class="modal-close" onclick="closeLogDetailsModal()"> + × + </button> + </div> + <div class="modal-body"> + <div class="log-details-grid"> + <div class="detail-section"> + <h4><i class="fas fa-tag"></i> Event Information</h4> + <div class="detail-row"> + <span class="detail-label">Event ID:</span> + <span class="detail-value" id="detailEventId">-</span> + </div> + <div class="detail-row"> + <span class="detail-label">Event Type:</span> + <span class="detail-value" id="detailEventType">-</span> + </div> + <div class="detail-row"> + <span class="detail-label">Category:</span> + <span class="detail-value" id="detailCategory">-</span> + </div> + <div class="detail-row"> + <span class="detail-label">Severity:</span> + <span class="detail-value" id="detailSeverity">-</span> + </div> + <div class="detail-row"> + <span class="detail-label">Timestamp:</span> + <span class="detail-value" id="detailTimestamp">-</span> + </div> + </div> + + <div class="detail-section"> + <h4><i class="fas fa-user"></i> User Information</h4> + <div class="detail-row"> + <span class="detail-label">Username:</span> + <span class="detail-value" id="detailUsername">-</span> + </div> + <div class="detail-row"> + <span class="detail-label">User ID:</span> + <span class="detail-value" id="detailUserId">-</span> + </div> + <div class="detail-row"> + <span class="detail-label">IP Address:</span> + <span class="detail-value" id="detailIpAddress">-</span> + </div> + </div> + </div> + + <div class="detail-section full-width"> + <h4><i class="fas fa-align-left"></i> Description</h4> + <div class="description-box" id="detailDescription">-</div> + </div> + + <div + class="detail-section full-width" + id="eventDataSection" + style="display: none" + > + <h4><i class="fas fa-code"></i> Additional Data</h4> + <div class="json-box" id="detailEventData">-</div> + </div> + </div> + <div class="modal-footer"> + <button class="btn btn-secondary" onclick="copyLogDetails()"> + <i class="fas fa-copy"></i> + Copy Details + </button> + <button class="btn btn-secondary" onclick="closeLogDetailsModal()"> + <i class="fas fa-times"></i> + Close + </button> + </div> + </div> +</div> + +<!-- Cleanup Modal --> +<div class="modal" id="cleanupModal"> + <div class="modal-content"> + <div class="modal-header"> + <h3><i class="fas fa-trash-alt"></i> Cleanup Old Logs</h3> + <button class="modal-close" onclick="closeCleanupModal()">×</button> + </div> + <div class="modal-body"> + <p> + This action will permanently delete log entries older than the specified + number of days. + </p> + <div class="form-group"> + <label for="daysToKeep">Keep logs for the last:</label> + <select id="daysToKeep" class="form-control"> + <option value="30">30 days</option> + <option value="60">60 days</option> + <option value="90" selected>90 days</option> + <option value="180">180 days</option> + <option value="365">1 year</option> + </select> + </div> + <div class="warning-note"> + <i class="fas fa-exclamation-triangle"></i> + <strong>Warning:</strong> This action cannot be undone. Deleted log + entries will be permanently removed from the system. + </div> + </div> + <div class="modal-footer"> + <button class="btn btn-secondary" onclick="closeCleanupModal()"> + Cancel + </button> + <button class="btn btn-danger" onclick="confirmCleanup()"> + <i class="fas fa-trash-alt"></i> + Delete Old Logs + </button> + </div> + </div> +</div> + +<!-- Clear Old Logs Modal --> +<div class="modal" id="clearOldLogsModal"> + <div class="modal-content"> + <div class="modal-header"> + <h3><i class="fas fa-broom"></i> Clear Old Logs</h3> + <button class="modal-close" onclick="closeClearOldLogsModal()"> + × + </button> + </div> + <div class="modal-body"> + <p> + This action will permanently delete log entries older than the selected + period. + </p> + <div class="form-group"> + <label for="clearOldLogsDays">Clear logs older than:</label> + <select id="clearOldLogsDays" class="form-control"> + <option value="30">30 days</option> + <option value="60">60 days</option> + <option value="90" selected>90 days</option> + </select> + </div> + <div class="warning-note"> + <i class="fas fa-exclamation-triangle"></i> + <strong>Warning:</strong> This action cannot be undone. Log entries + older than the selected period will be permanently removed from the + system. + </div> + </div> + <div class="modal-footer"> + <button class="btn btn-secondary" onclick="closeClearOldLogsModal()"> + Cancel + </button> + <button class="btn btn-danger" onclick="confirmClearOldLogs()"> + <i class="fas fa-broom"></i> + Clear Old Logs + </button> + </div> + </div> +</div> + +<script> + // Global variables + let currentPage = 1; + let logsPerPage = 50; + let totalLogs = 0; + let currentFilters = { + search: "", + category: "", + severity: "", + days: 7, + }; + + // Initialize page + document.addEventListener("DOMContentLoaded", function () { + console.log("Admin logs page loading..."); + loadLogs(); + loadStats(); + setupEventListeners(); + }); + + // Setup event listeners + function setupEventListeners() { + // Search input + const searchInput = document.getElementById("searchLogs"); + if (searchInput) { + let searchTimeout; + searchInput.addEventListener("input", function () { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + currentFilters.search = this.value; + currentPage = 1; + loadLogs(); + }, 500); + }); + } + + // Filter selects + const categoryFilter = document.getElementById("categoryFilter"); + if (categoryFilter) { + categoryFilter.addEventListener("change", function () { + currentFilters.category = this.value; + currentPage = 1; + loadLogs(); + }); + } + + const severityFilter = document.getElementById("severityFilter"); + if (severityFilter) { + severityFilter.addEventListener("change", function () { + currentFilters.severity = this.value; + currentPage = 1; + loadLogs(); + }); + } + + const daysFilter = document.getElementById("daysFilter"); + if (daysFilter) { + daysFilter.addEventListener("change", function () { + currentFilters.days = parseInt(this.value); + currentPage = 1; + loadLogs(); + loadStats(); + }); + } + } + + // Load logs from API + async function loadLogs() { + console.log( + `Loading logs - Page: ${currentPage}, Filters:`, + currentFilters + ); + showLoading(); + + try { + const params = new URLSearchParams({ + days: currentFilters.days, + limit: logsPerPage, + page: currentPage, + }); + + // Add filters to API request + if (currentFilters.category) { + params.append("category", currentFilters.category); + } + if (currentFilters.severity) { + params.append("severity", currentFilters.severity); + } + if (currentFilters.search) { + params.append("search", currentFilters.search); + } + + const response = await fetch(`/api/logs/recent?${params}`); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.success) { + displayLogs(data.logs); + totalLogs = data.total; // Use actual total count from backend + + console.log( + `📊 Loaded ${data.logs.length} logs, Total: ${data.total}, Page: ${data.page}` + ); + + updatePagination(); + + if (data.logs.length > 0) { + showTable(); + } else { + showEmpty(); + } + } else { + showError("Failed to load logs: " + (data.error || "Unknown error")); + } + } catch (error) { + console.error("Error loading logs:", error); + showError("Failed to load logs: " + error.message); + } + } + + // Display logs in table + function displayLogs(logs) { + const tbody = document.getElementById("logsTableBody"); + tbody.innerHTML = ""; + + if (logs.length === 0) { + showEmpty(); + return; + } + + // No need to filter here - filtering is now done on backend + logs.forEach((log, index) => { + const row = document.createElement("tr"); + const transformedLog = { + description: log.description || log.event_description || "No description", + severity: log.severity || log.severity_level || "INFO", + timestamp: log.timestamp || log.created_timestamp, + event_type: log.event_type || "Unknown", + event_category: log.event_category || "system", + event_id: log.event_id || "-", + username: log.username || "System", + user_id: log.user_id || "-", + ip_address: log.ip_address || "-", + event_data: log.event_data + }; + // Store the transformed log + logs[index] = transformedLog; + + row.className = getSeverityClass(log.severity); + + const timestamp = new Date(log.timestamp).toLocaleString(); + + // Truncate description for table display + const shortDescription = + log.description && log.description.length > 80 + ? log.description.substring(0, 80) + "..." + : log.description || "No description"; + + row.innerHTML = ` + <td class="timestamp">${timestamp}</td> + <td class="event-type">${escapeHtml(log.event_type || "Unknown")}</td> + <td class="category"> + <span class="category-badge ${log.event_category || "system"}">${ + log.event_category || "system" + }</span> + </td> + <td class="description" title="${escapeHtml( + log.description || "" + )}">${escapeHtml(shortDescription)}</td> + <td class="severity"> + <span class="severity-badge ${( + log.severity || "info" + ).toLowerCase()}">${log.severity || "INFO"}</span> + </td> + <td class="username">${ + log.username ? escapeHtml(log.username) : "<em>System</em>" + }</td> + <td class="ip-address">${escapeHtml(log.ip_address || "-")}</td> + <td class="actions"> + <button class="btn btn-sm btn-secondary" onclick="viewLogDetails(${index})"> + <i class="fas fa-eye"></i> + </button> + </td> + `; + + tbody.appendChild(row); + }); + + // Store logs globally for details modal + window.currentLogs = logs; + console.log(`📊 Displayed ${logs.length} log entries`); + } + + // Load log statistics + async function loadStats() { + try { + console.log("📊 Loading statistics..."); + const days = currentFilters.days; + + const response = await fetch(`/api/logs/stats?days=${days}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": (window.qrConfig && window.qrConfig.csrfToken) || "", + }, + }); + + const data = await response.json(); + console.log("📈 Stats received:", data); + + if (data.success) { + const stats = data.stats; + + // Update stat cards with new categories + document.getElementById("totalEventsCount").textContent = + stats.total_events || 0; + document.getElementById("securityEventsCount").textContent = + stats.security_events || 0; + document.getElementById("authEventsCount").textContent = + stats.authentication_events || 0; + document.getElementById("qrEventsCount").textContent = + stats.qr_management_events || 0; + document.getElementById("databaseErrorsCount").textContent = + stats.database_errors || 0; + document.getElementById("applicationEventsCount").textContent = + stats.application_events || 0; + + console.log("✅ Statistics updated successfully"); + } else { + console.error("Failed to load stats:", data.error); + showError("Failed to load statistics: " + (data.error || "Unknown error")); + } + } catch (error) { + console.error("Error loading stats:", error); + showError("Failed to load statistics: " + error.message); + } + } + + // Export logs functionality + async function exportLogs() { + try { + showMessage("Preparing log export...", "info"); + + // Get current filters to include in export + const params = new URLSearchParams({ + days: currentFilters.days, + category: currentFilters.category || "", + severity: currentFilters.severity || "", + search: currentFilters.search || "", + export: "csv", + }); + + const response = await fetch(`/api/logs/export?${params}`); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.success) { + // Create CSV content + const headers = [ + "Timestamp", + "Event Type", + "Category", + "Description", + "Severity", + "Username", + "User ID", + "IP Address", + ]; + const csvData = [headers]; + + data.logs.forEach((log) => { + csvData.push([ + new Date(log.timestamp).toLocaleString(), + log.event_type || "", + log.event_category || "", + (log.description || "").replace(/"/g, '""'), // Escape quotes + log.severity || "", + log.username || "System", + log.user_id || "", + log.ip_address || "", + ]); + }); + + // Generate CSV string + const csvContent = csvData + .map((row) => row.map((field) => `"${field}"`).join(",")) + .join("\n"); + + // Create and download file + const blob = new Blob([csvContent], { + type: "text/csv;charset=utf-8;", + }); + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + link.setAttribute("href", url); + + const timestamp = new Date().toISOString().split("T")[0]; + const filename = `system_logs_${timestamp}.csv`; + link.setAttribute("download", filename); + + link.style.visibility = "hidden"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + showSuccess(`✅ Logs exported successfully as ${filename}`); + } else { + showError( + "❌ Error exporting logs: " + (data.error || "Unknown error") + ); + } + } catch (error) { + console.error("Error exporting logs:", error); + showError("❌ Failed to export logs: " + error.message); + } + } + + // View log details in modal + function viewLogDetails(logIndex) { + console.log(`🔍 Opening log details for index: ${logIndex}`); + console.log('📊 Available logs count:', window.currentLogs ? window.currentLogs.length : 0); + + if (!window.currentLogs || !window.currentLogs[logIndex]) { + console.error(`❌ No log found at index ${logIndex}`); + showError("Unable to load log details. Please refresh the page and try again."); + return; + } + + const log = window.currentLogs[logIndex]; + console.log('📝 Selected log data:', log); + + try { + // Map the API response fields to what we expect + const logData = { + event_id: log.event_id || log.id || "-", + event_type: log.event_type || "Unknown", + event_category: log.event_category || log.category || "system", + severity: log.severity || log.severity_level || "INFO", + timestamp: log.timestamp || log.created_timestamp, + username: log.username || "System", + user_id: log.user_id || "-", + ip_address: log.ip_address || "-", + description: log.description || log.event_description || "-", + event_data: log.event_data + }; + + // Populate modal with log details + document.getElementById("detailEventId").textContent = logData.event_id; + document.getElementById("detailEventType").textContent = logData.event_type; + document.getElementById("detailCategory").textContent = logData.event_category; + document.getElementById("detailSeverity").innerHTML = + `<span class="severity-badge ${logData.severity.toLowerCase()}">${logData.severity}</span>`; + document.getElementById("detailTimestamp").textContent = + logData.timestamp ? new Date(logData.timestamp).toLocaleString() : "-"; + document.getElementById("detailUsername").textContent = logData.username; + document.getElementById("detailUserId").textContent = logData.user_id; + document.getElementById("detailIpAddress").textContent = logData.ip_address; + document.getElementById("detailDescription").textContent = logData.description; + + // Handle event data + const eventDataSection = document.getElementById("eventDataSection"); + const eventDataElement = document.getElementById("detailEventData"); + + if (logData.event_data) { + try { + const formattedData = typeof logData.event_data === "string" + ? JSON.stringify(JSON.parse(logData.event_data), null, 2) + : JSON.stringify(logData.event_data, null, 2); + eventDataElement.textContent = formattedData; + eventDataSection.style.display = "block"; + } catch (e) { + eventDataElement.textContent = logData.event_data; + eventDataSection.style.display = "block"; + } + } else { + eventDataSection.style.display = "none"; + } + + // Store current log for copying + window.currentLogDetails = logData; + + // Show modal using the same pattern as clearOldLogs + const modal = document.getElementById("logDetailsModal"); + if (modal) { + modal.style.display = "flex"; + modal.style.visibility = "visible"; + modal.style.opacity = "1"; + console.log('✅ Modal should be visible now'); + } else { + console.error('❌ logDetailsModal element not found!'); + } + + } catch (error) { + console.error('💥 Error in viewLogDetails:', error); + showError("Error displaying log details: " + error.message); + } + } + + // Close log details modal + function closeLogDetailsModal() { + const modal = document.getElementById("logDetailsModal"); + if (modal) { + modal.style.display = "none"; + modal.style.visibility = "hidden"; + modal.style.opacity = "0"; + } + } + + // Copy log details to clipboard + function copyLogDetails() { + const log = window.currentLogDetails; + if (!log) return; + + const details = `Log Entry Details +================== +Event ID: ${log.event_id || "-"} +Event Type: ${log.event_type || "-"} +Category: ${log.event_category || "-"} +Severity: ${log.severity || "-"} +Timestamp: ${new Date(log.timestamp).toLocaleString()} +Username: ${log.username || "System"} +User ID: ${log.user_id || "-"} +IP Address: ${log.ip_address || "-"} + +Description: +${log.description || "-"} + +${ + log.event_data + ? "Additional Data:\n" + + (typeof log.event_data === "string" + ? log.event_data + : JSON.stringify(log.event_data, null, 2)) + : "" +}`; + + navigator.clipboard + .writeText(details) + .then(() => { + showSuccess("Log details copied to clipboard!"); + }) + .catch(() => { + // Fallback for older browsers + const textArea = document.createElement("textarea"); + textArea.value = details; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand("copy"); + document.body.removeChild(textArea); + showSuccess("Log details copied to clipboard!"); + }); + } + + // Utility functions + function getSeverityClass(severity) { + switch ((severity || "info").toLowerCase()) { + case "high": + return "severity-high"; + case "medium": + return "severity-medium"; + case "low": + return "severity-low"; + default: + return "severity-info"; + } + } + + function escapeHtml(text) { + if (!text) return ""; + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + function showLoading() { + document.getElementById("loadingState").style.display = "block"; + document.getElementById("emptyState").style.display = "none"; + document.getElementById("logsTable").style.display = "none"; + document.getElementById("paginationWrapper").style.display = "none"; + } + + function showEmpty() { + document.getElementById("loadingState").style.display = "none"; + document.getElementById("emptyState").style.display = "block"; + document.getElementById("logsTable").style.display = "none"; + document.getElementById("paginationWrapper").style.display = "none"; + } + + function showTable() { + document.getElementById("loadingState").style.display = "none"; + document.getElementById("emptyState").style.display = "none"; + document.getElementById("logsTable").style.display = "table"; + document.getElementById("paginationWrapper").style.display = "flex"; + } + + function showError(message) { + showMessage("❌ " + message, "error"); + showEmpty(); + } + + function showSuccess(message) { + showMessage("✅ " + message, "success"); + } + + function showMessage(message, type) { + const messageDiv = document.createElement("div"); + messageDiv.className = `flash-message flash-${type}`; + messageDiv.innerHTML = ` + <span>${message}</span> + <button onclick="this.parentElement.remove()" style="background: none; border: none; color: inherit; font-size: 1.2em; cursor: pointer; margin-left: 10px;">×</button> + `; + + const container = document.querySelector(".logs-page"); + if (container) { + container.insertBefore(messageDiv, container.firstChild); + } + + setTimeout(() => { + if (messageDiv.parentElement) { + messageDiv.remove(); + } + }, 5000); + } + + // Update pagination + function updatePagination() { + const totalPages = Math.ceil(totalLogs / logsPerPage); + const info = document.getElementById("paginationInfo"); + + if (info) { + const start = totalLogs > 0 ? (currentPage - 1) * logsPerPage + 1 : 0; + const end = Math.min(currentPage * logsPerPage, totalLogs); + info.textContent = `Showing ${start}-${end} of ${totalLogs} entries`; + } + + const prevBtn = document.getElementById("prevPage"); + const nextBtn = document.getElementById("nextPage"); + const pageNumbers = document.getElementById("pageNumbers"); + + if (prevBtn) prevBtn.disabled = currentPage <= 1; + if (nextBtn) nextBtn.disabled = currentPage >= totalPages; + + // Generate page numbers + if (pageNumbers && totalPages > 1) { + let pageNumbersHTML = ""; + + // Show page numbers (max 5 visible) + const maxVisible = 5; + let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2)); + let endPage = Math.min(totalPages, startPage + maxVisible - 1); + + // Adjust start if we're near the end + if (endPage - startPage + 1 < maxVisible) { + startPage = Math.max(1, endPage - maxVisible + 1); + } + + // First page + ellipsis + if (startPage > 1) { + pageNumbersHTML += `<button class="btn btn-sm page-number" onclick="goToPage(1)">1</button>`; + if (startPage > 2) { + pageNumbersHTML += `<span class="pagination-ellipsis">...</span>`; + } + } + + // Page numbers + for (let i = startPage; i <= endPage; i++) { + const isActive = i === currentPage ? " active" : ""; + pageNumbersHTML += `<button class="btn btn-sm page-number${isActive}" onclick="goToPage(${i})">${i}</button>`; + } + + // Last page + ellipsis + if (endPage < totalPages) { + if (endPage < totalPages - 1) { + pageNumbersHTML += `<span class="pagination-ellipsis">...</span>`; + } + pageNumbersHTML += `<button class="btn btn-sm page-number" onclick="goToPage(${totalPages})">${totalPages}</button>`; + } + + pageNumbers.innerHTML = pageNumbersHTML; + } else if (pageNumbers) { + pageNumbers.innerHTML = ""; + } + + console.log( + `📊 Pagination updated - Page ${currentPage}/${totalPages}, Total logs: ${totalLogs}` + ); + } + + // Pagination controls + function previousPage() { + if (currentPage > 1) { + currentPage--; + loadLogs(); + } + } + + function nextPage() { + const totalPages = Math.ceil(totalLogs / logsPerPage); + + console.log( + `Next page requested. Current: ${currentPage}, Total Pages: ${totalPages}` + ); + + if (currentPage < totalPages) { + currentPage++; + loadLogs(); + } else { + console.log("Already on last page"); + } + } + + // Go to specific page + function goToPage(page) { + const totalPages = Math.ceil(totalLogs / logsPerPage); + + if (page < 1 || page > totalPages) { + console.log(`Invalid page: ${page}. Valid range: 1-${totalPages}`); + return; + } + + console.log(`Going to page ${page}`); + currentPage = page; + loadLogs(); + } + + // Refresh logs + function refreshLogs() { + loadLogs(); + loadStats(); + } + + // Clear all logs functionality + async function clearLogs() { + // Show confirmation dialog + if ( + !confirm( + "⚠️ Are you sure you want to clear ALL logs?\n\nThis action will permanently delete ALL log entries from the system and cannot be undone." + ) + ) { + return; + } + + // Second confirmation for safety + if ( + !confirm( + "🚨 FINAL WARNING: This will delete ALL logs!\n\nClick OK to proceed or Cancel to abort." + ) + ) { + return; + } + + try { + showMessage("Clearing all logs...", "info"); + + const response = await fetch("/api/logs/clear", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": (window.qrConfig && window.qrConfig.csrfToken) || "", + }, + }); + + const data = await response.json(); + + if (data.success) { + showSuccess( + `✅ Successfully cleared ${data.deleted_count} log entries` + ); + setTimeout(() => { + loadLogs(); + loadStats(); + }, 1000); + } else { + showError("❌ Error clearing logs: " + (data.error || "Unknown error")); + } + } catch (error) { + console.error("Error clearing logs:", error); + showError("❌ Failed to clear logs: " + error.message); + } + } + + // Cleanup logs functionality + function cleanupLogs() { + document.getElementById("cleanupModal").style.display = "flex"; + } + + function closeCleanupModal() { + document.getElementById("cleanupModal").style.display = "none"; + } + + async function confirmCleanup() { + const daysToKeep = parseInt(document.getElementById("daysToKeep").value); + + if (daysToKeep < 7) { + showError("Cannot keep logs for less than 7 days"); + return; + } + + try { + showMessage("Cleaning up old logs...", "info"); + + const response = await fetch("/api/logs/cleanup", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": (window.qrConfig && window.qrConfig.csrfToken) || "", + }, + body: JSON.stringify({ days_to_keep: daysToKeep }), + }); + + const data = await response.json(); + + if (data.success) { + showSuccess( + `Successfully cleaned up ${data.deleted_count} old log entries` + ); + closeCleanupModal(); + setTimeout(() => { + loadLogs(); + loadStats(); + }, 1000); + } else { + showError("Error cleaning up logs: " + (data.error || "Unknown error")); + } + } catch (error) { + console.error("Error cleaning up logs:", error); + showError("Failed to cleanup logs. Please try again."); + } + } + + // Clear old logs functionality + function clearOldLogs() { + console.log("clearOldLogs function called"); + const modal = document.getElementById("clearOldLogsModal"); + if (modal) { + modal.style.display = "flex"; + modal.style.visibility = "visible"; + modal.style.opacity = "1"; + console.log("Modal should be visible now"); + } else { + console.error("clearOldLogsModal not found!"); + } + } + + function closeClearOldLogsModal() { + const modal = document.getElementById("clearOldLogsModal"); + if (modal) { + modal.style.display = "none"; + modal.style.visibility = "hidden"; + modal.style.opacity = "0"; + } + } + + async function confirmClearOldLogs() { + const daysThreshold = parseInt( + document.getElementById("clearOldLogsDays").value + ); + + try { + showMessage("Clearing old logs...", "info"); + + const response = await fetch("/api/logs/clear-old", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": (window.qrConfig && window.qrConfig.csrfToken) || "", + }, + body: JSON.stringify({ days_threshold: daysThreshold }), + }); + + const data = await response.json(); + + if (data.success) { + showSuccess( + `Successfully cleared ${data.deleted_count} log entries older than ${daysThreshold} days` + ); + closeClearOldLogsModal(); + setTimeout(() => { + loadLogs(); + loadStats(); + }, 1000); + } else { + showError( + "Error clearing old logs: " + (data.error || "Unknown error") + ); + } + } catch (error) { + console.error("Error clearing old logs:", error); + showError("Failed to clear old logs: " + error.message); + } + } + + // Update stats display + function updateStatsDisplay(stats) { + const totalElement = document.getElementById("totalEventsCount"); + const securityElement = document.getElementById("securityEventsCount"); + const errorsElement = document.getElementById("databaseErrorsCount"); + const usersElement = document.getElementById("userActivitiesCount"); + + if (totalElement) totalElement.textContent = stats.total_events || 0; + if (securityElement) + securityElement.textContent = stats.security_events || 0; + if (errorsElement) errorsElement.textContent = stats.database_errors || 0; + if (usersElement) usersElement.textContent = stats.user_activities || 0; + + console.log("Stats updated:", stats); + } +</script> + +<style> + /* Admin Logs Styles */ + .logs-page { + padding: var(--spacing-6); + max-width: 1600px; + margin: 0 auto; + } + + .logs-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: var(--spacing-8); + padding-bottom: var(--spacing-6); + border-bottom: 2px solid var(--gray-200); + } + + .header-content h1 { + font-size: var(--font-size-3xl); + font-weight: 700; + color: var(--gray-900); + margin-bottom: var(--spacing-2); + display: flex; + align-items: center; + gap: var(--spacing-3); + } + + .header-content p { + color: var(--gray-600); + font-size: var(--font-size-lg); + } + + .back-button { + display: inline-flex; + align-items: center; + gap: var(--spacing-2); + color: var(--primary-color); + text-decoration: none; + font-size: var(--font-size-sm); + margin-bottom: var(--spacing-4); + transition: var(--transition); + } + + .back-button:hover { + color: var(--primary-hover); + } + + .header-actions { + display: flex; + gap: var(--spacing-3); + } + + /* Button Styles */ + .btn { + display: inline-flex; + align-items: center; + gap: var(--spacing-2); + padding: var(--spacing-2) var(--spacing-4); + border: none; + border-radius: var(--radius); + font-size: var(--font-size-sm); + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: var(--transition); + } + + .btn-primary { + background: var(--primary-color); + color: var(--white); + } + + .btn-primary:hover { + background: var(--primary-hover); + transform: translateY(-1px); + } + + .btn-secondary { + background: var(--gray-500); + color: var(--white); + } + + .btn-secondary:hover { + background: var(--gray-600); + transform: translateY(-1px); + } + + .btn-warning { + background: #f59e0b; + color: var(--white); + } + + .btn-warning:hover { + background: #d97706; + transform: translateY(-1px); + } + + .btn-danger { + background: #ef4444; + color: var(--white); + } + + .btn-danger:hover { + background: #dc2626; + transform: translateY(-1px); + } + + .btn-info { + background: #0ea5e9; + color: var(--white); + } + + .btn-info:hover { + background: #0284c7; + transform: translateY(-1px); + } + + .btn-sm { + padding: var(--spacing-1) var(--spacing-3); + font-size: var(--font-size-xs); + } + + .btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + } + + /* Form Controls */ + .form-control { + width: 100%; + padding: var(--spacing-3); + border: 1px solid var(--gray-300); + border-radius: var(--radius); + font-size: var(--font-size-sm); + transition: var(--transition); + } + + .form-control:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + } + + .form-group { + margin-bottom: var(--spacing-4); + } + + .form-group label { + display: block; + margin-bottom: var(--spacing-2); + font-weight: 600; + color: var(--gray-700); + } + + /* Log Statistics */ + .log-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--spacing-6); + margin-bottom: var(--spacing-8); + } + + .stat-card { + background: var(--white); + padding: var(--spacing-6); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); + display: flex; + align-items: center; + gap: var(--spacing-4); + transition: var(--transition); + } + + .stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); + } + + /* Auth stat card styling */ + .stat-card.auth .stat-icon { + background: linear-gradient(135deg, #667eea, #764ba2); + } + + /* QR Management stat card styling */ + .stat-card.qr .stat-icon { + background: linear-gradient(135deg, #f093fb, #f5576c); + } + + /* Application stat card styling */ + .stat-card.application .stat-icon { + background: linear-gradient(135deg, #4facfe, #00f2fe); + } + + .stat-icon { + width: 60px; + height: 60px; + border-radius: var(--radius-lg); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-xl); + color: var(--white); + background: linear-gradient( + 135deg, + var(--primary-color), + var(--primary-hover) + ); + } + + .stat-icon.security { + background: linear-gradient(135deg, var(--warning-color), #b45309); + } + + .stat-icon.errors { + background: linear-gradient(135deg, var(--danger-color), #b91c1c); + } + + .stat-icon.users { + background: linear-gradient(135deg, var(--success-color), #059669); + } + + + .stat-info h3 { + font-size: var(--font-size-2xl); + font-weight: 700; + color: var(--gray-900); + margin-bottom: var(--spacing-1); + } + + .stat-info p { + color: var(--gray-500); + font-size: var(--font-size-sm); + } + + /* Log Controls */ + .log-controls { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-6); + padding: var(--spacing-4); + background: var(--white); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + } + + .search-filters { + display: flex; + align-items: center; + gap: var(--spacing-4); + flex: 1; + } + + .search-box { + position: relative; + flex: 1; + max-width: 400px; + } + + .search-box input { + width: 100%; + padding: var(--spacing-3) var(--spacing-3) var(--spacing-3) + var(--spacing-10); + border: 1px solid var(--gray-300); + border-radius: var(--radius); + font-size: var(--font-size-sm); + } + + .search-box i { + position: absolute; + left: var(--spacing-3); + top: 50%; + transform: translateY(-50%); + color: var(--gray-400); + } + + .filter-group { + display: flex; + gap: var(--spacing-3); + } + + .filter-select { + padding: var(--spacing-2) var(--spacing-3); + border: 1px solid var(--gray-300); + border-radius: var(--radius); + font-size: var(--font-size-sm); + background: var(--white); + } + + /* Logs Table */ + .logs-table-container { + background: var(--white); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + overflow: hidden; + margin-bottom: var(--spacing-6); + } + + .logs-table { + width: 100%; + border-collapse: collapse; + } + + .logs-table th, + .logs-table td { + padding: var(--spacing-3) var(--spacing-4); + text-align: left; + border-bottom: 1px solid var(--gray-200); + } + + .logs-table th { + background: var(--gray-50); + font-weight: 600; + color: var(--gray-700); + font-size: var(--font-size-sm); + } + + .logs-table tr:hover { + background: var(--gray-50); + } + + .logs-table tr.severity-high { + border-left: 4px solid var(--danger-color); + } + + .logs-table tr.severity-medium { + border-left: 4px solid var(--warning-color); + } + + .logs-table tr.severity-low { + border-left: 4px solid var(--success-color); + } + + .logs-table tr.severity-info { + border-left: 4px solid var(--primary-color); + } + + .timestamp { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--gray-600); + white-space: nowrap; + } + + .event-type { + font-weight: 600; + color: var(--gray-900); + font-size: var(--font-size-sm); + } + + .category-badge, + .severity-badge { + padding: var(--spacing-1) var(--spacing-2); + border-radius: var(--radius); + font-size: var(--font-size-xs); + font-weight: 600; + text-transform: uppercase; + } + + .category-badge.security { + background: #fef3c7; + color: #92400e; + } + + .category-badge.database { + background: #dbeafe; + color: #1e40af; + } + + .category-badge.user_activity { + background: #d1fae5; + color: #065f46; + } + + .category-badge.system { + background: #f3e8ff; + color: #7c3aed; + } + + .severity-badge.high { + background: var(--danger-color); + color: var(--white); + } + + .severity-badge.medium { + background: var(--warning-color); + color: var(--white); + } + + .severity-badge.low { + background: var(--success-color); + color: var(--white); + } + + .severity-badge.info { + background: var(--primary-color); + color: var(--white); + } + + .description { + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .username { + font-weight: 500; + color: var(--gray-700); + } + + .ip-address { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--gray-500); + } + + /* Loading and Empty States */ + .loading-state, + .empty-state { + text-align: center; + padding: var(--spacing-12); + color: var(--gray-500); + } + + .loading-state i { + font-size: var(--font-size-2xl); + margin-bottom: var(--spacing-4); + color: var(--primary-color); + } + + .empty-state i { + font-size: 4rem; + margin-bottom: var(--spacing-4); + color: var(--gray-300); + } + + .empty-state h3 { + font-size: var(--font-size-xl); + color: var(--gray-700); + margin-bottom: var(--spacing-2); + } + + /* Pagination */ + .pagination-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-4); + background: var(--white); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + } + + .pagination-info { + color: var(--gray-600); + font-size: var(--font-size-sm); + } + + .pagination-controls { + display: flex; + align-items: center; + gap: var(--spacing-2); + } + + .pagination-controls button { + min-width: 80px; + padding: var(--spacing-2) var(--spacing-3); + border: 1px solid var(--gray-300); + background: var(--white); + color: var(--gray-700); + border-radius: var(--radius); + cursor: pointer; + transition: var(--transition); + font-size: var(--font-size-sm); + } + + .pagination-controls button:hover:not(:disabled) { + background: var(--gray-50); + border-color: var(--primary-color); + } + + .pagination-controls button:disabled { + opacity: 0.5; + cursor: not-allowed; + background: var(--gray-100); + } + + /* Page number buttons */ + .page-number { + min-width: 35px !important; + margin: 0 2px; + padding: var(--spacing-1) var(--spacing-2) !important; + border: 1px solid var(--gray-300); + background: var(--white); + color: var(--gray-700); + } + + .page-number:hover:not(.active) { + background: var(--gray-50); + border-color: var(--primary-color); + } + + .page-number.active { + background: var(--primary-color) !important; + color: var(--white) !important; + border-color: var(--primary-color) !important; + } + + .pagination-ellipsis { + padding: var(--spacing-1) var(--spacing-2); + color: var(--gray-500); + font-size: var(--font-size-sm); + } + + .pagination-numbers { + display: flex; + align-items: center; + gap: var(--spacing-1); + } + + /* Modal Styles */ + .modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); + } + + .modal-content { + background: var(--white); + border-radius: var(--radius-lg); + max-width: 500px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + box-shadow: var(--shadow-xl); + animation: modalSlideIn 0.3s ease-out; + } + + @keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-50px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-6); + border-bottom: 1px solid var(--gray-200); + } + + .modal-header h3 { + margin: 0; + display: flex; + align-items: center; + gap: var(--spacing-2); + color: var(--gray-900); + font-size: var(--font-size-lg); + } + + .modal-close { + background: none; + border: none; + font-size: var(--font-size-xl); + cursor: pointer; + color: var(--gray-400); + padding: var(--spacing-1); + border-radius: var(--radius); + transition: var(--transition); + } + + .modal-close:hover { + color: var(--gray-600); + background: var(--gray-100); + } + + .modal-body { + padding: var(--spacing-6); + } + + .modal-footer { + display: flex; + justify-content: flex-end; + gap: var(--spacing-3); + padding: var(--spacing-6); + border-top: 1px solid var(--gray-200); + background: var(--gray-50); + } + + .warning-note { + background: #fef3c7; + border: 1px solid #f59e0b; + border-radius: var(--radius); + padding: var(--spacing-4); + margin-top: var(--spacing-4); + display: flex; + align-items: flex-start; + gap: var(--spacing-3); + } + + .warning-note i { + color: #f59e0b; + margin-top: 2px; + flex-shrink: 0; + } + + .warning-note strong { + color: #92400e; + } + + /* Log Details Modal Specific Styles */ + .log-details-modal { + max-width: 800px; + } + + .detail-section { + margin-bottom: var(--spacing-4); + } + + .detail-section h4 { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--gray-900); + margin-bottom: var(--spacing-3); + display: flex; + align-items: center; + gap: var(--spacing-2); + } + + .detail-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-2) 0; + border-bottom: 1px solid var(--gray-100); + } + + .detail-label { + font-weight: 600; + color: var(--gray-700); + font-size: var(--font-size-sm); + } + + .detail-value { + color: var(--gray-900); + font-size: var(--font-size-sm); + text-align: right; + } + + .description-box, + .json-box { + background: var(--gray-50); + border: 1px solid var(--gray-200); + border-radius: var(--radius); + padding: var(--spacing-4); + font-family: var(--font-mono); + font-size: var(--font-size-sm); + white-space: pre-wrap; + max-height: 200px; + overflow-y: auto; + } + + .log-details-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: var(--spacing-6); + margin-bottom: var(--spacing-6); + } + + .full-width { + grid-column: 1 / -1; + } + + /* Responsive Design */ + @media (max-width: 768px) { + .logs-page { + padding: var(--spacing-4); + } + + .logs-header { + flex-direction: column; + gap: var(--spacing-4); + } + + .header-actions { + width: 100%; + justify-content: stretch; + } + + .header-actions .btn { + flex: 1; + } + + .log-stats { + grid-template-columns: 1fr; + } + + .log-controls { + flex-direction: column; + gap: var(--spacing-4); + } + + .search-filters { + flex-direction: column; + width: 100%; + } + + .filter-group { + width: 100%; + } + + .filter-select { + width: 100%; + } + + .logs-table-container { + overflow-x: auto; + } + + .logs-table { + min-width: 800px; + } + + .pagination-wrapper { + flex-direction: column; + gap: var(--spacing-3); + } + + .pagination-controls { + width: 100%; + justify-content: center; + } + + .log-details-grid { + grid-template-columns: 1fr; + } + } + + @media (max-width: 480px) { + .stat-card { + flex-direction: column; + text-align: center; + gap: var(--spacing-3); + } + + .stat-icon { + width: 50px; + height: 50px; + } + + .modal-content { + width: 95%; + margin: var(--spacing-4); + } + + .modal-footer { + flex-direction: column; + } + + .modal-footer .btn { + width: 100%; + justify-content: center; + } + + .logs-table { + font-size: var(--font-size-xs); + } + + .description { + max-width: 150px; + } + } + + /* Enhanced Animations */ + .stat-card { + animation: fadeInUp 0.6s ease-out; + } + + .stat-card:nth-child(1) { + animation-delay: 0.1s; + } + .stat-card:nth-child(2) { + animation-delay: 0.2s; + } + .stat-card:nth-child(3) { + animation-delay: 0.3s; + } + .stat-card:nth-child(4) { + animation-delay: 0.4s; + } + + @keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .logs-table tbody tr { + animation: fadeIn 0.3s ease-out; + } + + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + /* Focus and accessibility improvements */ + .btn:focus, + .filter-select:focus, + .search-box input:focus { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + + /* Print styles */ + @media print { + .logs-header .header-actions, + .log-controls, + .pagination-wrapper, + .modal { + display: none !important; + } + + .logs-page { + padding: 0; + max-width: none; + } + + .logs-table { + font-size: 10px; + } + + .logs-table th, + .logs-table td { + padding: 4px; + } + } +</style> +{% endblock %} diff --git a/templates/attendance_report.html b/templates/attendance_report.html new file mode 100644 index 0000000..cb6af70 --- /dev/null +++ b/templates/attendance_report.html @@ -0,0 +1,1109 @@ +{% extends "base_authenticated.html" %} + +{% block title %}Attendance Report - QR Code Management{% endblock %} + +{% block extra_head %} +<!-- Attendance-specific CSS --> +<link rel="stylesheet" href="{{ url_for('static', filename='css/attendance.css') }}"> +<style> +/* Dynamic QR record row — subtle left accent to distinguish from standard records */ +tr.dynamic-qr-record { + border-left: 3px solid #3b5bdb; + background-color: rgba(59, 91, 219, 0.03); +} +tr.dynamic-qr-record:hover { + background-color: rgba(59, 91, 219, 0.07) !important; +} +</style> +<!-- Fullscreen CSS --> +<link rel="stylesheet" href="{{ url_for('static', filename='css/attendance_fullscreen.css') }}"> +<!-- Chart.js for analytics --> +<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script> +<!-- Fullscreen JavaScript --> +<script src="{{ url_for('static', filename='js/attendance_fullscreen.js') }}" defer></script> +{% endblock %} + +{% block content %} +<!-- Fullscreen Toggle Button --> +<button id="fullscreenToggle" class="fullscreen-toggle-btn" onclick="toggleFullscreen()" title="Toggle Fullscreen"> + <i id="fullscreenIcon" class="fas fa-expand"></i> +</button> + +<!-- Attendance Report Container with Fullscreen Support --> +<div id="attendanceReportContainer" class="attendance-page" data-user-role="{{ session.role }}"> + <!-- Header Section --> + <div class="attendance-header"> + <div class="header-content"> + <h1> + <i class="fas fa-chart-line"></i> + Attendance Report + </h1> + <p>Monitor and analyze staff attendance with enhanced location tracking</p> + </div> + + <div class="header-actions"> + {% if session.role in ['admin', 'payroll', 'accounting'] %} + <button onclick="exportAttendance()" class="btn btn-success"> + <i class="fas fa-download"></i> + Export Data + </button> + {% endif %} + + <!-- NEW: Manual Add Record Button --> + {% if session.role in ['admin', 'accounting'] %} + <button onclick="window.location.href='{{ url_for('attendance.add_manual_attendance') }}'" class="btn btn-primary"> + <i class="fas fa-plus-circle"></i> + Add Record + </button> + {% endif %} + + <button onclick="refreshReport()" class="btn btn-secondary"> + <i class="fas fa-sync-alt"></i> + Refresh + </button> + </div> + </div> + + <!-- Enhanced Statistics Cards --> + + + <!-- Enhanced Filters Section with Date Range --> + <div class="filters-section"> + <div class="filters-card"> + <div class="filters-header"> + <h3> + <i class="fas fa-filter"></i> + Filter Records + </h3> + </div> + <div class="filters-form"> + <form method="GET" action="{{ url_for('attendance.attendance_report') }}"> + <!-- Hidden field to preserve fullscreen state --> + <input type="hidden" id="fullscreenState" name="fullscreen" value=""> + <div class="filter-row"> + <!-- Date Range Filters --> + <div class="filter-group"> + <label for="date_from"> + <i class="fas fa-calendar-alt"></i> + From Date + </label> + <input type="date" + id="date_from" + name="date_from" + value="{{ date_from }}" + max="{{ today_date }}"> + </div> + + <div class="filter-group"> + <label for="date_to"> + <i class="fas fa-calendar-alt"></i> + To Date + </label> + <input type="date" + id="date_to" + name="date_to" + value="{{ date_to }}" + max="{{ today_date }}"> + </div> + + <div class="filter-group"> + <label for="project"> + <i class="fas fa-project-diagram"></i> + Project + </label> + <select id="project" name="project"> + <option value="">All Projects</option> + {% for project in projects %} + <option value="{{ project.id }}" {{ 'selected' if project.id|string == project_filter else '' }}> + {{ project.name }} ({{ project.attendance_count }} records) + </option> + {% endfor %} + </select> + </div> + + <div class="filter-group"> + <label for="location"> + <i class="fas fa-map-marker-alt"></i> + Location + </label> + <select id="location" name="location"> + <option value="">All Locations</option> + {% for location in locations %} + <option value="{{ location }}" {{ 'selected' if location == location_filter else '' }}> + {{ location }} + </option> + {% endfor %} + </select> + </div> + + <div class="filter-group"> + <label for="employee_chip_input"> + <i class="fas fa-user-search"></i> + Employee + </label> + <div class="autocomplete-container-filter"> + <input type="hidden" id="employee" name="employee" value="{{ employee_filter }}"> + <div class="employee-chips-wrapper" id="employeeChipsWrapper"> + {% for emp in employee_display_names %} + <span class="employee-chip" data-id="{{ emp.id }}"> + <span title="{{ emp.name }}">{{ emp.name }}</span> + <button type="button" class="employee-chip-remove" title="Remove"> + <i class="fas fa-times"></i> + </button> + </span> + {% endfor %} + <input type="text" + id="employee_chip_input" + class="employee-chip-input" + placeholder="{% if employee_display_names %}Add more...{% else %}Search by ID or Name...{% endif %}" + autocomplete="off"> + {% if employee_filter %} + <button type="button" class="employee-chips-clear-all" id="clearAllEmployees" title="Clear all"> + <i class="fas fa-times"></i> + </button> + {% endif %} + </div> + <div id="employee_autocomplete_results" class="autocomplete-results-filter"></div> + </div> + </div> + + <div class="filter-actions"> + <button type="submit" class="btn btn-primary"> + <i class="fas fa-search"></i> + Apply Filters + </button> + <button type="button" onclick="clearFilters()" class="btn btn-outline"> + <i class="fas fa-times"></i> + Clear + </button> + </div> + </div> + </form> + </div> + </div> + </div> + + <!-- Enhanced Attendance Table --> + <div class="attendance-table-section"> + <div class="table-header"> + <h3> + <i class="fas fa-list"></i> + Attendance Records + {% if date_from or date_to or location_filter or employee_filter or project_filter %} + <span class="filter-indicator">(Filtered)</span> + {% endif %} + </h3> + <div class="table-controls"> + <div class="entries-per-page"> + <label>Show:</label> + <select id="entriesPerPage" onchange="changeEntriesPerPage()"> + <option value="25">25</option> + <option value="50" selected>50</option> + <option value="100">100</option> + <option value="all">All</option> + </select> + <span>entries</span> + </div> + </div> + </div> + + <div class="table-container"> + {% if records_truncated %} + <div class="alert alert-warning" style="margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;"> + <i class="fas fa-exclamation-triangle"></i> + <span> + Showing the most recent <strong>{{ records_limit }}</strong> records. + Your current filters match more than {{ records_limit }} entries — + please narrow the date range or apply additional filters to see all results. + </span> + </div> + {% endif %} + {% if attendance_records %} + <table class="attendance-table" id="attendanceTable"> + <thead> + <tr> + <th onclick="sortTable(0)">#</th> + <th onclick="sortTable(1)">Employee ID <i class="fas fa-sort"></i></th> + <th onclick="sortTable(2)">Employee Name <i class="fas fa-sort"></i></th> + <th onclick="sortTable(3)">Location <i class="fas fa-sort"></i></th> + <th onclick="sortTable(4)">Event <i class="fas fa-sort"></i></th> + <th onclick="sortTable(5)">Date <i class="fas fa-sort"></i></th> + <th onclick="sortTable(6)">Time <i class="fas fa-sort"></i></th> + <th onclick="sortTable(7)" class="address-column">QR Address <i class="fas fa-sort"></i></th> + <th onclick="sortTable(8)" class="address-column">Check-in Address <i class="fas fa-sort"></i></th> + <th onclick="sortTable(9)"> + {% if has_location_accuracy_feature %}Location Accuracy{% else %}GPS Accuracy{% endif %} + <i class="fas fa-sort"></i> + </th> + <th onclick="sortTable(10)">Device <i class="fas fa-sort"></i></th> + <th>Actions</th> + </tr> + </thead> + <tbody> + {% for record in attendance_records %} + <tr data-record-id="{{ record.id }}" + data-is-dynamic="{{ '1' if (record.get('is_dynamic_qr') or record.location_name == 'Dynamic') else '0' }}" + class="{% if record.updated_timestamp > record.created_timestamp %}modified-record{% endif %}{% if record.get('is_dynamic_qr') or record.location_name == 'Dynamic' %} dynamic-qr-record{% endif %}"> + <td>{{ loop.index }}</td> + <td> + <div class="employee-info"> + <span class="employee-id">{{ record.employee_id }}</span> + </div> + </td> + <td> + <div class="employee-name"> + <i class="fas fa-user"></i> + <span>{{ record.employee_name if record.employee_name else 'Unknown' }}</span> + </div> + </td> + <td> + <div class="location-info"> + <i class="fas fa-map-marker-alt"></i> + {{ record.location_name if record.location_name != 'Dynamic' else '(No location recorded)' }} + </div> + </td> + <td> + <div class="event-info"> + {{ record.location_event }} + </div> + </td> + <td> + <div class="date-info"> + {{ record.check_in_date.strftime('%m/%d/%Y') }} + </div> + </td> + <td> + <div class="time-info"> + {% if record.check_in_time %} + {% set check_in_time = record.check_in_time %} + {% if check_in_time is string %} + {{ check_in_time }} + {% else %} + {% if check_in_time.strftime is defined %} + {{ check_in_time.strftime('%H:%M') }} + {% elif check_in_time.total_seconds is defined %} + {% set total_seconds = check_in_time.total_seconds() | int %} + {% set hours = (total_seconds // 3600) % 24 %} + {% set minutes = (total_seconds % 3600) // 60 %} + {{ "%02d:%02d"|format(hours, minutes) }} + {% else %} + {{ check_in_time }} + {% endif %} + {% endif %} + {% else %} + N/A + {% endif %} + </div> + </td> + <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"> + <div class="address-info checkin-address"> + <i class="fas fa-location-arrow"></i> + <span title="{{ record.checked_in_address }}"> + {{ (record.checked_in_address or 'N/A')[:50] }}{% if (record.checked_in_address or '')|length > 50 %}...{% endif %} + </span> + </div> + </td> + <td> + {% if record.verification_required and record.verification_status == 'pending' %} + <!-- Show Review Needed badge with link to review page --> + <div class="location-accuracy-info"> + <a href="{{ url_for('attendance.verification_review_detail', record_id=record.id) }}" + class="location-accuracy-badge badge-review-needed" + style="cursor: pointer; text-decoration: none;" + title="Click to review verification photo - Distance: {{ '%.3f'|format(record.location_accuracy) }} miles"> + <i class="fas fa-exclamation-triangle"></i> + Review Needed + <small>({{ '%.3f'|format(record.location_accuracy) }} mi)</small> + </a> + </div> + {% elif record.verification_status == 'approved' %} + <!-- Show Verified badge --> + <div class="location-accuracy-info"> + <span class="location-accuracy-badge badge-verified" + title="Verification approved - Distance: {{ '%.3f'|format(record.location_accuracy) }} miles"> + <i class="fas fa-check-circle"></i> + Verified + <small>({{ '%.3f'|format(record.location_accuracy) }} mi)</small> + </span> + </div> + {% elif record.verification_status == 'rejected' %} + <!-- Show Rejected badge --> + <div class="location-accuracy-info"> + <span class="location-accuracy-badge badge-rejected" + title="Verification rejected - Distance: {{ '%.3f'|format(record.location_accuracy) }} miles"> + <i class="fas fa-times-circle"></i> + Rejected + <small>({{ '%.3f'|format(record.location_accuracy) }} mi)</small> + </span> + </div> + {% elif has_location_accuracy_feature and record.location_accuracy %} + <!-- Show location accuracy in miles (normal case) --> + <div class="location-accuracy-info"> + <span class="location-accuracy-badge accuracy-{{ record.accuracy_level }}" + title="Distance between QR location and check-in location: {{ record.location_accuracy }} miles"> + <i class="fas fa-ruler"></i> + {{ "%.3f"|format(record.location_accuracy) }} mi + <small>({{ record.accuracy_level }})</small> + </span> + </div> + {% elif record.gps_accuracy %} + <!-- Fallback to GPS accuracy in meters --> + <div class="accuracy-info"> + <span class="accuracy-badge accuracy-{{ record.accuracy_level }}" + title="GPS accuracy: {{ record.gps_accuracy }}m"> + <i class="fas fa-crosshairs"></i> + {{ "%.1f"|format(record.gps_accuracy) }}m + <small>(gps)</small> + </span> + </div> + {% else %} + <!-- No accuracy data --> + <div class="accuracy-info"> + <span class="accuracy-badge accuracy-unknown" title="No accuracy data available"> + <i class="fas fa-question-circle"></i> + Unknown + </span> + </div> + {% endif %} + </td> + <td> + <div class="device-info"> + <i class="fas fa-mobile-alt"></i> + <span title="{{ record.device_info }}"> + {{ record.device_info[:20] }}{% if record.device_info|length > 20 %}...{% endif %} + </span> + </div> + </td> + <td> + <div class="record-actions"> + {% if record.verification_required and record.verification_status == 'pending' %} + <!-- Show Review button linking to review page --> + <a href="{{ url_for('attendance.verification_review_detail', record_id=record.id) }}" + class="action-btn btn-review" + title="Review Verification Photo"> + <i class="fas fa-camera"></i> + </a> + {% endif %} + + {% if session.role in ['admin', 'payroll', 'accounting'] %} + <button onclick="editRecord('{{ record.id }}')" + class="action-btn btn-edit" + title="Edit Record"> + <i class="fas fa-edit"></i> + </button> + <button onclick="deleteRecord('{{ record.id }}', '{{ record.employee_id }}')" + class="action-btn btn-delete" + title="Delete Record"> + <i class="fas fa-trash"></i> + </button> + {% else %} + <span class="text-muted" title="Admin access required"> + <i class="fas fa-lock"></i> + </span> + {% endif %} + </div> + </td> + </tr> + {% endfor %} + </tbody> + </table> + {% else %} + <div class="empty-state"> + <div class="empty-icon"> + <i class="fas fa-clipboard-list"></i> + </div> + <h3>No Attendance Records Found</h3> + <p>{% if date_from or date_to or location_filter or employee_filter %} + No records match your current filters. Try adjusting the filter criteria. + {% else %} + No staff have checked in yet. QR codes need to be scanned to generate attendance data. + {% endif %}</p> + {% if date_from or date_to or location_filter or employee_filter or project_filter %} + <button onclick="clearFilters()" class="btn btn-primary"> + <i class="fas fa-times"></i> + Clear All Filters + </button> + {% endif %} + </div> + {% endif %} + </div> + + <!-- Pagination --> + {% if attendance_records %} + <div class="pagination-container" id="paginationContainer"> + <!-- Pagination will be generated by JavaScript --> + </div> + {% endif %} + </div> +</div> + +<!-- Enhanced Record Details Modal - KEPT FOR OTHER FEATURES --> +<div id="recordModal" class="modal"> + <div class="modal-content"> + <div class="modal-header"> + <h3 id="modalTitle">Record Details</h3> + <button onclick="closeModal()" class="modal-close"> + <i class="fas fa-times"></i> + </button> + </div> + <div class="modal-body" id="modalBody"> + <!-- Content will be populated by JavaScript --> + </div> + <div class="modal-footer"> + <button onclick="closeModal()" class="btn btn-secondary">Close</button> + </div> + </div> +</div> + +<!-- Map Modal for Location Viewing - KEPT FOR OTHER FEATURES --> +<div id="mapModal" class="modal"> + <div class="modal-content modal-large"> + <div class="modal-header"> + <h3 id="mapModalTitle">Location Map</h3> + <button onclick="closeMapModal()" class="modal-close"> + <i class="fas fa-times"></i> + </button> + </div> + <div class="modal-body" id="mapModalBody"> + <div id="locationMap" style="height: 400px; width: 100%;"> + <div style="text-align: center; padding: 100px; color: #6b7280;"> + <i class="fas fa-map-marked-alt" style="font-size: 3rem; margin-bottom: 1rem;"></i> + <p><strong>GPS Coordinates will be displayed here</strong></p> + <p><em>Map integration requires additional setup</em></p> + </div> + </div> + </div> + <div class="modal-footer"> + <button onclick="closeMapModal()" class="btn btn-secondary">Close</button> + </div> + </div> +</div> +{% endblock %} + +{% block extra_scripts %} +<script src="{{ url_for('static', filename='js/attendance_report.js') }}"></script> +<script> +// Template variables (processed by Flask) +const userRole = '{{ session.role }}'; +const hasEditPermission = ['admin', 'payroll', 'accounting'].includes(userRole); +const hasDeletePermission = ['admin'].includes(userRole); + +// Enhanced JavaScript for new functionality +const hasLocationAccuracy = {{ 'true' if has_location_accuracy_feature else 'false' }}; + +function clearFilters() { + // Get current fullscreen state + const fullscreenState = window.isAttendanceFullscreen && window.isAttendanceFullscreen() ? '1' : ''; + + // Redirect to clean URL with only fullscreen parameter if active + if (fullscreenState === '1') { + window.location.href = '{{ url_for('attendance.attendance_report') }}?fullscreen=1'; + } else { + window.location.href = '{{ url_for('attendance.attendance_report') }}'; + } +} + +function refreshReport() { + // Get current fullscreen state + const fullscreenState = window.isAttendanceFullscreen && window.isAttendanceFullscreen() ? '1' : ''; + + // Build URL with current filters and fullscreen state + const params = new URLSearchParams(window.location.search); + + // Add or update fullscreen parameter + if (fullscreenState === '1') { + params.set('fullscreen', '1'); + } else { + params.delete('fullscreen'); + } + + // Reload with preserved state + window.location.href = window.location.pathname + '?' + params.toString(); +} + +function showLocationMap(recordId) { + // Find the record data + const record = attendanceData.find(r => r.id == recordId); + if (!record || !record.has_location_data) { + alert('No GPS data available for this record'); + return; + } + + // Show map modal + document.getElementById('mapModal').style.display = 'block'; + document.getElementById('mapModalTitle').textContent = `Location - ${record.employeeId}`; + + // Update map container with coordinates info + const mapContainer = document.getElementById('locationMap'); + mapContainer.innerHTML = ` + <div style="text-align: center; padding: 100px;"> + <i class="fas fa-map-marked-alt" style="font-size: 3rem; color: #6b7280; margin-bottom: 1rem;"></i> + <p><strong>GPS Coordinates:</strong> ${record.coordinates}</p> + <p><strong>Employee:</strong> ${record.employeeId}</p> + <p><strong>Location:</strong> ${record.location}</p> + <p><strong>Check-in Time:</strong> ${record.date} ${record.time}</p> + ${hasLocationAccuracy && record.location_accuracy ? + `<p><strong>Location Accuracy:</strong> ${record.location_accuracy.toFixed(3)} miles (${record.accuracy_level})</p>` : + '' + } + <p style="margin-top: 2rem; color: #6b7280;"><em>Full map integration requires Google Maps API setup</em></p> + </div> + `; +} + +function closeMapModal() { + document.getElementById('mapModal').style.display = 'none'; +} + +function closeModal() { + document.getElementById('recordModal').style.display = 'none'; +} + +// Enhanced record details view +function viewRecordDetails(recordId) { + const record = attendanceData.find(r => r.id == recordId); + if (!record) return; + + const modal = document.getElementById('recordModal'); + const modalTitle = document.getElementById('modalTitle'); + const modalBody = document.getElementById('modalBody'); + + if (!modal || !modalTitle || !modalBody) return; + + modalTitle.textContent = `Attendance Record - ${record.employeeId}`; + + // Build accuracy section based on available data + let accuracySection = ''; + if (hasLocationAccuracy && record.location_accuracy) { + accuracySection = ` + <div class="detail-section"> + <h4><i class="fas fa-ruler"></i> Location Accuracy</h4> + <div class="detail-item"> + <strong>Distance:</strong> + <span>${record.location_accuracy.toFixed(3)} miles</span> + </div> + <div class="detail-item"> + <strong>Accuracy Level:</strong> + <span class="location-accuracy-badge accuracy-${record.accuracy_level}"> + ${record.accuracy_level.toUpperCase()} + </span> + </div> + </div> + `; + } + + modalBody.innerHTML = ` + <div class="record-details"> + <div class="detail-section"> + <h4><i class="fas fa-user"></i> Employee Information</h4> + <div class="detail-item"> + <strong>Employee ID:</strong> + <span>${record.employeeId}</span> + </div> + <div class="detail-item"> + <strong>Check-in Date:</strong> + <span>${record.date}</span> + </div> + <div class="detail-item"> + <strong>Check-in Time:</strong> + <span>${record.time}</span> + </div> + </div> + + <div class="detail-section"> + <h4><i class="fas fa-map-marker-alt"></i> Location Information</h4> + <div class="detail-item"> + <strong>Location Name:</strong> + <span>${record.location}</span> + </div> + <div class="detail-item"> + <strong>Event:</strong> + <span>${record.event}</span> + </div> + <div class="detail-item"> + <strong>Device:</strong> + <span>${record.device}</span> + </div> + </div> + + ${accuracySection} + </div> + `; + + modal.style.display = 'block'; +} + +// Edit and Delete functions +function editRecord(recordId) { + console.log('Edit function called for record:', recordId); + + // Check permissions before allowing edit + if (!hasEditPermission) { + alert('Access denied. Only administrators, accounting staff can edit attendance records.'); + return; + } + + console.log(`[LOG] User attempting to edit attendance record: ${recordId}`); + + // Redirect to edit page + window.location.href = `/attendance/${recordId}/edit`; +} + +function deleteRecord(recordId, employeeId) { + console.log('Delete function called for record:', recordId); + + // Check permissions before allowing delete + if (!hasDeletePermission) { + alert('Access denied. Only administrators can delete attendance records.'); + return; + } + + console.log(`Delete record: ${recordId}`); + + // Confirmation dialog + const confirmMessage = `Are you sure you want to delete the attendance record for employee "${employeeId}"?\n\nThis action cannot be undone.`; + + if (confirm(confirmMessage)) { + console.log(`[LOG] User confirmed deletion of attendance record: ${recordId}`); + + // Send delete request + fetch(`/attendance/${recordId}/delete`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-Token': (window.qrConfig && window.qrConfig.csrfToken) || '' + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + console.log(`[LOG] Successfully deleted attendance record: ${recordId}`); + alert('Attendance record deleted successfully!'); + window.location.reload(); + } else { + console.error(`[LOG] Failed to delete attendance record: ${recordId} - ${data.message}`); + alert(data.message || 'Error deleting record. Please try again.'); + } + }) + .catch(error => { + console.error(`[LOG] Error during attendance record deletion: ${recordId}`, error); + alert('Error deleting record. Please try again.'); + }); + } +} + +// Close modals when clicking outside +window.addEventListener('click', function(event) { + const recordModal = document.getElementById('recordModal'); + const mapModal = document.getElementById('mapModal'); + + if (event.target === recordModal) { + closeModal(); + } + if (event.target === mapModal) { + closeMapModal(); + } +}); + +// Keyboard shortcuts +document.addEventListener('keydown', function(event) { + if (event.key === 'Escape') { + closeModal(); + closeMapModal(); + } +}); + +// Force update buttons on page load for admin users +document.addEventListener('DOMContentLoaded', function() { + + if (hasEditPermission) { + // Find all record action containers and update them + const recordActions = document.querySelectorAll('.record-actions'); + recordActions.forEach(function(actionDiv) { + // Check if it contains a lock icon (meaning user is locked out) + const lockIcon = actionDiv.querySelector('.fa-lock'); + if (lockIcon) { + const recordRow = actionDiv.closest('tr'); + const recordId = recordRow.dataset.recordId; + const employeeCell = recordRow.querySelector('.employee-id'); + const employeeId = employeeCell ? employeeCell.textContent.trim() : 'Unknown'; + + // Replace lock icon with edit/delete buttons + actionDiv.innerHTML = ` + <button onclick="editRecord('${recordId}')" + class="action-btn btn-edit" + title="Edit Record"> + <i class="fas fa-edit"></i> + </button> + <button onclick="deleteRecord('${recordId}', '${employeeId}')" + class="action-btn btn-delete" + title="Delete Record"> + <i class="fas fa-trash"></i> + </button> + `; + } + }); + console.log('✅ Admin edit/delete buttons have been activated'); + } +}); +window.userRole = '{{ session.role }}'; +</script> +<script> +// Initialize fullscreen functionality when page loads +document.addEventListener('DOMContentLoaded', function() { + if (typeof initializeFullscreen === 'function') { + initializeFullscreen(); + console.log('Fullscreen functionality initialized for attendance report'); + } + + // Check if we should restore fullscreen from URL parameter + const urlParams = new URLSearchParams(window.location.search); + const shouldBeFullscreen = urlParams.get('fullscreen') === '1'; + + if (shouldBeFullscreen) { + console.log('URL indicates fullscreen should be active'); + // The initializeFullscreen function will handle restoration + } +}); +</script> + +<script> +// ─── Multi-Employee Chip Filter Autocomplete ────────────────────────────── +(function () { + const chipInput = document.getElementById('employee_chip_input'); + const employeeHidden = document.getElementById('employee'); + const autocomplete = document.getElementById('employee_autocomplete_results'); + const chipsWrapper = document.getElementById('employeeChipsWrapper'); + let searchTimeout; + + // ── State: map of id -> displayName for currently selected employees + const selected = {}; + {% for emp in employee_display_names %} + selected['{{ emp.id }}'] = '{{ emp.name | e }}'; + {% endfor %} + + function syncHiddenField() { + employeeHidden.value = Object.keys(selected).join(','); + // Show/hide the clear-all button dynamically + const hasChips = Object.keys(selected).length > 0; + let btn = document.getElementById('clearAllEmployees'); + if (hasChips && !btn) { + btn = document.createElement('button'); + btn.type = 'button'; + btn.id = 'clearAllEmployees'; + btn.className = 'employee-chips-clear-all'; + btn.title = 'Clear all'; + btn.innerHTML = '<i class="fas fa-times"></i>'; + btn.addEventListener('click', clearAll); + chipsWrapper.appendChild(btn); + } else if (!hasChips && btn) { + btn.remove(); + } + } + + function addChip(id, name) { + id = String(id); + if (selected[id]) return; // already added + selected[id] = name; + + const chip = document.createElement('span'); + chip.className = 'employee-chip'; + chip.dataset.id = id; + + const nameSpan = document.createElement('span'); + nameSpan.title = name; + nameSpan.textContent = name; + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'employee-chip-remove'; + removeBtn.title = 'Remove'; + removeBtn.innerHTML = '<i class="fas fa-times"></i>'; + removeBtn.addEventListener('click', function () { + delete selected[chip.dataset.id]; + chip.remove(); + syncHiddenField(); + updateInputPlaceholder(); + }); + + chip.appendChild(nameSpan); + chip.appendChild(removeBtn); + + // Insert chip before the text input + chipsWrapper.insertBefore(chip, chipInput); + syncHiddenField(); + updateInputPlaceholder(); + } + + function updateInputPlaceholder() { + chipInput.placeholder = Object.keys(selected).length > 0 + ? 'Add more...' + : 'Search by ID or Name...'; + } + + function clearAll() { + Object.keys(selected).forEach(function(k) { delete selected[k]; }); + chipsWrapper.querySelectorAll('.employee-chip').forEach(function(c) { c.remove(); }); + syncHiddenField(); + updateInputPlaceholder(); + } + + // Wire remove buttons for server-rendered chips on page load + chipsWrapper.querySelectorAll('.employee-chip-remove').forEach(function (btn) { + btn.addEventListener('click', function () { + const chip = btn.closest('.employee-chip'); + delete selected[chip.dataset.id]; + chip.remove(); + syncHiddenField(); + updateInputPlaceholder(); + }); + }); + + const clearAllBtn = document.getElementById('clearAllEmployees'); + if (clearAllBtn) { + clearAllBtn.addEventListener('click', clearAll); + } + + // Clicking the wrapper focuses the text input + chipsWrapper.addEventListener('click', function (e) { + if (!e.target.closest('.employee-chip') && !e.target.closest('.employee-chips-clear-all')) { + chipInput.focus(); + } + }); + + // ── Autocomplete input handler + chipInput.addEventListener('input', function () { + const q = this.value.trim(); + + if (q.length === 0) { + autocomplete.classList.remove('show'); + return; + } + + if (q.length < 2) { + autocomplete.classList.remove('show'); + return; + } + + clearTimeout(searchTimeout); + searchTimeout = setTimeout(function () { + fetch('/api/search_employees?q=' + encodeURIComponent(q)) + .then(function(r) { return r.json(); }) + .then(function(data) { renderDropdown(data.employees); }) + .catch(function(err) { console.error('Employee search error:', err); }); + }, 300); + }); + + // Keyboard shortcuts: Enter to add numeric ID, Backspace to remove last chip + chipInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + const q = chipInput.value.trim(); + if (/^\d+$/.test(q) && !selected[q]) { + addChip(q, 'ID: ' + q); + chipInput.value = ''; + autocomplete.classList.remove('show'); + } + } + if (e.key === 'Backspace' && chipInput.value === '') { + const chips = chipsWrapper.querySelectorAll('.employee-chip'); + if (chips.length > 0) { + const last = chips[chips.length - 1]; + delete selected[last.dataset.id]; + last.remove(); + syncHiddenField(); + updateInputPlaceholder(); + } + } + }); + + function renderDropdown(employees) { + autocomplete.innerHTML = ''; + + const unselected = employees.filter(function(emp) { + return !selected[String(emp.id)]; + }); + + if (unselected.length === 0) { + const noResult = document.createElement('div'); + noResult.className = 'autocomplete-item-filter'; + noResult.style.cssText = 'cursor:default;color:#999;'; + noResult.textContent = employees.length === 0 ? 'No employees found' : 'All matching employees already selected'; + autocomplete.appendChild(noResult); + } else { + unselected.forEach(function (emp) { + const isUnregistered = (emp.lastName === '(no record)'); + const displayName = isUnregistered + ? 'ID: ' + emp.id + : emp.lastName + ', ' + emp.firstName; + + const item = document.createElement('div'); + item.className = 'autocomplete-item-filter'; + + const info = document.createElement('div'); + info.className = 'employee-info-filter'; + + const nameSpan = document.createElement('span'); + nameSpan.className = 'employee-name-filter'; + if (isUnregistered) { + nameSpan.textContent = 'ID: ' + emp.id + ' '; + const em = document.createElement('em'); + em.style.color = '#94a3b8'; + em.textContent = '(no record)'; + nameSpan.appendChild(em); + } else { + nameSpan.textContent = displayName; + } + + const idSpan = document.createElement('span'); + idSpan.className = 'employee-id-filter'; + idSpan.textContent = 'ID: ' + emp.id; + + info.appendChild(nameSpan); + info.appendChild(idSpan); + item.appendChild(info); + + item.addEventListener('click', (function (id, name) { + return function () { + addChip(id, name); + chipInput.value = ''; + autocomplete.classList.remove('show'); + }; + }(String(emp.id), displayName))); + + autocomplete.appendChild(item); + }); + } + + positionDropdown(); + autocomplete.classList.add('show'); + } + + function positionDropdown() { + const wrapperRect = chipsWrapper.getBoundingClientRect(); + autocomplete.style.top = (wrapperRect.bottom + window.scrollY) + 'px'; + autocomplete.style.left = wrapperRect.left + 'px'; + autocomplete.style.width = wrapperRect.width + 'px'; + } + + document.addEventListener('click', function (e) { + if (!e.target.closest('.autocomplete-container-filter')) { + autocomplete.classList.remove('show'); + } + }); + + window.addEventListener('scroll', function () { + if (autocomplete.classList.contains('show')) positionDropdown(); + }, true); + + window.addEventListener('resize', function () { + if (autocomplete.classList.contains('show')) positionDropdown(); + }); + + // ── Legacy globals for backward compatibility + window.selectEmployeeFilter = function (id, name) { + addChip(String(id), name); + chipInput.value = ''; + autocomplete.classList.remove('show'); + }; + + window.clearEmployeeFilter = function () { + clearAll(); + chipsWrapper.closest('form').submit(); + }; +}()); +</script> +<script> +// ─── Dynamic Location Dropdown (scoped by selected project) ─────────────── +(function () { + const projectSelect = document.getElementById('project'); + const locationSelect = document.getElementById('location'); + + if (!projectSelect || !locationSelect) return; + + // Capture the full server-rendered location list on page load so we can + // restore it when the project filter is cleared. + const allLocationOptions = Array.from(locationSelect.options).map(function (opt) { + return { value: opt.value, text: opt.text }; + }); + + // The location value that was active when the page loaded (from URL param). + const initialLocationValue = locationSelect.value; + + function rebuildLocationDropdown(locations, preserveValue) { + // Remove all options except the first "All Locations" placeholder + while (locationSelect.options.length > 1) { + locationSelect.remove(1); + } + + locations.forEach(function (locName) { + const opt = document.createElement('option'); + opt.value = locName; + opt.textContent = locName; + if (locName === preserveValue) { + opt.selected = true; + } + locationSelect.appendChild(opt); + }); + } + + function loadLocationsForProject(projectId) { + const url = '/api/attendance/locations?project_id=' + encodeURIComponent(projectId); + fetch(url) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.success) { + // Only preserve current location selection if it exists in the new list + const currentLoc = locationSelect.value; + const validLoc = data.locations.includes(currentLoc) ? currentLoc : ''; + rebuildLocationDropdown(data.locations, validLoc); + } else { + console.error('Failed to load locations:', data.error); + } + }) + .catch(function (err) { + console.error('Error fetching locations:', err); + }); + } + + function restoreAllLocations() { + while (locationSelect.options.length > 1) { + locationSelect.remove(1); + } + // Re-add all server-rendered options (skip index 0, the "All Locations" option) + for (let i = 1; i < allLocationOptions.length; i++) { + const opt = document.createElement('option'); + opt.value = allLocationOptions[i].value; + opt.textContent = allLocationOptions[i].text; + if (allLocationOptions[i].value === initialLocationValue) { + opt.selected = true; + } + locationSelect.appendChild(opt); + } + } + + projectSelect.addEventListener('change', function () { + const projectId = this.value; + if (projectId) { + loadLocationsForProject(projectId); + } else { + restoreAllLocations(); + } + }); +}()); +</script> + +{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..d3f2a37 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,105 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>{% block title %}{{ COMPANY_NAME }}{% endblock %} + + + + + + {% if THEME_NAME %} + + {% endif %} + + + + + + {% block extra_head %}{% endblock %} + + + + + + +
+ + {% with messages = get_flashed_messages(with_categories=true) %} {% if + messages %} +
+ {% for category, message in messages %} +
+ + {{ message }} + +
+ {% endfor %} +
+ {% endif %} {% endwith %} + + +
{% block content %}{% endblock %}
+
+ + + + + + + + + {% block extra_scripts %}{% endblock %} + + + + {% if turnstile_enabled %} + + + {% endif %} + + diff --git a/templates/base_authenticated.html b/templates/base_authenticated.html new file mode 100644 index 0000000..d2afc32 --- /dev/null +++ b/templates/base_authenticated.html @@ -0,0 +1,254 @@ + + + + + + {% block title %}{{ COMPANY_NAME }}{% endblock %} + + + + + + {% if THEME_NAME %} + + {% endif %} + + + + + + {% block extra_head %}{% endblock %} + + + + + + +
+ + + + + + + +
+ +
+
+ +

+ {% block page_title %}{{ COMPANY_NAME }}{% endblock %} +

+
+ +
+ +
+
+ + +
+ + {% with messages = get_flashed_messages(with_categories=true) %} {% if + messages %} +
+ {% for category, message in messages %} +
+ + {{ message }} + +
+ {% endfor %} +
+ {% endif %} {% endwith %} + + +
{% block content %}{% endblock %}
+
+ + + +
+
+ + + + + + + + + {% block extra_scripts %}{% endblock %} + + \ No newline at end of file diff --git a/templates/bulk_qr_import.html b/templates/bulk_qr_import.html new file mode 100644 index 0000000..6226426 --- /dev/null +++ b/templates/bulk_qr_import.html @@ -0,0 +1,618 @@ +{% extends "base_authenticated.html" %} + +{% block title %}Bulk QR Code Import{% endblock %} + +{% block content %} +
+ + + + +
+
+

+ + Import Instructions +

+
+
+
+
+
+ +
+

Required Columns

+
    +
  • QR Code Name - Unique name for the QR code
  • +
  • QR Code Location - Location identifier
  • +
  • Project - Project name (must exist)
  • +
  • Location Address - Complete address
  • +
  • Event - Check In or Check Out
  • +
+
+ +
+
+ +
+

Optional Columns

+
    +
  • Latitude - GPS latitude coordinate
  • +
  • Longitude - GPS longitude coordinate
  • +
+

+ + If providing coordinates, both Latitude and Longitude are required +

+
+ +
+
+ +
+

Download Template

+

Use our pre-formatted template to ensure proper column structure:

+ + + Download Excel Template + +
+
+ + +
+

+ + Example Data +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
QR Code NameQR Code LocationProjectLocation AddressEventLatitudeLongitude
HQ-EntranceMain BuildingCorporate HQ123 Main St, Springfield, IL 62701Check In39.781721-89.650148
HQ-ExitMain BuildingCorporate HQ123 Main St, Springfield, IL 62701Check Out39.781721-89.650148
Site-A-Gate1Construction Site AConstruction Projects456 Oak Ave, Chicago, IL 60601Check In
+
+
+
+
+ + +
+
+

+ + Upload Excel File +

+
+
+
+ + +
+
+ +
+
+

Drag & Drop Excel File

+

Or click to browse and select an Excel file (.xlsx, .xls)

+ +
+ +
+ + +
+

+ + Import Options +

+ +
+
+ + +
+
+
+ + +
+ + + Cancel + + +
+ + + +
+
+
+ + + {% if validation_result %} +
+
+

+ + Validation Results +

+
+
+
+
+ +
+

{{ validation_result.valid_rows }}

+

Valid Records

+
+
+
+ +
+

{{ validation_result.invalid_rows }}

+

Invalid Records

+
+
+
+ +
+

{{ validation_result.total_rows }}

+

Total Records

+
+
+
+ + {% if validation_result.success and validation_result.valid_rows > 0 %} +
+ +
+

Validation Successful!

+

All {{ validation_result.valid_rows }} records are valid and ready to import.

+
+
+ + + +
+
+ {% endif %} + + {% if validation_result.errors %} +
+

+ + Validation Errors ({{ validation_result.errors|length }}) +

+
    + {% for error in validation_result.errors[:20] %} +
  • {{ error }}
  • + {% endfor %} + {% if validation_result.errors|length > 20 %} +
  • ...and {{ validation_result.errors|length - 20 }} more errors
  • + {% endif %} +
+
+ {% endif %} + + {% if validation_result.warnings %} +
+

+ + Warnings ({{ validation_result.warnings|length }}) +

+
    + {% for warning in validation_result.warnings[:10] %} +
  • {{ warning }}
  • + {% endfor %} + {% if validation_result.warnings|length > 10 %} +
  • ...and {{ validation_result.warnings|length - 10 }} more warnings
  • + {% endif %} +
+
+ {% endif %} +
+
+ {% endif %} + + + {% if import_result %} +
+
+

+ + Import Results +

+
+
+
+
+ +
+

{{ import_result.imported_records }}

+

Successfully Imported

+
+
+
+ +
+

{{ import_result.failed_records }}

+

Failed Records

+
+
+
+ +
+

{{ import_result.total_rows }}

+

Total Records

+
+
+
+ + {% if import_result.success and import_result.imported_records > 0 %} +
+ +
+

Import Successful!

+

Successfully imported {{ import_result.imported_records }} QR codes.

+
+ + + View QR Codes + +
+ + {% if import_result.imported_qr_codes %} +
+

+ + Imported QR Codes +

+
+ + + + + + + + + + + {% for qr in import_result.imported_qr_codes[:10] %} + + + + + + + {% endfor %} + {% if import_result.imported_qr_codes|length > 10 %} + + + + {% endif %} + +
IDNameLocationProject
{{ qr.id }}{{ qr.name }}{{ qr.location }}{{ qr.project }}
+ ...and {{ import_result.imported_qr_codes|length - 10 }} more QR codes +
+
+
+ {% endif %} + {% endif %} + + {% if import_result.errors %} +
+

+ + Import Errors ({{ import_result.errors|length }}) +

+
    + {% for error in import_result.errors[:20] %} +
  • {{ error }}
  • + {% endfor %} + {% if import_result.errors|length > 20 %} +
  • ...and {{ import_result.errors|length - 20 }} more errors
  • + {% endif %} +
+
+ {% endif %} +
+
+ {% endif %} +
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/confirm_delete_qr.html b/templates/confirm_delete_qr.html new file mode 100644 index 0000000..a541e75 --- /dev/null +++ b/templates/confirm_delete_qr.html @@ -0,0 +1,610 @@ +{% extends "base.html" %} {% block title %}Confirm Delete - QR Code Management{% +endblock %} {% block extra_head %} + +{% endblock %} {% block content %} +
+
+
+
+ +
+

Confirm QR Code Deletion

+
+ +
+
+
+ QR Code for {{ qr_code.name }} +
+ +
+

+ {{ qr_code.name }} + + + {{ 'Active' if qr_code.active_status else 'Inactive' }} + +

+ +
+ Location: + {{ qr_code.location }} +
+ + {% if qr_code.location_address %} +
+ Address: + {{ qr_code.location_address }} +
+ {% endif %} {% if qr_code.location_event %} +
+ Event: + {{ qr_code.location_event }} +
+ {% endif %} + +
+ Created: + {{ qr_code.created_date.strftime('%B %d, %Y at %H:%M') }} +
+ +
+ Created by: + {{ qr_code.creator.full_name }} +
+ +
+ QR Code ID: + #{{ qr_code.id }} +
+
+
+ +
+

+ + Warning: This action cannot be undone! +

+

Permanently deleting this QR code will:

+
    +
  • Remove the QR code completely from the database
  • +
  • Make the QR code link permanently inaccessible
  • +
  • Delete all associated scan data and statistics
  • +
  • Remove any references to this QR code in reports
  • +
+

+ Alternative: Consider deactivating the QR code + instead if you might need it later. +

+
+
+ +
+ + + Cancel & Go Back + + + + + Deactivate Instead + + +
+ + +
+
+
+
+ + + +{% endblock %} {% block extra_scripts %} + + + +{% endblock %} diff --git a/templates/create_employee.html b/templates/create_employee.html new file mode 100644 index 0000000..d6de50c --- /dev/null +++ b/templates/create_employee.html @@ -0,0 +1,380 @@ +{% extends "base_authenticated.html" %} +{% set page_title = "Add New Employee" %} + +{% block title %}{{ page_title }}{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+ +
+
+ + +

Add New Employee

+

+ Create a new employee record in the system +

+
+
+ + +
+
+

+ + Employee Information +

+
+ +
+
+ + +
+
+ + +
+ Must be a unique numeric identifier +
+
+ +
+ + +
+ Select the project this employee will be assigned to +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ + +
+ Optional field, maximum 20 characters +
+
+ + +
+ + + Cancel + + +
+ +
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/create_project.html b/templates/create_project.html new file mode 100644 index 0000000..08e0932 --- /dev/null +++ b/templates/create_project.html @@ -0,0 +1,160 @@ +{% extends "base.html" %} +{% block title %}Create Project - QR Code Management{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ +
+
+

Create New Project

+

Create a new project to organize your QR codes

+
+ +
+ + +
+
+

Project Information

+
+ +
+
+ +
+ +
+ + + 0/100 +
+ + +
+ + + 0/500 + + + Provide a brief description of what this project is for + +
+ + +
+ + + Cancel + + +
+
+
+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/create_qr_code.html b/templates/create_qr_code.html new file mode 100644 index 0000000..5d896aa --- /dev/null +++ b/templates/create_qr_code.html @@ -0,0 +1,1530 @@ + + + + + + Create QR Code - QR Management System + + + + + + +
+
+
+

+ + Create New QR Code +

+

Generate a QR code for attendance tracking at your location

+
+ + + +
+ + +
+
+

+ + Basic Information +

+

Provide the basic details for your QR code

+
+ +
+ + + A unique name to identify this QR code +
+ 0/100 +
+
+ + +
+ + + + Standard: employee checks in at one fixed location.
+ Dynamic: employee picks a location from a list when scanning. +
+
+ + + +
+
+ + + The name of the physical location where this QR code will be + used +
+ 0/100 +
+
+
+ +
+ + + + + Organize your QR code by assigning it to a project + +
+
+ + +
+
+
+

+ + Location Details +

+

Provide the complete address and event information

+
+ +
+ + + Include all address details for accurate location + identification +
+ 0/500 +
+ + + +
+ +
+
+ + +
+
+ + + Select the event type for this QR code +
+ +
+ + + +
+
+

+ + Photo Verification +

+

Require a photo when employee check-in location is too far from the QR code

+
+
+ + + When disabled, photo verification is skipped for this QR code regardless of distance +
+
+ + +
+
+

+ + QR Code Customization +

+

Customize the appearance and style of your QR code

+
+ + +
+ + + Choose a pre-defined style or customize manually below +
+ + +
+
+ +
+ + +
+ Color of the QR code modules +
+ +
+ +
+ + +
+ Background color of the QR code +
+
+ + +
+
+ +
+ + 10px +
+ Size of each QR code module (affects overall size) +
+ +
+ +
+ + 4 modules +
+ White border around the QR code +
+
+ + +
+ + + Higher levels allow QR code to work even if partially damaged +
+ + +
+

+ + Live Preview +

+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+

Sample QR Code Preview

+

Colors and styling will be applied to your actual QR code

+
+
+
+
+ + + + + + + +
+ + + Cancel + + + +
+
+
+
+ + + + + + + + + + + + \ No newline at end of file diff --git a/templates/create_user.html b/templates/create_user.html new file mode 100644 index 0000000..729189d --- /dev/null +++ b/templates/create_user.html @@ -0,0 +1,671 @@ +{% extends "base_authenticated.html" %} {% block title %}Create New User - QR +Code Management{% endblock %} {% block content %} +
+ + +
+
+ +
+

+ + User Information +

+ +
+
+ + + First and last name +
+ +
+ + + Must be unique in the system +
+
+ +
+ + + Letters, numbers, and underscores only +
+ +
+ + + Determines user permissions and system access level +
+
+ +
+ + +
+ Minimum 6 characters. User should change on first login +
+ + + + + + + + + + +
+ + + Back to Users + + + +
+
+
+
+{% endblock %} {% block extra_scripts %} + +{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..31c156e --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,2070 @@ +{% extends "base_authenticated.html" %} {% block title %}Dashboard - QR Code +Management{% endblock %} {% block extra_head %} + + +{% endblock %} {% block content %} +
+ +
+
+

Welcome back, {{ session.full_name }}!

+

Manage your QR codes organized by projects

+ +
+ + + {% if session.role == 'admin' %} +
+
+
+
+ +
+
+

{{ qr_codes|length }}

+

Total QR Codes

+
+
+
+
+ +
+
+

+ {{ qr_codes|selectattr('active_status', 'equalto', + True)|list|length }} +

+

Active QR Codes

+
+
+
+
+ +
+
+

+ {{ qr_codes|selectattr('active_status', 'equalto', + False)|list|length }} +

+

Inactive QR Codes

+
+
+
+
+ +
+
+

{{ projects|length if projects else 0 }}

+

Total Projects

+
+
+
+
+ {% endif %} +
+ +
+
+
+
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+ + + {% if search_name or search_status %} +
+
+ + + Showing {{ qr_codes|length }} results + {% if search_name %}matching "{{ search_name }}"{% endif %} + {% if search_status %}with status: {{ search_status }}{% endif %} + + + + Clear Filters + +
+
+ + + {% if qr_codes %} + + +
+

+ + Search Results +

+
+ + {{ qr_codes|length }} QR code{{ 's' if qr_codes|length != 1 else '' }} found + + {% set projects_with_results = qr_codes|map(attribute='project_id')|select|unique|list|length %} + {% if projects_with_results > 0 %} + across {{ projects_with_results }} project{{ 's' if projects_with_results != 1 else '' }} + {% endif %} +
+
+ + + {% for project in projects %} + {% set project_search_results = qr_codes|selectattr('project_id', 'equalto', project.id)|list %} + {% if project_search_results %} +
+
+
+
+ +
+
+

{{ project.name }}

+ {% if project.description %} +

{{ project.description }}

+ {% endif %} +
+
+ + {{ project_search_results|length }} QR code{{ 's' if project_search_results|length != 1 else '' }} + +
+ +
+ {% for qr in project_search_results %} +
+
+
+ QR Code for {{ qr.name }} +
+ +
+

{{ qr.name }}

+ +
+ + {{ qr.location }} +
+ + {% if qr.location_address %} +
+ + {{ qr.location_address }} +
+ {% endif %} + + {% if qr.location_event %} +
+ + {{ qr.location_event }} +
+ {% endif %} + +
+ + {{ get_qr_code_checkin_count(qr.id) }} check-ins +
+ + {% if qr.qr_url %} +
+ + {{ qr.qr_url[:40] }}{% if qr.qr_url|length > 40 %}...{% endif %} +
+ {% endif %} + + + + {{ 'Active' if qr.active_status else 'Inactive' }} + +
+
+ +
+ + + + + + + + + + + {% if session.role == 'admin' %} + + {% endif %} +
+
+ {% endfor %} +
+
+ {% endif %} + {% endfor %} + + + {% set unassigned_search_results = qr_codes|selectattr('project_id', 'equalto', None)|list %} + {% if unassigned_search_results %} +
+
+
+
+ +
+
+

Unassigned QR Codes

+

QR codes not assigned to any project

+
+
+ + {{ unassigned_search_results|length }} QR code{{ 's' if unassigned_search_results|length != 1 else '' }} + +
+ +
+ {% for qr in unassigned_search_results %} +
+
+
+ QR Code for {{ qr.name }} +
+ +
+

{{ qr.name }}

+ +
+ + {{ qr.location }} +
+ + {% if qr.location_address %} +
+ + {{ qr.location_address }} +
+ {% endif %} + + {% if qr.location_event %} +
+ + {{ qr.location_event }} +
+ {% endif %} + +
+ + {{ get_qr_code_checkin_count(qr.id) }} check-ins +
+ + {% if qr.qr_url %} +
+ + {{ qr.qr_url[:40] }}{% if qr.qr_url|length > 40 %}...{% endif %} +
+ {% endif %} + + + + {{ 'Active' if qr.active_status else 'Inactive' }} + +
+
+ +
+ + + + + + + + + + + {% if session.role == 'admin' %} + + {% endif %} +
+
+ {% endfor %} +
+
+ {% endif %} + + {% else %} + +
+
+ +
+

No QR Codes Found

+

No QR codes match your search criteria. Try adjusting your filters.

+ + + Clear Filters + +
+ {% endif %} + {% else %} + +
+ {% if projects %} +
+ {% for project in projects %} {% set project_qr_codes = + qr_codes|selectattr('project_id', 'equalto', project.id)|list %} + +
+ +
+
+
+ +
+
+

{{ project.name }}

+ {% if project.description %} +

{{ project.description }}

+ {% endif %} +
+
+ +
+
+
+ + {{ project_qr_codes|length }} QR Code{{ 's' if + project_qr_codes|length != 1 else '' }} +
+ + {{ 'Active' if project.active_status else 'Inactive' }} + +
+ + +
+
+ + +
+ {% if project_qr_codes %} +
+ {% for qr in project_qr_codes[:12] %} +
+
+
+ QR Code for {{ qr.name }} +
+ +
+

{{ qr.name }}

+
+ + {{ qr.location }} +
+ + {% if qr.location_address %} +
+ + {{ qr.location_address }} +
+ {% endif %} + +
+ + {{ get_qr_code_checkin_count(qr.id) }} check-ins +
+ + + + {{ 'Active' if qr.active_status else 'Inactive' }} + +
+
+ +
+ + + + + + + + + + + {% if session.role == 'admin' %} + + {% endif %} +
+
+ {% endfor %} + + {% if project_qr_codes|length > 12 %} + + + View All Project's QR Codes + + {% endif %} +
+ {% else %} +
+
+ +
+

No QR Codes in this Project

+

Create your first QR code for this project

+ + + Create QR Code + +
+ {% endif %} +
+
+ {% endfor %} +
+ {% endif %} +
+ + {% set unassigned_qr_codes = qr_codes|selectattr('project_id', 'equalto', + None)|list %} {% if unassigned_qr_codes %} +
+
+

+ + Unassigned QR Codes +

+ {{ unassigned_qr_codes|length }} QR Code{{ 's' if + unassigned_qr_codes|length != 1 else '' }} +
+ +
+ {% for qr in unassigned_qr_codes %} +
+
+
+ QR Code for {{ qr.name }} +
+ +
+

{{ qr.name }}

+
+
+ + {{ qr.location }} +
+ + {% if qr.location_address %} +
+ + {{ qr.location_address }} +
+ {% endif %} + +
+ + {{ get_qr_code_checkin_count(qr.id) }} check-ins +
+ + {% if qr.qr_url %} +
+ + {{ qr.qr_url[:50] }}{% if qr.qr_url|length > 50 %}...{% endif + %} +
+ {% endif %} +
+
+ +
+ + + {{ 'Active' if qr.active_status else 'Inactive' }} + +
+ +
+ + + + + + + + + + + {% if session.role == 'admin' %} + + {% endif %} +
+
+
+ {% endfor %} +
+
+ {% endif %} + + + {% if not qr_codes and not projects %} +
+ {% if search_name or search_status %} +
+ +
+

No QR Codes Found

+

+ No QR codes match your search criteria. + {% if search_name %}Try a different name{% endif %} + {% if search_name and search_status %} or {% endif %} + {% if search_status %}try changing the status filter{% endif %}. +

+ + + Clear Filters + + {% elif not projects %} +
+ +
+

Welcome to Your QR Code Dashboard

+

+ Get started by creating your first project and QR codes to organize your + digital assets effectively. +

+ + + Create Your First Project + + {% endif %} +
+ {% endif %} +
+{% endif %} + + +
+ +
+ + + +{% endblock %} {% block extra_scripts %} + +{% endblock %} diff --git a/templates/edit_attendance.html b/templates/edit_attendance.html new file mode 100644 index 0000000..a0a4c8c --- /dev/null +++ b/templates/edit_attendance.html @@ -0,0 +1,539 @@ +{% extends "base_authenticated.html" %} +{% block title %}Edit Attendance Record - QR Code Management{% endblock %} +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+
+

+ + Edit Attendance Record +

+

Modify attendance record details (Admin & Accounting Access)

+
+ + +
+

Current Record Information

+

Record ID: {{ attendance_record.id }}

+

Current Employee: {{ attendance_record.employee_id }}

+

Current Date: {{ attendance_record.check_in_date.strftime('%Y-%m-%d') }}

+

Current Time: {{ attendance_record.check_in_time.strftime('%H:%M') }}

+

Current Location: {{ attendance_record.location_name }}

+

Current Event Type: + + {{ attendance_record.qr_code.location_event if attendance_record.qr_code else 'Unknown' }} + +

+
+ +
+ + + +
+

Audit Information

+ + {% if attendance_record.edit_note %} +
+

Previous Edit History

+
+ +
+
+ {% endif %} + +
+ + + + {% if attendance_record.edit_note %} + Note: Your new reason will be appended to the existing audit trail. + {% else %} + This note will be logged for audit purposes and added to the record's audit trail. + {% endif %} + +
+
+ + +
+

Employee Information

+
+ + +
+
+ + +
+

Check-in Details

+
+
+ + +
+
+ + +
+
+
+ + +
+

Location & Event Information

+ + +
+ + +
+ + +
+ + + +
+ + +
+ + + Select whether this is a Check In or Check Out event +
+ + + + + +
+ + + This field is automatically updated based on your location selection above +
+
+ + + {% if session.role not in ['admin', 'accounting'] %} +
+ + Read-Only Access: You can view this record but cannot make changes. Only Admin and Accounting staff can modify attendance records. +
+ {% endif %} + + +
+ {% if session.role in ['admin', 'accounting'] %} + + {% endif %} + + + Cancel + +
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/edit_employee.html b/templates/edit_employee.html new file mode 100644 index 0000000..60c2938 --- /dev/null +++ b/templates/edit_employee.html @@ -0,0 +1,453 @@ +{% extends "base_authenticated.html" %} {% set page_title = "Edit Employee" %} +{% block title %}{{ page_title }}{% endblock %} {% block extra_head %} + + +{% endblock %} {% block content %} +
+ +
+
+ + +

Edit Employee

+

+ Update employee information in the system +

+
+
+ + +
+
+

+ + Employee Information +

+ +
+

+ + Current Employee +

+

{{ employee.full_name }} (ID: {{ employee.id }})

+
+
+ +
+
+ + +
+
+ + +
Must be a unique numeric identifier
+
+ +
+ + +
+ Select the project this employee will be assigned to +
+
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ + +
Optional field, maximum 20 characters
+
+ + +
+ + + Cancel + + +
+
+
+
+
+{% endblock %} {% block extra_scripts %} + +{% endblock %} diff --git a/templates/edit_project.html b/templates/edit_project.html new file mode 100644 index 0000000..3e15c70 --- /dev/null +++ b/templates/edit_project.html @@ -0,0 +1,199 @@ +{% extends "base.html" %} +{% block title %}Edit Project - QR Code Management{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ +
+
+

Edit Project

+

Update project information and settings

+
+ +
+ + +
+
+

Current Project Information

+
+ +
+
+
+ Project Name: + {{ project.name }} +
+ +
+ Description: + {{ project.description if project.description else 'No description provided' }} +
+ +
+ QR Codes: + {{ project.qr_count }} active QR codes +
+ +
+ Created: + {{ project.created_date.strftime('%B %d, %Y at %I:%M %p') }} +
+ +
+ Status: + + {% if project.active_status %}Active{% else %}Inactive{% endif %} + +
+
+
+
+ + +
+
+

Update Project Information

+
+ +
+
+ +
+ +
+ + + {{ project.name|length }}/100 +
+ + +
+ + + {{ (project.description|length) if project.description else 0 }}/500 + + + Provide a brief description of what this project is for + +
+ + +
+ + + Cancel + + +
+
+
+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/edit_qr_code.html b/templates/edit_qr_code.html new file mode 100644 index 0000000..f08f9de --- /dev/null +++ b/templates/edit_qr_code.html @@ -0,0 +1,1577 @@ +{% extends "base_authenticated.html" %} +{% block title %}Edit QR Code - QR Management System{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+
+ +
+
+

+ + Edit QR Code +

+

Update the information for your QR code

+
+ +
+ + +
+
+

+ + Basic Information +

+

Update the basic details for your QR code

+
+ +
+ + + + + QR code name cannot be changed after creation as it is linked to the QR URL. + +
+ + +
+ + + + Standard: employee checks in at one fixed location.
+ Dynamic: employee picks a location from a list when scanning. +
+
+ + + +
+
+ + + The name of the physical location where this QR code will be + used +
+ {{ qr_code.location|length if qr_code.qr_type != 'dynamic' else '0' }}/100 +
+
+
+ +
+ + + + + Organize your QR code by assigning it to a project + +
+
+ + +
+
+
+

+ + Location Details +

+

Update the complete address and event information

+
+ +
+ + + Include all address details for accurate location + identification +
+ {{ qr_code.location_address|length }}/500 +
+ + +
+
+ +

Address Coordinates

+
+ +
+
+
+
+ {% if qr_code.has_coordinates %} + {{ "%.6f"|format(qr_code.address_latitude) }} + {% else %} + ---.---------- + {% endif %} +
+
Latitude
+
+
+
+ {% if qr_code.has_coordinates %} + {{ "%.6f"|format(qr_code.address_longitude) }} + {% else %} + ---.---------- + {% endif %} +
+
Longitude
+
+
+ +
+ + +
+ +
+
+
+
+ +
+
+ + +
+
+ + + Select the event type for this QR code +
+ +
+ + + +
+
+

+ + Photo Verification +

+

Require a photo when employee check-in location is too far from the QR code

+
+
+ + + When disabled, photo verification is skipped for this QR code regardless of distance +
+
+ + +
+
+

+ + QR Code Customization +

+

Update the appearance and style of your QR code

+
+ + +
+ + + Choose a pre-defined style or customize manually below +
+ + +
+
+ +
+ + +
+ Color of the QR code modules +
+ +
+ +
+ + +
+ Background color of the QR code +
+
+ + +
+
+ +
+ + {{ qr_code.box_size or 10 }}px +
+ Size of each QR code module (affects overall size) +
+ +
+ +
+ + {{ qr_code.border or 4 }} modules +
+ White border around the QR code +
+
+ + +
+ + + Higher levels allow QR code to work even if partially damaged +
+ + +
+

+ + Live Preview +

+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+

Sample QR Code Preview

+

Colors and styling will be applied to your updated QR code

+
+
+
+
+ + + + + + + +
+ + + Cancel + + + +
+
+
+ + +
+
+

+ + Current Information +

+
+ +
+
+ QR Code Name: + {{ qr_code.name }} +
+
+ Location: + {{ qr_code.location }} +
+
+ Event: + {{ qr_code.location_event }} +
+
+ Address: + {{ qr_code.location_address }} +
+ + + {% if qr_code.has_coordinates %} +
+
+ {{ qr_code.coordinates_display }} +
+
Current Coordinates
+
+ Accuracy: {{ qr_code.coordinate_accuracy|title }} + {% if qr_code.coordinates_updated_date %} +
Updated: {{ qr_code.coordinates_updated_date.strftime('%Y-%m-%d') }} + {% endif %} +
+
+ {% else %} +
+ +
No coordinates available
+
+ Use "Get Coordinates" to add location data +
+
+ {% endif %} + + +
+ QR Code Style: + + {% if qr_code.style %} + {{ qr_code.style.name }} + {% else %} + Custom + {% endif %} + +
+
+ Colors: + + + on + + +
+
+
+
+
+ + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/edit_user.html b/templates/edit_user.html new file mode 100644 index 0000000..1d63f02 --- /dev/null +++ b/templates/edit_user.html @@ -0,0 +1,731 @@ +{% extends "base_authenticated.html" %} +{% block title %}Edit User - QR Code Management{% endblock %} + +{% block content %} +
+ + +
+
+ + +
+

+ + User Information +

+ +
+
+ + + First and last name +
+ +
+ + + Must be unique in the system +
+
+ +
+ + + + Username cannot be changed +
+ +
+ + + Changes role permissions immediately +
+
+ + + + + + + + +
+

+ + Password Management +

+ +
+ + +
+ Only enter a password if you want to change it +
+ +
+ + + Must match the new password above +
+
+ + +
+

+ + User Status Information +

+ +
+
+ Current Status: + + + {{ 'Active' if user.active_status else 'Inactive' }} + +
+ +
+ Account Created: + {{ user.created_date.strftime('%B %d, %Y at %I:%M %p') if user.created_date else 'Unknown' }} +
+ +
+ Created By: + {{ user.creator.full_name if user.creator else 'System' }} +
+ +
+ Last Login: + + {% if user.last_login_date %} + {{ user.last_login_date.strftime('%B %d, %Y at %I:%M %p') }} + {% else %} + Never logged in + {% endif %} + +
+
+
+ + +
+ + + Back to Users + + + + + +
+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/employee_detail.html b/templates/employee_detail.html new file mode 100644 index 0000000..50065d8 --- /dev/null +++ b/templates/employee_detail.html @@ -0,0 +1,511 @@ +{% extends "base_authenticated.html" %} +{% set page_title = "Employee Details - " + employee.full_name %} + +{% block title %}{{ page_title }}{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+ + + + +
+
+
+
+ +
+
+

{{ employee.full_name }}

+
+
+ + Employee ID: {{ employee.id }} +
+
+ + {{ employee.display_title }} +
+
+ + Contract: {{ employee.contract_name }} +
+ {% if attendance_stats.latest_attendance %} +
+ + Last seen: {{ attendance_stats.latest_attendance.check_in_date.strftime('%B %d, %Y') }} +
+ {% endif %} +
+
+
+
+ +
+
+
+

+ + Personal Information +

+
+

First Name: {{ employee.firstName }}

+

Last Name: {{ employee.lastName }}

+

Full Name: {{ employee.full_name }}

+
+
+ +
+

+ + Employment Details +

+
+

Employee ID: {{ employee.id }}

+

Job Title: {{ employee.display_title }}

+

Contract ID: {{ employee.contract_name }}

+
+
+ +
+

+ + System Information +

+
+

Database Index: {{ employee.index }}

+

Record Status: Active

+

Attendance Records: {{ attendance_stats.total_attendance }}

+
+
+
+
+
+ + +
+
+

+ + Attendance Statistics +

+
+ +
+
+
+ +
+
+

{{ attendance_stats.total_attendance }}

+

Total Attendance

+
+
+ +
+
+ +
+
+

{{ attendance_stats.recent_attendance }}

+

Last 30 Days

+
+
+ +
+
+ +
+
+

{{ attendance_stats.unique_projects }}

+

Unique Projects

+
+
+ + {% if attendance_stats.latest_attendance %} +
+
+ +
+
+

{{ attendance_stats.latest_attendance.check_in_date.strftime('%m/%d') }}

+

Latest Check-in

+
+
+ {% endif %} +
+ + + {% if attendance_stats.projects %} +
+
+

+ + Projects Participated ({{ attendance_stats.unique_projects }}) +

+
+
+ {% for project in attendance_stats.projects %} +
+
+ +
+
+

{{ project.name }}

+

+ {% if project.description %} + {{ project.description[:100] }}{% if project.description|length > 100 %}...{% endif %} + {% else %} + No description available + {% endif %} +

+
+
+ {% endfor %} +
+
+ {% else %} +
+
+
+ +

No Projects Yet

+

This employee hasn't participated in any projects yet.

+
+
+
+ {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/employees.html b/templates/employees.html new file mode 100644 index 0000000..2362ab7 --- /dev/null +++ b/templates/employees.html @@ -0,0 +1,432 @@ +{% extends "base_authenticated.html" %} {% set page_title = "Employee +Management" %} {% block title %}{{ page_title }}{% endblock %} {% block +extra_head %} + +{% endblock %} {% block content %} +
+ +
+
+ + +

Employee Management

+

+ Manage employee records and view attendance statistics +

+
+ +
+ {% if session.role in ['admin', 'payroll'] %} + + + Add Employee + + {% endif %} +
+
+ + +
+
+
+ +
+
+

{{ stats.total_employees }}

+

Total Employees

+
+
+ +
+
+ +
+
+

{{ stats.employees_with_title }}

+

With Job Titles

+
+
+ +
+
+ +
+
+

{{ stats.unique_titles }}

+

Unique Job Titles

+
+
+ + {% if search %} +
+
+ +
+
+

{{ stats.search_results }}

+

Search Results

+
+
+ {% endif %} +
+ + +
+
+
+ + + {% if search %} + + + + {% endif %} +
+
+
+ + +
+
+

+ {% if search %} Search Results for "{{ search }}" {% else %} All + Employees {% endif %} +

+
+ Showing {{ employees.items|length }} of {{ employees.total }} employees + {% if employees.pages > 1 %} (Page {{ employees.page }} of {{ + employees.pages }}) {% endif %} +
+
+ +
+ + + + + + + + + + + + + {% for employee in employees.items %} + + + + + + + + + + + + + + {% endfor %} + +
#Employee IDNameJob TitleContractActions
+ {{ loop.index + (employees.page - 1) * employees.per_page }} + + {{ employee.id }} + +
+
+ +
+
+

{{ employee.full_name }}

+

{{ employee.firstName }} {{ employee.lastName }}

+
+
+
+ {% if employee.title %} + {{ employee.title }} + {% else %} + No Title + {% endif %} + + {{ employee.contract_name }} + +
+ + + + + {% if session.role in ['admin', 'payroll'] %} + + + + + + {% endif %} +
+
+
+ + + {% if employees.pages > 1 %} +
+ +
+ {% endif %} + + + {% if employees.total == 0 %} +
+
+ +
+

+ {% if search %} No employees found for "{{ search }}" {% else %} No + employees found {% endif %} +

+

+ {% if search %} Try adjusting your search terms or + view all employees. {% else %} + Get started by + adding your first employee. {% endif %} +

+
+ {% endif %} +
+
+ + + +{% endblock %} {% block extra_scripts %} + +{% endblock %} diff --git a/templates/errors/403.html b/templates/errors/403.html new file mode 100644 index 0000000..2cddac4 --- /dev/null +++ b/templates/errors/403.html @@ -0,0 +1,79 @@ + +{% extends "base.html" %} {% block title %}Access Forbidden - QR Code +Management{% endblock %} {% block content %} +
+
+
403
+
+

Access Forbidden

+

You don't have permission to access this resource.

+

This action requires administrator privileges.

+
+
+ + + Go to Dashboard + + {% if session.role == 'staff' %} + + + View Profile + + {% endif %} +
+
+
+ + +{% endblock %} diff --git a/templates/errors/404.html b/templates/errors/404.html new file mode 100644 index 0000000..22a8170 --- /dev/null +++ b/templates/errors/404.html @@ -0,0 +1,73 @@ + +{% extends "base.html" %} {% block title %}Page Not Found - QR Code Management{% +endblock %} {% block content %} +
+
+
404
+
+

Page Not Found

+

The page you're looking for doesn't exist or has been moved.

+
+ +
+
+ + +{% endblock %} diff --git a/templates/errors/500.html b/templates/errors/500.html new file mode 100644 index 0000000..9174bc4 --- /dev/null +++ b/templates/errors/500.html @@ -0,0 +1,77 @@ + +{% extends "base.html" %} {% block title %}Server Error - QR Code Management{% +endblock %} {% block content %} +
+
+
500
+
+

Internal Server Error

+

Something went wrong on our end. We're working to fix it.

+

Please try again in a few moments.

+
+
+ + + Go to Dashboard + + +
+
+
+ + +{% endblock %} diff --git a/templates/export_configuration.html b/templates/export_configuration.html new file mode 100644 index 0000000..89070c6 --- /dev/null +++ b/templates/export_configuration.html @@ -0,0 +1,276 @@ +{% extends "base_authenticated.html" %} + +{% block title %}Export Configuration - QR Code Management{% endblock %} + +{% block extra_head %} + + + + +{% endblock %} + +{% block content %} +
+ +
+
+
+ +
+

+ + Export Configuration +

+

Customize your attendance report export with selected columns, custom names, and column ordering

+
+
+
+ +
+ +
+
+ + + {% if filters.date_from or filters.date_to or filters.location_filter or filters.employee_filter or filters.project_filter %} +
+
+
+

+ + Applied Filters +

+
+
+ {% if filters.date_from %} + + + From: {{ filters.date_from }} + + {% endif %} + {% if filters.date_to %} + + + To: {{ filters.date_to }} + + {% endif %} + {% if filters.location_filter %} + + + Location: {{ filters.location_filter }} + + {% endif %} + {% if filters.employee_filter %} + + + Employee: {{ filters.employee_filter }} + + {% endif %} + {% if filters.project_filter %} + + + Project: {% if project_name %}{{ project_name }}{% else %}{{ filters.project_filter }}{% endif %} + + {% endif %} +
+
+
+ {% endif %} + + +
+
+ + + + + + + + + + + +
+
+

+ Select & Order Columns for Export +

+
+ + + +
+
+ + +
+
+

+ + Export Preview +

+
+
+ + + + + + + + + + + +
+ Select columns above to see preview +
+
+
+ + +
+
+

+ + Your column preferences and order will be saved for next time +

+
+
+ +
+
+{% endblock %} + +{% block extra_scripts %} + + +{% endblock %} \ No newline at end of file diff --git a/templates/legacy_attendance_dashboard.html b/templates/legacy_attendance_dashboard.html new file mode 100644 index 0000000..fce1915 --- /dev/null +++ b/templates/legacy_attendance_dashboard.html @@ -0,0 +1,90 @@ +{% extends "base_authenticated.html" %} +{% block title %}Legacy Attendance Dashboard - {{ COMPANY_NAME }}{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ +
+
+

+ + Legacy Attendance Dashboard +

+

Live view of attendance records from the legacy database

+
+ + +
+ + +
+
+
+ +
+
+

{{ "{:,}".format(stats.total_records or 0) }}

+

Total Records

+
+
+ +
+
+ +
+
+

{{ "{:,}".format(stats.unique_employees or 0) }}

+

Employees

+
+
+ +
+
+ +
+
+

{{ "{:,}".format(stats.unique_locations or 0) }}

+

Locations

+
+
+ +
+
+ +
+
+

+ {% if stats.earliest_record and stats.latest_record %} + {{ stats.earliest_record.strftime('%m/%d/%Y') }} – {{ stats.latest_record.strftime('%m/%d/%Y') }} + {% else %} + — + {% endif %} +

+

Date Range

+
+
+
+ +
+
+ +
+

This page reads live from the legacy database

+

+ Records are queried directly from the old attendance system on each visit — + nothing is imported or stored locally. Use + View All Records + to search, filter, and export. +

+
+
+{% endblock %} diff --git a/templates/legacy_attendance_records.html b/templates/legacy_attendance_records.html new file mode 100644 index 0000000..2bf961f --- /dev/null +++ b/templates/legacy_attendance_records.html @@ -0,0 +1,262 @@ +{% extends "base_authenticated.html" %} +{% block title %}Legacy Attendance Records - {{ COMPANY_NAME }}{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ +
+
+

+ + Legacy Attendance Records +

+

Live results from the legacy database

+
+ + +
+ + +
+
+

Filters

+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + Clear + +
+
+
+
+ + +
+
+

Attendance Records

+
+ {% if records and records.items %} + Showing {{ records.per_page * (records.page - 1) + 1 }} - + {{ records.per_page * (records.page - 1) + records.items|length }} + of {{ records.total }} records + {% else %} + No records found + {% endif %} +
+
+ + {% if records and records.items %} +
+ + + + + + + + + + + + + + + + {% for record in records.items %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% endfor %} + +
IDNamePlatformDateTimeLocation NameAction DescriptionEvent DescriptionRecorded Address
+
+ {{ record.employee_id }} +
+
+
+ {{ record.resolved_employee_name }} +
+
+ {{ 'Manual' if record.is_manual else '—' }} + + {{ record.record_time.strftime('%Y-%m-%d') if record.record_time else '' }} + + {{ record.record_time.strftime('%H:%M:%S') if record.record_time else '' }} + +
+ + {{ record.location_name if record.location_name else '-' }} +
+
+ + {% if record.record_type.lower() == 'check in' %} + + {% elif record.record_type.lower() == 'check out' %} + + {% else %} + + {% endif %} + {{ record.record_type }} + + + {{ record.location_address if record.location_address else '-' }} + + {% if record.recorded_address %} + + {{ record.recorded_address[:40] }}{% if record.recorded_address|length > 40 %}...{% endif %} + + {% else %} + No address + {% endif %} +
+
+ + + {% if records.pages > 1 %} + + {% endif %} + + {% else %} +
+
+ +
+

No legacy attendance records found

+

Try adjusting your filters.

+
+ {% endif %} +
+
+{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..0b70b7d --- /dev/null +++ b/templates/login.html @@ -0,0 +1,146 @@ +{% extends "base.html" %} +{% block title %}Login - QR Code Management{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+
+
+ +

{{ COMPANY_NAME }}

+

Sign in to your account

+
+ +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ + + {% if turnstile_enabled %} +
+
+
+
+ {% endif %} + + +
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..20ec190 --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,1184 @@ +{% extends "base_authenticated.html" %} {% block title %}My Profile - QR Code Management{% +endblock %} {% block content %} +
+
+
+
+ +
+
+ +
+
+ +
+

{{ user.full_name }}

+
+
+ + {{ user.email }} +
+
+ + @{{ user.username }} +
+
+ + + {{ user.role.title() }} + +
+
+ + Member since {{ user.created_date.strftime('%B %Y') }} +
+ {% if user.last_login_date %} +
+ + Last login {{ user.last_login_date.strftime('%Y-%m-%d %H:%M') + }} +
+ {% endif %} +
+
+
+ +
+ +
+
+
+ +
+
+

{{ user.created_qr_codes.count() }}

+

QR Codes Created

+
+
+ +
+
+ +
+
+

+ {{ user.created_qr_codes.filter_by(active_status=True).count() }} +

+

Active QR Codes

+
+
+ +
+
+ +
+
+

{{ user.created_date|days_since }}

+

Days as Member

+
+
+
+ + +
+
+ + + +
+ + +
+
+
+

+ + Update Profile Information +

+

+ Keep your profile information up to date for better collaboration +

+
+ +
+ + + +
+
+ + + Your display name in the system +
+ +
+ + + Used for notifications and account recovery +
+
+ +
+ + + Username cannot be changed. Contact admin if needed. +
+ +
+ + +
+
+
+
+ + +
+
+
+

+ + Change Password +

+

Ensure your account security with a strong password

+
+ +
+ + + +
+ + + Enter your current password for verification +
+ +
+ + +
+ Minimum 6 characters with mixed case, numbers, and + symbols +
+ +
+ + +
+
+ +
+

+ + Password Security Tips +

+
    +
  • Use at least 8 characters
  • +
  • Include uppercase and lowercase letters
  • +
  • Add numbers and special characters
  • +
  • Avoid common words or personal information
  • +
  • Don't reuse passwords from other accounts
  • +
+
+ +
+ + +
+
+
+
+ + +
+
+
+

+ + Account Information +

+

View your account details and system information

+
+ +
+
+
+ + Account ID +
+
{{ user.id }}
+
+ +
+
+ + Role +
+
+ + {{ user.role.title() }} + +
+
+ +
+
+ + Account Created +
+
+ {{ user.created_date.strftime('%B %d, %Y at %H:%M') }} +
+
+ + {% if user.creator %} +
+
+ + Created By +
+
{{ user.creator.full_name }}
+
+ {% endif %} + +
+
+ + Account Status +
+
+ + + {{ 'Active' if user.active_status else 'Inactive' }} + +
+
+ + {% if user.last_login_date %} +
+
+ + Last Login +
+
+ {{ user.last_login_date.strftime('%B %d, %Y at %H:%M') }} ({{ + user.last_login_date|time_ago }}) +
+
+ {% endif %} +
+ + + +
+
+
+
+
+{% endblock %} {% block extra_scripts %} + + + +{% endblock %} diff --git a/templates/project_qr_codes.html b/templates/project_qr_codes.html new file mode 100644 index 0000000..30f3acc --- /dev/null +++ b/templates/project_qr_codes.html @@ -0,0 +1,1032 @@ +{% extends "base_authenticated.html" %} + +{% block title %}{{ project.name }} - QR Codes{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+ +
+
+
+ +
+
+

{{ project.name }}

+ {% if project.description %} +

{{ project.description }}

+ {% endif %} +
+ + + Back to Dashboard + +
+ +
+
+ + {{ qr_codes|length }} Total QR Codes +
+
+ + {{ qr_codes|selectattr('active_status', 'equalto', True)|list|length }} Active +
+
+ + {{ qr_codes|selectattr('active_status', 'equalto', False)|list|length }} Inactive +
+
+
+ + +
+
+
+
+ + +
+ +
+ + +
+ +
+ + {% if search_name or search_status %} + + + Clear + + {% endif %} +
+
+
+
+ + + {% if search_name or search_status %} +
+
+ + + Showing filtered results + {% if search_name %} - Name: "{{ search_name }}"{% endif %} + {% if search_status %} - Status: {{ search_status|title }}{% endif %} + ({{ qr_codes|length }} QR code{{ 's' if qr_codes|length != 1 else '' }} found) + + + + Clear Filters + +
+
+ {% endif %} + + + {% if qr_codes %} +
+ {% for qr in qr_codes %} +
+
+
+ QR Code for {{ qr.name }} +
+ +
+

{{ qr.name }}

+
+ + {{ qr.location }} +
+ + {% if qr.location_address %} +
+ + {{ qr.location_address }} +
+ {% endif %} + +
+ + {{ get_qr_code_checkin_count(qr.id) }} check-ins +
+ + {% if qr.qr_url %} +
+ + {{ qr.qr_url[:50] }}{% if qr.qr_url|length > 50 %}...{% endif %} +
+ {% endif %} + + + + {{ 'Active' if qr.active_status else 'Inactive' }} + +
+
+ + +
+ + + + + + + + + + + {% if session.role == 'admin' %} + + {% endif %} +
+
+ {% endfor %} +
+ {% else %} + +
+ {% if search_name or search_status %} + +

No Matching QR Codes

+

+ No QR codes in this project match your search criteria. + {% if search_name %}Try a different name{% endif %} + {% if search_name and search_status %} or {% endif %} + {% if search_status %}try changing the status filter{% endif %}. +

+ + + Clear Filters + + {% else %} + +

No QR Codes Found

+

This project doesn't have any QR codes yet.

+ {% if session.role == 'admin' %} + + Create QR Code + + {% endif %} + {% endif %} +
+ {% endif %} +
+ + +
+ +
+ + + + +{% endblock %} + +{% block extra_scripts %} + + + +{% endblock %} \ No newline at end of file diff --git a/templates/projects.html b/templates/projects.html new file mode 100644 index 0000000..f810ebc --- /dev/null +++ b/templates/projects.html @@ -0,0 +1,197 @@ +{% extends "base_authenticated.html" %} +{% block title %}Projects - QR Code Management{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ +
+
+ +

Projects

+

Organize and manage your QR code projects

+
+ +
+ + +
+
+
+ +
+
+

{{ projects|length }}

+

Total Projects

+
+
+ +
+
+ +
+
+

{{ projects|selectattr('active_status', 'equalto', True)|list|length }}

+

Active Projects

+
+
+ +
+
+ +
+
+

{{ projects|sum(attribute='total_qr_count') }}

+

Total QR Codes

+
+
+
+ + +
+
+

All Projects

+
+ + {% if projects %} +
+ {% for project in projects %} +
+
+
+

{{ project.name }}

+

+ {% if project.description %} + {{ project.description[:100] }}{% if project.description|length > 100 %}...{% endif %} + {% else %} + No description provided + {% endif %} +

+
+
+ {% if project.active_status %} + Active + {% else %} + Inactive + {% endif %} +
+
+ +
+
+ + {{ project.qr_count }} QR Codes +
+
+ + {{ project.created_date.strftime('%b %d, %Y') }} +
+
+ + {{ project.creator.full_name if project.creator else 'Unknown' }} +
+
+ +
+ + + Edit + +
+ + +
+
+
+ {% endfor %} +
+ {% else %} +
+
+ +
+

No Projects Yet

+

Create your first project to organize your QR codes

+ + + Create First Project + +
+ {% endif %} +
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/qr_destination.html b/templates/qr_destination.html new file mode 100644 index 0000000..4a74b53 --- /dev/null +++ b/templates/qr_destination.html @@ -0,0 +1,2237 @@ + + + + + + {{ qr_code.location_event }} + + + + +
+ +
+ {% if qr_code.location_event == 'Check In' %} +

+ {{ qr_code.location_event }} + / + Entrada +

+ {% else %} +

+ {{ qr_code.location_event }} + / + Salida +

+ {% endif %} +

+ + {{ qr_code.location }} +

+
+ + +
+
+ + Status message +
+
+ + + + + {% if qr_code.qr_type == 'dynamic' %} +
+
+
+

+ + Please select your work location. + + / + Por favor seleccione su ubicación de trabajo. +

+
+
+ +
+ +
+ + + + +
+ {% endif %} + + + +
+
+
+

+ Please enter your Employee ID. + / + Por favor ingrese su ID de empleado. +

+
+
+ +
+ + + + + + {% if qr_code.qr_type == 'dynamic' %} + + {% endif %} + +
+ + +
+ +
+ + + + + +
+ + +
+
+ +
+
+ +
+

+ Success! + / + ¡Éxito! +

+
+

+ Your submission has been recorded successfully. + / + Su registro ha sido guardado exitosamente. +

+
+ + +
+
+ + Employee ID + / + ID del Empleado + + - +
+ +
+ + Type of Work + / + Tipo de Trabajo + + - +
+ +
+ + Date + / + Fecha + + - +
+ +
+ + Time + / + Hora + + - +
+ +
+ + Action + / + Acción + + - +
+ +
+ + Location + / + Ubicación + + - +
+ + + + + +
+
+ +
+
+
+

+ + Distance Violation
+ Violación de distancia +

+

+ + Please take a selfie with the QR code to verification.
+ + Por favor, tome una selfie con el QR en el closet de custodios para verificar su locacion. +

+

+ Distance/Distancia: -- miles + (Threshold/Umbral: 0.3 miles) +

+
+ +
+ + + +
+ +
+ + + + +
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/templates/qr_not_found.html b/templates/qr_not_found.html new file mode 100644 index 0000000..ff22a12 --- /dev/null +++ b/templates/qr_not_found.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} {% block title %}QR Code Not Found - QR Code +Management{% endblock %} {% block content %} +
+
+
+ +
+
+

QR Code Not Found

+

+ The QR code you're looking for doesn't exist or has been deactivated. +

+

+ Please check with the person who provided this QR code. +

+
+
+ {% if session.user_id %} + + + Go to Dashboard + + {% else %} + + + Login + + {% endif %} +
+
+
+ + +{% endblock %} diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..71367e8 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,182 @@ +{% extends "base.html" %} {% block title %}Register - QR Code Management{% +endblock %} {% block content %} +
+
+
+ +

Create Account

+

Join our QR management platform

+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ + +
+ + +
+
+{% endblock %} {% block extra_scripts %} + +{% endblock %} diff --git a/templates/statistics.html b/templates/statistics.html new file mode 100644 index 0000000..b2334bc --- /dev/null +++ b/templates/statistics.html @@ -0,0 +1,659 @@ +{% extends "base_authenticated.html" %} + +{% block title %}QR Code Statistics - QR Code Management{% endblock %} + +{% block extra_head %} + + + + +{% endblock %} + +{% block content %} +
+ +
+
+

+ + QR Code Analytics +

+

Comprehensive insights into QR code usage, devices, locations, and user behavior

+
+ +
+ +
+
+ + +
+
+
+

+ + Filter Analytics +

+
+
+
+
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ +
+ + + + Clear Filters + +
+
+
+
+
+ + +
+
+

+ + Overview Statistics + +

+
+
+
+
+
+ +
+
+

{{ "{:,}".format(general_stats.total_scans) }}

+

Total Scans

+ + + {{ general_stats.today_scans }} today + +
+
+ +
+
+ +
+
+

{{ "{:,}".format(general_stats.unique_users) }}

+

Unique Users

+ + + Across {{ general_stats.active_days }} days + +
+
+ +
+
+ +
+
+

{{ general_stats.active_qr_codes }}

+

Active QR Codes

+ + + Currently in use + +
+
+ +
+
+ +
+
+

{{ "{:.1f}".format((general_stats.gps_enabled_scans / general_stats.total_scans * 100) if general_stats.total_scans > 0 else 0) }}%

+

GPS Usage

+ + + {{ "{:,}".format(general_stats.gps_enabled_scans) }} with location + +
+
+
+
+
+ + +
+
+

+ + Analytics Charts + +

+
+
+
+ +
+
+

+ + Device Distribution +

+
+
+ {% if device_stats %} + + {% else %} +
+ +

No device data available

+
+ {% endif %} +
+
+ + +
+
+

+ + Browser Usage +

+
+
+ {% if browser_stats %} + + {% else %} +
+ +

No browser data available

+
+ {% endif %} +
+
+
+
+
+ + +
+ +
+
+

+ + QR Code Location Analytics + +

+ {{ location_stats|length }} locations +
+
+ + + + + + + + + + + + + + + {% for location in location_stats %} + + + + + + + + + + + {% endfor %} + +
QR CodeLocationEventTotal ScansUnique UsersGPS ScansDate RangeGPS Rate
+ {{ location.qr_name }} + {{ location.qr_location }}{{ location.location_event }} + + {{ "{:,}".format(location.total_scans) }} + + + + {{ location.unique_users }} + + + + {{ location.gps_scans }} + + + {{ location.first_scan.strftime('%m/%d') }} - {{ location.last_scan.strftime('%m/%d') }} + + {% set gps_rate = (location.gps_scans / location.total_scans * 100) if location.total_scans > 0 else 0 %} +
+
+ {{ "{:.0f}".format(gps_rate) }}% +
+
+
+
+ + +
+
+

+ + IP Address Analysis (Top 3) + +

+ {{ ip_stats|length }} unique IPs +
+
+ + + + + + + + + + + + + + {% for ip in ip_stats %} + + + + + + + + + + {% endfor %} + +
IP AddressTotal ScansUnique UsersQR Codes UsedFirst SeenLast SeenActivity Score
+ {{ ip.ip_address }} + + + {{ "{:,}".format(ip.scan_count) }} + + + + {{ ip.unique_users }} + + + + {{ ip.qr_codes_used }} + + {{ ip.first_scan.strftime('%m/%d/%Y') }}{{ ip.last_scan.strftime('%m/%d/%Y') }} + {% set activity_score = (ip.scan_count * ip.unique_users * ip.qr_codes_used) %} +
+ {% if activity_score > 100 %} + High + {% elif activity_score > 20 %} + Medium + {% else %} + Low + {% endif %} +
+
+
+
+ + + {% if project_stats %} +
+
+

+ + Project Analytics + +

+ {{ project_stats|length }} projects +
+
+ + + + + + + + + + + + + {% for project in project_stats %} + + + + + + + + + {% endfor %} + +
Project NameTotal ScansUnique UsersQR CodesGPS UsagePerformance
+ {{ project.project_name }} + + + {{ "{:,}".format(project.total_scans) }} + + + + {{ project.unique_users }} + + + + {{ project.qr_codes_in_project }} + + +
+
+ {{ "{:.0f}".format(project.gps_usage_percentage) }}% +
+
+ {% set avg_scans_per_qr = (project.total_scans / project.qr_codes_in_project) if project.qr_codes_in_project > 0 else 0 %} +
+ {% if avg_scans_per_qr > 50 %} + Excellent + {% elif avg_scans_per_qr > 20 %} + Good + {% elif avg_scans_per_qr > 5 %} + Fair + {% else %} + Poor + {% endif %} + {{ "{:.1f}".format(avg_scans_per_qr) }} scans/QR +
+
+
+
+ {% endif %} +
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/templates/time_attendance_batch_detail.html b/templates/time_attendance_batch_detail.html new file mode 100644 index 0000000..16733eb --- /dev/null +++ b/templates/time_attendance_batch_detail.html @@ -0,0 +1,211 @@ +{% extends "base_authenticated.html" %} +{% block title %}Import Batch Details - {{ COMPANY_NAME }}{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block page_title %}Import Batch Details{% endblock %} + +{% block content %} +
+ +
+ + +
+

+ + Import Batch Details +

+

+ Batch ID: {{ batch_summary.batch_id }} +

+
+ +
+ + + View All Records + + {% if session.role == 'admin' %} + + {% endif %} +
+
+ + +
+
+
{{ batch_summary.total_records }}
+
Total Records
+
+ +
+
{{ batch_summary.unique_employees }}
+
Unique Employees
+
+ +
+
{{ batch_summary.unique_locations }}
+
Locations
+
+ +
+
{{ batch_summary.date_range.start.strftime('%Y-%m-%d') }}
+
Start Date
+
+ +
+
{{ batch_summary.date_range.end.strftime('%Y-%m-%d') }}
+
End Date
+
+
+ + +
+

+ + Import Information +

+

Import Date: {{ batch_summary.import_date.strftime('%Y-%m-%d %H:%M:%S') if batch_summary.import_date else 'Unknown' }}

+

Import Source: {{ batch_summary.import_source or 'Not specified' }}

+

Batch ID: {{ batch_summary.batch_id }}

+
+ + +
+

+ + Actions Breakdown +

+
+ {% for action, count in batch_summary.actions.items() %} +
+ {{ action }} + {{ count }} records +
+ {% endfor %} +
+
+ + +
+

+ + Employee Summary +

+
+ {% for emp_id, emp_data in batch_summary.employee_summary.items() %} +
+ {{ emp_data.name }} (ID: {{ emp_id }}) + {{ emp_data.count }} records +
+ {% endfor %} +
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/time_attendance_dashboard.html b/templates/time_attendance_dashboard.html new file mode 100644 index 0000000..ff0051e --- /dev/null +++ b/templates/time_attendance_dashboard.html @@ -0,0 +1,547 @@ +{% extends "base_authenticated.html" %} +{% block title %}Time Attendance Dashboard - {{ COMPANY_NAME }}{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ +
+
+

+ + Time Attendance Dashboard +

+

Manage and analyze employee time attendance data from Excel imports

+
+ +
+ {% if session.role in ['admin', 'payroll', 'accounting'] %} + + + Import Excel Data + + {% endif %} + + + View All Records + +
+
+ + +
+
+
+
+ +
+
+

{{ "{:,}".format(total_records) }}

+

Total Records

+
+
+ +
+
+ +
+
+

{{ "{:,}".format(unique_employees) }}

+

Employees

+
+
+ +
+
+ +
+
+

{{ "{:,}".format(unique_locations) }}

+

Locations

+
+
+ +
+
+ +
+
+

{{ "{:,}".format(recent_imports|length) }}

+

Import Batches

+
+
+
+
+ + {% if recent_records %} +
+
+

+ + Recent Time Attendance Records +

+
+
+ Showing latest {{ recent_records|length }} records +
+
+
+ +
+ + + + + + + + + + + + + + + + + {% for record in recent_records %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% endfor %} + +
IDNamePlatformDateTimeLocation NameAction DescriptionEvent DescriptionRecorded AddressActions
+
+ {{ record.employee_id }} +
+
+
+ {{ record.employee_name or 'Unknown' }} +
+
+ {{ record.platform or 'Unknown' }} + + {{ record.attendance_date.strftime('%Y-%m-%d') }} + + {{ record.attendance_time.strftime('%H:%M:%S') }} + +
+ + {{ record.location_name }} +
+
+ + {% if record.action_description.lower() == 'check in' %} + + {% elif record.action_description.lower() == 'check out' %} + + {% else %} + + {% endif %} + {{ record.action_description }} + + + {{ record.event_description if record.event_description else '-' }} + + {% if record.recorded_address %} + + {{ record.recorded_address[:35] }}{% if record.recorded_address|length > 35 %}...{% endif %} + + {% else %} + No address + {% endif %} + +
+ + {% if session.role == 'admin' %} + + {% endif %} +
+
+
+ + + +
+ {% endif %} + + + {% if recent_imports %} +
+
+

+ + Recent Import Batches +

+
+ Last {{ recent_imports|length }} import batches +
+
+ +
+ + + + + + + + + + + + {% for import_batch in recent_imports %} + + + + + + + + {% endfor %} + +
Import DateSourceRecordsBatch IDActions
+
+ + {{ import_batch.import_date.strftime('%Y-%m-%d %H:%M') if import_batch.import_date else 'Unknown' }} +
+
+
+ + {{ import_batch.import_source or 'Excel Import' }} +
+
+ {{ "{:,}".format(import_batch.record_count) }} + + {{ import_batch.import_batch_id[:8] }}... + +
+ + + + + + + {% if session.role == 'admin' %} + + {% endif %} +
+
+
+
+ {% endif %} + + + {% if total_records == 0 %} +
+
+ +
+

No Time Attendance Data

+

+ Get started by importing your first Excel file with time attendance data. + The system supports various Excel formats (.xlsx, .xls) and will automatically process your data. +

+ {% if session.role == 'admin' %} + + {% endif %} +
+ {% endif %} +
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/time_attendance_duplicate_review.html b/templates/time_attendance_duplicate_review.html new file mode 100644 index 0000000..1538c32 --- /dev/null +++ b/templates/time_attendance_duplicate_review.html @@ -0,0 +1,644 @@ +{% extends "base_authenticated.html" %} +{% block title %}Review Duplicate Records - {{ COMPANY_NAME }}{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block page_title %}Review Duplicate Records{% endblock %} + +{% block content %} +
+ +
+ + +
+

+ + Review Duplicate Records +

+

+ The following records already exist in the system. Please review and select which ones you want to import anyway. +

+
+
+ + +
+

+ + File: {{ filename }} +

+

Review duplicate records before importing

+ +
+
+
{{ analysis.total_records }}
+
Total Records in File
+
+ +
+
{{ analysis.new_records }}
+
New Records
+
+ +
+
{{ analysis.duplicate_records }}
+
Duplicate Records
+
+
+
+ + +
+ + 0 duplicate record(s) selected to import +
+ + + {% if analysis.duplicate_records > 0 %} +
+ + + + + + + +
+ {% for duplicate in analysis.duplicates %} +
+
+
+ Row {{ duplicate.row_number }} +

{{ duplicate.new_record.employee_name }} (ID: {{ duplicate.new_record.employee_id }})

+
+
+ +
+
+ +
+

+ + New Record (From File) +

+
+
+ Employee ID: + {{ duplicate.new_record.employee_id }} +
+
+ Name: + {{ duplicate.new_record.employee_name }} +
+
+ Date: + {{ duplicate.new_record.attendance_date }} +
+
+ Time: + {{ duplicate.new_record.attendance_time }} +
+
+ Location: + {{ duplicate.new_record.location_name }} +
+
+ Action: + {{ duplicate.new_record.action_description }} +
+ {% if duplicate.new_record.platform %} +
+ Platform: + {{ duplicate.new_record.platform }} +
+ {% endif %} + {% if duplicate.new_record.event_description %} +
+ Event: + {{ duplicate.new_record.event_description }} +
+ {% endif %} + {% if duplicate.new_record.recorded_address %} +
+ Address: + {{ duplicate.new_record.recorded_address }} +
+ {% endif %} +
+
+ + +
+

+ + Existing Record (In System) +

+
+
+ Employee ID: + {{ duplicate.existing_record.employee_id }} +
+
+ Name: + {{ duplicate.existing_record.employee_name }} +
+
+ Date: + {{ duplicate.existing_record.attendance_date }} +
+
+ Time: + {{ duplicate.existing_record.attendance_time }} +
+
+ Location: + {{ duplicate.existing_record.location_name }} +
+
+ Action: + {{ duplicate.existing_record.action_description }} +
+ {% if duplicate.existing_record.platform %} +
+ Platform: + {{ duplicate.existing_record.platform }} +
+ {% endif %} + {% if duplicate.existing_record.event_description %} +
+ Event: + {{ duplicate.existing_record.event_description }} +
+ {% endif %} + {% if duplicate.existing_record.recorded_address %} +
+ Address: + {{ duplicate.existing_record.recorded_address }} +
+ {% endif %} +
+ Import Date: + + + {{ duplicate.existing_record.import_date.strftime('%Y-%m-%d %H:%M') if duplicate.existing_record.import_date else 'Unknown' }} + +
+ {% if duplicate.existing_record.import_source %} +
+ Import Source: + {{ duplicate.existing_record.import_source }} +
+ {% endif %} +
+
+
+ + +
+ + +
+
+
+ {% endfor %} +
+ + +
+ + +
+ + + +
+
+
+ {% else %} +
+ +

No Duplicates Found

+

All records in the file are unique. You can proceed with the import.

+ + + Back to Import + +
+ {% endif %} +
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/time_attendance_import.html b/templates/time_attendance_import.html new file mode 100644 index 0000000..1846193 --- /dev/null +++ b/templates/time_attendance_import.html @@ -0,0 +1,1005 @@ +{% extends "base_authenticated.html" %} +{% block title %}Import Time Attendance Data - {{ COMPANY_NAME }}{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+ +
+ + +
+

+ + Import Time Attendance Data +

+

+ Upload Excel files containing employee time attendance records with enhanced validation +

+
+ + +
+ + +
+
+

+ + Upload Excel File +

+
+
+
+ + + +
+

+ + Select Project * +

+ +

+ + Required: Select which project this import data belongs to for filtering and reporting +

+
+ + +
+
+ +
+
+

Drag & Drop Excel Files

+

Or click to browse and select Excel files (.xlsx, .xls). Multiple files supported.

+ +
+ +
+ + + + + +
+
+ + + Optional description for this import batch +
+
+ + +
+
+
+ Uploading & preparing import... +
+
+
+ 0% +
+
+
+ 0 of 0 records processed + 0% +
+
Starting...
+
+
+ + +
+ + +
+
+
+
+ + +
+
+

+ + Import Instructions +

+
+
+
+
+
+ +
+

Supported Formats

+

Excel files (.xlsx, .xls) with time attendance data

+
+ +
+
+ +
+

Required Columns

+

ID, Date, Time, Location Name, Action Description

+
+ +
+
+ +
+

Smart Validation

+

Automatic duplicate detection and data validation

+
+ +
+
+ +
+

Duplicate Handling

+

Automatically skip duplicate records during import

+
+
+ +
+

+ + Expected Excel Format +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDNamePlatformDateTimeLocation NameAction DescriptionEvent DescriptionRecorded AddressDistance
12345John DoeiPhone - iOS2025-10-0609:00:00HQ Suite 210Check InMain Office123 Main St0.125
67890Jane SmithAndroid2025-10-0617:30:00HQ Suite 210Check OutMain Office123 Main St0.125
+
+
+
+
+ + + {% if validation_result %} +
+
+

+ {% if validation_result.valid %} + + File Validation Successful + {% else %} + + File Validation Failed + {% endif %} +

+
+
+
+
+
{{ validation_result.total_rows }}
+
Total Rows
+
+
+
{{ validation_result.valid_rows }}
+
Valid Rows
+
+ {% if validation_result.invalid_rows > 0 %} +
+
{{ validation_result.invalid_rows }}
+
Invalid Rows
+
+ {% endif %} +
+
{{ validation_result.columns|length }}
+
Columns Found
+
+
+ + {% if validation_result.file_info %} +

File Size: {{ validation_result.file_info.size_mb }} MB

+ {% endif %} + + {% if validation_result.errors %} +
+

+ + Validation Errors +

+
    + {% for error in validation_result.errors %} +
  • {{ error }}
  • + {% endfor %} +
+
+ {% endif %} + + {% if validation_result.warnings %} +
+

+ + Warnings +

+
    + {% for warning in validation_result.warnings %} +
  • {{ warning }}
  • + {% endfor %} +
+
+ {% endif %} + + {% if validation_result.sample_data %} +
+

+ + Sample Data Preview (First 3 Rows) +

+
+ + + + {% for column in validation_result.columns %} + + {% endfor %} + + + + {% for row in validation_result.sample_data[:3] %} + + {% for column in validation_result.columns %} + + {% endfor %} + + {% endfor %} + +
{{ column }}
{{ row.get(column, '') }}
+
+
+ {% endif %} +
+
+ {% endif %} + + + {% if import_result %} +
+
+

+ {% if import_result.success %} + + Import Completed + {% else %} + + Import Failed + {% endif %} +

+
+
+
+
+
{{ import_result.total_records }}
+
Total Records
+
+
+
{{ import_result.imported_records }}
+
Successfully Imported
+
+ {% if import_result.duplicate_records > 0 %} +
+
{{ import_result.duplicate_records }}
+
Duplicates Skipped
+
+ {% endif %} + {% if import_result.failed_records > 0 %} +
+
{{ import_result.failed_records }}
+
Failed Records
+
+ {% endif %} +
+ + {% if import_result.success %} +
+

+ Batch ID: {{ import_result.batch_id }} +

+ +
+ {% endif %} + + {% if import_result.errors %} +
+

+ + Import Errors (Showing first 10) +

+
    + {% for error in import_result.errors[:10] %} +
  • {{ error }}
  • + {% endfor %} + {% if import_result.errors|length > 10 %} +
  • ...and {{ import_result.errors|length - 10 }} more errors
  • + {% endif %} +
+
+ {% endif %} + + {% if import_result.warnings %} +
+

+ + Import Warnings (Showing first 10) +

+
    + {% for warning in import_result.warnings[:10] %} +
  • {{ warning }}
  • + {% endfor %} + {% if import_result.warnings|length > 10 %} +
  • ...and {{ import_result.warnings|length - 10 }} more warnings
  • + {% endif %} +
+
+ {% endif %} +
+
+ {% endif %} +
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/time_attendance_import_progress.html b/templates/time_attendance_import_progress.html new file mode 100644 index 0000000..b75470a --- /dev/null +++ b/templates/time_attendance_import_progress.html @@ -0,0 +1,307 @@ +{% extends "base_authenticated.html" %} +{% block title %}Importing Data - {{ COMPANY_NAME }}{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block page_title %}Importing Time Attendance Data{% endblock %} + +{% block content %} +
+
+
+

+ + Importing Data +

+ {% if filename is string and ',' in filename %} +

Files: {{ filename.split(', ')|length }} files

+

{{ filename }}

+ {% else %} +

File: {{ filename }}

+ {% endif %} +
+ + +
+ + + + +
0%
+
+ + +
+ + Initializing import... +
+ + +
+
+ Total Records: + {{ total_rows }} +
+
+ Processed: + 0 +
+
+ Remaining: + {{ total_rows }} +
+
+ + +
+

+ + Import Completed Successfully! +

+ +
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/time_attendance_import_result.html b/templates/time_attendance_import_result.html new file mode 100644 index 0000000..dfdfc6b --- /dev/null +++ b/templates/time_attendance_import_result.html @@ -0,0 +1,853 @@ +{% extends "base_authenticated.html" %} +{% block title %}Import Results - {{ COMPANY_NAME }}{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block page_title %}Import Results{% endblock %} + +{% block content %} +
+ +
+ + +
+

+ {% if import_result.success %} + + Import Completed Successfully + {% else %} + + Import Failed + {% endif %} +

+

+ {% if import_result.success %} + Your time attendance data has been successfully imported into the system. + {% else %} + There were issues importing your time attendance data. Please review the errors below. + {% endif %} +

+
+ +
+ {% if import_result.success %} + + + View Imported Records + + {% endif %} + + + Dashboard + +
+
+ + +
+
+

+ + Import Summary +

+
+
+
+
+
+ +
+
+
{{ import_result.total_records }}
+
Total Records
+
+
+ +
+
+ +
+
+
{{ import_result.imported_records }}
+
Successfully Imported
+
+
+ + {% if import_result.failed_records > 0 %} +
+
+ +
+
+
{{ import_result.failed_records }}
+
Failed Records
+
+
+ {% endif %} + +
+
+ +
+
+
+ {{ "%.1f"|format((import_result.imported_records / import_result.total_records * 100) if import_result.total_records > 0 else 0) }}% +
+
Success Rate
+
+
+
+ + + {% if import_result.success %} +
+

+ + Import Details +

+
+
+ Batch ID: + {{ import_result.batch_id }} +
+
+ Import Date: + {{ import_result.import_date.strftime('%Y-%m-%d %H:%M:%S') }} +
+
+ Processing Time: + {{ "%.2f"|format((import_result.import_date - import_result.import_date).total_seconds()) }} seconds +
+
+
+ {% endif %} + + + {% if import_result.errors %} +
+

+ + Error Details +

+
+ {% for error in import_result.errors %} +
+ + {{ error }} +
+ {% endfor %} +
+
+ {% endif %} + + + {% if import_result.success %} + + {% endif %} + + + {% if not import_result.success %} +
+

+ + Try Again +

+

Here are some suggestions to resolve the import issues:

+
+
+ + Ensure your Excel file has all required columns: ID, Name, Date, Time, Location Name, Action Description +
+
+ + Check that dates are in YYYY-MM-DD format and times are in HH:MM:SS format +
+
+ + Verify that your file is a valid Excel format (.xlsx or .xls) +
+
+ + Make sure employee IDs and location names are properly formatted +
+
+ + +
+ {% endif %} +
+
+ + + {% if import_result.success and import_result.imported_records > 0 %} +
+
+

+ + Import Statistics +

+
+
+
+
+
+

Records by Action

+
+
+
+ Check In +
+
+
+ 75% +
+
+ Check Out +
+
+
+ 25% +
+
+
+ +
+
+

Processing Speed

+
+
+
+
{{ import_result.imported_records }}
+
records/minute
+
+
+ Fast and efficient processing of your attendance data +
+
+
+
+
+
+ {% endif %} +
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/time_attendance_invalid_review.html b/templates/time_attendance_invalid_review.html new file mode 100644 index 0000000..990cee3 --- /dev/null +++ b/templates/time_attendance_invalid_review.html @@ -0,0 +1,518 @@ +{% extends "base_authenticated.html" %} +{% block title %}Review Invalid Rows - {{ COMPANY_NAME }}{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block page_title %}Review Invalid Rows{% endblock %} + +{% block content %} +
+ +
+ + +
+

+ + Review Invalid Rows +

+

+ The following rows contain errors and cannot be imported. Please review the issues below. +

+
+
+ + +
+

+ + File: {{ filename }} +

+

Review invalid rows before importing valid records

+ +
+
+
{{ analysis.total_rows }}
+
Total Rows in File
+
+ +
+
{{ analysis.valid_rows }}
+
Valid Rows
+
+ +
+
{{ analysis.invalid_rows }}
+
Invalid Rows
+
+
+
+ + +
+ +
+

What happens next?

+

+ The invalid rows shown below will be skipped during import. Only the {{ analysis.valid_rows }} valid rows + will be imported into the system. You can proceed with importing the valid records, or cancel and fix the issues in your Excel file. +

+
+
+ + + {% if analysis.invalid_rows > 0 %} +
+ + + + + + + + + +
+ {% for invalid in analysis.invalid_details %} +
+
+
+ Row {{ invalid.row_number }} +

+ {% if invalid.row_data.employee_name %} + {{ invalid.row_data.employee_name }} + {% if invalid.row_data.employee_id %}(ID: {{ invalid.row_data.employee_id }}){% endif %} + {% else %} + Invalid Record + {% endif %} +

+
+ + {{ invalid.errors|length }} Error{% if invalid.errors|length != 1 %}s{% endif %} + +
+ +
+ +
+

+ + Validation Errors +

+
    + {% for error in invalid.errors %} +
  • {{ error }}
  • + {% endfor %} +
+
+ + +
+

+ + Row Data +

+
+
+ Employee ID: + + {{ invalid.row_data.employee_id or 'Missing' }} + +
+
+ Name: + + {{ invalid.row_data.employee_name or 'Missing' }} + +
+
+ Date: + + {{ invalid.row_data.attendance_date or 'Missing' }} + +
+
+ Time: + + {{ invalid.row_data.attendance_time or 'Missing' }} + +
+
+ Location: + + {{ invalid.row_data.location_name or 'Missing' }} + +
+
+ Action: + + {{ invalid.row_data.action_description or 'Missing' }} + +
+ {% if invalid.row_data.platform %} +
+ Platform: + {{ invalid.row_data.platform }} +
+ {% endif %} + {% if invalid.row_data.event_description %} +
+ Event: + {{ invalid.row_data.event_description }} +
+ {% endif %} + {% if invalid.row_data.recorded_address %} +
+ Address: + {{ invalid.row_data.recorded_address }} +
+ {% endif %} +
+
+
+
+ {% endfor %} +
+ + +
+ +
+ +
+
+
+ {% else %} +
+ +

All rows are valid!

+

No invalid rows found. You can proceed with the import.

+ + + Back to Import + +
+ {% endif %} +
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/time_attendance_record_detail.html b/templates/time_attendance_record_detail.html new file mode 100644 index 0000000..7776f1e --- /dev/null +++ b/templates/time_attendance_record_detail.html @@ -0,0 +1,903 @@ +{% extends "base_authenticated.html" %} +{% block title %}Record Details - {{ record.employee_name }} - {{ COMPANY_NAME }}{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block page_title %}Record Details{% endblock %} + +{% block content %} +
+ +
+ + +
+

+ + Time Attendance Record Details +

+

+ Detailed information for {{ record.employee_name }} +

+
+ +
+ {% if session.role == 'admin' %} + + {% endif %} + +
+
+ + +
+ + +
+
+

+ + Attendance Information +

+
+
+
+ +
+
+ + ID +
+
+ {{ record.employee_id }} +
+
+ + +
+
+ + Name +
+
+ {{ record.employee_name }} +
+
+ + +
+
+ + Platform +
+
+ {{ record.platform if record.platform else 'Not specified' }} +
+
+ + +
+
+ + Date +
+
+ {{ record.attendance_date.strftime('%Y-%m-%d') }} +
+
+ + +
+
+ + Time +
+
+ {{ record.attendance_time.strftime('%H:%M:%S') }} +
+
+ + +
+
+ + Location Name +
+
+ {{ record.location_name }} +
+
+ + +
+
+ + Action Description +
+
+ + {% if record.action_description.lower() == 'check in' %} + + {% elif record.action_description.lower() == 'check out' %} + + {% else %} + + {% endif %} + {{ record.action_description }} + +
+
+ + + {% if record.event_description %} +
+
+ + Event Description +
+
+ {{ record.event_description }} +
+
+ {% endif %} + + +
+
+ + Recorded Address +
+
+ {% if record.recorded_address %} + {{ record.recorded_address }} + {% else %} + No address recorded + {% endif %} +
+
+
+
+
+ + +
+
+

+ + Import Information +

+
+
+
+ {% if record.import_batch_id %} +
+
+ + Batch ID +
+
+ {{ record.import_batch_id }} +
+
+ {% endif %} + + {% if record.import_source %} +
+
+ + Import Source +
+
+ {{ record.import_source }} +
+
+ {% endif %} + +
+
+ + Import Date +
+
+ {{ record.import_date.strftime('%Y-%m-%d %H:%M:%S') if record.import_date else 'Unknown' }} +
+
+ +
+
+ + Created Date +
+
+ {{ record.created_date.strftime('%Y-%m-%d %H:%M:%S') if record.created_date else 'Unknown' }} +
+
+
+
+
+ + + +
+
+ + +{% if session.role == 'admin' %} + +{% endif %} + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/time_attendance_records.html b/templates/time_attendance_records.html new file mode 100644 index 0000000..afa980a --- /dev/null +++ b/templates/time_attendance_records.html @@ -0,0 +1,1208 @@ +{% extends "base_authenticated.html" %} +{% block title %}Time Attendance Records - {{ COMPANY_NAME }}{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+ +
+ + +
+

+ + Time Attendance Records +

+

+ View and manage imported time attendance data +

+
+ +
+ {% if session.role == 'admin' %} + + + Import Data + + {% endif %} + + + +
+
+ + +
+
+
+

+ + Filter Records +

+
+
+
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ {% for emp in employee_display_names %} + + {{ emp.name }} + + + {% endfor %} + + {% if employee_filter %} + + {% endif %} +
+
+
+
+ +
+ + +
+ +
+
+
+
+
+ + +
+
+

+ + Attendance Records +

+
+ {% if records.items %} + Showing {{ records.per_page * (records.page - 1) + 1 }} - + {{ records.per_page * (records.page - 1) + records.items|length }} + of {{ records.total }} records + {% else %} + No records found + {% endif %} +
+
+ + {% if records.items %} +
+ + + + + + + + + + + + + + + + + {% for record in records.items %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% endfor %} + +
IDNamePlatformDateTimeLocation NameAction DescriptionEvent DescriptionRecorded AddressActions
+
+ {{ record.employee_id }} +
+
+
+ {{ record.resolved_employee_name }} +
+
+ {{ record.platform or 'Unknown' }} + + {{ record.attendance_date.strftime('%Y-%m-%d') }} + + {{ record.attendance_time.strftime('%H:%M:%S') }} + +
+ + {{ record.location_name }} +
+
+ + {% if record.action_description.lower() == 'check in' %} + + {% elif record.action_description.lower() == 'check out' %} + + {% else %} + + {% endif %} + {{ record.action_description }} + + + {{ record.event_description if record.event_description else '-' }} + + {% if record.recorded_address %} + + {{ record.recorded_address[:40] }}{% if record.recorded_address|length > 40 %}...{% endif %} + + {% else %} + No address + {% endif %} + +
+ + + View + + {% if session.role == 'admin' %} + + {% endif %} +
+
+
+ + + {% if records.pages > 1 %} + + {% endif %} + + {% else %} +
+ +

No attendance records found matching your criteria.

+
+ {% endif %} +
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/users.html b/templates/users.html new file mode 100644 index 0000000..df1810d --- /dev/null +++ b/templates/users.html @@ -0,0 +1,575 @@ +{% extends "base_authenticated.html" %} {% block title %}User Management - QR +Code Management{% endblock %} {% block extra_head %} + + +{% endblock %} {% block content %} +
+
+
+

+ + User Management +

+

Manage system users and their access permissions

+
+ + +
+ + +
+
+
+ +
+
+

+ {{ users|selectattr('role', 'equalto', + 'admin')|selectattr('active_status', 'equalto', True)|list|length }} +

+

Active Administrators

+
+
+ +
+
+ +
+
+

+ {{ users|selectattr('role', 'equalto', + 'staff')|selectattr('active_status', 'equalto', True)|list|length }} +

+

Active Staff Members

+
+
+ +
+
+ +
+
+

+ {{ users|selectattr('role', 'equalto', + 'payroll')|selectattr('active_status', 'equalto', True)|list|length }} +

+

Payroll Specialists

+
+
+ +
+
+ +
+
+

+ {{ users|selectattr('role', 'equalto', + 'accounting')|selectattr('active_status', 'equalto', True)|list|length }} +

+

Accounting Specialists

+
+
+ +
+
+ +
+
+

+ {{ users|selectattr('role', 'equalto', + 'project_manager')|selectattr('active_status', 'equalto', + True)|list|length }} +

+

Project Managers

+
+
+ +
+
+ +
+
+

+ {{ users|selectattr('active_status', 'equalto', True)|list|length }} +

+

Total Active Users

+
+
+ +
+
+ +
+
+

+ {{ users|selectattr('active_status', 'equalto', False)|list|length }} +

+

Inactive Users

+
+
+
+ + +
+
+ + +
+ + + +
+
+ +
+ +
+
+ + +
+ + + + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% endfor %} + +
#User InformationContactRoleStatusQR CodesCreatedLast LoginActions
{{ loop.index }} + + +
+ + {{ user.email }} +
+
+
+ {% if user.role == 'admin' %} + + Administrator {% elif user.role == 'staff' %} + + Staff User {% elif user.role == 'payroll' %} + + Payroll Specialist {% elif user.role == 'project_manager' %} + + Project Manager {% else %} + + {{ user.role.title() }} {% endif %} +
+
+
+ + {{ 'Active' if user.active_status else 'Inactive' }} +
+
+
+ {{ user.created_qr_codes.filter_by(active_status=True).count() + }} + QR Codes +
+
+ {% if user.created_date %} +
+
+ {{ user.created_date.strftime('%m/%d/%Y') }} +
+
+ {{ user.created_date.strftime('%I:%M %p') }} +
+
+ {% else %} + Unknown + {% endif %} +
+ {% if user.last_login_date %} +
+
+ {{ user.last_login_date.strftime('%m/%d/%Y') }} +
+
+ {{ user.last_login_date.strftime('%I:%M %p') }} +
+
+ {% else %} + Never + {% endif %} +
+
+ + + {% if not users %} +
+
+ +

No Users Found

+

There are currently no users in the system.

+ + + Create First User + +
+
+ {% endif %} +
+{% endblock %} {% block extra_scripts %} + +{% endblock %} diff --git a/templates/verification_review.html b/templates/verification_review.html new file mode 100644 index 0000000..5cfe962 --- /dev/null +++ b/templates/verification_review.html @@ -0,0 +1,971 @@ +{% extends "base_authenticated.html" %} + +{% block title %}Photo Verification Review{% endblock %} + +{% block content %} +
+ + + + +
+
+
+ +
+
+

{{ pending_count }}

+

Pending Review

+
+
+ +
+
+ +
+
+

{{ approved_count }}

+

Approved

+
+
+ +
+
+ +
+
+

{{ rejected_count }}

+

Rejected

+
+
+
+ + +
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + Reset + +
+
+
+
+ + +
+ {% if verifications %} +
+ {% for record in verifications %} +
+
+
+

+ + {{ record.employee_id }} + {% if employee_names.get(record.employee_id) %} + - {{ employee_names.get(record.employee_id) }} + {% endif %} +

+
+ + + {{ record.check_in_date.strftime('%b %d, %Y') }} + + + + {{ record.check_in_time.strftime('%I:%M %p') }} + + + + {{ record.location_name }} + + {% if record.qr_code and record.qr_code.location_event %} + + + {{ record.qr_code.location_event }} + + {% endif %} +
+ {% if record.qr_code and record.qr_code.project_id and project_names.get(record.qr_code.project_id) %} +
+ + + {{ project_names.get(record.qr_code.project_id) }} + +
+ {% endif %} +
+
+ {% if record.verification_status == 'pending' %} + + Pending + + {% elif record.verification_status == 'approved' %} + + Approved + + {% elif record.verification_status == 'rejected' %} + + Rejected + + {% endif %} +
+
+ +
+
+
+ {% if record.verification_photo %} + Verification Photo + {% else %} +
+ +

No photo available

+
+ {% endif %} +
+ {% if record.verification_photo %} +
+ + + + + +
+ {% endif %} +
+ +
+
+ + Distance from Location: + + + {% if record.location_accuracy %} + {{ "%.3f"|format(record.location_accuracy) }} miles + {% else %} + N/A + {% endif %} + +
+ +
+ + QR Location: + + + {{ record.qr_code.location_address if record.qr_code.location_address else 'N/A' }} + +
+ +
+ + Check-in Address: + + + {{ record.address if record.address else 'N/A' }} + +
+ + {% if record.latitude and record.longitude %} + + {% endif %} + +
+ + Verification Submitted: + + + {{ record.verification_timestamp.strftime('%b %d, %Y %I:%M %p') if record.verification_timestamp else 'N/A' }} + +
+ + {% if record.edit_note %} +
+ + Note: + + + {{ record.edit_note }} + +
+ {% endif %} +
+
+ + {% if record.verification_status == 'pending' %} +
+ + +
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +
+ +

No Verifications Found

+

There are no photo verifications matching your current filters.

+
+ {% endif %} +
+
+ + + + + + +
+
+ + +
+
Click anywhere outside the image to close  ·  ESC to dismiss
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/verification_review_detail.html b/templates/verification_review_detail.html new file mode 100644 index 0000000..305dd06 --- /dev/null +++ b/templates/verification_review_detail.html @@ -0,0 +1,620 @@ +{% extends "base_authenticated.html" %} {% block title %}Verification Review - +QR Code Management{% endblock %} {% block extra_head %} + +{% endblock %} {% block content %} +
+ +
+

+ + Verification Photo Review +

+

+ Review and approve/reject employee verification photo for off-site + check-in +

+
+ + + {% if record.verification_status != 'pending' %} +
+ + This verification has already been {{ record.verification_status + }}. +
+ {% endif %} + + +
+ +
+

+ + Employee Information +

+
+ Employee ID + {{ record.employee_id }} +
+
+ Employee Name + {{ employee_name or 'Unknown' }} +
+
+ Check-in Date + {{ check_in_date }} +
+
+ Check-in Time + {{ check_in_time }} +
+
+ Status + + + {{ record.verification_status.upper() }} + + +
+
+ + +
+

+ + Location Information +

+
+ Location Name + {{ record.location_name or 'Unknown' }} +
+
+ Event + {{ location_event or 'N/A' }} +
+
+ Distance from QR + + {% if record.location_accuracy %} {% if record.location_accuracy > 0.5 + %} + {{ "%.3f"|format(record.location_accuracy) }} miles + {% elif record.location_accuracy > 0.2 %} + {{ "%.3f"|format(record.location_accuracy) }} miles + {% else %} + {{ "%.3f"|format(record.location_accuracy) }} miles + {% endif %} {% else %} N/A {% endif %} + +
+
+ QR Address + {{ qr_code.location_address if qr_code else 'N/A' }} +
+
+ Check-in Address + {{ record.address or 'N/A' }} +
+
+ Device + {{ record.device_info or 'Unknown' }} +
+
+ + +
+

+ + Verification Photo +

+ {% if record.verification_photo %} + Verification Photo for {{ record.employee_id }} +
+ + +
+ {% else %} +
+ +

No Photo Available

+

This record does not have a verification photo.

+
+ {% endif %} +
+
+ + + {% if record.verification_status == 'pending' %} +
+ + + + + Back to Attendance + +
+ {% else %} + + {% endif %} +
+ + +{% endblock %} diff --git a/time_attendance_import_service.py b/time_attendance_import_service.py new file mode 100644 index 0000000..e071bd0 --- /dev/null +++ b/time_attendance_import_service.py @@ -0,0 +1,1326 @@ +""" +Enhanced Time Attendance Import Service with Duplicate Review +============================================================ + +Added functionality to detect and present duplicates for user review. +""" + +import pandas as pd +import uuid +from datetime import datetime, time +from sqlalchemy.exc import SQLAlchemyError +from typing import Dict, List, Any, Optional, Tuple +import traceback +import hashlib + +class TimeAttendanceImportService: + """Enhanced service with duplicate detection and review""" + + def __init__(self, db, logger_handler=None): + """Initialize the import service with database and logger""" + self.db = db + self.logger = logger_handler + + def _update_progress(self, current: int, total: int, status: str = "Processing"): + """ + Update progress information for real-time tracking + + Args: + current: Current record number being processed + total: Total number of records + status: Status message + """ + if hasattr(self, 'progress_callback') and self.progress_callback: + percentage = int((current / total) * 100) if total > 0 else 0 + self.progress_callback({ + 'current': current, + 'total': total, + 'percentage': percentage, + 'status': status + }) + + def _read_excel_with_formulas(self, file_path: str) -> pd.DataFrame: + """ + Read Excel file preserving HYPERLINK formulas in Recorded Address column + Uses openpyxl to extract formulas, then creates DataFrame + + Args: + file_path: Path to the Excel file + + Returns: + DataFrame with formulas preserved + """ + from openpyxl import load_workbook + + # Load workbook with openpyxl to get formulas (data_only=False preserves formulas) + wb = load_workbook(file_path, data_only=False) + ws = wb.active + + # Get header row + headers = [] + for cell in ws[1]: + headers.append(cell.value) + + # Find the index of 'Recorded Address' column + recorded_address_idx = None + try: + recorded_address_idx = headers.index('Recorded Address') + except ValueError: + pass # Column doesn't exist + + # Read all data rows + data_rows = [] + for row in ws.iter_rows(min_row=2, values_only=False): + row_data = [] + for col_idx, cell in enumerate(row): + # For Recorded Address column, preserve the formula if it exists + if col_idx == recorded_address_idx and cell.value: + # Check if cell contains a formula + if isinstance(cell.value, str) and cell.value.startswith('='): + # This is a formula, keep it as-is + row_data.append(cell.value) + if self.logger: + self.logger.logger.debug(f"Found formula in Recorded Address: {cell.value[:60]}...") + else: + # Regular value + row_data.append(cell.value) + else: + # For other columns, just get the value + row_data.append(cell.value) + + # Skip completely empty rows + if any(val is not None for val in row_data): + data_rows.append(row_data) + + # Create DataFrame + df = pd.DataFrame(data_rows, columns=headers) + + if self.logger: + self.logger.logger.info(f"Read Excel with formulas preserved: {len(df)} rows, {len(headers)} columns") + + return df + + def _parse_excel_hyperlink(self, cell_value: str) -> str: + """ + Parse Excel HYPERLINK formula to extract the display text (address) + + Handles formats like: + - =HYPERLINK("http://maps.google.com/maps?q=38.8769894000,-77.2220616000","2815 Hartland Road, Falls Church, VA 22043") + - Regular text (no formula) + + Args: + cell_value: The cell value which may contain a HYPERLINK formula + + Returns: + Extracted address text or original value if not a hyperlink + """ + if not cell_value or not isinstance(cell_value, str): + return cell_value + + cell_value = cell_value.strip() + + # Check if it's a HYPERLINK formula + if cell_value.startswith('=HYPERLINK('): + try: + import re + + # Match the display text (second quoted string) + # Pattern: =HYPERLINK("url","display_text") + pattern = r'=HYPERLINK\s*\(\s*"[^"]*"\s*,\s*"([^"]*)"\s*\)' + match = re.search(pattern, cell_value) + + if match: + address_text = match.group(1).strip() + if self.logger: + self.logger.logger.debug(f"Parsed HYPERLINK: '{cell_value[:60]}...' -> '{address_text}'") + return address_text + else: + # Fallback: try to extract text between last pair of quotes + # Find all quoted strings + quoted_strings = re.findall(r'"([^"]*)"', cell_value) + if len(quoted_strings) >= 2: + # The address is typically the last quoted string + address_text = quoted_strings[-1].strip() + if self.logger: + self.logger.logger.debug(f"Parsed HYPERLINK (fallback): '{cell_value[:60]}...' -> '{address_text}'") + return address_text + else: + if self.logger: + self.logger.logger.warning(f"Could not parse HYPERLINK formula: {cell_value[:60]}...") + return None + + except Exception as e: + if self.logger: + self.logger.logger.error(f"Failed to parse HYPERLINK formula: {cell_value[:60]}... Error: {e}") + return None + + # Not a hyperlink formula, return as-is + return cell_value if cell_value else None + + def _parse_distance_field(self, row) -> Optional[float]: + """ + Parse Distance field from Excel (optional column) + + Args: + row: DataFrame row containing the 'Distance' column + + Returns: + Distance value as float in miles, or None if not present/invalid + """ + # Check if Distance column exists + if 'Distance' not in row.index: + return None + + distance_value = row.get('Distance') + + # Check if value exists and is not NaN + if pd.isna(distance_value): + return None + + # Try to parse as float + try: + # Handle string values + if isinstance(distance_value, str): + distance_str = distance_value.strip() + + # Skip empty strings + if not distance_str or distance_str.lower() in ['', 'n/a', 'na', 'none']: + return None + + # Remove any text like "miles", "mi", "m" + distance_str = distance_str.lower() + distance_str = distance_str.replace('miles', '').replace('mile', '').replace('mi', '').replace('m', '').strip() + + # Parse the number + distance_float = float(distance_str) + else: + # Already a number + distance_float = float(distance_value) + + # Validate the distance is reasonable (0 to 1000 miles) + if 0 <= distance_float <= 1000: + return distance_float + else: + if self.logger: + self.logger.logger.warning(f"Distance value {distance_float} is out of reasonable range") + return None + + except (ValueError, TypeError) as e: + if self.logger: + self.logger.logger.warning(f"Could not parse distance value '{distance_value}': {e}") + return None + + def _process_recorded_address(self, row) -> Optional[str]: + """ + Process Recorded Address field from Excel - handles HYPERLINK formulas + + Args: + row: DataFrame row containing the 'Recorded Address' column + + Returns: + Cleaned address text, or None if not present/invalid + """ + # Check if Recorded Address column exists + if 'Recorded Address' not in row.index: + return None + + address_value = row.get('Recorded Address') + + # Check if value exists and is not NaN + if pd.isna(address_value): + return None + + # Parse HYPERLINK formula if present, or return raw value + parsed_address = self._parse_excel_hyperlink(address_value) + + # Clean and return + if parsed_address: + return str(parsed_address).strip() + else: + return None + + def _generate_record_hash(self, record_data: Dict[str, Any]) -> str: + """ + Generate unique hash for a time attendance record + + Args: + record_data: Dictionary containing record data + + Returns: + SHA-256 hash string + """ + hash_string = ( + f"{record_data['employee_id']}-" + f"{record_data['attendance_date']}-" + f"{record_data['attendance_time']}-" + f"{record_data['location_name']}-" + f"{record_data['action_description']}" + ) + + return hashlib.sha256(hash_string.encode()).hexdigest() + + def _get_existing_record_hashes(self) -> set: + """ + Get hashes of all existing time attendance records + + Returns: + Set of hash strings + """ + try: + from models.time_attendance import TimeAttendance + + records = TimeAttendance.query.all() + hashes = set() + + for record in records: + record_data = { + 'employee_id': record.employee_id, + 'attendance_date': record.attendance_date, + 'attendance_time': record.attendance_time, + 'location_name': record.location_name, + 'action_description': record.action_description + } + hashes.add(self._generate_record_hash(record_data)) + + return hashes + + except Exception as e: + if self.logger: + self.logger.logger.error(f"Failed to get existing record hashes: {e}") + return set() + + def _get_existing_record_hashes_with_data(self) -> Dict[str, Dict]: + """ + Get hashes with corresponding record data for duplicate comparison + + Returns: + Dictionary mapping hash to record data + """ + try: + from models.time_attendance import TimeAttendance + + records = TimeAttendance.query.all() + hash_map = {} + + for record in records: + record_data = { + 'employee_id': record.employee_id, + 'employee_name': record.employee_name, + 'attendance_date': record.attendance_date, + 'attendance_time': record.attendance_time, + 'location_name': record.location_name, + 'action_description': record.action_description + } + record_hash = self._generate_record_hash(record_data) + hash_map[record_hash] = record_data + + return hash_map + + except Exception as e: + if self.logger: + self.logger.logger.error(f"Failed to get existing record hashes with data: {e}") + return {} + + def analyze_for_duplicates(self, file_path: str, project_id: int = None) -> Dict[str, Any]: + """ + Analyze file for potential duplicates WITHOUT importing. + + Args: + file_path: Path to the Excel file + project_id: Optional project ID — if supplied, every Location Name + in the file is validated against that project's QR code + locations before the duplicate analysis proceeds. + + Returns: + Dictionary containing duplicate analysis + """ + analysis_result = { + 'success': False, + 'total_records': 0, + 'new_records': 0, + 'duplicate_records': 0, + 'duplicates': [], + 'errors': [] + } + + try: + # Read Excel file + df = self._read_excel_with_formulas(file_path) + + # Validate required columns + required_columns = ['ID', 'Date', 'Time', 'Location Name', 'Action Description'] + missing_columns = [col for col in required_columns if col not in df.columns] + + if missing_columns: + analysis_result['errors'].append(f"Missing columns: {', '.join(missing_columns)}") + return analysis_result + + # Remove empty rows + df = df.dropna(how='all') + analysis_result['total_records'] = len(df) + + # ── Project-location validation ────────────────────────────── + if project_id: + try: + from models.qrcode import QRCode + from models.project import Project + file_locations = set( + str(loc).strip() + for loc in df['Location Name'].dropna().unique() + if str(loc).strip() + ) + project_locations = set( + qr.location + for qr in QRCode.query.filter_by(project_id=project_id) + .with_entities(QRCode.location).all() + ) + unmatched = next( + (loc for loc in sorted(file_locations) if loc not in project_locations), + None + ) + if unmatched: + project_obj = self.db.session.get(Project, project_id) + project_name = project_obj.name if project_obj else f'ID {project_id}' + error_msg = ( + f"The data in the file does not belong to the project '{project_name}'. " + f"Please verify the selected project or correct the file." + ) + analysis_result['errors'].append(error_msg) + if self.logger: + self.logger.logger.warning(f"Project-location mismatch during duplicate analysis: {error_msg}") + return analysis_result + if self.logger: + self.logger.logger.info( + f"Project-location validation passed (duplicate analysis): " + f"all {len(file_locations)} location(s) belong to project ID {project_id}." + ) + except Exception as e: + if self.logger: + self.logger.logger.warning(f"Could not perform project-location validation (duplicate analysis): {e}") + # ── End project-location validation ────────────────────────── + + # Get existing record hashes + existing_hashes = self._get_existing_record_hashes_with_data() + + # Process each row + duplicates_list = [] + new_records_count = 0 + + # Process each row with enhanced validation + for index, row in df.iterrows(): + # Update progress every 10 records or on last record + if (index + 1) % 10 == 0 or (index + 1) == len(df): + self._update_progress( + index + 1, + len(df), + f"Processing row {index + 2} of {len(df) + 1}" + ) + + try: + # Skip empty rows - only ID is required + if pd.isna(row['ID']): + continue + + # Clean the employee ID first (handles float issues like '1234.0') + clean_id = self._clean_employee_id(row['ID']) + + # Get employee name - either from Excel or lookup from employee table + employee_name = None + if 'Name' in df.columns and pd.notna(row.get('Name')): + employee_name = str(row['Name']).strip() + else: + # Lookup employee name from employee table using cleaned ID + employee_name = self._get_employee_name(clean_id) + + # Parse date and time + attendance_date = pd.to_datetime(row['Date']).date() + attendance_time = self._parse_time_field(row['Time']) + + # Prepare record data + record_data = { + 'employee_id': clean_id, + 'employee_name': employee_name, + 'platform': str(row.get('Platform', '')).strip() if pd.notna(row.get('Platform')) else None, + 'attendance_date': attendance_date, + 'attendance_time': attendance_time, + 'location_name': str(row['Location Name']).strip(), + 'action_description': str(row['Action Description']).strip(), + 'event_description': str(row.get('Event Description', '')).strip() if pd.notna(row.get('Event Description')) else None, + 'recorded_address': self._process_recorded_address(row), + } + + # Check for duplicates + record_hash = self._generate_record_hash(record_data) + + if record_hash in existing_hashes: + # Found duplicate - get existing record details + existing_record = existing_hashes[record_hash] + + duplicates_list.append({ + 'row_number': index + 2, + 'new_record': record_data, + 'existing_record': existing_record, + 'hash': record_hash + }) + else: + new_records_count += 1 + + except Exception as e: + if self.logger: + self.logger.logger.warning(f"Error analyzing row {index + 2}: {e}") + continue + + analysis_result['success'] = True + analysis_result['new_records'] = new_records_count + analysis_result['duplicate_records'] = len(duplicates_list) + analysis_result['duplicates'] = duplicates_list + + if self.logger: + self.logger.logger.info( + f"Duplicate analysis complete - Total: {analysis_result['total_records']}, " + f"New: {new_records_count}, Duplicates: {len(duplicates_list)}" + ) + + except Exception as e: + analysis_result['errors'].append(f"Analysis failed: {str(e)}") + if self.logger: + self.logger.logger.error(f"Duplicate analysis error: {e}") + + return analysis_result + + def analyze_for_invalid_rows(self, file_path: str, project_id: int = None) -> Dict[str, Any]: + """ + Analyze file for invalid rows with detailed error information. + + Args: + file_path: Path to the Excel file + project_id: Optional project ID — if supplied, every Location Name + in the file is validated against that project's QR code + locations before the row analysis proceeds. + + Returns: + Dictionary containing invalid row analysis + """ + analysis_result = { + 'success': False, + 'total_rows': 0, + 'valid_rows': 0, + 'invalid_rows': 0, + 'invalid_details': [], + 'errors': [] + } + + try: + # Read Excel file + df = self._read_excel_with_formulas(file_path) + + # Validate required columns + required_columns = ['ID', 'Date', 'Time', 'Location Name', 'Action Description'] + missing_columns = [col for col in required_columns if col not in df.columns] + + if missing_columns: + analysis_result['errors'].append(f"Missing columns: {', '.join(missing_columns)}") + return analysis_result + + # Remove empty rows + df = df.dropna(how='all') + analysis_result['total_rows'] = len(df) + + # ── Project-location validation ────────────────────────────── + if project_id: + try: + from models.qrcode import QRCode + from models.project import Project + file_locations = set( + str(loc).strip() + for loc in df['Location Name'].dropna().unique() + if str(loc).strip() + ) + project_locations = set( + qr.location + for qr in QRCode.query.filter_by(project_id=project_id) + .with_entities(QRCode.location).all() + ) + unmatched = next( + (loc for loc in sorted(file_locations) if loc not in project_locations), + None + ) + if unmatched: + project_obj = self.db.session.get(Project, project_id) + project_name = project_obj.name if project_obj else f'ID {project_id}' + error_msg = ( + f"The data in the file does not belong to the project '{project_name}'. " + f"Please verify the selected project or correct the file." + ) + analysis_result['errors'].append(error_msg) + if self.logger: + self.logger.logger.warning(f"Project-location mismatch during invalid-row analysis: {error_msg}") + return analysis_result + if self.logger: + self.logger.logger.info( + f"Project-location validation passed (invalid-row analysis): " + f"all {len(file_locations)} location(s) belong to project ID {project_id}." + ) + except Exception as e: + if self.logger: + self.logger.logger.warning(f"Could not perform project-location validation (invalid-row analysis): {e}") + # ── End project-location validation ────────────────────────── + + # Analyze each row + invalid_list = [] + valid_count = 0 + + for index, row in df.iterrows(): + row_errors = [] + row_data = { + 'row_number': index + 2, + 'employee_id': self._clean_employee_id(row['ID']) if pd.notna(row['ID']) else None, + } + + # Check ID + if pd.isna(row['ID']): + row_errors.append("Missing ID") + else: + row_data['employee_id'] = self._clean_employee_id(row['ID']) + # Get employee name + if 'Name' in df.columns and pd.notna(row.get('Name')): + row_data['employee_name'] = str(row['Name']).strip() + else: + row_data['employee_name'] = self._get_employee_name(row_data['employee_id']) + + # Check and parse Date + if pd.isna(row['Date']): + row_errors.append("Missing Date") + else: + try: + attendance_date = pd.to_datetime(row['Date']).date() + row_data['attendance_date'] = attendance_date + except Exception: + row_errors.append(f"Invalid date format: {row['Date']}") + + # Check and parse Time + if pd.isna(row['Time']): + row_errors.append("Missing Time") + else: + try: + attendance_time = self._parse_time_field(row['Time']) + row_data['attendance_time'] = attendance_time + except Exception as e: + row_errors.append(f"Invalid time format: {row['Time']}") + + # Check Location Name + if pd.isna(row['Location Name']): + row_errors.append("Missing Location Name") + else: + row_data['location_name'] = str(row['Location Name']).strip() + + # Check Action Description + if pd.isna(row['Action Description']): + row_errors.append("Missing Action Description") + else: + row_data['action_description'] = str(row['Action Description']).strip() + + # Optional fields + if pd.notna(row.get('Platform')): + row_data['platform'] = str(row['Platform']).strip() + + if pd.notna(row.get('Event Description')): + row_data['event_description'] = str(row['Event Description']).strip() + + row_data['recorded_address'] = self._process_recorded_address(row) + + # If row has errors, add to invalid list + if row_errors: + invalid_list.append({ + 'row_number': index + 2, # +2 for header and 0-based index + 'row_data': row_data, + 'errors': row_errors + }) + else: + valid_count += 1 + + analysis_result['success'] = True + analysis_result['valid_rows'] = valid_count + analysis_result['invalid_rows'] = len(invalid_list) + analysis_result['invalid_details'] = invalid_list + + if self.logger: + self.logger.logger.info( + f"Invalid row analysis complete - Total: {analysis_result['total_rows']}, " + f"Valid: {valid_count}, Invalid: {len(invalid_list)}" + ) + + except Exception as e: + analysis_result['errors'].append(f"Analysis failed: {str(e)}") + if self.logger: + self.logger.logger.error(f"Invalid row analysis error: {e}") + + return analysis_result + + def import_from_excel(self, file_path: str, created_by: int = None, + import_source: str = None, skip_duplicates: bool = True, + force_import_hashes: List[str] = None, project_id: int = None, + progress_callback=None) -> Dict[str, Any]: + """ + Import time attendance data from Excel file with enhanced duplicate handling. + + Args: + file_path: Path to the Excel file + created_by: User ID who initiated the import + import_source: Description of import source + skip_duplicates: Whether to skip duplicate records + force_import_hashes: List of hashes to force import (user confirmed duplicates) + progress_callback: Optional callable(current, total, message) for real-time progress + + Returns: + Dictionary containing import results + """ + batch_id = str(uuid.uuid4()) + import_results = { + 'batch_id': batch_id, + 'total_records': 0, + 'imported_records': 0, + 'failed_records': 0, + 'duplicate_records': 0, + 'skipped_records': 0, + 'forced_duplicates': 0, + 'errors': [], + 'warnings': [], + 'success': False, + 'import_date': datetime.utcnow() + } + + force_import_hashes = force_import_hashes or [] + + try: + # Log import start + if self.logger: + self.logger.logger.info( + f"Starting enhanced time attendance import from {file_path} by user {created_by} " + f"(skip_duplicates={skip_duplicates}, force_import={len(force_import_hashes)})" + ) + + # Read Excel file + try: + df = self._read_excel_with_formulas(file_path) + + if self.logger: + self.logger.logger.info(f"Read Excel file with {len(df)} rows and formulas preserved") + except Exception as e: + error_msg = f"Failed to read Excel file: {str(e)}" + import_results['errors'].append(error_msg) + if self.logger: + self.logger.logger.error(error_msg) + return import_results + + # Validate required columns + required_columns = ['ID', 'Date', 'Time', 'Location Name', 'Action Description'] + missing_columns = [col for col in required_columns if col not in df.columns] + + if missing_columns: + error_msg = f"Missing required columns: {', '.join(missing_columns)}" + import_results['errors'].append(error_msg) + if self.logger: + self.logger.logger.error(error_msg) + return import_results + + # Remove completely empty rows + df = df.dropna(how='all') + import_results['total_records'] = len(df) + + if import_results['total_records'] == 0: + error_msg = "No valid data rows found in Excel file" + import_results['errors'].append(error_msg) + return import_results + + # ── Project-location validation ────────────────────────────────────── + # If a project_id is provided, verify that every unique Location Name + # in the file belongs to that project. Return immediately on the first + # mismatch so the user can correct the file or project selection. + if project_id: + try: + from models.qrcode import QRCode + from models.project import Project + + # Collect all unique, non-empty location names from the file + file_locations = set( + str(loc).strip() + for loc in df['Location Name'].dropna().unique() + if str(loc).strip() + ) + + # Fetch all location names that belong to the selected project. + # Strip each value — DB entries may have trailing whitespace. + project_locations = set( + qr.location.strip() + for qr in QRCode.query.filter_by(project_id=project_id) + .with_entities(QRCode.location).all() + if qr.location + ) + + if self.logger: + self.logger.logger.info( + f"Project-location validation: project_id={project_id}, " + f"file_locations={sorted(file_locations)}, " + f"project_locations={sorted(project_locations)}" + ) + + # Find the first location in the file that is not in the project + unmatched = next( + (loc for loc in sorted(file_locations) if loc not in project_locations), + None + ) + + if unmatched: + project_obj = self.db.session.get(Project, project_id) + project_name = project_obj.name if project_obj else f'ID {project_id}' + error_msg = ( + f"The data in the file does not belong to the project '{project_name}'. " + f"Location '{unmatched}' was not found in this project. " + f"Please verify the selected project or correct the file." + ) + import_results['errors'].append(error_msg) + if self.logger: + self.logger.logger.warning( + f"Project-location mismatch: {error_msg}" + ) + return import_results + + if self.logger: + self.logger.logger.info( + f"Project-location validation passed: all {len(file_locations)} " + f"location(s) belong to project ID {project_id}." + ) + + except Exception as e: + # Fail closed: if validation cannot be performed, block the import. + # This prevents a DB or import error from silently bypassing the check. + error_msg = f"Project-location validation could not be completed: {e}" + import_results['errors'].append(error_msg) + if self.logger: + self.logger.logger.error( + f"Project-location validation error (failing closed): {e}", + exc_info=True + ) + return import_results + # ── End project-location validation ────────────────────────────────── + + # Track duplicates using hash + duplicate_hashes = set() + if skip_duplicates: + duplicate_hashes = self._get_existing_record_hashes() + + # Process each row with enhanced validation + for index, row in df.iterrows(): + try: + # Skip empty rows - only ID is required + if pd.isna(row['ID']): + import_results['skipped_records'] += 1 + import_results['warnings'].append(f"Row {index + 2}: Skipped due to missing ID") + continue + + # Clean the employee ID first (handles float issues like '1234.0') + clean_id = self._clean_employee_id(row['ID']) + + # Get employee name - either from Excel or lookup from employee table + employee_name = None + if 'Name' in df.columns and pd.notna(row.get('Name')): + employee_name = str(row['Name']).strip() + else: + # Lookup employee name from employee table using cleaned ID + employee_name = self._get_employee_name(clean_id) + + # Validate and parse date + try: + attendance_date = pd.to_datetime(row['Date']).date() + except Exception as date_error: + import_results['failed_records'] += 1 + import_results['errors'].append(f"Row {index + 2}: Invalid date format - {str(date_error)}") + continue + + # Validate and parse time + try: + attendance_time = self._parse_time_field(row['Time']) + except Exception as time_error: + import_results['failed_records'] += 1 + import_results['errors'].append(f"Row {index + 2}: Invalid time format - {str(time_error)}") + continue + + # Prepare record data + record_data = { + 'employee_id': clean_id, + 'employee_name': employee_name, + 'platform': str(row.get('Platform', '')).strip() if pd.notna(row.get('Platform')) else None, + 'attendance_date': attendance_date, + 'attendance_time': attendance_time, + 'location_name': str(row['Location Name']).strip(), + 'action_description': str(row['Action Description']).strip(), + 'event_description': str(row.get('Event Description', '')).strip() if pd.notna(row.get('Event Description')) else None, + 'recorded_address': self._process_recorded_address(row), + 'distance': self._parse_distance_field(row), + } + + # Check for duplicates + if skip_duplicates: + record_hash = self._generate_record_hash(record_data) + + # If duplicate and NOT in force import list, skip it + if record_hash in duplicate_hashes and record_hash not in force_import_hashes: + import_results['duplicate_records'] += 1 + import_results['warnings'].append( + f"Row {index + 2}: Duplicate record for {record_data['employee_name']} " + f"on {attendance_date} at {attendance_time} - Skipped" + ) + continue + + # If in force import list, track it + if record_hash in force_import_hashes: + import_results['forced_duplicates'] += 1 + + duplicate_hashes.add(record_hash) + + # Create TimeAttendance record + from models.time_attendance import TimeAttendance + + time_attendance_record = TimeAttendance( + **record_data, + import_batch_id=batch_id, + import_source=import_source or f"Excel Import - {file_path}", + created_by=created_by, + project_id=project_id + ) + + self.db.session.add(time_attendance_record) + import_results['imported_records'] += 1 + + # Commit in batches of 50 to prevent memory buildup on large files + if import_results['imported_records'] % 50 == 0: + self.db.session.commit() + + # Report progress via callback if provided + if progress_callback: + processed = (import_results['imported_records'] + + import_results['failed_records'] + + import_results['duplicate_records'] + + import_results['skipped_records']) + progress_callback( + processed, + import_results['total_records'], + f"Importing record {processed} of {import_results['total_records']}..." + ) + + except Exception as e: + import_results['failed_records'] += 1 + error_msg = f"Row {index + 2}: {str(e)}" + import_results['errors'].append(error_msg) + + if self.logger: + self.logger.logger.warning(f"Failed to import row {index + 2}: {e}") + + continue + + # Final commit + self.db.session.commit() + import_results['success'] = import_results['imported_records'] > 0 + + # Log successful import + if self.logger: + self.logger.logger.info( + f"Time attendance import completed - Batch: {batch_id}, " + f"Total: {import_results['total_records']}, " + f"Imported: {import_results['imported_records']}, " + f"Failed: {import_results['failed_records']}, " + f"Duplicates: {import_results['duplicate_records']}, " + f"Forced: {import_results['forced_duplicates']}, " + f"Skipped: {import_results['skipped_records']}" + ) + + except SQLAlchemyError as e: + self.db.session.rollback() + error_msg = f"Database error during import: {str(e)}" + import_results['errors'].append(error_msg) + + if self.logger: + self.logger.log_database_error('time_attendance_import', e) + + except Exception as e: + self.db.session.rollback() + error_msg = f"Unexpected error during import: {str(e)}" + import_results['errors'].append(error_msg) + import_results['traceback'] = traceback.format_exc() + + if self.logger: + self.logger.logger.error(f"Time attendance import failed: {e}") + self.logger.logger.error(f"Traceback: {traceback.format_exc()}") + + return import_results + + def _parse_time_field(self, time_value) -> time: + """ + Parse various time formats from Excel + + Handles: + - datetime.time objects + - datetime.datetime objects + - String formats (HH:MM, HH:MM:SS, HH:MM AM/PM) + + Args: + time_value: Time value from Excel + + Returns: + time object + """ + if pd.isna(time_value): + raise ValueError("Time value is empty") + + # If already a time object + if isinstance(time_value, time): + return time_value + + # If datetime object, extract time + if isinstance(time_value, datetime): + return time_value.time() + + # If string, parse it + if isinstance(time_value, str): + time_str = time_value.strip() + + # Try parsing with pandas + try: + dt = pd.to_datetime(time_str) + return dt.time() + except: + # Try manual parsing for common formats + try: + # Format: HH:MM or HH:MM:SS + parts = time_str.split(':') + if len(parts) >= 2: + hour = int(parts[0]) + minute = int(parts[1]) + second = int(parts[2]) if len(parts) > 2 else 0 + return time(hour, minute, second) + except: + pass + + raise ValueError(f"Could not parse time value: {time_value}") + + def _get_employee_name(self, employee_id: str) -> str: + """ + Get employee name from database, formatted as 'lastname, firstname' + + Args: + employee_id: Employee ID to lookup + + Returns: + Employee name in 'lastname, firstname' format + """ + try: + from models.employee import Employee + + # CRITICAL: Clean the employee_id to handle float values like '1234.0' + # Remove '.0' suffix if present and convert to integer + cleaned_id = str(employee_id).strip() + + # If it's a float string like '1234.0', remove the decimal part + if '.' in cleaned_id: + try: + # Convert to float first, then to int to handle '1234.0' -> 1234 + cleaned_id = str(int(float(cleaned_id))) + except (ValueError, TypeError): + pass # Keep original if conversion fails + + # Now lookup the employee + employee = Employee.get_by_employee_id(int(cleaned_id)) + if employee: + # Format name as "lastname, firstname" + return f"{employee.lastName}, {employee.firstName}" + else: + if self.logger: + self.logger.logger.warning(f"Employee ID {cleaned_id} not found in employee table") + return f"Employee {cleaned_id}" + except Exception as e: + if self.logger: + self.logger.logger.warning(f"Could not lookup employee name for ID {employee_id}: {e}") + # Return cleaned ID in error message too + try: + cleaned_id = str(int(float(str(employee_id).strip()))) + return f"Employee {cleaned_id}" + except: + return f"Employee {employee_id}" + + def _clean_employee_id(self, employee_id) -> str: + """ + Clean employee ID to handle various formats + + Args: + employee_id: Raw employee ID value + + Returns: + Cleaned employee ID string + """ + try: + # Convert to string first + id_str = str(employee_id).strip() + + # Handle float values like 1234.0 or '1234.0' + if '.' in id_str: + # Convert to float, then to int, then back to string + # This removes the decimal part: 1234.0 -> 1234 + id_str = str(int(float(id_str))) + + return id_str + except (ValueError, TypeError) as e: + # If conversion fails, return original string + if self.logger: + self.logger.logger.warning(f"Could not clean employee ID '{employee_id}': {e}") + return str(employee_id).strip() + + def validate_excel_file(self, file_path: str) -> Dict[str, Any]: + """ + Validate Excel file structure and content before import + + Args: + file_path: Path to the Excel file + + Returns: + Dictionary containing validation results + """ + validation_results = { + 'valid': False, + 'errors': [], + 'warnings': [], + 'total_rows': 0, + 'valid_rows': 0, + 'invalid_rows': 0 + } + + try: + # Try to read the Excel file + df = pd.read_excel(file_path) + validation_results['total_rows'] = len(df) + + # Check required columns + required_columns = ['ID', 'Date', 'Time', 'Location Name', 'Action Description'] + missing_columns = [col for col in required_columns if col not in df.columns] + + if missing_columns: + validation_results['errors'].append( + f"Missing required columns: {', '.join(missing_columns)}" + ) + + # Check optional columns + optional_columns = ['Name', 'Platform', 'Event Description', 'Recorded Address', 'Distance'] + present_optional = [col for col in optional_columns if col in df.columns] + + if present_optional: + validation_results['warnings'].append( + f"Optional columns found: {', '.join(present_optional)}" + ) + + # Validate data in rows + if not missing_columns: + valid_row_count = 0 + invalid_row_details = [] + + for index, row in df.iterrows(): + is_valid = True + missing_fields = [] + + for col in required_columns: + if col in df.columns: + if pd.isna(row[col]): + is_valid = False + missing_fields.append(col) + else: + # Column doesn't exist in file + is_valid = False + missing_fields.append(f"{col} (column not found)") + + if is_valid: + valid_row_count += 1 + else: + # Track invalid row for detailed reporting + invalid_row_details.append({ + 'row': index + 2, # +2 for header and 0-based index + 'missing': missing_fields + }) + + validation_results['valid_rows'] = valid_row_count + validation_results['invalid_rows'] = len(df) - valid_row_count + + if validation_results['invalid_rows'] > 0: + # Provide detailed warning about invalid rows + validation_results['warnings'].append( + f"{validation_results['invalid_rows']} rows have missing required data" + ) + + # Add details about first few invalid rows for debugging + if invalid_row_details: + sample_invalid = invalid_row_details[:3] # Show first 3 invalid rows + details_msg = "Examples: " + for detail in sample_invalid: + details_msg += f"Row {detail['row']} (missing: {', '.join(detail['missing'])}); " + validation_results['warnings'].append(details_msg.rstrip('; ')) + + if 'Date' in df.columns: + invalid_dates = 0 + for idx, date_val in df['Date'].items(): + if pd.notna(date_val): + try: + pd.to_datetime(date_val) + except: + invalid_dates += 1 + + if invalid_dates > 0: + validation_results['warnings'].append( + f"{invalid_dates} rows have invalid date format" + ) + + if 'Time' in df.columns: + invalid_times = 0 + for idx, time_val in df['Time'].items(): + if pd.notna(time_val): + try: + self._parse_time_field(time_val) + except: + invalid_times += 1 + + if invalid_times > 0: + validation_results['warnings'].append( + f"{invalid_times} rows have invalid time format" + ) + + if all(col in df.columns for col in ['ID', 'Date', 'Time', 'Location Name']): + duplicate_check = df[['ID', 'Date', 'Time', 'Location Name']].duplicated() + duplicate_count = duplicate_check.sum() + + if duplicate_count > 0: + validation_results['warnings'].append( + f"{duplicate_count} potential duplicate records detected" + ) + + validation_results['valid'] = ( + len(validation_results['errors']) == 0 and + validation_results['valid_rows'] > 0 + ) + + except Exception as e: + validation_results['errors'].append(f"Failed to validate Excel file: {str(e)}") + if self.logger: + self.logger.logger.error(f"Validation error: {e}") + + return validation_results + + def get_import_summary(self, batch_id: str) -> Optional[Dict[str, Any]]: + """ + Get detailed summary of imported data by batch ID + + Args: + batch_id: Import batch identifier + + Returns: + Dictionary containing import summary + """ + try: + from models.time_attendance import TimeAttendance + + records = TimeAttendance.get_by_import_batch(batch_id) + + if not records: + return None + + # Calculate summary statistics + total_records = len(records) + unique_employees = len(set(record.employee_id for record in records)) + unique_locations = len(set(record.location_name for record in records)) + date_range = { + 'start': min(record.attendance_date for record in records), + 'end': max(record.attendance_date for record in records) + } + + # Group by action description + actions = {} + for record in records: + action = record.action_description + actions[action] = actions.get(action, 0) + 1 + + # Group by employee + employee_summary = {} + for record in records: + emp_id = record.employee_id + if emp_id not in employee_summary: + employee_summary[emp_id] = { + 'name': record.employee_name, + 'count': 0 + } + employee_summary[emp_id]['count'] += 1 + + return { + 'batch_id': batch_id, + 'total_records': total_records, + 'unique_employees': unique_employees, + 'unique_locations': unique_locations, + 'date_range': date_range, + 'actions': actions, + 'employee_summary': employee_summary, + 'import_date': records[0].import_date if records else None, + 'import_source': records[0].import_source if records else None + } + + except Exception as e: + if self.logger: + self.logger.logger.error(f"Failed to get import summary for batch {batch_id}: {e}") + return None + + def delete_import_batch(self, batch_id: str, deleted_by: int = None) -> Dict[str, Any]: + """ + Delete all records from a specific import batch + + Args: + batch_id: Import batch identifier + deleted_by: User ID who initiated the deletion + + Returns: + Dictionary containing deletion results + """ + result = { + 'success': False, + 'deleted_count': 0, + 'message': '' + } + + try: + from models.time_attendance import TimeAttendance + + records = TimeAttendance.query.filter_by(import_batch_id=batch_id).all() + deleted_count = len(records) + + if deleted_count == 0: + result['message'] = 'No records found for this batch' + return result + + # Delete records + for record in records: + self.db.session.delete(record) + + self.db.session.commit() + + # Log deletion + if self.logger: + self.logger.logger.info( + f"User {deleted_by} deleted import batch {batch_id} - " + f"Removed {deleted_count} records" + ) + + result['success'] = True + result['deleted_count'] = deleted_count + result['message'] = f'Successfully deleted {deleted_count} records' + + except Exception as e: + self.db.session.rollback() + result['message'] = f'Error deleting batch: {str(e)}' + + if self.logger: + self.logger.logger.error(f"Failed to delete batch {batch_id}: {e}") + + return result \ No newline at end of file diff --git a/tools/migration_PM_permissions.py b/tools/migration_PM_permissions.py new file mode 100644 index 0000000..8b2c93b --- /dev/null +++ b/tools/migration_PM_permissions.py @@ -0,0 +1,121 @@ +""" +Database Migration Script for Project Manager Permissions (MySQL Version) +========================================================================== + +This script creates the necessary tables for Project Manager role permissions. +It adds support for assigning specific projects and locations to Project Managers. + +Tables Created: +1. user_project_permissions: Links users to projects they can access +2. user_location_permissions: Links users to locations they can access + +Run this script ONCE after backing up your database. +""" + +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import text +import os +from dotenv import load_dotenv + +load_dotenv() + +# Initialize Flask app for migration +app = Flask(__name__) +app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'mysql://user:pass@localhost/qr_management') +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +db = SQLAlchemy(app) + +def run_migration(): + """Execute the migration to add project manager permission tables""" + + with app.app_context(): + print("\n" + "="*70) + print("PROJECT MANAGER PERMISSIONS MIGRATION (MySQL)") + print("="*70 + "\n") + + try: + # Check if tables already exist + print("🔍 Checking if migration is needed...") + + result = db.session.execute(text(""" + SELECT TABLE_NAME + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME IN ('user_project_permissions', 'user_location_permissions') + """)) + existing_tables = [row[0] for row in result.fetchall()] + + if len(existing_tables) == 2: + print("✅ Migration tables already exist. No action needed.") + return True + + if 'user_project_permissions' in existing_tables: + print("⚠️ user_project_permissions table already exists, skipping...") + else: + # Create user_project_permissions table + print("\n📝 Creating user_project_permissions table...") + db.session.execute(text(""" + CREATE TABLE user_project_permissions ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + project_id INT NOT NULL, + created_date DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, + UNIQUE KEY unique_user_project (user_id, project_id), + INDEX idx_user_id (user_id), + INDEX idx_project_id (project_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """)) + print("✅ user_project_permissions table created successfully") + + if 'user_location_permissions' in existing_tables: + print("⚠️ user_location_permissions table already exists, skipping...") + else: + # Create user_location_permissions table + print("\n📝 Creating user_location_permissions table...") + db.session.execute(text(""" + CREATE TABLE user_location_permissions ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + location_name VARCHAR(200) NOT NULL, + created_date DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + UNIQUE KEY unique_user_location (user_id, location_name), + INDEX idx_user_id (user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """)) + print("✅ user_location_permissions table created successfully") + + # Commit all changes + db.session.commit() + + print("\n" + "="*70) + print("✅ MIGRATION COMPLETED SUCCESSFULLY") + print("="*70) + print("\nNext Steps:") + print("1. The tables are ready for use") + print("2. You can now assign projects and locations to Project Managers") + print("3. Restart your application\n") + + return True + + except Exception as e: + db.session.rollback() + print(f"\n❌ Migration failed: {e}") + print("Please check your database and try again.") + return False + +if __name__ == '__main__': + print("\n⚠️ IMPORTANT: Backup your database before running this migration!") + response = input("Continue with migration? (yes/no): ") + + if response.lower() == 'yes': + success = run_migration() + if success: + print("\n✅ Migration completed. You can now restart your application.") + else: + print("\n❌ Migration failed. Please check the errors above.") + else: + print("\n❌ Migration cancelled.") \ No newline at end of file diff --git a/tools/migration_dynamic_qr_locations.py b/tools/migration_dynamic_qr_locations.py new file mode 100644 index 0000000..03ab66c --- /dev/null +++ b/tools/migration_dynamic_qr_locations.py @@ -0,0 +1,150 @@ +""" +Migration: Dynamic QR Code Support +==================================== +Applies the following database changes required for the Dynamic QR feature: + + 1. Adds qr_type column to qr_codes (VARCHAR 20, default 'standard') + 2. Creates qr_code_locations table + 3. Makes qr_codes.location nullable (was NOT NULL) + 4. Makes qr_codes.location_address nullable (was NOT NULL) + +Usage (run once from the project root): + python tools/migration_dynamic_qr_locations.py + +Fully idempotent — safe to run multiple times without side effects. +""" +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app import app, db + + +def run_migration(): + with app.app_context(): + from sqlalchemy import text, inspect as sa_inspect + + inspector = sa_inspect(db.engine) + + # ---------------------------------------------------------------- + # Step 1: Add qr_type column to qr_codes if it does not exist yet + # ---------------------------------------------------------------- + existing_cols = {c['name'] for c in inspector.get_columns('qr_codes')} + + if 'qr_type' not in existing_cols: + with db.engine.connect() as conn: + conn.execute(text( + "ALTER TABLE qr_codes " + "ADD COLUMN qr_type VARCHAR(20) NOT NULL DEFAULT 'standard'" + )) + conn.commit() + print("✅ Added column: qr_codes.qr_type (default = 'standard')") + else: + print("ℹ️ Column qr_codes.qr_type already exists — skipped.") + + # ---------------------------------------------------------------- + # Step 2: Create qr_code_locations table if it does not exist yet + # ---------------------------------------------------------------- + existing_tables = set(inspector.get_table_names()) + + if 'qr_code_locations' not in existing_tables: + from models.qrcode import QRCodeLocation + QRCodeLocation.__table__.create(db.engine, checkfirst=True) + print("✅ Created table: qr_code_locations") + else: + print("ℹ️ Table qr_code_locations already exists — skipped.") + + # ---------------------------------------------------------------- + # Step 3: Make qr_codes.location and location_address nullable + # Dynamic QR codes have no single fixed location/address so these + # columns must allow NULL. + # ---------------------------------------------------------------- + # Re-inspect to get current column definitions + inspector2 = sa_inspect(db.engine) + col_map = {c['name']: c for c in inspector2.get_columns('qr_codes')} + + location_nullable = col_map.get('location', {}).get('nullable', True) + loc_addr_nullable = col_map.get('location_address', {}).get('nullable', True) + + if not location_nullable or not loc_addr_nullable: + with db.engine.connect() as conn: + if not location_nullable: + conn.execute(text( + "ALTER TABLE qr_codes " + "MODIFY COLUMN location VARCHAR(100) NULL" + )) + print("✅ Made qr_codes.location nullable") + else: + print("ℹ️ qr_codes.location already nullable — skipped.") + + if not loc_addr_nullable: + conn.execute(text( + "ALTER TABLE qr_codes " + "MODIFY COLUMN location_address TEXT NULL" + )) + print("✅ Made qr_codes.location_address nullable") + else: + print("ℹ️ qr_codes.location_address already nullable — skipped.") + + conn.commit() + else: + print("ℹ️ qr_codes.location and location_address already nullable — skipped.") + + # ---------------------------------------------------------------- + # Step 4: Add qr_address column to attendance_data if absent + # Stores the selected location's address for dynamic QR check-ins + # ---------------------------------------------------------------- + inspector3 = sa_inspect(db.engine) + att_cols = {c['name'] for c in inspector3.get_columns('attendance_data')} + if 'qr_address' not in att_cols: + with db.engine.connect() as conn: + conn.execute(text( + "ALTER TABLE attendance_data ADD COLUMN qr_address TEXT NULL" + )) + conn.commit() + print("\u2705 Added column: attendance_data.qr_address") + else: + print("\u2139\ufe0f Column attendance_data.qr_address already exists \u2014 skipped.") + + # ---------------------------------------------------------------- + # Step 5: Add is_dynamic_qr column to attendance_data if absent + # Flags records that came from a Dynamic QR code scan + # ---------------------------------------------------------------- + if 'is_dynamic_qr' not in att_cols: + with db.engine.connect() as conn: + conn.execute(text( + "ALTER TABLE attendance_data " + "ADD COLUMN is_dynamic_qr TINYINT(1) NOT NULL DEFAULT 0" + )) + conn.commit() + print("\u2705 Added column: attendance_data.is_dynamic_qr") + else: + print("\u2139\ufe0f Column attendance_data.is_dynamic_qr already exists \u2014 skipped.") + + # ---------------------------------------------------------------- + # Step 6: Backfill is_dynamic_qr=1 on old broken records + # Records created before this migration where location_name='Dynamic' + # are legacy dynamic QR check-ins that were saved with the wrong name. + # Mark them so the badge shows correctly in the attendance report. + # ---------------------------------------------------------------- + with db.engine.connect() as conn: + result = conn.execute(text( + "UPDATE attendance_data " + "SET is_dynamic_qr = 1 " + "WHERE location_name = 'Dynamic' AND is_dynamic_qr = 0" + )) + conn.commit() + updated = result.rowcount + if updated > 0: + print(f"\u2705 Backfilled is_dynamic_qr=1 on {updated} legacy 'Dynamic' record(s)") + else: + print("\u2139\ufe0f No legacy 'Dynamic' records to backfill.") + + print("\nMigration complete.") + print("All existing QR codes remain fully unaffected (qr_type = 'standard').") + print("All existing attendance records default to is_dynamic_qr = 0 (False).") + + +if __name__ == '__main__': + run_migration() diff --git a/tools/migration_legacy_attendance_remote_indexes.py b/tools/migration_legacy_attendance_remote_indexes.py new file mode 100644 index 0000000..1226ce8 --- /dev/null +++ b/tools/migration_legacy_attendance_remote_indexes.py @@ -0,0 +1,94 @@ +""" +migration_legacy_attendance_remote_indexes.py +================================================ +Adds missing indexes to the REMOTE legacy database (the one described by +QrCodeLtServices.sql — contract / employee / locations / records) so the +Legacy Attendance feature's live queries don't full-table-scan on every +page load. + +As shipped, that schema has NO index on: + records.employeeId, records.locationId, records.time, records.contractId + employee.id (only the meaningless auto-increment `index` is indexed) + locations.location + +Adding an index does not change or risk any existing data — it only +speeds up reads. Safe to re-run: each ALTER is skipped if the index +already exists. + +Uses pymysql directly (never SQLAlchemy ORM), consistent with every other +migration script in tools/ — this connects to REMOTE_DB_* (the legacy +server), not the app's own local database. + +Run once per server (LT and GOV each point at their own legacy DB): + python3 tools/migration_legacy_attendance_remote_indexes.py +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from dotenv import load_dotenv +load_dotenv() + +import pymysql + +# (table, column, index_name) +INDEXES_TO_ADD = [ + ('records', 'employeeId', 'idx_records_employeeId'), + ('records', 'locationId', 'idx_records_locationId'), + ('records', 'time', 'idx_records_time'), + ('records', 'contractId', 'idx_records_contractId'), + ('employee', 'id', 'idx_employee_id'), + ('locations', 'location', 'idx_locations_location'), +] + + +def get_connection(): + host = os.environ.get('REMOTE_DB_HOST', '') + port = int(os.environ.get('REMOTE_DB_PORT', '3306')) + user = os.environ.get('REMOTE_DB_USERNAME', '') + password = os.environ.get('REMOTE_DB_PASSWORD', '') + database = os.environ.get('REMOTE_DB_NAME', '') + + if not host or not database: + print("[ERROR] REMOTE_DB_HOST / REMOTE_DB_NAME not set in .env — aborting.") + sys.exit(1) + + print(f"[INFO] Connecting to legacy DB {user}@{host}:{port}/{database} ...") + return pymysql.connect( + host=host, port=port, user=user, password=password, database=database, + charset='utf8mb4', connect_timeout=10 + ) + + +def index_exists(cursor, table, index_name): + cursor.execute(""" + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = %s + """, (table, index_name)) + return cursor.fetchone()[0] > 0 + + +def main(): + conn = get_connection() + try: + with conn.cursor() as cur: + for table, column, index_name in INDEXES_TO_ADD: + if index_exists(cur, table, index_name): + print(f"[SKIP] {table}.{index_name} already exists") + continue + print(f"[ADD] {table}.{index_name} ON ({column}) ...") + cur.execute(f"ALTER TABLE `{table}` ADD INDEX `{index_name}` (`{column}`)") + conn.commit() + print(f"[OK] {table}.{index_name} created") + print("[DONE] Legacy database indexes are up to date.") + except pymysql.MySQLError as e: + print(f"[ERROR] {e}") + sys.exit(1) + finally: + conn.close() + + +if __name__ == '__main__': + main() diff --git a/tools/migration_photo_verification_toggle.py b/tools/migration_photo_verification_toggle.py new file mode 100644 index 0000000..160d5e6 --- /dev/null +++ b/tools/migration_photo_verification_toggle.py @@ -0,0 +1,122 @@ +""" +migration_photo_verification_toggle.py +======================================= +Adds `photo_verification_enabled` column to the `qr_codes` table. + +Uses pymysql directly to avoid SQLAlchemy ORM loading the model +(which would fail if the column doesn't exist yet). + +Default: 1 (True) for all existing rows — preserves current behaviour. + +Run once on each server (LT and GOV): + python3 tools/migration_photo_verification_toggle.py + +Safe to re-run — skips if column already exists. +""" + +import os, sys, re + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from dotenv import load_dotenv +load_dotenv() + +import pymysql + +TABLE = 'qr_codes' +COLUMN = 'photo_verification_enabled' + + +def parse_db_url(url): + """ + Parse DATABASE_URL robustly using regex to handle special characters + (including @ or : ) in the password. + + Supports: + mysql+pymysql://user:pass@host:port/dbname + mysql+pymysql://user:pass@host/dbname + """ + # Strip driver prefix + url = re.sub(r'^mysql\+pymysql://', '', url) + url = re.sub(r'^mysql://', '', url) + + # Split credentials from host/db on the LAST @ before the host + # Pattern: user:password@host[:port]/dbname[?...] + m = re.match( + r'^(?P[^:]+):(?P.+)@(?P[^@:/]+)(?::(?P\d+))?/(?P[^?]+)', + url + ) + if not m: + print(f"[ERROR] Could not parse DATABASE_URL. Raw (redacted): {url[:30]}...") + sys.exit(1) + + return { + 'host': m.group('host'), + 'port': int(m.group('port')) if m.group('port') else 3306, + 'user': m.group('user'), + 'password': m.group('password'), + 'database': m.group('db'), + } + + +def get_connection(): + db_url = os.environ.get('DATABASE_URL', '') + if not db_url: + print("[ERROR] DATABASE_URL not set in .env") + sys.exit(1) + + params = parse_db_url(db_url) + return pymysql.connect( + host=params['host'], + port=params['port'], + user=params['user'], + password=params['password'], + database=params['database'], + charset='utf8mb4', + autocommit=False, + ) + + +def run(): + conn = get_connection() + try: + with conn.cursor() as cur: + # Check if column already exists + cur.execute( + "SELECT COUNT(*) FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() " + f"AND TABLE_NAME = '{TABLE}' " + f"AND COLUMN_NAME = '{COLUMN}'" + ) + exists = cur.fetchone()[0] > 0 + + if exists: + print(f"[SKIP] Column '{COLUMN}' already exists on '{TABLE}'. Nothing to do.") + return + + print(f"[ADD] Adding column '{COLUMN}' to '{TABLE}' ...") + cur.execute( + f"ALTER TABLE `{TABLE}` " + f"ADD COLUMN `{COLUMN}` TINYINT(1) NOT NULL DEFAULT 1" + ) + conn.commit() + + # Verify + cur.execute( + "SELECT COUNT(*) FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() " + f"AND TABLE_NAME = '{TABLE}' " + f"AND COLUMN_NAME = '{COLUMN}'" + ) + if cur.fetchone()[0] > 0: + print(f"[OK] Column '{COLUMN}' added. All existing rows default to 1 (enabled).") + else: + print(f"[FAIL] Column was not created — check DB permissions.") + sys.exit(1) + + finally: + conn.close() + + +if __name__ == '__main__': + run() \ No newline at end of file diff --git a/tools/optimize_time_attendance_db.py b/tools/optimize_time_attendance_db.py new file mode 100644 index 0000000..83174d1 --- /dev/null +++ b/tools/optimize_time_attendance_db.py @@ -0,0 +1,600 @@ +#!/usr/bin/env python3 +""" +============================================================================== +Time Attendance Database Optimization Script +============================================================================== + +Standalone script for optimizing the time_attendance table. +Can be run manually or as a cronjob. + +Usage: + python optimize_time_attendance_db.py --action optimize + python optimize_time_attendance_db.py --action analyze + python optimize_time_attendance_db.py --action archive --days 365 + python optimize_time_attendance_db.py --action cleanup --days 90 + python optimize_time_attendance_db.py --action report + python optimize_time_attendance_db.py --action all + +Requirements: + - Must be run from the application directory + - Database credentials must be configured in config.py or environment + +Author: Database Optimization Team +Date: 2025-10-14 +============================================================================== +""" + +import sys +import os +import argparse +from datetime import datetime, timedelta +import logging + +# Add the application directory to the path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# Import Flask app and database +try: + from app import app, db + from models.time_attendance import TimeAttendance + from sqlalchemy import text +except ImportError as e: + print(f"❌ Error: Cannot import required modules: {e}") + print(" Make sure this script is in the same directory as app.py") + sys.exit(1) + + +class TimeAttendanceOptimizer: + """Standalone optimizer for time_attendance table""" + + def __init__(self, verbose=True): + self.verbose = verbose + self.setup_logging() + + def setup_logging(self): + """Setup logging configuration""" + log_format = '%(asctime)s - %(levelname)s - %(message)s' + logging.basicConfig( + level=logging.INFO if self.verbose else logging.WARNING, + format=log_format + ) + self.logger = logging.getLogger(__name__) + + def log(self, message, level='info'): + """Log message with appropriate level""" + if level == 'info': + self.logger.info(message) + if self.verbose: + print(f"ℹ️ {message}") + elif level == 'success': + self.logger.info(message) + if self.verbose: + print(f"✅ {message}") + elif level == 'warning': + self.logger.warning(message) + if self.verbose: + print(f"⚠️ {message}") + elif level == 'error': + self.logger.error(message) + if self.verbose: + print(f"❌ {message}") + + def create_indexes(self): + """Create optimized indexes for time_attendance table""" + self.log("Creating optimized indexes...", 'info') + + indexes = [ + { + 'name': 'idx_ta_employee_date_time', + 'columns': 'employee_id, attendance_date DESC, attendance_time DESC', + 'purpose': 'Employee-based queries with date filtering' + }, + { + 'name': 'idx_ta_date_location_employee', + 'columns': 'attendance_date DESC, location_name, employee_id', + 'purpose': 'Date and location filtering' + }, + { + 'name': 'idx_ta_project_date_employee', + 'columns': 'project_id, attendance_date DESC, employee_id', + 'purpose': 'Project-based attendance queries' + }, + { + 'name': 'idx_ta_batch_date', + 'columns': 'import_batch_id, attendance_date DESC', + 'purpose': 'Import batch management' + }, + { + 'name': 'idx_ta_action_date_employee', + 'columns': 'action_description, attendance_date DESC, employee_id', + 'purpose': 'Action-based analytics' + }, + { + 'name': 'idx_ta_date_time_desc', + 'columns': 'attendance_date DESC, attendance_time DESC, id DESC', + 'purpose': 'Recent records retrieval' + }, + { + 'name': 'idx_ta_location_action_date', + 'columns': 'location_name, action_description, attendance_date DESC', + 'purpose': 'Location-based action analysis' + }, + ] + + created = 0 + skipped = 0 + failed = 0 + + for idx in indexes: + try: + # Check if index exists + check_query = f""" + SELECT COUNT(*) as count + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'time_attendance' + AND INDEX_NAME = '{idx['name']}' + """ + result = db.session.execute(text(check_query)).fetchone() + + if result and result.count > 0: + self.log(f"Index {idx['name']} already exists - skipped", 'info') + skipped += 1 + continue + + # Create index + create_query = f""" + CREATE INDEX {idx['name']} + ON time_attendance ({idx['columns']}) + """ + + self.log(f"Creating index: {idx['name']}", 'info') + db.session.execute(text(create_query)) + db.session.commit() + + self.log(f"Created index: {idx['name']} - {idx['purpose']}", 'success') + created += 1 + + except Exception as e: + self.log(f"Failed to create index {idx['name']}: {str(e)[:100]}", 'warning') + failed += 1 + db.session.rollback() + continue + + self.log(f"Index creation complete: {created} created, {skipped} skipped, {failed} failed", 'success') + return {'created': created, 'skipped': skipped, 'failed': failed} + + def analyze_table(self): + """Analyze time_attendance table statistics""" + self.log("Analyzing table statistics...", 'info') + + try: + stats_query = """ + SELECT + COUNT(*) as total_records, + COUNT(DISTINCT employee_id) as unique_employees, + COUNT(DISTINCT location_name) as unique_locations, + COUNT(DISTINCT DATE(attendance_date)) as unique_dates, + COUNT(DISTINCT import_batch_id) as unique_batches, + COUNT(DISTINCT project_id) as unique_projects, + MIN(attendance_date) as earliest_date, + MAX(attendance_date) as latest_date, + COUNT(CASE WHEN recorded_address IS NOT NULL THEN 1 END) as records_with_address + FROM time_attendance + """ + + result = db.session.execute(text(stats_query)).fetchone() + + # Get table size + size_query = """ + SELECT + ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2) as size_mb, + ROUND(DATA_LENGTH / 1024 / 1024, 2) as data_mb, + ROUND(INDEX_LENGTH / 1024 / 1024, 2) as index_mb + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'time_attendance' + """ + + size_result = db.session.execute(text(size_query)).fetchone() + + if result: + date_range_days = (result.latest_date - result.earliest_date).days if result.latest_date and result.earliest_date else 0 + + stats = { + 'total_records': result.total_records, + 'unique_employees': result.unique_employees, + 'unique_locations': result.unique_locations, + 'unique_dates': result.unique_dates, + 'unique_batches': result.unique_batches, + 'unique_projects': result.unique_projects, + 'earliest_date': result.earliest_date, + 'latest_date': result.latest_date, + 'date_range_days': date_range_days, + 'records_with_address': result.records_with_address, + 'table_size_mb': size_result.size_mb if size_result else 0, + 'data_size_mb': size_result.data_mb if size_result else 0, + 'index_size_mb': size_result.index_mb if size_result else 0 + } + + print("\n" + "="*60) + print("📊 TIME ATTENDANCE TABLE STATISTICS") + print("="*60) + print(f"Total Records: {stats['total_records']:>15,}") + print(f"Unique Employees: {stats['unique_employees']:>15,}") + print(f"Unique Locations: {stats['unique_locations']:>15,}") + print(f"Unique Dates: {stats['unique_dates']:>15,}") + print(f"Import Batches: {stats['unique_batches']:>15,}") + print(f"Projects: {stats['unique_projects']:>15,}") + print(f"Date Range: {stats['date_range_days']:>15,} days") + print(f"Earliest Date: {stats['earliest_date']:>15}") + print(f"Latest Date: {stats['latest_date']:>15}") + print(f"Records w/ Address: {stats['records_with_address']:>15,}") + print(f"\nTable Size: {stats['table_size_mb']:>15.2f} MB") + print(f" Data Size: {stats['data_size_mb']:>15.2f} MB") + print(f" Index Size: {stats['index_size_mb']:>15.2f} MB") + print("="*60 + "\n") + + return stats + + except Exception as e: + self.log(f"Error analyzing table: {e}", 'error') + return None + + def optimize_table(self): + """Run MySQL OPTIMIZE TABLE and ANALYZE TABLE""" + self.log("Optimizing table structure...", 'info') + + try: + # Analyze table + self.log("Running ANALYZE TABLE...", 'info') + db.session.execute(text("ANALYZE TABLE time_attendance")) + db.session.commit() + self.log("ANALYZE TABLE completed", 'success') + + # Optimize table + self.log("Running OPTIMIZE TABLE (this may take a while)...", 'info') + db.session.execute(text("OPTIMIZE TABLE time_attendance")) + db.session.commit() + self.log("OPTIMIZE TABLE completed", 'success') + + return {'success': True} + + except Exception as e: + self.log(f"Error optimizing table: {e}", 'error') + db.session.rollback() + return {'success': False, 'error': str(e)} + + def create_archive_table(self): + """Create archive table if it doesn't exist""" + try: + create_query = """ + CREATE TABLE IF NOT EXISTS time_attendance_archive + LIKE time_attendance + """ + db.session.execute(text(create_query)) + db.session.commit() + return True + except Exception as e: + self.log(f"Error creating archive table: {e}", 'error') + db.session.rollback() + return False + + def archive_old_records(self, days=365, execute=False): + """Archive records older than specified days""" + self.log(f"Archive process for records older than {days} days...", 'info') + + cutoff_date = datetime.now() - timedelta(days=days) + + try: + # Count records to archive + count_query = """ + SELECT COUNT(*) as count + FROM time_attendance + WHERE attendance_date < :cutoff_date + """ + result = db.session.execute( + text(count_query), + {'cutoff_date': cutoff_date.date()} + ).fetchone() + + records_to_archive = result.count if result else 0 + + print(f"\n📦 Archive Summary:") + print(f" Cutoff Date: {cutoff_date.date()}") + print(f" Records to Archive: {records_to_archive:,}") + + if records_to_archive == 0: + self.log("No records to archive", 'info') + return {'records_archived': 0} + + if not execute: + print(f"\n⚠️ DRY RUN MODE - No records will be archived") + print(f" Use --execute flag to actually archive records\n") + return {'records_archived': 0, 'dry_run': True} + + # Create archive table + if not self.create_archive_table(): + return {'success': False, 'error': 'Failed to create archive table'} + + # Archive records in batches + self.log("Starting archive process...", 'info') + batch_size = 10000 + total_archived = 0 + + while total_archived < records_to_archive: + # Insert to archive + archive_query = """ + INSERT INTO time_attendance_archive + SELECT * FROM time_attendance + WHERE attendance_date < :cutoff_date + LIMIT :batch_size + """ + + db.session.execute( + text(archive_query), + {'cutoff_date': cutoff_date.date(), 'batch_size': batch_size} + ) + + # Delete from main table + delete_query = """ + DELETE FROM time_attendance + WHERE attendance_date < :cutoff_date + LIMIT :batch_size + """ + + result = db.session.execute( + text(delete_query), + {'cutoff_date': cutoff_date.date(), 'batch_size': batch_size} + ) + + rows_affected = result.rowcount + + if rows_affected == 0: + break + + db.session.commit() + total_archived += rows_affected + + self.log(f"Archived {total_archived:,} / {records_to_archive:,} records...", 'info') + + # Safety limit + if total_archived >= 100000: + self.log("Reached safety limit of 100,000 records per run", 'warning') + break + + self.log(f"Archive complete: {total_archived:,} records archived", 'success') + return {'records_archived': total_archived, 'success': True} + + except Exception as e: + self.log(f"Error during archive: {e}", 'error') + db.session.rollback() + return {'success': False, 'error': str(e)} + + def cleanup_old_records(self, days=90, execute=False): + """Delete records older than specified days""" + self.log(f"Cleanup process for records older than {days} days...", 'info') + + cutoff_date = datetime.now() - timedelta(days=days) + + try: + # Count records to delete + count_query = """ + SELECT COUNT(*) as count + FROM time_attendance + WHERE import_date < :cutoff_date + """ + result = db.session.execute( + text(count_query), + {'cutoff_date': cutoff_date} + ).fetchone() + + records_to_delete = result.count if result else 0 + + print(f"\n🗑️ Cleanup Summary:") + print(f" Cutoff Date: {cutoff_date.date()}") + print(f" Records to Delete: {records_to_delete:,}") + + if records_to_delete == 0: + self.log("No records to delete", 'info') + return {'records_deleted': 0} + + if not execute: + print(f"\n⚠️ DRY RUN MODE - No records will be deleted") + print(f" Use --execute flag to actually delete records\n") + return {'records_deleted': 0, 'dry_run': True} + + # Delete records + delete_query = """ + DELETE FROM time_attendance + WHERE import_date < :cutoff_date + """ + + self.log("Deleting old records...", 'info') + db.session.execute( + text(delete_query), + {'cutoff_date': cutoff_date} + ) + db.session.commit() + + self.log(f"Cleanup complete: {records_to_delete:,} records deleted", 'success') + return {'records_deleted': records_to_delete, 'success': True} + + except Exception as e: + self.log(f"Error during cleanup: {e}", 'error') + db.session.rollback() + return {'success': False, 'error': str(e)} + + def generate_report(self): + """Generate comprehensive optimization report""" + print("\n" + "="*60) + print("📋 TIME ATTENDANCE OPTIMIZATION REPORT") + print("="*60) + print(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") + + # Get statistics + stats = self.analyze_table() + + if stats: + # Generate recommendations + print("\n" + "="*60) + print("💡 RECOMMENDATIONS") + print("="*60) + + recommendations = [] + + if stats['total_records'] > 100000: + recommendations.append({ + 'priority': 'HIGH', + 'type': 'Indexing', + 'message': f"Table has {stats['total_records']:,} records. Run index optimization." + }) + + if stats['total_records'] > 500000: + recommendations.append({ + 'priority': 'HIGH', + 'type': 'Archiving', + 'message': f"Consider archiving records older than 365 days." + }) + + if stats['table_size_mb'] > 500: + recommendations.append({ + 'priority': 'MEDIUM', + 'type': 'Optimization', + 'message': f"Table size is {stats['table_size_mb']:.2f} MB. Run OPTIMIZE TABLE." + }) + + if stats['date_range_days'] > 365: + recommendations.append({ + 'priority': 'MEDIUM', + 'type': 'Data Retention', + 'message': f"Data spans {stats['date_range_days']} days. Implement retention policy." + }) + + if stats['index_size_mb'] > stats['data_size_mb'] * 1.5: + recommendations.append({ + 'priority': 'LOW', + 'type': 'Index Review', + 'message': f"Index size ({stats['index_size_mb']:.2f} MB) is large. Review index usage." + }) + + if not recommendations: + print("✓ No major issues found. Database is well optimized.\n") + else: + for rec in recommendations: + priority_icon = "🔴" if rec['priority'] == 'HIGH' else "🟡" if rec['priority'] == 'MEDIUM' else "🟢" + print(f"{priority_icon} [{rec['priority']}] {rec['type']}") + print(f" {rec['message']}\n") + + print("="*60) + + # Suggested actions + print("\n💻 SUGGESTED ACTIONS:") + print("-"*60) + if stats['total_records'] > 100000: + print("• python optimize_time_attendance_db.py --action optimize") + if stats['total_records'] > 500000: + print("• python optimize_time_attendance_db.py --action archive --days 365 --execute") + if stats['date_range_days'] > 180: + print("• python optimize_time_attendance_db.py --action cleanup --days 90 --execute") + print("="*60 + "\n") + + +def main(): + """Main function to handle command line arguments""" + parser = argparse.ArgumentParser( + description='Time Attendance Database Optimization Tool', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s --action optimize # Create indexes and optimize table + %(prog)s --action analyze # Analyze table statistics + %(prog)s --action archive --days 365 # Archive records older than 365 days (dry run) + %(prog)s --action archive --days 365 --execute # Actually archive records + %(prog)s --action cleanup --days 90 --execute # Delete records older than 90 days + %(prog)s --action report # Generate optimization report + %(prog)s --action all # Run full optimization (indexes + analyze + optimize) + """ + ) + + parser.add_argument( + '--action', + required=True, + choices=['optimize', 'analyze', 'archive', 'cleanup', 'report', 'all', 'indexes'], + help='Action to perform' + ) + + parser.add_argument( + '--days', + type=int, + default=365, + help='Number of days for archive/cleanup (default: 365)' + ) + + parser.add_argument( + '--execute', + action='store_true', + help='Actually execute archive/cleanup (otherwise dry run)' + ) + + parser.add_argument( + '--quiet', + action='store_true', + help='Suppress verbose output' + ) + + args = parser.parse_args() + + # Create optimizer instance + optimizer = TimeAttendanceOptimizer(verbose=not args.quiet) + + print("\n" + "="*60) + print("🔧 TIME ATTENDANCE DATABASE OPTIMIZER") + print("="*60) + print(f"Action: {args.action.upper()}") + print(f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print("="*60 + "\n") + + try: + with app.app_context(): + if args.action == 'indexes': + optimizer.create_indexes() + + elif args.action == 'optimize': + optimizer.create_indexes() + optimizer.optimize_table() + + elif args.action == 'analyze': + optimizer.analyze_table() + + elif args.action == 'archive': + optimizer.archive_old_records(days=args.days, execute=args.execute) + + elif args.action == 'cleanup': + optimizer.cleanup_old_records(days=args.days, execute=args.execute) + + elif args.action == 'report': + optimizer.generate_report() + + elif args.action == 'all': + optimizer.create_indexes() + optimizer.analyze_table() + optimizer.optimize_table() + print("\n✅ Full optimization complete!") + + print("\n" + "="*60) + print("✅ OPTIMIZATION COMPLETED SUCCESSFULLY") + print("="*60 + "\n") + + except KeyboardInterrupt: + print("\n\n⚠️ Operation cancelled by user") + sys.exit(1) + except Exception as e: + print(f"\n❌ Error: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/turnstile_utils.py b/turnstile_utils.py new file mode 100644 index 0000000..ea2fc0a --- /dev/null +++ b/turnstile_utils.py @@ -0,0 +1,65 @@ +import os +import requests +from flask import current_app, request as flask_request + +class TurnstileUtils: + """Cloudflare Turnstile utility class for verification""" + + def __init__(self): + self.site_key = os.environ.get('TURNSTILE_SITE_KEY', '') + self.secret_key = os.environ.get('TURNSTILE_SECRET_KEY', '') + self.enabled = os.environ.get('TURNSTILE_ENABLED', 'False').lower() == 'true' + self.verify_url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify' + + def is_enabled(self): + """Check if Turnstile is enabled and properly configured""" + return self.enabled and self.site_key and self.secret_key + + def get_site_key(self): + """Get the Turnstile site key for frontend usage""" + return self.site_key if self.is_enabled() else None + + def verify_turnstile(self, turnstile_response): + """Verify Turnstile response with Cloudflare""" + if not self.is_enabled(): + return True # Skip verification if disabled + + if not turnstile_response: + return False + + try: + # Get client IP for additional security + client_ip = self._get_client_ip() + + # Prepare verification request + payload = { + 'secret': self.secret_key, + 'response': turnstile_response, + 'remoteip': client_ip + } + + # Send verification request to Cloudflare + response = requests.post(self.verify_url, data=payload, timeout=10) + result = response.json() + + # Log verification attempt + current_app.logger.info(f"Turnstile verification: success={result.get('success', False)}, IP={client_ip}") + + return result.get('success', False) + + except Exception as e: + current_app.logger.error(f"Turnstile verification error: {e}") + return False # Fail secure + + def _get_client_ip(self): + """Get client IP address with proxy support""" + # Check for forwarded IP (behind proxy/load balancer) + if flask_request.headers.get('X-Forwarded-For'): + return flask_request.headers.get('X-Forwarded-For').split(',')[0].strip() + elif flask_request.headers.get('X-Real-IP'): + return flask_request.headers.get('X-Real-IP') + else: + return flask_request.environ.get('REMOTE_ADDR', 'unknown') + +# Initialize global instance +turnstile_utils = TurnstileUtils() \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/geocoding.py b/utils/geocoding.py new file mode 100644 index 0000000..5f57692 --- /dev/null +++ b/utils/geocoding.py @@ -0,0 +1,824 @@ +""" +utils/geocoding.py +================== +All geocoding, distance calculation, and location-accuracy helpers. + +Extracted verbatim from app.py (lines 188–1317). +No logic changes — only import paths updated. +""" + +import os +import re +import requests +import traceback +from datetime import datetime, timedelta +from math import radians, sin, cos, asin, sqrt + +import googlemaps + +from extensions import db, logger_handler +from address_normalization_fix import normalize_address, addresses_are_similar + +# --------------------------------------------------------------------------- +# Google Maps client (initialized once at module import) +# --------------------------------------------------------------------------- +try: + GOOGLE_MAPS_API_KEY = os.environ.get('GOOGLE_MAPS_API_KEY') + if GOOGLE_MAPS_API_KEY: + gmaps_client = googlemaps.Client(key=GOOGLE_MAPS_API_KEY) + print("✅ Google Maps client initialized successfully") + else: + gmaps_client = None + print("⚠️ Google Maps API key not found, falling back to OpenStreetMap") +except Exception as e: + gmaps_client = None + print(f"❌ Error initializing Google Maps client: {e}") + + +def is_gmaps_available(): + """Return True if the Google Maps client is initialized and usable. + + Always call this (or check ``if gmaps_client:``) before calling any + method on ``gmaps_client`` to prevent AttributeError when the API key + is absent. + """ + return gmaps_client is not None + +# --------------------------------------------------------------------------- +# Geocoding cache +# --------------------------------------------------------------------------- +geocoding_cache = {} +CACHE_MAX_SIZE = 1000 +CACHE_EXPIRY_HOURS = 24 + + +def get_cached_coordinates(address): + """Get coordinates from cache if available and not expired""" + if address in geocoding_cache: + cached_data = geocoding_cache[address] + cache_time = cached_data.get('timestamp', datetime.min) + if datetime.now() - cache_time < timedelta(hours=CACHE_EXPIRY_HOURS): + print(f"📋 Using cached coordinates for: {address[:50]}...") + return cached_data.get('lat'), cached_data.get('lng'), cached_data.get('accuracy') + return None, None, None + + +def cache_coordinates(address, lat, lng, accuracy): + """Cache coordinates to reduce future API calls""" + try: + if len(geocoding_cache) >= CACHE_MAX_SIZE: + oldest_key = min(geocoding_cache.keys(), key=lambda k: geocoding_cache[k]['timestamp']) + del geocoding_cache[oldest_key] + geocoding_cache[address] = { + 'lat': lat, + 'lng': lng, + 'accuracy': accuracy, + 'timestamp': datetime.now() + } + print(f"💾 Cached coordinates for: {address[:50]}...") + except Exception as e: + print(f"⚠️ Error caching coordinates: {e}") + + +# --------------------------------------------------------------------------- +# Geocoding helpers +# --------------------------------------------------------------------------- + +def log_google_maps_usage(operation_type): + """Log Google Maps API usage for monitoring""" + try: + logger_handler.log_user_activity('google_maps_api_usage', f'Google Maps API used: {operation_type}') + except Exception as e: + print(f"⚠️ Usage logging error: {e}") + + +def get_coordinates_from_address(address): + """ + Get latitude and longitude from address using Google Maps Geocoding API. + Falls back to OpenStreetMap if Google Maps is unavailable. + Returns (lat, lng) tuple or (None, None) if failed. + """ + if not address or address.strip() == '': + return None, None + + address = address.strip() + print(f"🌍 Geocoding address: {address}") + + try: + logger_handler.log_user_activity('geocoding', f'Geocoding address: {address[:50]}...') + except Exception as log_error: + print(f"⚠️ Logging error (non-critical): {log_error}") + + try: + if gmaps_client: + print("🗺️ Using Google Maps Geocoding API") + geocode_result = gmaps_client.geocode(address) + if geocode_result: + location = geocode_result[0]['geometry']['location'] + lat = location['lat'] + lng = location['lng'] + print(f"✅ Google Maps geocoded address '{address[:50]}...' to coordinates: {lat}, {lng}") + try: + logger_handler.log_user_activity('geocoding_success', f'Successfully geocoded: {address[:50]}... -> {lat}, {lng}') + except Exception as log_error: + print(f"⚠️ Logging error (non-critical): {log_error}") + return lat, lng + else: + print(f"⚠️ Google Maps: No results found for address: {address}") + + print("🌐 Falling back to OpenStreetMap Nominatim") + url = "https://nominatim.openstreetmap.org/search" + params = {'q': address, 'format': 'json', 'limit': 1, 'addressdetails': 1} + headers = {'User-Agent': 'QR-Attendance-System/1.0'} + response = requests.get(url, params=params, headers=headers, timeout=10) + + if response.status_code == 200: + data = response.json() + if data and len(data) > 0: + lat = float(data[0]['lat']) + lng = float(data[0]['lon']) + print(f"✅ OSM geocoded address '{address[:50]}...' to coordinates: {lat}, {lng}") + try: + logger_handler.log_user_activity('geocoding_fallback', f'OSM fallback geocoded: {address[:50]}... -> {lat}, {lng}') + except Exception as log_error: + print(f"⚠️ Logging error (non-critical): {log_error}") + return lat, lng + + print(f"⚠️ Could not geocode address: {address}") + try: + logger_handler.log_user_activity('geocoding_failed', f'Failed to geocode: {address[:50]}...') + except Exception as log_error: + print(f"⚠️ Logging error (non-critical): {log_error}") + return None, None + + except Exception as e: + print(f"❌ Error geocoding address '{address}': {e}") + try: + logger_handler.log_flask_error('geocoding_error', f'Error geocoding {address[:50]}...: {str(e)}') + except Exception as log_error: + print(f"⚠️ Logging error (non-critical): {log_error}") + return None, None + + +def get_coordinates_from_address_enhanced(address): + """ + Enhanced geocoding function using Google Maps with caching and better error handling. + Returns (latitude, longitude, accuracy_level). + """ + if not address or address.strip() == "": + return None, None, None + + address = address.strip() + print(f"🌍 Enhanced geocoding for: {address}") + + normalized_address = normalize_address(address) + cached_lat, cached_lng, cached_accuracy = get_cached_coordinates(normalized_address) + if cached_lat is not None: + print("✅ Using cached coordinates for normalized address") + return cached_lat, cached_lng, cached_accuracy + + try: + logger_handler.log_user_activity('enhanced_geocoding', f'Enhanced geocoding: {address[:50]}...') + except Exception as log_error: + print(f"⚠️ Logging error (non-critical): {log_error}") + + try: + if gmaps_client: + print("🗺️ Using Google Maps Geocoding API (Enhanced)") + geocode_result = gmaps_client.geocode(address) + if geocode_result: + result = geocode_result[0] + location = result['geometry']['location'] + lat = location['lat'] + lng = location['lng'] + location_type = result['geometry'].get('location_type', 'UNKNOWN') + place_types = result.get('types', []) + + if location_type == 'ROOFTOP': + accuracy = 'excellent' + elif location_type == 'RANGE_INTERPOLATED': + accuracy = 'good' + elif location_type == 'GEOMETRIC_CENTER': + if any(ptype in place_types for ptype in ['premise', 'subpremise', 'street_address']): + accuracy = 'good' + elif any(ptype in place_types for ptype in ['neighborhood', 'sublocality']): + accuracy = 'fair' + else: + accuracy = 'poor' + elif location_type == 'APPROXIMATE': + accuracy = 'poor' + else: + accuracy = 'fair' + + print(f"✅ Google Maps enhanced geocoding successful:") + print(f" Coordinates: {lat:.10f}, {lng:.10f}") + print(f" Accuracy: {accuracy} (location_type: {location_type})") + print(f" Place types: {place_types[:3]}") + + cache_coordinates(normalized_address, lat, lng, accuracy) + try: + logger_handler.log_user_activity('enhanced_geocoding_success', f'Google Maps enhanced: {address[:50]}... -> {lat}, {lng} ({accuracy})') + except Exception as log_error: + print(f"⚠️ Logging error (non-critical): {log_error}") + return lat, lng, accuracy + else: + print(f"⚠️ Google Maps: No results found for enhanced geocoding: {address}") + + print("🌐 Falling back to OpenStreetMap Nominatim (Enhanced)") + nominatim_url = "https://nominatim.openstreetmap.org/search" + params = {'q': address, 'format': 'json', 'limit': 1, 'addressdetails': 1, 'extratags': 1} + headers = {'User-Agent': 'QR-Attendance-System/1.0 (Enhanced Location Accuracy)'} + response = requests.get(nominatim_url, params=params, headers=headers, timeout=10) + + if response.status_code == 200: + results = response.json() + if results: + result = results[0] + lat = float(result['lat']) + lng = float(result['lon']) + place_type = result.get('type', 'unknown') + osm_type = result.get('osm_type', 'unknown') + + if place_type in ['house', 'building', 'shop', 'office'] or osm_type == 'way': + accuracy = 'good' + elif place_type in ['neighbourhood', 'suburb', 'quarter', 'residential']: + accuracy = 'fair' + elif place_type in ['city', 'town', 'village']: + accuracy = 'poor' + else: + accuracy = 'poor' + + print(f"✅ OSM enhanced geocoding successful:") + print(f" Coordinates: {lat:.10f}, {lng:.10f}") + print(f" Accuracy: {accuracy} (fallback)") + cache_coordinates(normalized_address, lat, lng, accuracy) + try: + logger_handler.log_user_activity('enhanced_geocoding_fallback', f'OSM enhanced fallback: {address[:50]}... -> {lat}, {lng} ({accuracy})') + except Exception as log_error: + print(f"⚠️ Logging error (non-critical): {log_error}") + return lat, lng, accuracy + + print(f"⚠️ No results from enhanced geocoding for: {address}") + try: + logger_handler.log_user_activity('enhanced_geocoding_failed', f'Enhanced geocoding failed: {address[:50]}...') + except Exception as log_error: + print(f"⚠️ Logging error (non-critical): {log_error}") + return None, None, None + + except Exception as e: + print(f"❌ Enhanced geocoding error: {e}") + try: + logger_handler.log_flask_error('enhanced_geocoding_error', f'Enhanced geocoding error {address[:50]}...: {str(e)}') + except Exception as log_error: + print(f"⚠️ Logging error (non-critical): {log_error}") + return None, None, None + + +def geocode_address_enhanced(address): + """ + Enhanced geocoding using Nominatim API with better accuracy classification. + Returns: (latitude, longitude, accuracy_level) + """ + if not address or len(address.strip()) < 5: + print("❌ Address too short for geocoding") + return None, None, None + + try: + url = "https://nominatim.openstreetmap.org/search" + params = {'q': address.strip(), 'format': 'json', 'limit': 1, 'addressdetails': 1} + headers = {'User-Agent': 'QR-Attendance-System/1.0'} + response = requests.get(url, params=params, headers=headers, timeout=10) + + if response.status_code == 200: + data = response.json() + if data and len(data) > 0: + result = data[0] + lat = float(result['lat']) + lng = float(result['lon']) + place_type = result.get('type', 'unknown') + osm_type = result.get('osm_type', 'unknown') + + if place_type in ['house', 'building'] or osm_type == 'way': + accuracy = 'high' + elif place_type in ['neighbourhood', 'suburb', 'quarter']: + accuracy = 'medium' + else: + accuracy = 'low' + + print(f"✅ Geocoded address: {address}") + print(f" Coordinates: {lat:.10f}, {lng:.10f}") + print(f" Accuracy: {accuracy} ({place_type})") + return lat, lng, accuracy + + print(f"⚠️ No geocoding results for address: {address}") + return None, None, None + + except Exception as e: + logger_handler.log_flask_error('geocoding_error', str(e)) + print(f"❌ Geocoding error: {e}") + return None, None, None + + +# --------------------------------------------------------------------------- +# Distance / accuracy +# --------------------------------------------------------------------------- + +def calculate_distance_miles(lat1, lng1, lat2, lng2): + """ + Calculate DIRECT straight-line distance between two points using Haversine formula. + Returns distance in miles (float) or None if calculation fails. + """ + if any(coord is None for coord in [lat1, lng1, lat2, lng2]): + print("⚠️ Missing coordinates for distance calculation") + return None + + try: + try: + lat1_val = float(lat1) + lng1_val = float(lng1) + lat2_val = float(lat2) + lng2_val = float(lng2) + except (ValueError, TypeError) as e: + print(f"⚠️ Invalid coordinate format: {e}") + return None + + if not (-90 <= lat1_val <= 90) or not (-90 <= lat2_val <= 90): + print(f"⚠️ Invalid latitude values: {lat1_val}, {lat2_val}") + return None + if not (-180 <= lng1_val <= 180) or not (-180 <= lng2_val <= 180): + print(f"⚠️ Invalid longitude values: {lng1_val}, {lng2_val}") + return None + + try: + logger_handler.log_user_activity( + 'distance_calculation', + f'Calculating direct distance: ({lat1_val:.6f}, {lng1_val:.6f}) to ({lat2_val:.6f}, {lng2_val:.6f})' + ) + except Exception: + pass + + print("📐 Calculating direct straight-line distance using Haversine formula") + + lat1_rad = radians(lat1_val) + lng1_rad = radians(lng1_val) + lat2_rad = radians(lat2_val) + lng2_rad = radians(lng2_val) + + dlat = lat2_rad - lat1_rad + dlng = lng2_rad - lng1_rad + + sin_dlat_half = sin(dlat / 2.0) + sin_dlng_half = sin(dlng / 2.0) + + a = (sin_dlat_half * sin_dlat_half + + cos(lat1_rad) * cos(lat2_rad) * sin_dlng_half * sin_dlng_half) + a = max(0.0, min(1.0, a)) + c = 2.0 * asin(sqrt(a)) + + # DO NOT CHANGE Earth's mean radius value + EARTH_RADIUS_MILES = 3959.87433 + distance = round(c * EARTH_RADIUS_MILES, 4) + + print(f"📏 Direct straight-line distance calculation:") + print(f" Point 1: ({lat1_val:.10f}, {lng1_val:.10f})") + print(f" Point 2: ({lat2_val:.10f}, {lng2_val:.10f})") + print(f" Δlat: {abs(lat2_val - lat1_val):.10f}° = {dlat:.12f} radians") + print(f" Δlng: {abs(lng2_val - lng1_val):.10f}° = {dlng:.12f} radians") + print(f" a value: {a:.15f}") + print(f" c value (central angle): {c:.15f} radians") + print(f" 🎯 Distance: {distance:.4f} miles = {distance * 5280:.2f} feet = {distance * 1609.34:.2f} meters") + + try: + logger_handler.log_user_activity('distance_calculation_success', f'Direct distance: {distance:.4f} miles') + except Exception: + pass + + return distance + + except Exception as e: + print(f"❌ Error in distance calculation: {e}") + print(f" Traceback: {traceback.format_exc()}") + try: + logger_handler.log_flask_error('distance_calculation_error', f'Distance calculation error: {str(e)}') + except Exception: + pass + return None + + +def get_location_accuracy_level_enhanced(location_accuracy): + """ + Enhanced function to categorize location accuracy with more granular levels. + """ + if not location_accuracy or location_accuracy is None: + return 'unknown' + if location_accuracy <= 0.05: + return 'excellent' + elif location_accuracy <= 0.1: + return 'very_good' + elif location_accuracy <= 0.25: + return 'good' + elif location_accuracy <= 0.5: + return 'fair' + elif location_accuracy <= 1.0: + return 'poor' + else: + return 'very_poor' + + +def calculate_location_accuracy(qr_address, checkin_address, checkin_lat=None, checkin_lng=None): + """ + Calculate location accuracy by comparing QR code address with check-in location. + Returns distance in miles between the two locations. + """ + print(f"\n📍 CALCULATING LOCATION ACCURACY:") + print(f" QR Address: {qr_address}") + print(f" Check-in Address: {checkin_address}") + print(f" Check-in Coordinates: {checkin_lat}, {checkin_lng}") + + qr_lat, qr_lng = get_coordinates_from_address(qr_address) + if qr_lat is None or qr_lng is None: + print("⚠️ Could not geocode QR address, cannot calculate accuracy") + return None + + if checkin_lat is not None and checkin_lng is not None: + checkin_coords_lat, checkin_coords_lng = checkin_lat, checkin_lng + print("✅ Using GPS coordinates for check-in location") + else: + checkin_coords_lat, checkin_coords_lng = get_coordinates_from_address(checkin_address) + if checkin_coords_lat is None or checkin_coords_lng is None: + print("⚠️ Could not geocode check-in address, cannot calculate accuracy") + return None + print("✅ Using geocoded coordinates for check-in address") + + distance = calculate_distance_miles(qr_lat, qr_lng, checkin_coords_lat, checkin_coords_lng) + if distance is not None: + print(f"✅ Location accuracy calculated: {distance} miles") + return distance + + +def calculate_location_accuracy_enhanced(qr_address, checkin_address, checkin_lat=None, checkin_lng=None): + """ + ENHANCED location accuracy calculation comparing QR address with check-in location. + Returns distance in miles between QR location and check-in location. + """ + print(f"\n🎯 ENHANCED LOCATION ACCURACY CALCULATION:") + print(f" QR Address: {qr_address}") + print(f" Check-in Address: {checkin_address}") + print(f" Check-in GPS: {checkin_lat}, {checkin_lng}") + print(f" Timestamp: {datetime.now()}") + + if not qr_address or qr_address.strip() == "": + print("❌ QR address is empty or invalid") + return None + + print("\n📍 Step 1: Geocoding QR address...") + try: + if addresses_are_similar(qr_address, checkin_address, threshold=0.90): + print("🎯 Addresses are essentially identical - returning near-zero distance") + return 0.01 + + qr_lat, qr_lng, qr_accuracy = get_coordinates_from_address_enhanced(qr_address) + print(f" Geocoding result: lat={qr_lat}, lng={qr_lng}, accuracy={qr_accuracy}") + if qr_lat is None or qr_lng is None: + print(f"❌ Could not geocode QR address: {qr_address}") + return None + print(f"✅ QR location coordinates: {qr_lat:.10f}, {qr_lng:.10f} (accuracy: {qr_accuracy})") + except Exception as e: + print(f"❌ Error geocoding QR address: {e}") + return None + + print("\n📱 Step 2: Determining check-in coordinates...") + checkin_coords_lat = None + checkin_coords_lng = None + checkin_source = "unknown" + + if checkin_lat is not None and checkin_lng is not None: + try: + lat_val = float(checkin_lat) + lng_val = float(checkin_lng) + if -90 <= lat_val <= 90 and -180 <= lng_val <= 180: + checkin_coords_lat = lat_val + checkin_coords_lng = lng_val + checkin_source = "gps" + print(f"✅ Using GPS coordinates: {lat_val:.10f}, {lng_val:.10f}") + else: + print(f"⚠️ Invalid GPS coordinates: {lat_val}, {lng_val}") + except (ValueError, TypeError) as e: + print(f"⚠️ Could not parse GPS coordinates: {e}") + + if checkin_coords_lat is None and checkin_address: + print("🌍 Falling back to geocoding check-in address...") + try: + checkin_coords_lat, checkin_coords_lng, checkin_accuracy = get_coordinates_from_address_enhanced(checkin_address) + print(f" Checkin geocoding result: lat={checkin_coords_lat}, lng={checkin_coords_lng}, accuracy={checkin_accuracy}") + if checkin_coords_lat is not None: + checkin_source = "address" + print(f"✅ Using geocoded coordinates: {checkin_coords_lat:.10f}, {checkin_coords_lng:.10f} (accuracy: {checkin_accuracy})") + except Exception as e: + print(f"❌ Error geocoding check-in address: {e}") + + if checkin_coords_lat is None or checkin_coords_lng is None: + print(f"❌ Could not determine check-in coordinates") + print(f" GPS: {checkin_lat}, {checkin_lng}") + print(f" Address: {checkin_address}") + return None + + print("\n📏 Step 3: Calculating distance...") + try: + print(f" QR coordinates: {qr_lat:.10f}, {qr_lng:.10f}") + print(f" Check-in coordinates: {checkin_coords_lat:.10f}, {checkin_coords_lng:.10f}") + print(f" Source: {checkin_source}") + + distance = calculate_distance_miles(qr_lat, qr_lng, checkin_coords_lat, checkin_coords_lng) + print(f" Distance calculation result: {distance}") + + if distance is not None: + accuracy_level = get_location_accuracy_level_enhanced(distance) + print(f"✅ Enhanced location accuracy calculated successfully!") + print(f" Distance: {distance:.4f} miles") + print(f" Accuracy Level: {accuracy_level}") + return distance + else: + print("❌ Distance calculation returned None") + return None + except Exception as e: + print(f"❌ Error calculating distance: {e}") + print(f"❌ Distance calculation traceback: {traceback.format_exc()}") + return None + + +# --------------------------------------------------------------------------- +# Reverse geocoding +# --------------------------------------------------------------------------- + +def reverse_geocode_coordinates(latitude, longitude): + """ + Convert GPS coordinates to human-readable address. + Falls back to OpenStreetMap if Google Maps is unavailable. + Returns address string or None if failed. + """ + if not latitude or not longitude: + return None + + try: + print(f"🌍 Reverse geocoding coordinates: {latitude}, {longitude}") + try: + logger_handler.log_user_activity('reverse_geocoding', f'Reverse geocoding: {latitude}, {longitude}') + except Exception as log_error: + print(f"⚠️ Logging error (non-critical): {log_error}") + + if gmaps_client: + print("🗺️ Using Google Maps Reverse Geocoding API") + reverse_geocode_result = gmaps_client.reverse_geocode((latitude, longitude)) + if reverse_geocode_result: + address = reverse_geocode_result[0]['formatted_address'] + print(f"✅ Google Maps reverse geocoded address: {address}") + try: + logger_handler.log_user_activity('reverse_geocoding_success', f'Google Maps reverse geocoded: {latitude}, {longitude} -> {address[:50]}...') + except Exception as log_error: + print(f"⚠️ Logging error (non-critical): {log_error}") + return address + else: + print("⚠️ Google Maps: No address found for coordinates") + + print("🌐 Falling back to OpenStreetMap Nominatim reverse geocoding") + url = "https://nominatim.openstreetmap.org/reverse" + params = {'lat': latitude, 'lon': longitude, 'format': 'json', 'addressdetails': 1, 'zoom': 18} + headers = {'User-Agent': 'QR-Attendance-System/1.0'} + response = requests.get(url, params=params, headers=headers, timeout=10) + + if response.status_code == 200: + data = response.json() + if data and 'display_name' in data: + address = data['display_name'] + print(f"✅ OSM reverse geocoded address: {address}") + try: + logger_handler.log_user_activity('reverse_geocoding_fallback', f'OSM reverse geocoded: {latitude}, {longitude} -> {address[:50]}...') + except Exception as log_error: + print(f"⚠️ Logging error (non-critical): {log_error}") + return address + else: + print("⚠️ No address found for coordinates") + return None + else: + print(f"⚠️ Reverse geocoding API returned status: {response.status_code}") + return None + + except Exception as e: + print(f"❌ Error in reverse geocoding: {e}") + try: + logger_handler.log_flask_error('reverse_geocoding_error', f'Reverse geocoding error {latitude}, {longitude}: {str(e)}') + except Exception as log_error: + print(f"⚠️ Logging error (non-critical): {log_error}") + return None + + +# --------------------------------------------------------------------------- +# Location data processing +# --------------------------------------------------------------------------- + +def process_location_data(location_data): + """ + Process and validate location data from form. + Returns clean location data or None values for invalid data. + """ + processed = { + 'latitude': None, + 'longitude': None, + 'accuracy': None, + 'altitude': None, + 'source': location_data.get('location_source', 'manual'), + 'address': location_data.get('address', '')[:500] if location_data.get('address') else None + } + + try: + if location_data.get('latitude') and location_data['latitude'] not in ['null', '']: + lat = float(location_data['latitude']) + if -90 <= lat <= 90: + processed['latitude'] = lat + else: + print(f"⚠️ Invalid latitude: {lat}") + + if location_data.get('longitude') and location_data['longitude'] not in ['null', '']: + lng = float(location_data['longitude']) + if -180 <= lng <= 180: + processed['longitude'] = lng + else: + print(f"⚠️ Invalid longitude: {lng}") + + if location_data.get('accuracy') and location_data['accuracy'] not in ['null', '']: + acc = float(location_data['accuracy']) + if acc >= 0: + processed['accuracy'] = acc + else: + print(f"⚠️ Invalid accuracy: {acc}") + + if location_data.get('altitude') and location_data['altitude'] not in ['null', '']: + alt = float(location_data['altitude']) + processed['altitude'] = alt + + except (ValueError, TypeError) as e: + print(f"⚠️ Error processing location data: {e}") + + return processed + + +def process_location_data_enhanced(form_data): + """ + Enhanced processing of location data from form submission. + Validates and cleans location data for storage, including reverse geocoding. + """ + processed = { + 'latitude': None, + 'longitude': None, + 'accuracy': None, + 'altitude': None, + 'source': form_data.get('location_source', 'manual'), + 'address': None + } + + try: + if form_data.get('latitude') and form_data['latitude'] not in ['null', '', 'undefined']: + lat = float(form_data['latitude']) + if -90 <= lat <= 90: + processed['latitude'] = lat + else: + print(f"⚠️ Invalid latitude: {lat}") + + if form_data.get('longitude') and form_data['longitude'] not in ['null', '', 'undefined']: + lng = float(form_data['longitude']) + if -180 <= lng <= 180: + processed['longitude'] = lng + else: + print(f"⚠️ Invalid longitude: {lng}") + + if form_data.get('accuracy') and form_data['accuracy'] not in ['null', '', 'undefined']: + acc = float(form_data['accuracy']) + if acc >= 0: + processed['accuracy'] = acc + else: + print(f"⚠️ Invalid GPS accuracy: {acc}") + + if form_data.get('altitude') and form_data['altitude'] not in ['null', '', 'undefined']: + alt = float(form_data['altitude']) + processed['altitude'] = alt + + if form_data.get('address'): + address = form_data['address'].strip() + if address and address not in ['null', '', 'undefined']: + if re.match(r'^-?\d+\.\d+,?\s*-?\d+\.\d+$', address.replace(' ', '')): + print(f"🔍 Detected coordinate-format address: {address}") + processed['address'] = None + else: + processed['address'] = address[:500] + print(f"✅ Using provided address: {processed['address'][:100]}...") + + if (processed['latitude'] is not None and processed['longitude'] is not None + and not processed['address']): + print(f"🌍 Performing reverse geocoding for coordinates: {processed['latitude']}, {processed['longitude']}") + reverse_geocoded_address = reverse_geocode_coordinates(processed['latitude'], processed['longitude']) + if reverse_geocoded_address: + processed['address'] = reverse_geocoded_address[:500] + print(f"✅ Reverse geocoded address: {processed['address']}") + else: + print("⚠️ Could not reverse geocode coordinates, keeping coordinates as fallback") + processed['address'] = f"{processed['latitude']:.10f}, {processed['longitude']:.10f}" + + print("📍 Final processed location data:") + print(f" Coordinates: {processed['latitude']}, {processed['longitude']}") + print(f" GPS Accuracy: {processed['accuracy']}m") + print(f" Source: {processed['source']}") + print(f" Address: {processed['address'][:100] if processed['address'] else 'None'}...") + + return processed + + except Exception as e: + print(f"❌ Error processing location data: {e}") + return processed + + +def migrate_to_enhanced_location_accuracy(): + """Migration function to recalculate all existing records with enhanced accuracy.""" + from sqlalchemy import text as sa_text + try: + print("🔄 Starting enhanced location accuracy migration...") + records = db.session.execute(sa_text(""" + SELECT ad.id, qc.location_address, ad.address, ad.latitude, ad.longitude, ad.location_accuracy + FROM attendance_data ad + LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id + WHERE qc.location_address IS NOT NULL + """)).fetchall() + + print(f"📊 Found {len(records)} records to process") + updated_count = 0 + improved_count = 0 + + for record in records: + try: + new_accuracy = calculate_location_accuracy_enhanced( + qr_address=record.location_address, + checkin_address=record.address, + checkin_lat=record.latitude, + checkin_lng=record.longitude + ) + if new_accuracy is not None: + db.session.execute(sa_text(""" + UPDATE attendance_data SET location_accuracy = :accuracy WHERE id = :record_id + """), {'accuracy': new_accuracy, 'record_id': record.id}) + updated_count += 1 + if record.location_accuracy is None or abs(new_accuracy - (record.location_accuracy or 0)) > 0.001: + improved_count += 1 + print(f" ✅ Updated record {record.id}: {record.location_accuracy} → {new_accuracy:.4f} miles") + except Exception as e: + print(f" ⚠️ Error processing record {record.id}: {e}") + + db.session.commit() + print(f"✅ Enhanced migration completed!") + print(f" 📊 Records processed: {len(records)}") + print(f" ✅ Records updated: {updated_count}") + print(f" 📈 Records improved: {improved_count}") + return True + + except Exception as e: + print(f"❌ Enhanced migration failed: {e}") + db.session.rollback() + return False + + +def check_location_accuracy_column_exists(): + """Check if location_accuracy column exists in attendance_data table (MySQL compatible).""" + from sqlalchemy import text as sa_text + try: + result = db.session.execute(sa_text(""" + SELECT COUNT(*) as count + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'attendance_data' + AND COLUMN_NAME = 'location_accuracy' + """)) + count = result.fetchone().count + return count > 0 + except Exception as e: + print(f"Error checking location_accuracy column: {e}") + return False + + +# --------------------------------------------------------------------------- +# QR code location helpers +# --------------------------------------------------------------------------- + +def get_all_locations_from_qr_codes(): + """Helper function to get all unique locations from QR codes""" + from sqlalchemy import text as sa_text + try: + result = db.session.execute(sa_text(""" + SELECT DISTINCT location + FROM qr_codes + WHERE location IS NOT NULL + AND active_status = 1 + ORDER BY location + """)) + return [row[0] for row in result.fetchall()] + except Exception as e: + logger_handler.logger.error(f"Error loading locations: {e}") + return [] \ No newline at end of file diff --git a/utils/helpers.py b/utils/helpers.py new file mode 100644 index 0000000..9a4d45d --- /dev/null +++ b/utils/helpers.py @@ -0,0 +1,522 @@ +""" +utils/helpers.py +================ +Shared utility functions, decorators, QR-code generation helpers, +and role/permission helpers. + +Extracted verbatim from app.py (lines 234-329, 910-969, 1274-1467). +No logic changes — only import paths updated. +""" + +import io +import re +import os +import base64 +from datetime import datetime, date, time, timedelta +from functools import wraps + +import qrcode +from flask import session, redirect, flash, request, url_for +from user_agents import parse + +from extensions import logger_handler + +# --------------------------------------------------------------------------- +# Role constants +# --------------------------------------------------------------------------- +VALID_ROLES = ['admin', 'staff', 'payroll', 'project_manager', 'accounting'] +STAFF_LEVEL_ROLES = ['staff', 'payroll', 'project_manager', 'accounting'] + + +# --------------------------------------------------------------------------- +# Employee ID filter helpers — SP / PW / PT aware +# --------------------------------------------------------------------------- +# Extra-work records are stored with a work-type code attached to the employee +# ID ("1234SP", "1234 PW", "PT-1234", ...) in both attendance_data and +# time_attendance. Filtering with a plain equality test on the numeric ID drops +# every one of those records. These helpers expand a selected ID into all of its +# spellings, and normalize IDs so separators/spacing differences still match. +# +# Used by: routes/attendance.py, routes/attendance_export.py, +# routes/time_attendance.py + +WORK_TYPE_CODES = ('SP', 'PW', 'PT', 'C') + +def get_base_employee_id(raw_employee_id): + """Return the numeric base ID for a possibly work-type-suffixed employee ID.""" + from working_hours_calculator import parse_employee_id_for_work_type + + base_id, _ = parse_employee_id_for_work_type(str(raw_employee_id or '').strip()) + return base_id + + +def _work_type_codes_for(raw_employee_id): + """ + Return (base_id, codes) for one selected ID. + + A plain ID ("1234") matches regular AND every work type. An ID that already + carries a work type ("1234SP") matches only that work type — the user picked + it deliberately, so don't broaden the result set. + """ + from working_hours_calculator import parse_employee_id_for_work_type + + base_id, work_type = parse_employee_id_for_work_type(str(raw_employee_id or '').strip()) + codes = WORK_TYPE_CODES if work_type == 'regular' else (work_type,) + return base_id, work_type, codes + + +def build_employee_id_variants(raw_employee_id): + """ + Expand one selected employee ID into the exact ID spellings stored in the + database — suffix and prefix forms, with and without a space. + + "1234" -> 1234, 1234SP, "1234 SP", SP1234, "SP 1234", ... (PW / PT / C too) + "1234SP" -> the four SP spellings only + """ + raw = str(raw_employee_id or '').strip() + if not raw: + return [] + + base_id, work_type, codes = _work_type_codes_for(raw) + + variants = [raw] + if work_type == 'regular': + variants.append(base_id) + + for code in codes: + variants.extend([ + f"{base_id}{code}", + f"{base_id} {code}", + f"{code}{base_id}", + f"{code} {base_id}", + ]) + + return _dedupe(variants) + + +def build_employee_id_regex(raw_employee_id): + """ + Build a MySQL REGEXP pattern matching this employee's ID in ANY spelling + stored in the database, whatever separator the source file used: + + 1759, "1759 SP", 1759SP, 1759.PW, 1759-PT, SP1759, "SP 1759", "01759 SP" + + This is what a fixed variant list cannot do — imported IDs come straight from + customer Excel files, so the separator is unpredictable. + + Precision is preserved: the numeric part is anchored, so 17590, 11759 and + 1759.0 do NOT match a search for 1759. + + Returns None when the base ID is not purely numeric. The pattern is built by + string interpolation, and the ID is user-supplied text, so anything that could + carry regex metacharacters is refused here — callers fall back to exact matching. + """ + raw = str(raw_employee_id or '').strip() + if not raw: + return None + + base_id, work_type, codes = _work_type_codes_for(raw) + if not base_id.isdigit(): + return None + + codes_alt = '|'.join(codes) + # 0* tolerates zero-padded IDs. The separator class excludes letters and digits, + # so it matches any run of " ", ".", "-", "_" — or nothing at all. That covers + # everything people actually type: "1759 PW", "1759.PW", "1759. PW", "1759 . PW". + # It never runs over a word, so "1759 SPX" stays a different ID. + sep = '[^0-9A-Z]*' + number = f'0*{base_id}' + + # Leading and trailing sep runs absorb stray spaces or a trailing dot + # ("1759 PW ", "1759 PW.", " SP 1759"). + if work_type == 'regular': + # Regular records AND every work type — code optional on either side + return f'^{sep}({codes_alt})?{sep}{number}{sep}({codes_alt})?{sep}$' + + # An explicitly picked work type ("1759SP") must NOT pull in regular records, + # so the code is required — on one side or the other. + return f'^{sep}(({codes_alt}){sep}{number}|{number}{sep}({codes_alt})){sep}$' + + +def expand_employee_id_filter(employee_ids): + """ + Expand a list of selected employee IDs into (exact_variants, regex_patterns), + both de-duplicated and order-preserving. + + Callers OR the two together: the exact list is index-friendly and covers the + common spellings, the regex list catches every other separator style. + """ + exact, patterns = [], [] + for raw in employee_ids or []: + exact.extend(build_employee_id_variants(raw)) + pattern = build_employee_id_regex(raw) + if pattern: + patterns.append(pattern) + return _dedupe(exact), _dedupe(patterns) + + +def employee_id_regex_condition(column, patterns): + """ + SQLAlchemy condition: column matches any of the REGEXP patterns, upper-cased so + the match does not depend on the column's collation. Patterns are bound as + query parameters, never inlined into the SQL string. + """ + from sqlalchemy import func, or_ + + return or_(*[func.upper(column).op('REGEXP')(pattern) for pattern in patterns]) + + +def _dedupe(values): + """Order-preserving de-duplication, dropping empties.""" + seen, unique_values = set(), [] + for value in values: + if value and value not in seen: + seen.add(value) + unique_values.append(value) + return unique_values + + +# --------------------------------------------------------------------------- +# Role helpers +# --------------------------------------------------------------------------- + +def is_valid_role(role): + """Check if role is valid""" + return role in VALID_ROLES + + +def has_admin_privileges(role): + """Check if role has admin privileges""" + return role == 'admin' + + +def has_staff_level_access(role): + """Check if role has staff-level access (includes new roles)""" + return role in STAFF_LEVEL_ROLES + + +def get_role_permissions(role): + """Get permissions description for a role""" + permissions = { + 'admin': { + 'title': 'Administrator Permissions', + 'permissions': [ + 'Full QR code management (create, edit, delete)', + 'Complete user management capabilities', + 'System configuration access', + 'View all system analytics', + 'Bulk operations and data export', + 'Access to all admin features' + ], + 'restrictions': ['With great power comes great responsibility!'] + }, + 'staff': { + 'title': 'Staff User Permissions', + 'permissions': [ + 'Create and edit QR codes', + 'View all QR codes in the system', + 'Download QR code images', + 'Update personal profile information', + ], + 'restrictions': [ + 'Cannot delete QR codes', + 'Cannot manage other users', + 'Cannot access admin settings' + ] + }, + 'payroll': { + 'title': 'Payroll Specialist Permissions', + 'permissions': [ + 'Create and edit QR codes', + 'View all QR codes in the system', + 'Download QR code images', + 'Update personal profile information', + 'Access dashboard and reports', + 'Same permissions as Staff (additional features coming soon)' + ], + 'restrictions': [ + 'Cannot delete QR codes', + 'Cannot manage other users', + 'Cannot access admin settings' + ] + }, + 'project_manager': { + 'title': 'Project Manager Permissions', + 'permissions': [ + 'Create and edit QR codes', + 'View all QR codes in the system', + 'Download QR code images', + 'Update personal profile information', + 'Access dashboard and reports', + 'Same permissions as Staff (additional features coming soon)' + ], + 'restrictions': [ + 'Cannot delete QR codes', + 'Cannot manage other users', + 'Cannot access admin settings' + ] + }, + 'accounting': { + 'title': 'Accounting Specialist Permissions', + 'permissions': [ + 'View and modify employee records', + 'Access attendance reports and analytics', + 'View and manage time attendance data', + 'Export payroll and attendance data', + 'Access financial reports and statistics', + 'Update personal profile information', + 'Delete attendance records (same as payroll)' + ], + 'restrictions': [ + 'Cannot create or delete QR codes', + 'Cannot manage other users', + 'Cannot access admin settings', + 'Cannot manage projects' + ] + } + } + return permissions.get(role, {}) + + +# --------------------------------------------------------------------------- +# Auth decorators +# --------------------------------------------------------------------------- + +def login_required(f): + """Decorator to ensure user is logged in""" + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user_id' not in session: + flash('Please log in to access this page.', 'error') + return redirect(url_for('auth.login')) + return f(*args, **kwargs) + return decorated_function + + +def admin_required(f): + """Decorator to ensure user has admin privileges""" + @wraps(f) + def decorated_function(*args, **kwargs): + if 'username' not in session: + flash('Please log in to access this page.', 'error') + return redirect(url_for('auth.login')) + user_role = session.get('role') + if not has_admin_privileges(user_role): + flash('Administrator privileges required for this action.', 'error') + return redirect(url_for('dashboard.dashboard')) + return f(*args, **kwargs) + return decorated_function + + +def staff_or_admin_required(f): + """Decorator to ensure user has staff-level or admin privileges""" + @wraps(f) + def decorated_function(*args, **kwargs): + if 'username' not in session: + flash('Please log in to access this page.', 'error') + return redirect(url_for('auth.login')) + user_role = session.get('role') + if not (has_admin_privileges(user_role) or has_staff_level_access(user_role)): + flash('Insufficient privileges to access this page.', 'error') + return redirect(url_for('dashboard.dashboard')) + return f(*args, **kwargs) + return decorated_function + + +def is_admin_user(user_id): + """Helper function to safely check if user is admin""" + from extensions import db + from models import set_db + try: + # User model is available through the app context + from flask import current_app + with current_app.app_context(): + # Access via db session to avoid circular import + from sqlalchemy import text + result = db.session.execute( + text("SELECT role, active_status FROM users WHERE id = :uid"), + {'uid': user_id} + ).fetchone() + return result and result.active_status and result.role == 'admin' + except Exception: + return False + + +# --------------------------------------------------------------------------- +# Request helpers +# --------------------------------------------------------------------------- + +def detect_device_info(user_agent_string): + """Extract device information from user agent""" + try: + user_agent = parse(user_agent_string) + device_info = f"{user_agent.device.family}" + if user_agent.os.family: + device_info += f" - {user_agent.os.family}" + if user_agent.os.version_string: + device_info += f" {user_agent.os.version_string}" + if user_agent.browser.family: + device_info += f" ({user_agent.browser.family})" + return device_info[:200] + except Exception: + return "Unknown Device" + + +def get_client_ip(): + """Get client IP address""" + if request.environ.get('HTTP_X_FORWARDED_FOR') is None: + return request.environ['REMOTE_ADDR'] + else: + return request.environ['HTTP_X_FORWARDED_FOR'] + + +# --------------------------------------------------------------------------- +# QR code generation +# --------------------------------------------------------------------------- + +def generate_qr_url(name, qr_id): + """Generate a unique URL for QR code destination""" + clean_name = re.sub(r'[^a-zA-Z0-9\s-]', '', name) + clean_name = re.sub(r'\s+', '-', clean_name.strip()) + clean_name = clean_name.lower() + url_slug = f"qr-{qr_id}-{clean_name}" + return url_slug[:200] + + +def generate_qr_code(data, fill_color="black", back_color="white", box_size=10, border=4, error_correction='L'): + """Generate a QR code image and return as base64 string""" + error_correction_map = { + 'L': qrcode.constants.ERROR_CORRECT_L, + 'M': qrcode.constants.ERROR_CORRECT_M, + 'Q': qrcode.constants.ERROR_CORRECT_Q, + 'H': qrcode.constants.ERROR_CORRECT_H + } + + try: + qr = qrcode.QRCode( + version=1, + error_correction=error_correction_map.get(error_correction, qrcode.constants.ERROR_CORRECT_L), + box_size=int(box_size), + border=int(border), + ) + qr.add_data(data) + qr.make(fit=True) + + img = qr.make_image(fill_color=fill_color, back_color=back_color) + + buffer = io.BytesIO() + img.save(buffer, format='PNG') + img_str = base64.b64encode(buffer.getvalue()).decode() + + try: + logger_handler.log_qr_code_generated( + data_length=len(data), + fill_color=fill_color, + back_color=back_color, + box_size=box_size, + border=border, + error_correction=error_correction + ) + except Exception: + pass + + return img_str + + except Exception as e: + logger_handler.log_database_error('qr_code_generation', e) + return generate_default_qr_code(data) + + +def generate_default_qr_code(data): + """Fallback function for basic QR code generation""" + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=10, + border=4, + ) + qr.add_data(data) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + buffer = io.BytesIO() + img.save(buffer, format='PNG') + img_str = base64.b64encode(buffer.getvalue()).decode() + return img_str + + +def get_qr_styling(qr_code): + """Extract QR code styling parameters from database record""" + return { + 'fill_color': getattr(qr_code, 'fill_color', '#000000') or '#000000', + 'back_color': getattr(qr_code, 'back_color', '#FFFFFF') or '#FFFFFF', + 'box_size': getattr(qr_code, 'box_size', 10) or 10, + 'border': getattr(qr_code, 'border', 4) or 4, + 'error_correction': getattr(qr_code, 'error_correction', 'L') or 'L' + } + + +# --------------------------------------------------------------------------- +# Check-in history helpers +# --------------------------------------------------------------------------- + +def get_employee_checkin_history(employee_id, qr_code_id, date_filter=None): + """Get check-in history for an employee at a specific location""" + from extensions import db + try: + if date_filter is None: + date_filter = date.today() + # AttendanceData imported at call site to avoid circular import + from flask import current_app + from models.attendance import AttendanceData + if AttendanceData: + checkins = AttendanceData.query.filter_by( + employee_id=employee_id.upper(), + qr_code_id=qr_code_id, + check_in_date=date_filter + ).order_by(AttendanceData.check_in_time.asc()).all() + return checkins + return [] + except Exception as e: + print(f"❌ Error retrieving checkin history: {e}") + return [] + + +def format_checkin_intervals(checkins): + """Format time intervals between check-ins for display""" + if len(checkins) < 2: + return [] + + intervals = [] + for i in range(1, len(checkins)): + previous_time = datetime.combine(checkins[i - 1].check_in_date, checkins[i - 1].check_in_time) + current_time = datetime.combine(checkins[i].check_in_date, checkins[i].check_in_time) + interval = current_time - previous_time + interval_minutes = int(interval.total_seconds() / 60) + intervals.append({ + 'from_time': checkins[i - 1].check_in_time.strftime('%H:%M'), + 'to_time': checkins[i].check_in_time.strftime('%H:%M'), + 'interval_minutes': interval_minutes, + 'interval_text': format_time_interval(interval_minutes) + }) + return intervals + + +def format_time_interval(minutes): + """Format minutes into human-readable time interval""" + if minutes < 60: + return f"{minutes} minutes" + elif minutes < 1440: + hours = minutes // 60 + remaining_minutes = minutes % 60 + if remaining_minutes == 0: + return f"{hours} hour{'s' if hours != 1 else ''}" + else: + return f"{hours}h {remaining_minutes}m" + else: + days = minutes // 1440 + remaining_hours = (minutes % 1440) // 60 + if remaining_hours == 0: + return f"{days} day{'s' if days != 1 else ''}" + else: + return f"{days}d {remaining_hours}h" \ No newline at end of file diff --git a/utils/template_helpers.py b/utils/template_helpers.py new file mode 100644 index 0000000..5bb55a7 --- /dev/null +++ b/utils/template_helpers.py @@ -0,0 +1,75 @@ +""" +utils/template_helpers.py +========================= +Template utility functions injected into Jinja2 via context processors. + +Extracted from create_app() in app.py so they can be independently +imported, tested, and reused. +""" + +from datetime import datetime +from sqlalchemy import text as sa_text + +from extensions import db, logger_handler + + +def get_employee_name(employee_id): + """Return 'Lastname, Firstname' for a given employee ID. + Falls back to 'Employee ' if not found or on error. + """ + try: + result = db.session.execute(sa_text(""" + SELECT CONCAT(firstName, ' ', lastName) as full_name + FROM employee + WHERE id = :employee_id + """), {'employee_id': employee_id}) + row = result.fetchone() + return row[0] if row else f"Employee {employee_id}" + except Exception as e: + print(f"⚠️ Error getting employee name for ID {employee_id}: {e}") + return f"Employee {employee_id}" + + +def get_qr_code_checkin_count(qr_code_id): + """Return total number of check-ins for a given QR code ID.""" + from models.attendance import AttendanceData + try: + return AttendanceData.query.filter_by(qr_code_id=qr_code_id).count() + except Exception as e: + logger_handler.logger.error( + f"Error getting check-ins count for QR {qr_code_id}: {e}" + ) + return 0 + + +def format_hours(hours): + """Format a decimal hours value to 2 decimal places.""" + return f"{hours:.2f}" if hours else "0.00" + + +def register_template_helpers(app): + """ + Register all template helper context processors on the given Flask app. + Call this once inside create_app() after the app is configured. + """ + from working_hours_calculator import ( + convert_minutes_to_base100, round_base100_hours + ) + + @app.context_processor + def inject_payroll_utils(): + """Inject payroll utility functions into all templates.""" + return { + 'convert_minutes_to_base100': convert_minutes_to_base100, + 'round_base100_hours': round_base100_hours, + 'get_employee_name': get_employee_name, + 'format_hours': format_hours, + } + + @app.context_processor + def inject_dashboard_utils(): + """Inject dashboard utility functions into all templates.""" + return { + 'now': datetime.utcnow, + 'get_qr_code_checkin_count': get_qr_code_checkin_count, + } diff --git a/working_hours_calculator.py b/working_hours_calculator.py new file mode 100644 index 0000000..d907190 --- /dev/null +++ b/working_hours_calculator.py @@ -0,0 +1,838 @@ +#!/usr/bin/env python3 +""" +Working Hours Calculator for Employee Payroll +============================================ + +This module implements the working hours calculation logic +based on the Java files provided. It handles: +- Daily time calculations with travel time options +- Weekly regular and overtime hours +- Record pairing (check-in/check-out) +- Missing punch detection +- Quarter-hour rounding +- SP/PW/PT/C (Special Project/Periodic Work/Part-Time/Covering) support with consolidation + +Based on the Java classes: +- DailyTimeCalculator.java +- WeeklyTimeCalculator.java +- PayrollReport.java +""" + +from datetime import datetime, timedelta, time +from typing import List, Dict, Optional, Tuple, Any +from dataclasses import dataclass +import math +import re +import logging +from logger_handler import log_database_operations + +_calc_logger = logging.getLogger('qr_attendance_app') + +# Constants from Java implementation +RECORD_GROUPING_MAX_MINUTES = 60 * 6 # 6 hours +MAX_REGULAR_TIME_MINUTES = 60 * 40 # 40 hours per week + + +# --------------------------------------------------------------------------- +# Time rounding utilities (ported from SingleCheckInCalculator) +# --------------------------------------------------------------------------- + +def round_time_to_quarter_hour(minutes: float) -> float: + """ + Round a duration in minutes to the nearest quarter hour using + 7.5-minute boundary increments. + + Rules: + 0:00 – 0:07 → 0:00 + 0:08 – 0:22 → 0:15 + 0:23 – 0:37 → 0:30 + 0:38 – 0:52 → 0:45 + 0:53 – 1:07 → 1:00 + """ + if minutes < 0: + return 0.0 + + hours = int(minutes // 60) + minutes_in_hour = minutes % 60 + + if minutes_in_hour <= 7: + rounded_in_hour = 0 + elif minutes_in_hour <= 22: + rounded_in_hour = 15 + elif minutes_in_hour <= 37: + rounded_in_hour = 30 + elif minutes_in_hour <= 52: + rounded_in_hour = 45 + else: # 53 – 59 + hours += 1 + rounded_in_hour = 0 + + return hours * 60 + rounded_in_hour + + +def convert_minutes_to_base100(minutes: float) -> float: + """ + Convert a duration in minutes to base-100 hours + (each fractional hour expressed as hundredths, not sixtieths). + + Example: 90 min → 1.50 base-100 hours + """ + if minutes < 0: + return 0.0 + + decimal_hours = minutes / 60.0 + whole_hours = int(decimal_hours) + fractional_hours = decimal_hours - whole_hours + base100_fraction = fractional_hours * 100 + + return whole_hours + (base100_fraction / 100) + + +def round_base100_hours(base100_hours: float) -> float: + """ + Round base-100 hours to the nearest quarter (.00 / .25 / .50 / .75) + using 12.5-unit thresholds. + + Examples: + 4.12 → 4.00 + 4.18 → 4.25 + 8.02 → 8.00 + 8.87 → 9.00 + """ + if base100_hours < 0: + return 0.0 + + whole_hours = int(base100_hours) + fractional_part = (base100_hours - whole_hours) * 100 + + if fractional_part < 12.5: + rounded_fraction = 0 + elif fractional_part < 37.5: + rounded_fraction = 25 + elif fractional_part < 62.5: + rounded_fraction = 50 + elif fractional_part < 87.5: + rounded_fraction = 75 + else: + whole_hours += 1 + rounded_fraction = 0 + + return round(whole_hours + (rounded_fraction / 100), 2) + + +# --------------------------------------------------------------------------- + +def parse_employee_id_for_work_type(employee_id: str) -> Tuple[str, str]: + """ + Parse employee ID to extract base ID and work type (supports SP, PW, PT, C) + + Handles multiple formats: + - Suffix with space: "1234 SP", "1234 PW", "1234 PT", "1234 C" + - Suffix without space: "1234SP", "1234PW", "1234PT", "1234C" + - Prefix with space: "SP 1234", "PW 1234", "PT 1234", "C 1234" + - Prefix without space: "SP1234", "PW1234", "PT1234", "C1234" + + Args: + employee_id: Employee ID string in any of the above formats + + Returns: + Tuple of (base_employee_id, work_type) + work_type is one of: 'regular', 'SP', 'PW', 'PT', 'C' + """ + if not employee_id: + return str(employee_id), 'regular' + + employee_id_clean = str(employee_id).strip().upper() + + # Define work type codes + work_type_codes = ['SP', 'PW', 'PT', 'C'] + + for work_type in work_type_codes: + # Pattern 1: Suffix with optional space - "1234 SP" or "1234SP" + suffix_pattern = rf'^(\d+)\s*{work_type}$' + suffix_match = re.match(suffix_pattern, employee_id_clean) + if suffix_match: + return suffix_match.group(1), work_type + + # Pattern 2: Prefix with optional space - "SP 1234" or "SP1234" + prefix_pattern = rf'^{work_type}\s*(\d+)$' + prefix_match = re.match(prefix_pattern, employee_id_clean) + if prefix_match: + return prefix_match.group(1), work_type + + # Default to regular work + return employee_id_clean, 'regular' + + +@dataclass +class AttendanceRecord: + """Represents a single attendance record""" + id: int + employee_id: str + check_in_date: datetime + check_in_time: time + location_name: str + record_type: str = 'check_in' # 'check_in' or 'check_out' + timestamp: datetime = None + action_description: str = '' + + def __post_init__(self): + if self.timestamp is None: + # Combine date and time for timestamp + self.timestamp = datetime.combine(self.check_in_date, self.check_in_time) + + +@dataclass +class RecordPair: + """Represents a paired check-in/check-out record""" + check_in: Optional[AttendanceRecord] + check_out: Optional[AttendanceRecord] + is_miss_punch: bool = False + date: datetime = None + location: str = "" + + def __post_init__(self): + if self.check_in: + self.date = self.check_in.check_in_date + self.location = self.check_in.location_name + elif self.check_out: + self.date = self.check_out.check_in_date + self.location = self.check_out.location_name + + @property + def duration_minutes(self) -> int: + """Calculate duration in minutes between check-in and check-out""" + if self.is_miss_punch or not self.check_in or not self.check_out: + return -1 + + duration = self.check_out.timestamp - self.check_in.timestamp + return int(duration.total_seconds() / 60) + + +class TimeCalculator: + """Base time calculator with rounding functionality""" + + @staticmethod + def round_time_to_nearest_quarter_hour(minutes: int) -> int: + """Round time to nearest quarter hour (15 minutes)""" + if minutes < 0: + return minutes # Keep negative values for miss punches + + # Round to nearest 15-minute interval + return round(minutes / 15) * 15 + + +class DailyTimeCalculator(TimeCalculator): + """Calculate daily working hours with travel time options""" + + def __init__(self): + self.record_pairs: List[RecordPair] = [] + + def add_record_pair(self, pair: RecordPair): + """Add a record pair to the daily calculation""" + self.record_pairs.append(pair) + + def get_minutes_total_exclude_travel_time(self) -> float: + """ + Calculate total minutes excluding travel time. + Returns -1 for miss punches, otherwise returns the quarter-hour- + rounded total minutes (using the 7.5-minute boundary rule). + """ + minute_total = 0 + + for pair in self.record_pairs: + if not pair.is_miss_punch: + minute_total += pair.duration_minutes + else: + return -1 # Miss punch detected + + # Use the 7.5-minute boundary rounding (matches SingleCheckInCalculator) + return round_time_to_quarter_hour(minute_total) + + def get_base100_hours(self) -> float: + """ + Return daily total as base-100 rounded hours. + Returns 0.0 for miss punches. + """ + rounded_minutes = self.get_minutes_total_exclude_travel_time() + if rounded_minutes < 0: + return 0.0 + base100 = convert_minutes_to_base100(rounded_minutes) + return round_base100_hours(base100) + + +class WeeklyTimeCalculator(TimeCalculator): + """Calculate weekly regular and overtime hours""" + + def __init__(self): + self.daily_calculators: List[DailyTimeCalculator] = [] + self.total_minutes = 0 + self.regular_minutes = 0 + self.overtime_minutes = 0 + + def add_daily_calculator(self, daily_calc: DailyTimeCalculator): + """Add a daily time calculator to the weekly calculation""" + self.daily_calculators.append(daily_calc) + + def calculate_time(self): + """Calculate weekly totals with regular and overtime split""" + self.total_minutes = 0 + + for daily_calc in self.daily_calculators: + daily_minutes = daily_calc.get_minutes_total_exclude_travel_time() + if daily_minutes > 0: + self.total_minutes += daily_minutes + + # Calculate regular and overtime + if self.total_minutes > MAX_REGULAR_TIME_MINUTES: + self.regular_minutes = MAX_REGULAR_TIME_MINUTES + else: + self.regular_minutes = self.total_minutes + + self.overtime_minutes = self.total_minutes - self.regular_minutes + + @property + def total_hours(self) -> float: + """Get total hours as decimal""" + return self.total_minutes / 60.0 + + @property + def regular_hours(self) -> float: + """Get regular hours as decimal""" + return self.regular_minutes / 60.0 + + @property + def overtime_hours(self) -> float: + """Get overtime hours as decimal""" + return self.overtime_minutes / 60.0 + + +class RecordPairBuilder: + """Builds record pairs from attendance records""" + + @staticmethod + def build_pairs_from_records(records: List[AttendanceRecord]) -> List[RecordPair]: + """ + Build check-in/check-out pairs from a list of attendance records + + Args: + records: List of AttendanceRecord objects, should be for a single day + + Returns: + List of RecordPair objects + """ + if not records: + return [] + + # Sort by timestamp + sorted_records = sorted(records, key=lambda r: r.timestamp) + + pairs = [] + i = 0 + + while i < len(sorted_records): + current_record = sorted_records[i] + + # Check if this is a check-in + if current_record.record_type == 'check_in': + # Look for matching check-out + check_out_record = None + is_miss_punch = False + j = i + 1 + + while j < len(sorted_records): + next_record = sorted_records[j] + if next_record.record_type == 'check_out': + check_out_record = next_record + i = j + 1 # Move past the check-out + break + elif next_record.record_type == 'check_in': + # Another IN, keep looking + j += 1 + + # If no OUT found, it's an incomplete pair (missed punch) + if check_out_record is None: + is_miss_punch = True + i += 1 + + # Create the pair + pair = RecordPair( + check_in=current_record, + check_out=check_out_record, + is_miss_punch=is_miss_punch + ) + pairs.append(pair) + + else: + # Orphaned check-out (OUT without preceding IN) + pair = RecordPair( + check_in=None, + check_out=current_record, + is_miss_punch=True + ) + pairs.append(pair) + i += 1 + + return pairs + + +class WorkingHoursCalculator: + """ + Main calculator for employee working hours with SP/PW/PT/C support. + + This calculator consolidates employees by base ID, grouping records for + 1234, 1234 SP, 1234 PW, 1234 PT under base employee 1234. + """ + + def __init__(self): + pass + + @log_database_operations('working_hours_calculation') + def calculate_employee_hours(self, employee_id: str, start_date: datetime, end_date: datetime, + attendance_records: List[Dict]) -> Dict[str, Any]: + """ + Calculate working hours for an employee over a date range with SP/PW/PT/C support. + + This method consolidates all records for base employee ID including SP, PW, PT variants. + + Args: + employee_id: Base employee ID (without SP/PW/PT suffix) + start_date: Start date for calculation + end_date: End date for calculation + attendance_records: List of attendance records from database + + Returns: + Dictionary containing daily and weekly hour calculations with SP/PW/PT/C breakdown + """ + try: + _calc_logger.debug(f"Calculating hours for base employee {employee_id} with SP/PW/PT/C support") + + # Parse base employee ID + base_employee_id, _ = parse_employee_id_for_work_type(employee_id) + + # Filter and categorize records by work type + records_by_type = {'regular': [], 'SP': [], 'PW': [], 'PT': [], 'C': []} + + for record in attendance_records: + try: + # Extract employee_id from record + if hasattr(record, '__dict__'): + record_emp_id = str(getattr(record, 'employee_id', '')).strip() + record_date = getattr(record, 'check_in_date', None) + record_time = getattr(record, 'check_in_time', None) + location = getattr(record, 'location_name', 'Unknown Location') + record_id = getattr(record, 'id', 0) + record_type = getattr(record, 'record_type', 'check_in') + action_desc = getattr(record, 'action_description', '') + else: + record_emp_id = str(record.get('employee_id', '')).strip() + record_date = record.get('check_in_date') + record_time = record.get('check_in_time') + location = record.get('location_name', 'Unknown Location') + record_id = record.get('id', 0) + record_type = record.get('record_type', 'check_in') + action_desc = record.get('action_description', '') + + # Skip invalid records + if not record_emp_id or record_date is None or record_time is None: + continue + + # Parse work type from record's employee ID + record_base_id, work_type = parse_employee_id_for_work_type(record_emp_id) + + # Only include records for this base employee + if record_base_id == base_employee_id: + # Determine record type from action_description if available + if action_desc: + action_lower = action_desc.lower() + if 'out' in action_lower or 'checkout' in action_lower: + record_type = 'check_out' + else: + record_type = 'check_in' + + # Create AttendanceRecord object + att_record = AttendanceRecord( + id=record_id, + employee_id=record_emp_id, + check_in_date=record_date if isinstance(record_date, datetime) else datetime.combine(record_date, datetime.min.time()), + check_in_time=record_time, + location_name=location, + record_type=record_type, + action_description=action_desc + ) + records_by_type[work_type].append(att_record) + + except Exception as record_error: + _calc_logger.warning(f"Error processing attendance record: {record_error}") + continue + + total_records = sum(len(records_by_type[wt]) for wt in records_by_type) + _calc_logger.debug( + f"Employee {employee_id}: {total_records} records found — " + f"Regular: {len(records_by_type['regular'])}, SP: {len(records_by_type['SP'])}, " + f"PW: {len(records_by_type['PW'])}, PT: {len(records_by_type['PT'])}, " + f"C: {len(records_by_type['C'])}" + ) + + # Group records by date for each work type + daily_records_by_type = {wt: {} for wt in ['regular', 'SP', 'PW', 'PT', 'C']} + + for work_type in ['regular', 'SP', 'PW', 'PT', 'C']: + for record in records_by_type[work_type]: + date_key = record.check_in_date.strftime('%Y-%m-%d') if isinstance(record.check_in_date, datetime) else record.check_in_date.strftime('%Y-%m-%d') + if date_key not in daily_records_by_type[work_type]: + daily_records_by_type[work_type][date_key] = [] + daily_records_by_type[work_type][date_key].append(record) + + # --------------------------------------------------------------- + # OVERNIGHT SHIFT DETECTION + # If a late-evening check-in (>= 18:00) on Day N has no matching + # check-out on the same day, AND there is an early-morning check-out + # (<= 06:00) on Day N+1 that is itself unpaired, re-assign that + # check-out record to Day N so the pair resolves correctly. + # Hours are attributed to the earlier day (Day N). + # --------------------------------------------------------------- + OVERNIGHT_CHECKIN_HOUR = 19 # Check-in must be at or after 7 PM + OVERNIGHT_CHECKOUT_HOUR = 3 # Check-out must be at or before 3 AM + + for work_type in ['regular', 'SP', 'PW', 'PT', 'C']: + all_dates = sorted(daily_records_by_type[work_type].keys()) + for i, date_key in enumerate(all_dates): + # Guard: date_key may have been deleted by a prior iteration when all + # its records were moved to the previous day's bucket. + # Without this check, iterating the stale all_dates snapshot raises KeyError, + # which is silently caught by the outer try/except and returns an empty + # daily_hours dict — causing the employee to show zero rows in the export. + if date_key not in daily_records_by_type[work_type]: + continue + + day_records = daily_records_by_type[work_type][date_key] + + # Count unpaired check-ins (late evening) + check_ins = [r for r in day_records if r.record_type == 'check_in'] + check_outs = [r for r in day_records if r.record_type == 'check_out'] + + # Early-morning OUTs on Day N (hour <= OVERNIGHT_CHECKOUT_HOUR) are + # themselves overnight orphans from Day N-1. Counting them as regular + # Day N outs inflates the out-count and makes the day appear balanced, + # suppressing overnight detection for the late IN that actually needs + # a next-day OUT. Exclude them from the balance comparison. + check_outs_non_early = [ + r for r in check_outs + if (r.check_in_time.hour if isinstance(r.check_in_time, time) else r.timestamp.hour) + > OVERNIGHT_CHECKOUT_HOUR + ] + + # Any unmatched check-ins that started late in the evening? + unmatched_late_ins = [] + for ci in check_ins: + ci_hour = ci.check_in_time.hour if isinstance(ci.check_in_time, time) else ci.timestamp.hour + if ci_hour >= OVERNIGHT_CHECKIN_HOUR: + # Use non-early outs so orphaned early-morning OUTs from + # the prior night do not mask an unmatched late IN. + if len(check_outs_non_early) < len(check_ins): + unmatched_late_ins.append(ci) + + if not unmatched_late_ins: + continue + + # Look at the next calendar day + if i + 1 >= len(all_dates): + continue + + next_date_key = all_dates[i + 1] + # Guard: next_date_key may also have been deleted by a prior iteration + if next_date_key not in daily_records_by_type[work_type]: + continue + + # Verify it is truly the next day + from datetime import date as date_type + day_n = datetime.strptime(date_key, '%Y-%m-%d').date() + day_n1 = datetime.strptime(next_date_key, '%Y-%m-%d').date() + if (day_n1 - day_n).days != 1: + continue + + next_day_records = daily_records_by_type[work_type][next_date_key] + next_check_outs = [r for r in next_day_records if r.record_type == 'check_out'] + next_check_ins = [r for r in next_day_records if r.record_type == 'check_in'] + + # Identify early-morning check-outs on Day N+1 that are orphaned + orphaned_early_outs = [] + for co in next_check_outs: + co_hour = co.check_in_time.hour if isinstance(co.check_in_time, time) else co.timestamp.hour + if co_hour <= OVERNIGHT_CHECKOUT_HOUR: + # Considered orphaned if there are fewer or equal check-ins to cover it + if len(next_check_ins) < len(next_check_outs): + orphaned_early_outs.append(co) + + # Move orphaned early check-outs from Day N+1 → Day N + for co in orphaned_early_outs[:len(unmatched_late_ins)]: + _calc_logger.info( + f"Overnight shift detected for work_type={work_type} on {date_key}: " + f"moving check-out {co.check_in_time} from {next_date_key} -> {date_key}" + ) + daily_records_by_type[work_type][date_key].append(co) + daily_records_by_type[work_type][next_date_key].remove(co) + + # Clean up empty buckets on Day N+1 + if not daily_records_by_type[work_type][next_date_key]: + del daily_records_by_type[work_type][next_date_key] + # --------------------------------------------------------------- + # END OVERNIGHT SHIFT DETECTION + # --------------------------------------------------------------- + + # Calculate daily hours for each work type + daily_hours = {} + weekly_hours = [] + current_week_hours = {'regular': 0, 'SP': 0, 'PW': 0, 'PT': 0, 'C': 0} + + current_date = start_date + if isinstance(current_date, datetime): + current_date = current_date.date() if hasattr(current_date, 'date') else current_date + + end_date_val = end_date + if isinstance(end_date_val, datetime): + end_date_val = end_date_val.date() if hasattr(end_date_val, 'date') else end_date_val + + while current_date <= end_date_val: + date_key = current_date.strftime('%Y-%m-%d') + + # Calculate hours for each work type on this day + hours_by_type = {} + is_miss_punch_by_type = {} + records_count_by_type = {} + + for work_type in ['regular', 'SP', 'PW', 'PT', 'C']: + day_records = daily_records_by_type[work_type].get(date_key, []) + records_count_by_type[work_type] = len(day_records) + + if day_records: + # Build pairs and calculate hours + daily_calc = DailyTimeCalculator() + pairs = RecordPairBuilder.build_pairs_from_records(day_records) + for pair in pairs: + daily_calc.add_record_pair(pair) + + total_minutes = daily_calc.get_minutes_total_exclude_travel_time() + + if total_minutes < 0: + hours_by_type[work_type] = 0.0 + is_miss_punch_by_type[work_type] = True + else: + # Apply full rounding pipeline: + # 1. round_time_to_quarter_hour (already done inside get_minutes_total) + # 2. convert to base-100 + # 3. round_base100_hours + base100 = convert_minutes_to_base100(total_minutes) + hours_by_type[work_type] = round_base100_hours(base100) + is_miss_punch_by_type[work_type] = False + else: + hours_by_type[work_type] = 0.0 + is_miss_punch_by_type[work_type] = False + + # Store daily data with SP/PW/PT/C breakdown + total_day_hours = sum(hours_by_type.values()) + total_records_count = sum(records_count_by_type.values()) + + daily_hours[date_key] = { + 'total_minutes': int(total_day_hours * 60), + 'total_hours': total_day_hours, + 'regular_hours': hours_by_type['regular'], + 'sp_hours': hours_by_type['SP'], + 'pw_hours': hours_by_type['PW'], + 'pt_hours': hours_by_type['PT'], + 'c_hours': hours_by_type['C'], + 'is_miss_punch': any(is_miss_punch_by_type.values()), + 'records_count': total_records_count, + 'miss_punch_details': { + 'regular': is_miss_punch_by_type['regular'], + 'SP': is_miss_punch_by_type['SP'], + 'PW': is_miss_punch_by_type['PW'], + 'PT': is_miss_punch_by_type['PT'], + 'C': is_miss_punch_by_type['C'] + } + } + + # Accumulate weekly hours by type + for work_type in ['regular', 'SP', 'PW', 'PT', 'C']: + current_week_hours[work_type] += hours_by_type[work_type] + + # Check for end of week (Sunday) or end of period + is_end_of_week = current_date.weekday() == 6 + is_end_of_period = current_date >= end_date_val + + if is_end_of_week or is_end_of_period: + # Calculate weekly totals + week_regular_total = current_week_hours['regular'] + week_sp_total = current_week_hours['SP'] + week_pw_total = current_week_hours['PW'] + week_pt_total = current_week_hours['PT'] + week_c_total = current_week_hours['C'] + week_total = week_regular_total + week_sp_total + week_pw_total + week_pt_total + week_c_total + + # Only regular hours count toward overtime (40 hour rule) + week_regular_hours = min(week_regular_total, 40.0) + week_overtime_hours = max(0, week_regular_total - 40.0) + + # Apply round_base100_hours to all weekly totals + week_total_r = round_base100_hours(week_total) + week_regular_r = round_base100_hours(week_regular_hours) + week_overtime_r = round_base100_hours(week_overtime_hours) + week_sp_r = round_base100_hours(week_sp_total) + week_pw_r = round_base100_hours(week_pw_total) + week_pt_r = round_base100_hours(week_pt_total) + week_c_r = round_base100_hours(week_c_total) + + weekly_hours.append({ + 'total_hours': week_total_r, + 'regular_hours': week_regular_r, + 'overtime_hours': week_overtime_r, + 'sp_hours': week_sp_r, + 'pw_hours': week_pw_r, + 'pt_hours': week_pt_r, + 'c_hours': week_c_r, + 'total_minutes': int(week_total_r * 60), + 'regular_minutes': int(week_regular_r * 60), + 'overtime_minutes': int(week_overtime_r * 60), + 'sp_minutes': int(week_sp_r * 60), + 'pw_minutes': int(week_pw_r * 60), + 'pt_minutes': int(week_pt_r * 60), + 'c_minutes': int(week_c_r * 60), + }) + + # Reset for next week + current_week_hours = {'regular': 0, 'SP': 0, 'PW': 0, 'PT': 0, 'C': 0} + + current_date += timedelta(days=1) + + # Calculate grand totals — apply round_base100_hours to each sum + grand_total_hours = round_base100_hours(sum(week['total_hours'] for week in weekly_hours)) + grand_regular_hours = round_base100_hours(sum(week['regular_hours'] for week in weekly_hours)) + grand_overtime_hours = round_base100_hours(sum(week['overtime_hours'] for week in weekly_hours)) + grand_sp_hours = round_base100_hours(sum(week['sp_hours'] for week in weekly_hours)) + grand_pw_hours = round_base100_hours(sum(week['pw_hours'] for week in weekly_hours)) + grand_pt_hours = round_base100_hours(sum(week.get('pt_hours', 0) for week in weekly_hours)) + grand_c_hours = round_base100_hours(sum(week.get('c_hours', 0) for week in weekly_hours)) + + _calc_logger.info( + f"Employee {employee_id}: Total={grand_total_hours:.2f}h " + f"(Regular={grand_regular_hours:.2f}h, OT={grand_overtime_hours:.2f}h, " + f"SP={grand_sp_hours:.2f}h, PW={grand_pw_hours:.2f}h, PT={grand_pt_hours:.2f}h, " + f"C={grand_c_hours:.2f}h)" + ) + + return { + 'employee_id': employee_id, + 'base_employee_id': base_employee_id, + 'start_date': start_date.strftime('%Y-%m-%d') if hasattr(start_date, 'strftime') else str(start_date), + 'end_date': end_date.strftime('%Y-%m-%d') if hasattr(end_date, 'strftime') else str(end_date), + 'daily_hours': daily_hours, + 'weekly_hours': weekly_hours, + 'grand_totals': { + 'total_hours': grand_total_hours, + 'regular_hours': grand_regular_hours, + 'overtime_hours': grand_overtime_hours, + 'sp_hours': grand_sp_hours, + 'pw_hours': grand_pw_hours, + 'pt_hours': grand_pt_hours, + 'c_hours': grand_c_hours, + 'total_minutes': int(grand_total_hours * 60), + 'regular_minutes': int(grand_regular_hours * 60), + 'overtime_minutes': int(grand_overtime_hours * 60), + 'sp_minutes': int(grand_sp_hours * 60), + 'pw_minutes': int(grand_pw_hours * 60), + 'pt_minutes': int(grand_pt_hours * 60), + 'c_minutes': int(grand_c_hours * 60), + } + } + + except Exception as e: + _calc_logger.error(f"Error calculating working hours for employee {employee_id}: {e}", exc_info=True) + raise e + + def calculate_all_employees_hours(self, start_date: datetime, end_date: datetime, + attendance_records: List[Dict]) -> Dict[str, Any]: + """ + Calculate working hours for all employees in the given period. + + This method consolidates employees by base ID, so records for 1234, 1234 SP, + 1234 PW, 1234 PT, 1234 C will all be grouped under base employee 1234. + + Args: + start_date: Start date for calculation + end_date: End date for calculation + attendance_records: List of attendance records from database + + Returns: + Dictionary containing hours data for all employees with SP/PW/PT/C breakdown + """ + try: + _calc_logger.info("Starting hours calculation for all employees with SP/PW/PT/C consolidation") + + # Get unique BASE employee IDs (consolidate SP/PW/PT/C variants) + base_employee_ids = set() + for record in attendance_records: + try: + if hasattr(record, '__dict__'): + employee_id = str(getattr(record, 'employee_id', '')).strip() + else: + employee_id = str(record.get('employee_id', '')).strip() + + if employee_id: + base_id, _ = parse_employee_id_for_work_type(employee_id) + if base_id: + base_employee_ids.add(base_id) + + except Exception as e: + _calc_logger.warning(f"Error processing employee ID during consolidation: {e}") + continue + + _calc_logger.info(f"Found {len(base_employee_ids)} unique base employees (after SP/PW/PT/C consolidation)") + + results = {} + for base_emp_id in sorted(base_employee_ids): + try: + _calc_logger.debug(f"Processing base employee {base_emp_id}") + results[base_emp_id] = self.calculate_employee_hours( + base_emp_id, start_date, end_date, attendance_records + ) + except Exception as e: + _calc_logger.error(f"Error processing employee {base_emp_id}: {e}", exc_info=True) + # Return empty result for this employee + results[base_emp_id] = { + 'employee_id': base_emp_id, + 'base_employee_id': base_emp_id, + 'start_date': start_date.strftime('%Y-%m-%d') if hasattr(start_date, 'strftime') else str(start_date), + 'end_date': end_date.strftime('%Y-%m-%d') if hasattr(end_date, 'strftime') else str(end_date), + 'daily_hours': {}, + 'weekly_hours': [], + 'grand_totals': { + 'total_hours': 0.0, + 'regular_hours': 0.0, + 'overtime_hours': 0.0, + 'sp_hours': 0.0, + 'pw_hours': 0.0, + 'pt_hours': 0.0, + 'c_hours': 0.0, + 'total_minutes': 0, + 'regular_minutes': 0, + 'overtime_minutes': 0, + 'sp_minutes': 0, + 'pw_minutes': 0, + 'pt_minutes': 0, + 'c_minutes': 0 + } + } + continue + + return { + 'calculation_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'period_start': start_date.strftime('%Y-%m-%d') if hasattr(start_date, 'strftime') else str(start_date), + 'period_end': end_date.strftime('%Y-%m-%d') if hasattr(end_date, 'strftime') else str(end_date), + 'employee_count': len(base_employee_ids), + 'employees': results + } + + except Exception as e: + _calc_logger.error(f"Error calculating hours for all employees: {e}", exc_info=True) + raise e \ No newline at end of file