├── pibioffline ├── api │ ├── __init__.py │ └── attendance.py ├── public │ ├── .gitkeep │ ├── js │ │ ├── offline │ │ │ ├── pwa-init.js │ │ │ ├── service-worker.js │ │ │ ├── offline-storage.js │ │ │ └── qr-scanner.js │ │ ├── workspace-block.js │ │ └── pibioffline.js │ ├── manifest.json │ └── css │ │ └── pibioffline.css ├── config │ └── __init__.py ├── patches │ └── __init__.py ├── templates │ ├── __init__.py │ └── pages │ │ └── __init__.py ├── utils │ └── __init__.py ├── modules.txt ├── pibioffline │ ├── __init__.py │ ├── doctype │ │ ├── __init__.py │ │ ├── pl_person │ │ │ ├── __init__.py │ │ │ ├── pl_person.js │ │ │ ├── test_pl_person.py │ │ │ ├── pl_person.py │ │ │ └── pl_person.json │ │ ├── pl_organization │ │ │ ├── __init__.py │ │ │ ├── pl_organization.py │ │ │ └── pl_organization.json │ │ ├── pl_work_session │ │ │ ├── __init__.py │ │ │ ├── pl_work_session.js │ │ │ ├── test_pl_work_session.py │ │ │ ├── pl_work_session.py │ │ │ └── pl_work_session.json │ │ ├── pl_employee_assignment │ │ │ ├── __init__.py │ │ │ ├── test_pl_employee_assignment.py │ │ │ ├── pl_employee_assignment.js │ │ │ ├── pl_employee_assignment.py │ │ │ └── pl_employee_assignment.json │ │ └── pl_resource_attendance │ │ │ ├── __init__.py │ │ │ ├── pl_resource_attendance.js │ │ │ ├── test_pl_resource_attendance.py │ │ │ ├── pl_resource_attendance.py │ │ │ └── pl_resource_attendance.json │ └── workspace │ │ ├── pibioffline │ │ └── pibioffline.json │ │ └── pibioffline_settings │ │ └── pibioffline_settings.json ├── __init__.py ├── install.py ├── patches.txt ├── pl_offline.html ├── pl_offline.css ├── fixtures │ └── workspace.json └── hooks.py ├── .gitignore ├── requirements.txt ├── .editorconfig ├── license.txt ├── pyproject.toml ├── .pre-commit-config.yaml ├── .eslintrc └── README.md /pibioffline/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pibioffline/public/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pibioffline/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pibioffline/patches/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pibioffline/templates/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pibioffline/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pibioffline/modules.txt: -------------------------------------------------------------------------------- 1 | pibiOffline -------------------------------------------------------------------------------- /pibioffline/pibioffline/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pibioffline/templates/pages/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pibioffline/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.1" 2 | -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_person/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_organization/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_work_session/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_employee_assignment/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_resource_attendance/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.egg-info 4 | *.swp 5 | tags 6 | node_modules 7 | __pycache__ -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | opencv-python==4.8.1.78 2 | numpy==1.24.3 3 | qrcode==7.4.2 4 | Pillow==10.0.0 -------------------------------------------------------------------------------- /pibioffline/install.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | 3 | def after_install(): 4 | """Create custom fields after app installation""" 5 | # No custom fields needed since we're using PL Employee 6 | pass -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_person/pl_person.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025, pibiCo and contributors 2 | // For license information, please see license.txt 3 | 4 | // frappe.ui.form.on("PL Person", { 5 | // refresh(frm) { 6 | 7 | // }, 8 | // }); 9 | -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_work_session/pl_work_session.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025, pibiCo and contributors 2 | // For license information, please see license.txt 3 | 4 | // frappe.ui.form.on("PL Work Session", { 5 | // refresh(frm) { 6 | 7 | // }, 8 | // }); 9 | -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_person/test_pl_person.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025, pibiCo and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | from frappe.tests.utils import FrappeTestCase 6 | 7 | 8 | class TestPLPerson(FrappeTestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_work_session/test_pl_work_session.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025, pibiCo and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | from frappe.tests.utils import FrappeTestCase 6 | 7 | 8 | class TestPLWorkSession(FrappeTestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_resource_attendance/pl_resource_attendance.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025, pibiCo and contributors 2 | // For license information, please see license.txt 3 | 4 | // frappe.ui.form.on("PL Resource Attendance", { 5 | // refresh(frm) { 6 | 7 | // }, 8 | // }); 9 | -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_employee_assignment/test_pl_employee_assignment.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025, pibiCo and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | from frappe.tests.utils import FrappeTestCase 6 | 7 | 8 | class TestPLEmployeeAssignment(FrappeTestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_resource_attendance/test_pl_resource_attendance.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025, pibiCo and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | from frappe.tests.utils import FrappeTestCase 6 | 7 | 8 | class TestPLResourceAttendance(FrappeTestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /pibioffline/patches.txt: -------------------------------------------------------------------------------- 1 | [pre_model_sync] 2 | # Patches added in this section will be executed before doctypes are migrated 3 | # Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations 4 | 5 | [post_model_sync] 6 | # Patches added in this section will be executed after doctypes are migrated -------------------------------------------------------------------------------- /pibioffline/public/js/offline/pwa-init.js: -------------------------------------------------------------------------------- 1 | if ('serviceWorker' in navigator) { 2 | window.addEventListener('load', () => { 3 | navigator.serviceWorker.register('/assets/pibioffline/js/offline/service-worker.js') 4 | .then(registration => { 5 | console.log('ServiceWorker registration successful:', registration.scope); 6 | }) 7 | .catch(err => { 8 | console.log('ServiceWorker registration failed:', err); 9 | }); 10 | }); 11 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Root editor config file 2 | root = true 3 | 4 | # Common settings 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | charset = utf-8 10 | 11 | # python, js indentation settings 12 | [{*.py,*.js,*.vue,*.css,*.scss,*.html}] 13 | indent_style = tab 14 | indent_size = 4 15 | max_line_length = 99 16 | 17 | # JSON files - mostly doctype schema files 18 | [{*.json}] 19 | insert_final_newline = false 20 | indent_style = space 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_person/pl_person.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.model.document import Document 3 | 4 | class PLPerson(Document): 5 | def validate(self): 6 | self.validate_user_link() 7 | 8 | def validate_user_link(self): 9 | """Ensure user link is unique if provided""" 10 | if self.user: 11 | existing = frappe.db.exists("PL Person", { 12 | "user": self.user, 13 | "name": ["!=", self.name] 14 | }) 15 | if existing: 16 | frappe.throw(f"User {self.user} is already linked to another person") -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_organization/pl_organization.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.model.document import Document 3 | 4 | class PLOrganization(Document): 5 | def validate(self): 6 | self.validate_qr_prefix() 7 | 8 | def validate_qr_prefix(self): 9 | """Ensure QR code prefix is unique if provided""" 10 | if self.qr_code_prefix: 11 | existing = frappe.db.exists("PL Organization", { 12 | "qr_code_prefix": self.qr_code_prefix, 13 | "name": ["!=", self.name] 14 | }) 15 | if existing: 16 | frappe.throw(f"QR Code Prefix '{self.qr_code_prefix}' is already used by another organization") -------------------------------------------------------------------------------- /pibioffline/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pibiOffline - Work Attendance", 3 | "short_name": "pibiOffline", 4 | "description": "PWA for QR code identification and resource attendance", 5 | "start_url": "/app/pibioffline", 6 | "display": "standalone", 7 | "background_color": "#ffffff", 8 | "theme_color": "#1a73e8", 9 | "orientation": "portrait", 10 | "scope": "/", 11 | "prefer_related_applications": false, 12 | "icons": [ 13 | { 14 | "src": "/assets/pibioffline/images/icon-192.png", 15 | "sizes": "192x192", 16 | "type": "image/png", 17 | "purpose": "any maskable" 18 | }, 19 | { 20 | "src": "/assets/pibioffline/images/icon-512.png", 21 | "sizes": "512x512", 22 | "type": "image/png", 23 | "purpose": "any maskable" 24 | } 25 | ], 26 | "permissions": { 27 | "camera": { 28 | "description": "Required for QR code scanning" 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pibioffline/public/js/workspace-block.js: -------------------------------------------------------------------------------- 1 | // This code can be used in a Custom HTML Block in the workspace 2 | // Create a new workspace called "pibiOffline" and add a Custom HTML Block with this code 3 | 4 | frappe.ready(() => { 5 | // Initialize the attendance tracker when the workspace loads 6 | const container = document.getElementById('pibioffline-app-container'); 7 | if (container && !window.pibiofflineTracker) { 8 | // Initialize the offline storage first 9 | pibioffline.storage.init().then(() => { 10 | window.pibiofflineTracker = new pibioffline.AttendanceTracker(container); 11 | pibioffline.tracker = window.pibiofflineTracker; 12 | }); 13 | } 14 | }); 15 | 16 | // HTML to paste in the Custom HTML Block: 17 | /* 18 |
19 | 20 | 39 | */ -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pibioffline" 3 | authors = [ 4 | { name = "pibiCo", email = "pibico.sl@gmail.com"} 5 | ] 6 | description = "PWA for Work Attendance" 7 | requires-python = ">=3.10" 8 | readme = "README.md" 9 | dynamic = ["version"] 10 | dependencies = [ 11 | # "frappe~=15.0.0" # Installed and managed by bench. 12 | ] 13 | 14 | [build-system] 15 | requires = ["flit_core >=3.4,<4"] 16 | build-backend = "flit_core.buildapi" 17 | 18 | # These dependencies are only installed when developer mode is enabled 19 | [tool.bench.dev-dependencies] 20 | # package_name = "~=1.1.0" 21 | 22 | [tool.ruff] 23 | line-length = 110 24 | target-version = "py310" 25 | 26 | [tool.ruff.lint] 27 | select = [ 28 | "F", 29 | "E", 30 | "W", 31 | "I", 32 | "UP", 33 | "B", 34 | "RUF", 35 | ] 36 | ignore = [ 37 | "B017", # assertRaises(Exception) - should be more specific 38 | "B018", # useless expression, not assigned to anything 39 | "B023", # function doesn't bind loop variable - will have last iteration's value 40 | "B904", # raise inside except without from 41 | "E101", # indentation contains mixed spaces and tabs 42 | "E402", # module level import not at top of file 43 | "E501", # line too long 44 | "E741", # ambiguous variable name 45 | "F401", # "unused" imports 46 | "F403", # can't detect undefined names from * import 47 | "F405", # can't detect undefined names from * import 48 | "F722", # syntax error in forward type annotation 49 | "W191", # indentation contains tabs 50 | ] 51 | typing-modules = ["frappe.types.DF"] 52 | 53 | [tool.ruff.format] 54 | quote-style = "double" 55 | indent-style = "tab" 56 | docstring-code-format = true 57 | -------------------------------------------------------------------------------- /pibioffline/pibioffline/workspace/pibioffline/pibioffline.json: -------------------------------------------------------------------------------- 1 | { 2 | "charts": [], 3 | "content": "[{\"id\":\"xjnjJaoWVc\",\"type\":\"custom_block\",\"data\":{\"custom_block_name\":\"PL pibiOffline\",\"col\":12}}]", 4 | "creation": "2025-07-03 22:24:26.851961", 5 | "custom_blocks": [ 6 | { 7 | "custom_block_name": "PL pibiOffline", 8 | "label": "PL pibiOffline" 9 | } 10 | ], 11 | "docstatus": 0, 12 | "doctype": "Workspace", 13 | "for_user": "", 14 | "hide_custom": 0, 15 | "icon": "scan", 16 | "idx": 0, 17 | "indicator_color": "green", 18 | "is_hidden": 0, 19 | "label": "pibiOffline", 20 | "links": [ 21 | { 22 | "hidden": 0, 23 | "is_query_report": 0, 24 | "label": "Employee Assignment", 25 | "link_count": 0, 26 | "link_to": "PL Employee Assignment", 27 | "link_type": "DocType", 28 | "onboard": 0, 29 | "type": "Link" 30 | }, 31 | { 32 | "hidden": 0, 33 | "is_query_report": 0, 34 | "label": "Resource Attendance", 35 | "link_count": 0, 36 | "link_to": "PL Resource Attendance", 37 | "link_type": "DocType", 38 | "onboard": 0, 39 | "type": "Link" 40 | }, 41 | { 42 | "hidden": 0, 43 | "is_query_report": 0, 44 | "label": "Work Session", 45 | "link_count": 0, 46 | "link_to": "PL Work Session", 47 | "link_type": "DocType", 48 | "onboard": 0, 49 | "type": "Link" 50 | } 51 | ], 52 | "modified": "2025-07-04 08:43:35.641289", 53 | "modified_by": "Administrator", 54 | "module": "pibiOffline", 55 | "name": "pibiOffline", 56 | "number_cards": [], 57 | "owner": "Administrator", 58 | "parent_page": "", 59 | "public": 1, 60 | "quick_lists": [], 61 | "roles": [], 62 | "sequence_id": 1.0, 63 | "shortcuts": [], 64 | "title": "pibiOffline" 65 | } -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: 'node_modules|.git' 2 | default_stages: [pre-commit] 3 | fail_fast: false 4 | 5 | 6 | repos: 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v5.0.0 9 | hooks: 10 | - id: trailing-whitespace 11 | files: "pibioffline.*" 12 | exclude: ".*json$|.*txt$|.*csv|.*md|.*svg" 13 | - id: check-merge-conflict 14 | - id: check-ast 15 | - id: check-json 16 | - id: check-toml 17 | - id: check-yaml 18 | - id: debug-statements 19 | 20 | - repo: https://github.com/astral-sh/ruff-pre-commit 21 | rev: v0.8.1 22 | hooks: 23 | - id: ruff 24 | name: "Run ruff import sorter" 25 | args: ["--select=I", "--fix"] 26 | 27 | - id: ruff 28 | name: "Run ruff linter" 29 | 30 | - id: ruff-format 31 | name: "Run ruff formatter" 32 | 33 | - repo: https://github.com/pre-commit/mirrors-prettier 34 | rev: v2.7.1 35 | hooks: 36 | - id: prettier 37 | types_or: [javascript, vue, scss] 38 | # Ignore any files that might contain jinja / bundles 39 | exclude: | 40 | (?x)^( 41 | pibioffline/public/dist/.*| 42 | .*node_modules.*| 43 | .*boilerplate.*| 44 | pibioffline/templates/includes/.*| 45 | pibioffline/public/js/lib/.* 46 | )$ 47 | 48 | 49 | - repo: https://github.com/pre-commit/mirrors-eslint 50 | rev: v8.44.0 51 | hooks: 52 | - id: eslint 53 | types_or: [javascript] 54 | args: ['--quiet'] 55 | # Ignore any files that might contain jinja / bundles 56 | exclude: | 57 | (?x)^( 58 | pibioffline/public/dist/.*| 59 | cypress/.*| 60 | .*node_modules.*| 61 | .*boilerplate.*| 62 | pibioffline/templates/includes/.*| 63 | pibioffline/public/js/lib/.* 64 | )$ 65 | 66 | ci: 67 | autoupdate_schedule: weekly 68 | skip: [] 69 | submodules: false 70 | -------------------------------------------------------------------------------- /pibioffline/public/js/offline/service-worker.js: -------------------------------------------------------------------------------- 1 | const CACHE_NAME = 'pibioffline-v2'; 2 | const urlsToCache = [ 3 | '/app/pibioffline', 4 | '/assets/pibioffline/css/pibioffline.css', 5 | '/assets/pibioffline/js/offline/pibioffline.js', 6 | '/assets/pibioffline/js/offline/qr-scanner.js', 7 | '/assets/pibioffline/js/offline/jsQR.min.js' 8 | ]; 9 | 10 | self.addEventListener('install', event => { 11 | event.waitUntil( 12 | caches.open(CACHE_NAME) 13 | .then(cache => cache.addAll(urlsToCache)) 14 | ); 15 | self.skipWaiting(); 16 | }); 17 | 18 | self.addEventListener('fetch', event => { 19 | // Skip caching for camera/media streams 20 | if (event.request.url.includes('getUserMedia') || 21 | event.request.url.includes('mediaDevices') || 22 | event.request.url.includes('blob:')) { 23 | return; 24 | } 25 | 26 | // Network first for API calls 27 | if (event.request.url.includes('/api/')) { 28 | event.respondWith( 29 | fetch(event.request) 30 | .then(response => response) 31 | .catch(() => caches.match(event.request)) 32 | ); 33 | return; 34 | } 35 | 36 | // Cache first for assets 37 | event.respondWith( 38 | caches.match(event.request) 39 | .then(response => { 40 | if (response) { 41 | return response; 42 | } 43 | return fetch(event.request).then( 44 | response => { 45 | if(!response || response.status !== 200 || response.type !== 'basic') { 46 | return response; 47 | } 48 | const responseToCache = response.clone(); 49 | caches.open(CACHE_NAME) 50 | .then(cache => { 51 | cache.put(event.request, responseToCache); 52 | }); 53 | return response; 54 | } 55 | ); 56 | }) 57 | ); 58 | }); 59 | 60 | self.addEventListener('activate', event => { 61 | const cacheWhitelist = [CACHE_NAME]; 62 | event.waitUntil( 63 | caches.keys().then(cacheNames => { 64 | return Promise.all( 65 | cacheNames.map(cacheName => { 66 | if (cacheWhitelist.indexOf(cacheName) === -1) { 67 | return caches.delete(cacheName); 68 | } 69 | }) 70 | ); 71 | }) 72 | ); 73 | }); -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_resource_attendance/pl_resource_attendance.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.model.document import Document 3 | from frappe.utils import format_datetime, get_datetime 4 | import json 5 | 6 | class PLResourceAttendance(Document): 7 | def before_save(self): 8 | if not self.sync_status: 9 | self.sync_status = "Pending" 10 | 11 | # Set geolocation from lat/lng if available 12 | if self.latitude and self.longitude: 13 | self.geolocation = json.dumps({ 14 | "type": "FeatureCollection", 15 | "features": [{ 16 | "type": "Feature", 17 | "properties": {}, 18 | "geometry": { 19 | "type": "Point", 20 | "coordinates": [float(self.longitude), float(self.latitude)] 21 | } 22 | }] 23 | }) 24 | 25 | def after_insert(self): 26 | # Set auto-generated title with employee name, type and timestamp 27 | self.set_title() 28 | 29 | def set_title(self): 30 | # Get employee assignment details 31 | if self.resource: 32 | assignment = frappe.get_doc("PL Employee Assignment", self.resource) 33 | person_name = frappe.get_value("PL Person", assignment.person, "full_name") 34 | # Try to get abbreviation, fallback to organization code if not available 35 | try: 36 | org_abbr = frappe.get_value("PL Organization", assignment.organization, "abbr") 37 | except: 38 | org_abbr = None 39 | 40 | if not org_abbr: 41 | org_abbr = frappe.get_value("PL Organization", assignment.organization, "organization_code") or assignment.organization_name[:3].upper() 42 | 43 | # Format timestamp 44 | timestamp_str = format_datetime(self.timestamp, "dd-MMM HH:mm") 45 | 46 | # Create title: "John Doe - ABC - Check In - 04-Jan 14:30" 47 | title = f"{person_name} - {org_abbr} - {self.attendance_type} - {timestamp_str}" 48 | 49 | frappe.db.set_value("PL Resource Attendance", self.name, "title", title, update_modified=False) 50 | self.db_set("title", title, update_modified=False) -------------------------------------------------------------------------------- /pibioffline/pibioffline/workspace/pibioffline_settings/pibioffline_settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "charts": [], 3 | "content": "[{\"id\":\"Fz6fjhrLtL\",\"type\":\"header\",\"data\":{\"text\":\"pibiOffline Settings\",\"col\":12}},{\"id\":\"ctECjT_L4Y\",\"type\":\"card\",\"data\":{\"card_name\":\"Documents\",\"col\":4}}]", 4 | "creation": "2025-07-04 12:12:39.155854", 5 | "custom_blocks": [], 6 | "docstatus": 0, 7 | "doctype": "Workspace", 8 | "for_user": "", 9 | "hide_custom": 0, 10 | "icon": "users", 11 | "idx": 0, 12 | "indicator_color": "green", 13 | "is_hidden": 0, 14 | "label": "pibiOffline Settings", 15 | "links": [ 16 | { 17 | "hidden": 0, 18 | "is_query_report": 0, 19 | "label": "Documents", 20 | "link_count": 5, 21 | "link_type": "DocType", 22 | "onboard": 0, 23 | "type": "Card Break" 24 | }, 25 | { 26 | "hidden": 0, 27 | "is_query_report": 0, 28 | "label": "Organization", 29 | "link_count": 0, 30 | "link_to": "PL Organization", 31 | "link_type": "DocType", 32 | "onboard": 0, 33 | "type": "Link" 34 | }, 35 | { 36 | "hidden": 0, 37 | "is_query_report": 0, 38 | "label": "Person", 39 | "link_count": 0, 40 | "link_to": "PL Person", 41 | "link_type": "DocType", 42 | "onboard": 0, 43 | "type": "Link" 44 | }, 45 | { 46 | "hidden": 0, 47 | "is_query_report": 0, 48 | "label": "Employee Assignment", 49 | "link_count": 0, 50 | "link_to": "PL Employee Assignment", 51 | "link_type": "DocType", 52 | "onboard": 0, 53 | "type": "Link" 54 | }, 55 | { 56 | "hidden": 0, 57 | "is_query_report": 0, 58 | "label": "Resource Attendance", 59 | "link_count": 0, 60 | "link_to": "PL Resource Attendance", 61 | "link_type": "DocType", 62 | "onboard": 0, 63 | "type": "Link" 64 | }, 65 | { 66 | "hidden": 0, 67 | "is_query_report": 0, 68 | "label": "Work Session", 69 | "link_count": 0, 70 | "link_to": "PL Work Session", 71 | "link_type": "DocType", 72 | "onboard": 0, 73 | "type": "Link" 74 | } 75 | ], 76 | "modified": "2025-07-04 12:13:13.125848", 77 | "modified_by": "Administrator", 78 | "module": "pibiOffline", 79 | "name": "pibiOffline Settings", 80 | "number_cards": [], 81 | "owner": "Administrator", 82 | "parent_page": "pibiSettings", 83 | "public": 1, 84 | "quick_lists": [], 85 | "roles": [ 86 | { 87 | "role": "Administrator" 88 | } 89 | ], 90 | "sequence_id": 23.0, 91 | "shortcuts": [], 92 | "title": "pibiOffline Settings" 93 | } -------------------------------------------------------------------------------- /pibioffline/pl_offline.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Resource Attendance

5 |
6 | 7 | Checking... 8 |
9 |
10 | 11 |
12 |
13 |
14 | 15 |
16 |
17 |
Current Status
18 |
Not Checked In
19 |
20 |
21 |
22 | 23 |
24 | 27 |
28 | 29 | 51 | 52 | 56 |
57 |
-------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_employee_assignment/pl_employee_assignment.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on('PL Employee Assignment', { 2 | refresh: function(frm) { 3 | if (frm.doc.qr_code) { 4 | // Add button to view QR code 5 | frm.add_custom_button(__('View QR Code'), function() { 6 | frappe.call({ 7 | method: 'get_qr_code_html', 8 | doc: frm.doc, 9 | callback: function(r) { 10 | if (r.message) { 11 | const d = new frappe.ui.Dialog({ 12 | title: __('Employee QR Code'), 13 | fields: [ 14 | { 15 | fieldtype: 'HTML', 16 | fieldname: 'qr_html', 17 | options: r.message 18 | } 19 | ], 20 | size: 'large' 21 | }); 22 | d.show(); 23 | } 24 | } 25 | }); 26 | }, __('Actions')); 27 | 28 | // Add button to download QR code directly 29 | frm.add_custom_button(__('Download QR Code'), function() { 30 | frappe.call({ 31 | method: 'get_qr_code_html', 32 | doc: frm.doc, 33 | callback: function(r) { 34 | if (r.message) { 35 | // Extract base64 image from HTML 36 | const parser = new DOMParser(); 37 | const doc = parser.parseFromString(r.message, 'text/html'); 38 | const img = doc.querySelector('img[src^="data:image/png;base64,"]'); 39 | 40 | if (img) { 41 | const link = document.createElement('a'); 42 | link.href = img.src; 43 | link.download = `qr_${frm.doc.organization}_${frm.doc.employee_code}.png`; 44 | document.body.appendChild(link); 45 | link.click(); 46 | document.body.removeChild(link); 47 | frappe.show_alert({ 48 | message: __('QR Code downloaded successfully'), 49 | indicator: 'green' 50 | }); 51 | } 52 | } 53 | } 54 | }); 55 | }, __('Actions')); 56 | } 57 | } 58 | }); -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "es2022": true 6 | }, 7 | "parserOptions": { 8 | "sourceType": "module" 9 | }, 10 | "extends": "eslint:recommended", 11 | "rules": { 12 | "indent": "off", 13 | "brace-style": "off", 14 | "no-mixed-spaces-and-tabs": "off", 15 | "no-useless-escape": "off", 16 | "space-unary-ops": ["error", { "words": true }], 17 | "linebreak-style": "off", 18 | "quotes": ["off"], 19 | "semi": "off", 20 | "camelcase": "off", 21 | "no-unused-vars": "off", 22 | "no-console": ["warn"], 23 | "no-extra-boolean-cast": ["off"], 24 | "no-control-regex": ["off"], 25 | }, 26 | "root": true, 27 | "globals": { 28 | "frappe": true, 29 | "Vue": true, 30 | "SetVueGlobals": true, 31 | "__": true, 32 | "repl": true, 33 | "Class": true, 34 | "locals": true, 35 | "cint": true, 36 | "cstr": true, 37 | "cur_frm": true, 38 | "cur_dialog": true, 39 | "cur_page": true, 40 | "cur_list": true, 41 | "cur_tree": true, 42 | "msg_dialog": true, 43 | "is_null": true, 44 | "in_list": true, 45 | "has_common": true, 46 | "posthog": true, 47 | "has_words": true, 48 | "validate_email": true, 49 | "open_web_template_values_editor": true, 50 | "validate_name": true, 51 | "validate_phone": true, 52 | "validate_url": true, 53 | "get_number_format": true, 54 | "format_number": true, 55 | "format_currency": true, 56 | "comment_when": true, 57 | "open_url_post": true, 58 | "toTitle": true, 59 | "lstrip": true, 60 | "rstrip": true, 61 | "strip": true, 62 | "strip_html": true, 63 | "replace_all": true, 64 | "flt": true, 65 | "precision": true, 66 | "CREATE": true, 67 | "AMEND": true, 68 | "CANCEL": true, 69 | "copy_dict": true, 70 | "get_number_format_info": true, 71 | "strip_number_groups": true, 72 | "print_table": true, 73 | "Layout": true, 74 | "web_form_settings": true, 75 | "$c": true, 76 | "$a": true, 77 | "$i": true, 78 | "$bg": true, 79 | "$y": true, 80 | "$c_obj": true, 81 | "refresh_many": true, 82 | "refresh_field": true, 83 | "toggle_field": true, 84 | "get_field_obj": true, 85 | "get_query_params": true, 86 | "unhide_field": true, 87 | "hide_field": true, 88 | "set_field_options": true, 89 | "getCookie": true, 90 | "getCookies": true, 91 | "get_url_arg": true, 92 | "md5": true, 93 | "$": true, 94 | "jQuery": true, 95 | "moment": true, 96 | "hljs": true, 97 | "Awesomplete": true, 98 | "Sortable": true, 99 | "Showdown": true, 100 | "Taggle": true, 101 | "Gantt": true, 102 | "Slick": true, 103 | "Webcam": true, 104 | "PhotoSwipe": true, 105 | "PhotoSwipeUI_Default": true, 106 | "io": true, 107 | "JsBarcode": true, 108 | "L": true, 109 | "Chart": true, 110 | "DataTable": true, 111 | "Cypress": true, 112 | "cy": true, 113 | "it": true, 114 | "describe": true, 115 | "expect": true, 116 | "context": true, 117 | "before": true, 118 | "beforeEach": true, 119 | "after": true, 120 | "qz": true, 121 | "localforage": true, 122 | "extend_cscript": true 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_person/pl_person.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "autoname": "field:full_name", 5 | "creation": "2025-01-04 14:00:00", 6 | "doctype": "DocType", 7 | "editable_grid": 1, 8 | "engine": "InnoDB", 9 | "field_order": [ 10 | "personal_id", 11 | "full_name", 12 | "column_break_1", 13 | "user", 14 | "status", 15 | "section_break_1", 16 | "date_of_birth", 17 | "gender", 18 | "column_break_2", 19 | "personal_email", 20 | "personal_phone", 21 | "section_break_2", 22 | "city", 23 | "state", 24 | "country", 25 | "postal_code", 26 | "image", 27 | "column_break_iden", 28 | "address" 29 | ], 30 | "fields": [ 31 | { 32 | "description": "National ID, Passport Number, or other unique identifier", 33 | "fieldname": "personal_id", 34 | "fieldtype": "Data", 35 | "in_list_view": 1, 36 | "label": "Personal ID", 37 | "reqd": 1, 38 | "unique": 1 39 | }, 40 | { 41 | "fieldname": "full_name", 42 | "fieldtype": "Data", 43 | "in_list_view": 1, 44 | "label": "Full Name", 45 | "reqd": 1, 46 | "unique": 1 47 | }, 48 | { 49 | "fieldname": "column_break_1", 50 | "fieldtype": "Column Break" 51 | }, 52 | { 53 | "description": "Link to system user account", 54 | "fieldname": "user", 55 | "fieldtype": "Link", 56 | "label": "User", 57 | "options": "User", 58 | "unique": 1 59 | }, 60 | { 61 | "default": "Active", 62 | "fieldname": "status", 63 | "fieldtype": "Select", 64 | "label": "Status", 65 | "options": "Active\nInactive" 66 | }, 67 | { 68 | "fieldname": "section_break_1", 69 | "fieldtype": "Section Break", 70 | "label": "Personal Details" 71 | }, 72 | { 73 | "fieldname": "date_of_birth", 74 | "fieldtype": "Date", 75 | "label": "Date of Birth" 76 | }, 77 | { 78 | "fieldname": "gender", 79 | "fieldtype": "Select", 80 | "label": "Gender", 81 | "options": "\nMale\nFemale\nOther" 82 | }, 83 | { 84 | "fieldname": "column_break_2", 85 | "fieldtype": "Column Break" 86 | }, 87 | { 88 | "fieldname": "personal_email", 89 | "fieldtype": "Data", 90 | "label": "Personal Email", 91 | "options": "Email" 92 | }, 93 | { 94 | "fieldname": "personal_phone", 95 | "fieldtype": "Data", 96 | "label": "Personal Phone" 97 | }, 98 | { 99 | "fieldname": "section_break_2", 100 | "fieldtype": "Section Break", 101 | "label": "Address" 102 | }, 103 | { 104 | "fieldname": "address", 105 | "fieldtype": "Text", 106 | "label": "Address" 107 | }, 108 | { 109 | "fieldname": "city", 110 | "fieldtype": "Data", 111 | "label": "City" 112 | }, 113 | { 114 | "fieldname": "state", 115 | "fieldtype": "Data", 116 | "label": "State" 117 | }, 118 | { 119 | "fieldname": "country", 120 | "fieldtype": "Data", 121 | "label": "Country" 122 | }, 123 | { 124 | "fieldname": "postal_code", 125 | "fieldtype": "Data", 126 | "label": "Postal Code" 127 | }, 128 | { 129 | "fieldname": "image", 130 | "fieldtype": "Attach Image", 131 | "label": "Image" 132 | }, 133 | { 134 | "fieldname": "column_break_iden", 135 | "fieldtype": "Column Break" 136 | } 137 | ], 138 | "image_field": "image", 139 | "index_web_pages_for_search": 1, 140 | "links": [], 141 | "modified": "2025-07-04 17:06:18.949846", 142 | "modified_by": "Administrator", 143 | "module": "pibiOffline", 144 | "name": "PL Person", 145 | "naming_rule": "By fieldname", 146 | "owner": "Administrator", 147 | "permissions": [ 148 | { 149 | "create": 1, 150 | "delete": 1, 151 | "email": 1, 152 | "export": 1, 153 | "print": 1, 154 | "read": 1, 155 | "report": 1, 156 | "role": "System Manager", 157 | "share": 1, 158 | "write": 1 159 | } 160 | ], 161 | "row_format": "Dynamic", 162 | "sort_field": "modified", 163 | "sort_order": "DESC", 164 | "states": [], 165 | "title_field": "full_name", 166 | "track_changes": 1 167 | } -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_employee_assignment/pl_employee_assignment.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.model.document import Document 3 | import qrcode 4 | from io import BytesIO 5 | import base64 6 | 7 | class PLEmployeeAssignment(Document): 8 | def validate(self): 9 | self.validate_employee_code_unique() 10 | self.generate_qr_code() 11 | 12 | def validate_employee_code_unique(self): 13 | """Ensure employee code is unique within the organization""" 14 | existing = frappe.db.exists("PL Employee Assignment", { 15 | "organization": self.organization, 16 | "employee_code": self.employee_code, 17 | "name": ["!=", self.name] 18 | }) 19 | if existing: 20 | frappe.throw(f"Employee code {self.employee_code} already exists in {self.organization_name}") 21 | 22 | def generate_qr_code(self): 23 | """Generate QR code for this assignment""" 24 | if not self.qr_code: 25 | # Format: ORG_CODE:EMPLOYEE_CODE 26 | org_doc = frappe.get_doc("PL Organization", self.organization) 27 | self.qr_code = f"{org_doc.organization_code}:{self.employee_code}" 28 | 29 | @frappe.whitelist() 30 | def get_qr_code_html(self): 31 | """Generate QR code image and return HTML for viewing/downloading""" 32 | if not self.qr_code: 33 | frappe.throw("QR Code not found for this assignment") 34 | 35 | # Create QR code instance 36 | qr = qrcode.QRCode( 37 | version=1, 38 | error_correction=qrcode.constants.ERROR_CORRECT_L, 39 | box_size=10, 40 | border=4, 41 | ) 42 | 43 | # Add data to QR code 44 | qr.add_data(self.qr_code) 45 | qr.make(fit=True) 46 | 47 | # Create QR code image 48 | img = qr.make_image(fill_color="black", back_color="white") 49 | 50 | # Convert to base64 51 | buffer = BytesIO() 52 | img.save(buffer, format="PNG") 53 | img_str = base64.b64encode(buffer.getvalue()).decode() 54 | 55 | # Generate HTML 56 | html = f""" 57 |
58 |

Employee QR Code

59 |
60 |

Employee: {frappe.get_value("PL Person", self.person, "full_name")}

61 |

Organization: {self.organization_name}

62 |

Employee Code: {self.employee_code}

63 |

QR Code: {self.qr_code}

64 |
65 |
66 | QR Code 67 |
68 |
69 | 71 | Download QR Code 72 | 73 | 76 |
77 |
78 | 91 | """ 92 | 93 | return html -------------------------------------------------------------------------------- /pibioffline/public/css/pibioffline.css: -------------------------------------------------------------------------------- 1 | .pibioffline-container { 2 | max-width: 600px; 3 | margin: 0 auto; 4 | padding: 20px; 5 | } 6 | 7 | .pibioffline-header { 8 | display: flex; 9 | justify-content: space-between; 10 | align-items: center; 11 | margin-bottom: 30px; 12 | } 13 | 14 | .pibioffline-header h2 { 15 | margin: 0; 16 | color: var(--text-color); 17 | } 18 | 19 | .sync-status { 20 | display: flex; 21 | align-items: center; 22 | gap: 8px; 23 | } 24 | 25 | .sync-indicator { 26 | width: 12px; 27 | height: 12px; 28 | border-radius: 50%; 29 | background-color: #ccc; 30 | position: relative; 31 | } 32 | 33 | .sync-indicator.online { 34 | background-color: #4caf50; 35 | } 36 | 37 | .sync-indicator.offline { 38 | background-color: #ff9800; 39 | } 40 | 41 | .sync-indicator.syncing { 42 | background-color: #2196f3; 43 | animation: pulse 1s infinite; 44 | } 45 | 46 | .sync-indicator.error { 47 | background-color: #f44336; 48 | } 49 | 50 | @keyframes pulse { 51 | 0% { 52 | box-shadow: 0 0 0 0 rgba(33, 150, 243, 0.7); 53 | } 54 | 70% { 55 | box-shadow: 0 0 0 10px rgba(33, 150, 243, 0); 56 | } 57 | 100% { 58 | box-shadow: 0 0 0 0 rgba(33, 150, 243, 0); 59 | } 60 | } 61 | 62 | .status-card { 63 | background: var(--card-bg); 64 | border-radius: 8px; 65 | padding: 30px; 66 | text-align: center; 67 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); 68 | margin-bottom: 30px; 69 | } 70 | 71 | .status-icon { 72 | font-size: 60px; 73 | color: var(--primary-color); 74 | margin-bottom: 20px; 75 | } 76 | 77 | .status-label { 78 | font-size: 14px; 79 | color: var(--text-muted); 80 | margin-bottom: 5px; 81 | } 82 | 83 | .status-value { 84 | font-size: 24px; 85 | font-weight: 600; 86 | color: var(--text-color); 87 | } 88 | 89 | .status-value.checked-in { 90 | color: #4caf50; 91 | } 92 | 93 | .status-value.checked-out { 94 | color: #2196f3; 95 | } 96 | 97 | .action-buttons { 98 | text-align: center; 99 | margin-bottom: 30px; 100 | } 101 | 102 | .scan-btn { 103 | padding: 15px 30px; 104 | font-size: 18px; 105 | display: inline-flex; 106 | align-items: center; 107 | gap: 10px; 108 | } 109 | 110 | .attendance-details { 111 | background: var(--card-bg); 112 | border-radius: 8px; 113 | padding: 20px; 114 | margin-bottom: 20px; 115 | } 116 | 117 | .detail-row { 118 | display: flex; 119 | justify-content: space-between; 120 | padding: 10px 0; 121 | border-bottom: 1px solid var(--border-color); 122 | } 123 | 124 | .detail-row:last-child { 125 | border-bottom: none; 126 | } 127 | 128 | .detail-label { 129 | font-weight: 500; 130 | color: var(--text-muted); 131 | } 132 | 133 | .detail-value { 134 | color: var(--text-color); 135 | } 136 | 137 | .offline-records { 138 | background: #fff3cd; 139 | border: 1px solid #ffeaa7; 140 | border-radius: 8px; 141 | padding: 15px; 142 | } 143 | 144 | .offline-records h4 { 145 | margin: 0 0 15px 0; 146 | color: #856404; 147 | } 148 | 149 | .pending-record { 150 | display: flex; 151 | align-items: center; 152 | gap: 10px; 153 | padding: 8px 0; 154 | border-bottom: 1px solid #ffeaa7; 155 | color: #856404; 156 | } 157 | 158 | .pending-record:last-child { 159 | border-bottom: none; 160 | } 161 | 162 | .pending-record i { 163 | color: #f39c12; 164 | } 165 | 166 | /* QR Scanner Styles */ 167 | #qr-scanner-container { 168 | overflow: hidden; 169 | border-radius: 8px; 170 | } 171 | 172 | .scan-overlay { 173 | pointer-events: none; 174 | } 175 | 176 | .scan-frame { 177 | animation: scan-animation 2s ease-in-out infinite; 178 | } 179 | 180 | @keyframes scan-animation { 181 | 0%, 100% { 182 | transform: scale(1); 183 | } 184 | 50% { 185 | transform: scale(1.05); 186 | } 187 | } 188 | 189 | /* Responsive Design */ 190 | @media (max-width: 768px) { 191 | .pibioffline-container { 192 | padding: 15px; 193 | } 194 | 195 | .status-card { 196 | padding: 20px; 197 | } 198 | 199 | .status-icon { 200 | font-size: 48px; 201 | } 202 | 203 | .status-value { 204 | font-size: 20px; 205 | } 206 | 207 | .scan-btn { 208 | width: 100%; 209 | justify-content: center; 210 | } 211 | } -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_organization/pl_organization.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "autoname": "field:organization_code", 5 | "creation": "2025-01-04 13:00:00.000000", 6 | "doctype": "DocType", 7 | "editable_grid": 1, 8 | "engine": "InnoDB", 9 | "field_order": [ 10 | "organization_code", 11 | "organization_name", 12 | "abbr", 13 | "column_break_1", 14 | "status", 15 | "section_break_1", 16 | "address", 17 | "city", 18 | "state", 19 | "country", 20 | "column_break_2", 21 | "email", 22 | "phone", 23 | "website", 24 | "section_break_2", 25 | "qr_code_prefix", 26 | "default_check_in_time", 27 | "default_check_out_time", 28 | "column_break_3", 29 | "latitude", 30 | "longitude", 31 | "geofence_radius" 32 | ], 33 | "fields": [ 34 | { 35 | "fieldname": "organization_code", 36 | "fieldtype": "Data", 37 | "in_list_view": 1, 38 | "label": "Organization Code", 39 | "reqd": 1, 40 | "unique": 1 41 | }, 42 | { 43 | "fieldname": "organization_name", 44 | "fieldtype": "Data", 45 | "in_list_view": 1, 46 | "label": "Organization Name", 47 | "reqd": 1 48 | }, 49 | { 50 | "fieldname": "abbr", 51 | "fieldtype": "Data", 52 | "label": "Abbreviation", 53 | "description": "Short name for the organization (e.g., ABC, XYZ)" 54 | }, 55 | { 56 | "fieldname": "column_break_1", 57 | "fieldtype": "Column Break" 58 | }, 59 | { 60 | "fieldname": "status", 61 | "fieldtype": "Select", 62 | "label": "Status", 63 | "options": "Active\nInactive", 64 | "default": "Active" 65 | }, 66 | { 67 | "fieldname": "section_break_1", 68 | "fieldtype": "Section Break", 69 | "label": "Contact Details" 70 | }, 71 | { 72 | "fieldname": "address", 73 | "fieldtype": "Text", 74 | "label": "Address" 75 | }, 76 | { 77 | "fieldname": "city", 78 | "fieldtype": "Data", 79 | "label": "City" 80 | }, 81 | { 82 | "fieldname": "state", 83 | "fieldtype": "Data", 84 | "label": "State" 85 | }, 86 | { 87 | "fieldname": "country", 88 | "fieldtype": "Data", 89 | "label": "Country" 90 | }, 91 | { 92 | "fieldname": "column_break_2", 93 | "fieldtype": "Column Break" 94 | }, 95 | { 96 | "fieldname": "email", 97 | "fieldtype": "Data", 98 | "label": "Email", 99 | "options": "Email" 100 | }, 101 | { 102 | "fieldname": "phone", 103 | "fieldtype": "Data", 104 | "label": "Phone" 105 | }, 106 | { 107 | "fieldname": "website", 108 | "fieldtype": "Data", 109 | "label": "Website" 110 | }, 111 | { 112 | "fieldname": "section_break_2", 113 | "fieldtype": "Section Break", 114 | "label": "Settings" 115 | }, 116 | { 117 | "fieldname": "qr_code_prefix", 118 | "fieldtype": "Data", 119 | "label": "QR Code Prefix", 120 | "description": "Prefix for employee QR codes in this organization" 121 | }, 122 | { 123 | "fieldname": "default_check_in_time", 124 | "fieldtype": "Time", 125 | "label": "Default Check In Time" 126 | }, 127 | { 128 | "fieldname": "default_check_out_time", 129 | "fieldtype": "Time", 130 | "label": "Default Check Out Time" 131 | }, 132 | { 133 | "fieldname": "column_break_3", 134 | "fieldtype": "Column Break" 135 | }, 136 | { 137 | "fieldname": "latitude", 138 | "fieldtype": "Float", 139 | "label": "Latitude", 140 | "description": "Organization location latitude for geofencing" 141 | }, 142 | { 143 | "fieldname": "longitude", 144 | "fieldtype": "Float", 145 | "label": "Longitude", 146 | "description": "Organization location longitude for geofencing" 147 | }, 148 | { 149 | "fieldname": "geofence_radius", 150 | "fieldtype": "Float", 151 | "label": "Geofence Radius (meters)", 152 | "description": "Allowed radius for check-in/check-out", 153 | "default": 100 154 | } 155 | ], 156 | "index_web_pages_for_search": 1, 157 | "links": [], 158 | "modified": "2025-01-04 13:00:00.000000", 159 | "modified_by": "Administrator", 160 | "module": "Pibioffline", 161 | "name": "PL Organization", 162 | "naming_rule": "By fieldname", 163 | "owner": "Administrator", 164 | "permissions": [ 165 | { 166 | "create": 1, 167 | "delete": 1, 168 | "email": 1, 169 | "export": 1, 170 | "print": 1, 171 | "read": 1, 172 | "report": 1, 173 | "role": "System Manager", 174 | "share": 1, 175 | "write": 1 176 | } 177 | ], 178 | "sort_field": "modified", 179 | "sort_order": "DESC", 180 | "title_field": "organization_name", 181 | "track_changes": 1 182 | } -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_employee_assignment/pl_employee_assignment.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "autoname": "format:{employee_code}-{organization}", 5 | "creation": "2025-01-04 14:00:00", 6 | "doctype": "DocType", 7 | "editable_grid": 1, 8 | "engine": "InnoDB", 9 | "field_order": [ 10 | "person", 11 | "organization", 12 | "organization_name", 13 | "column_break_1", 14 | "employee_code", 15 | "qr_code", 16 | "status", 17 | "section_break_1", 18 | "department", 19 | "designation", 20 | "work_email", 21 | "work_phone", 22 | "column_break_2", 23 | "reporting_manager", 24 | "user", 25 | "location_required", 26 | "picture_required" 27 | ], 28 | "fields": [ 29 | { 30 | "fieldname": "person", 31 | "fieldtype": "Link", 32 | "in_list_view": 1, 33 | "label": "Person", 34 | "options": "PL Person", 35 | "reqd": 1 36 | }, 37 | { 38 | "fieldname": "organization", 39 | "fieldtype": "Link", 40 | "in_list_view": 1, 41 | "label": "Organization", 42 | "options": "PL Organization", 43 | "reqd": 1 44 | }, 45 | { 46 | "fetch_from": "organization.organization_name", 47 | "fieldname": "organization_name", 48 | "fieldtype": "Data", 49 | "label": "Organization Name", 50 | "read_only": 1 51 | }, 52 | { 53 | "fieldname": "column_break_1", 54 | "fieldtype": "Column Break" 55 | }, 56 | { 57 | "description": "Organization-specific employee identifier", 58 | "fieldname": "employee_code", 59 | "fieldtype": "Data", 60 | "in_list_view": 1, 61 | "label": "Employee Code", 62 | "reqd": 1 63 | }, 64 | { 65 | "description": "Auto-generated QR code for attendance", 66 | "fieldname": "qr_code", 67 | "fieldtype": "Data", 68 | "in_list_view": 1, 69 | "label": "QR Code", 70 | "read_only": 1, 71 | "unique": 1 72 | }, 73 | { 74 | "default": "Active", 75 | "fieldname": "status", 76 | "fieldtype": "Select", 77 | "label": "Status", 78 | "options": "Active\nInactive\nTerminated" 79 | }, 80 | { 81 | "fieldname": "section_break_1", 82 | "fieldtype": "Section Break", 83 | "label": "Work Details" 84 | }, 85 | { 86 | "fieldname": "department", 87 | "fieldtype": "Data", 88 | "label": "Department" 89 | }, 90 | { 91 | "fieldname": "designation", 92 | "fieldtype": "Data", 93 | "label": "Designation" 94 | }, 95 | { 96 | "fieldname": "work_email", 97 | "fieldtype": "Data", 98 | "label": "Work Email", 99 | "options": "Email" 100 | }, 101 | { 102 | "fieldname": "work_phone", 103 | "fieldtype": "Data", 104 | "label": "Work Phone" 105 | }, 106 | { 107 | "fieldname": "column_break_2", 108 | "fieldtype": "Column Break" 109 | }, 110 | { 111 | "fieldname": "reporting_manager", 112 | "fieldtype": "Link", 113 | "label": "Reporting Manager", 114 | "options": "PL Employee Assignment" 115 | }, 116 | { 117 | "fieldname": "user", 118 | "fieldtype": "Link", 119 | "label": "User", 120 | "options": "User", 121 | "description": "System user account for this employee" 122 | }, 123 | { 124 | "default": "0", 125 | "description": "Require GPS location for attendance check-in/check-out", 126 | "fieldname": "location_required", 127 | "fieldtype": "Check", 128 | "label": "Location Required" 129 | }, 130 | { 131 | "default": "0", 132 | "description": "Require photo capture for attendance check-in/check-out", 133 | "fieldname": "picture_required", 134 | "fieldtype": "Check", 135 | "label": "Picture Required" 136 | } 137 | ], 138 | "index_web_pages_for_search": 1, 139 | "links": [], 140 | "modified": "2025-07-04 18:49:46.046435", 141 | "modified_by": "Administrator", 142 | "module": "pibiOffline", 143 | "name": "PL Employee Assignment", 144 | "naming_rule": "Expression", 145 | "owner": "Administrator", 146 | "permissions": [ 147 | { 148 | "create": 1, 149 | "delete": 1, 150 | "email": 1, 151 | "export": 1, 152 | "print": 1, 153 | "read": 1, 154 | "report": 1, 155 | "role": "System Manager", 156 | "share": 1, 157 | "write": 1 158 | }, 159 | { 160 | "create": 1, 161 | "delete": 1, 162 | "email": 1, 163 | "export": 1, 164 | "print": 1, 165 | "read": 1, 166 | "report": 1, 167 | "role": "HR Manager", 168 | "share": 1, 169 | "write": 1 170 | }, 171 | { 172 | "create": 1, 173 | "email": 1, 174 | "export": 1, 175 | "print": 1, 176 | "read": 1, 177 | "report": 1, 178 | "role": "HR User", 179 | "share": 1, 180 | "write": 1 181 | } 182 | ], 183 | "row_format": "Dynamic", 184 | "sort_field": "modified", 185 | "sort_order": "DESC", 186 | "states": [], 187 | "title_field": "person", 188 | "track_changes": 1 189 | } -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_resource_attendance/pl_resource_attendance.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "creation": "2025-01-03 12:00:00", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "title", 10 | "organization", 11 | "organization_name", 12 | "resource", 13 | "qr_code", 14 | "column_break_1", 15 | "attendance_type", 16 | "timestamp", 17 | "section_break_1", 18 | "location", 19 | "latitude", 20 | "longitude", 21 | "geolocation", 22 | "section_break_2", 23 | "synced", 24 | "local_id", 25 | "sync_status", 26 | "work_session_ref", 27 | "check_in_ref_id" 28 | ], 29 | "fields": [ 30 | { 31 | "bold": 1, 32 | "fieldname": "title", 33 | "fieldtype": "Data", 34 | "in_list_view": 1, 35 | "label": "Title", 36 | "read_only": 1 37 | }, 38 | { 39 | "fieldname": "organization", 40 | "fieldtype": "Link", 41 | "label": "Organization", 42 | "options": "PL Organization", 43 | "reqd": 1 44 | }, 45 | { 46 | "fetch_from": "organization.organization_name", 47 | "fieldname": "organization_name", 48 | "fieldtype": "Data", 49 | "label": "Organization Name", 50 | "read_only": 1 51 | }, 52 | { 53 | "fieldname": "resource", 54 | "fieldtype": "Link", 55 | "in_list_view": 1, 56 | "label": "Employee Assignment", 57 | "options": "PL Employee Assignment", 58 | "reqd": 1 59 | }, 60 | { 61 | "fieldname": "qr_code", 62 | "fieldtype": "Data", 63 | "label": "QR Code", 64 | "reqd": 1 65 | }, 66 | { 67 | "fieldname": "column_break_1", 68 | "fieldtype": "Column Break" 69 | }, 70 | { 71 | "fieldname": "timestamp", 72 | "fieldtype": "Datetime", 73 | "in_list_view": 1, 74 | "label": "Timestamp", 75 | "reqd": 1 76 | }, 77 | { 78 | "depends_on": "eval:doc.attendance_type==\"Check In\";", 79 | "fieldname": "section_break_1", 80 | "fieldtype": "Section Break", 81 | "label": "Location" 82 | }, 83 | { 84 | "fieldname": "location", 85 | "fieldtype": "Data", 86 | "label": "Location" 87 | }, 88 | { 89 | "fieldname": "latitude", 90 | "fieldtype": "Float", 91 | "label": "Latitude", 92 | "precision": "9" 93 | }, 94 | { 95 | "fieldname": "longitude", 96 | "fieldtype": "Float", 97 | "label": "Longitude", 98 | "precision": "9" 99 | }, 100 | { 101 | "fieldname": "geolocation", 102 | "fieldtype": "Geolocation", 103 | "label": "Geolocation" 104 | }, 105 | { 106 | "fieldname": "section_break_2", 107 | "fieldtype": "Section Break", 108 | "label": "Sync Details" 109 | }, 110 | { 111 | "default": "0", 112 | "fieldname": "synced", 113 | "fieldtype": "Check", 114 | "label": "Synced" 115 | }, 116 | { 117 | "fieldname": "local_id", 118 | "fieldtype": "Data", 119 | "label": "Local ID" 120 | }, 121 | { 122 | "fieldname": "sync_status", 123 | "fieldtype": "Select", 124 | "label": "Sync Status", 125 | "options": "\nPending\nSynced\nFailed" 126 | }, 127 | { 128 | "fieldname": "attendance_type", 129 | "fieldtype": "Select", 130 | "label": "Attendance Type", 131 | "options": "Check In\nCheck Out", 132 | "reqd": 1 133 | }, 134 | { 135 | "fieldname": "work_session_ref", 136 | "fieldtype": "Link", 137 | "label": "Work Session Reference", 138 | "options": "PL Work Session", 139 | "read_only": 1 140 | }, 141 | { 142 | "description": "Local ID of the check-in record this check-out refers to", 143 | "fieldname": "check_in_ref_id", 144 | "fieldtype": "Data", 145 | "label": "Check In Reference ID" 146 | } 147 | ], 148 | "index_web_pages_for_search": 1, 149 | "links": [], 150 | "modified": "2025-07-04 20:25:44.744288", 151 | "modified_by": "Administrator", 152 | "module": "pibiOffline", 153 | "name": "PL Resource Attendance", 154 | "owner": "Administrator", 155 | "permissions": [ 156 | { 157 | "create": 1, 158 | "delete": 1, 159 | "email": 1, 160 | "export": 1, 161 | "print": 1, 162 | "read": 1, 163 | "report": 1, 164 | "role": "System Manager", 165 | "share": 1, 166 | "write": 1 167 | }, 168 | { 169 | "create": 1, 170 | "delete": 1, 171 | "email": 1, 172 | "export": 1, 173 | "print": 1, 174 | "read": 1, 175 | "report": 1, 176 | "role": "HR Manager", 177 | "share": 1, 178 | "write": 1 179 | }, 180 | { 181 | "create": 1, 182 | "email": 1, 183 | "export": 1, 184 | "print": 1, 185 | "read": 1, 186 | "report": 1, 187 | "role": "HR User", 188 | "share": 1, 189 | "write": 1 190 | } 191 | ], 192 | "row_format": "Dynamic", 193 | "sort_field": "modified", 194 | "sort_order": "DESC", 195 | "states": [], 196 | "title_field": "title", 197 | "track_changes": 1 198 | } -------------------------------------------------------------------------------- /pibioffline/pl_offline.css: -------------------------------------------------------------------------------- 1 | .pibioffline-wrapper { 2 | width: 100%; 3 | background: var(--bg-color); 4 | border-radius: var(--border-radius-lg); 5 | } 6 | 7 | .pibioffline-container { 8 | max-width: 600px; 9 | margin: 0 auto; 10 | padding: 20px; 11 | } 12 | 13 | .pibioffline-header { 14 | display: flex; 15 | justify-content: space-between; 16 | align-items: center; 17 | margin-bottom: 30px; 18 | } 19 | 20 | .pibioffline-header h2 { 21 | margin: 0; 22 | color: var(--text-color); 23 | } 24 | 25 | .sync-status { 26 | display: flex; 27 | align-items: center; 28 | gap: 8px; 29 | } 30 | 31 | .sync-indicator { 32 | width: 12px; 33 | height: 12px; 34 | border-radius: 50%; 35 | background-color: #ccc; 36 | position: relative; 37 | } 38 | 39 | .sync-indicator.online { 40 | background-color: #4caf50; 41 | } 42 | 43 | .sync-indicator.offline { 44 | background-color: #ff9800; 45 | } 46 | 47 | .sync-indicator.syncing { 48 | background-color: #2196f3; 49 | animation: pulse 1s infinite; 50 | } 51 | 52 | .sync-indicator.error { 53 | background-color: #f44336; 54 | } 55 | 56 | @keyframes pulse { 57 | 0% { 58 | box-shadow: 0 0 0 0 rgba(33, 150, 243, 0.7); 59 | } 60 | 70% { 61 | box-shadow: 0 0 0 10px rgba(33, 150, 243, 0); 62 | } 63 | 100% { 64 | box-shadow: 0 0 0 0 rgba(33, 150, 243, 0); 65 | } 66 | } 67 | 68 | .status-card { 69 | background: var(--card-bg); 70 | border-radius: 8px; 71 | padding: 30px; 72 | text-align: center; 73 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); 74 | margin-bottom: 30px; 75 | } 76 | 77 | .status-icon { 78 | font-size: 60px; 79 | color: var(--primary-color); 80 | margin-bottom: 20px; 81 | } 82 | 83 | .status-label { 84 | font-size: 14px; 85 | color: var(--text-muted); 86 | margin-bottom: 5px; 87 | } 88 | 89 | .status-value { 90 | font-size: 24px; 91 | font-weight: 600; 92 | color: var(--text-color); 93 | } 94 | 95 | .status-value.checked-in { 96 | color: #4caf50; 97 | } 98 | 99 | .status-value.checked-out { 100 | color: #2196f3; 101 | } 102 | 103 | .action-buttons { 104 | text-align: center; 105 | margin-bottom: 30px; 106 | } 107 | 108 | .scan-btn { 109 | padding: 15px 30px; 110 | font-size: 18px; 111 | display: inline-flex; 112 | align-items: center; 113 | gap: 10px; 114 | } 115 | 116 | .attendance-details { 117 | background: var(--card-bg); 118 | border-radius: 8px; 119 | padding: 20px; 120 | margin-bottom: 20px; 121 | } 122 | 123 | .detail-row { 124 | display: flex; 125 | justify-content: space-between; 126 | padding: 10px 0; 127 | border-bottom: 1px solid var(--border-color); 128 | } 129 | 130 | .detail-row:last-child { 131 | border-bottom: none; 132 | } 133 | 134 | .detail-label { 135 | font-weight: 500; 136 | color: var(--text-muted); 137 | } 138 | 139 | .detail-value { 140 | color: var(--text-color); 141 | } 142 | 143 | .offline-records { 144 | background: #fff3cd; 145 | border: 1px solid #ffeaa7; 146 | border-radius: 8px; 147 | padding: 15px; 148 | } 149 | 150 | .offline-records h4 { 151 | margin: 0 0 15px 0; 152 | color: #856404; 153 | } 154 | 155 | .pending-record { 156 | display: flex; 157 | align-items: center; 158 | gap: 10px; 159 | padding: 8px 0; 160 | border-bottom: 1px solid #ffeaa7; 161 | color: #856404; 162 | } 163 | 164 | .pending-record:last-child { 165 | border-bottom: none; 166 | } 167 | 168 | .pending-record i { 169 | color: #f39c12; 170 | } 171 | 172 | /* QR Scanner Dialog Styles */ 173 | #qr-scanner-container { 174 | overflow: hidden; 175 | border-radius: 8px; 176 | } 177 | 178 | .scan-overlay { 179 | pointer-events: none; 180 | } 181 | 182 | .scan-frame { 183 | animation: scan-animation 2s ease-in-out infinite; 184 | } 185 | 186 | @keyframes scan-animation { 187 | 0%, 100% { 188 | transform: scale(1); 189 | } 190 | 50% { 191 | transform: scale(1.05); 192 | } 193 | } 194 | 195 | /* Responsive Design */ 196 | @media (max-width: 768px) { 197 | .pibioffline-container { 198 | padding: 15px; 199 | } 200 | 201 | .status-card { 202 | padding: 20px; 203 | } 204 | 205 | .status-icon { 206 | font-size: 48px; 207 | } 208 | 209 | .status-value { 210 | font-size: 20px; 211 | } 212 | 213 | .scan-btn { 214 | width: 100%; 215 | justify-content: center; 216 | } 217 | } -------------------------------------------------------------------------------- /pibioffline/fixtures/workspace.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "charts": [], 4 | "content": "[{\"id\":\"xjnjJaoWVc\",\"type\":\"custom_block\",\"data\":{\"custom_block_name\":\"PL pibiOffline\",\"col\":12}}]", 5 | "custom_blocks": [ 6 | { 7 | "custom_block_name": "PL pibiOffline", 8 | "label": "PL pibiOffline", 9 | "parent": "pibiOffline", 10 | "parentfield": "custom_blocks", 11 | "parenttype": "Workspace" 12 | } 13 | ], 14 | "docstatus": 0, 15 | "doctype": "Workspace", 16 | "for_user": "", 17 | "hide_custom": 0, 18 | "icon": "scan", 19 | "indicator_color": "green", 20 | "is_hidden": 0, 21 | "label": "pibiOffline", 22 | "links": [], 23 | "modified": "2025-07-04 08:43:35.641289", 24 | "module": "pibiOffline", 25 | "name": "pibiOffline", 26 | "number_cards": [], 27 | "parent_page": "", 28 | "public": 1, 29 | "quick_lists": [], 30 | "restrict_to_domain": null, 31 | "roles": [], 32 | "sequence_id": 1.0, 33 | "shortcuts": [], 34 | "title": "pibiOffline" 35 | }, 36 | { 37 | "charts": [], 38 | "content": "[{\"id\":\"Fz6fjhrLtL\",\"type\":\"header\",\"data\":{\"text\":\"pibiOffline Settings\",\"col\":12}},{\"id\":\"ctECjT_L4Y\",\"type\":\"card\",\"data\":{\"card_name\":\"Documents\",\"col\":4}}]", 39 | "custom_blocks": [], 40 | "docstatus": 0, 41 | "doctype": "Workspace", 42 | "for_user": "", 43 | "hide_custom": 0, 44 | "icon": "users", 45 | "indicator_color": "green", 46 | "is_hidden": 0, 47 | "label": "pibiOffline Settings", 48 | "links": [ 49 | { 50 | "dependencies": null, 51 | "description": null, 52 | "hidden": 0, 53 | "icon": null, 54 | "is_query_report": 0, 55 | "label": "Documents", 56 | "link_count": 5, 57 | "link_to": null, 58 | "link_type": "DocType", 59 | "onboard": 0, 60 | "only_for": null, 61 | "parent": "pibiOffline Settings", 62 | "parentfield": "links", 63 | "parenttype": "Workspace", 64 | "report_ref_doctype": null, 65 | "type": "Card Break" 66 | }, 67 | { 68 | "dependencies": null, 69 | "description": null, 70 | "hidden": 0, 71 | "icon": null, 72 | "is_query_report": 0, 73 | "label": "Organization", 74 | "link_count": 0, 75 | "link_to": "PL Organization", 76 | "link_type": "DocType", 77 | "onboard": 0, 78 | "only_for": null, 79 | "parent": "pibiOffline Settings", 80 | "parentfield": "links", 81 | "parenttype": "Workspace", 82 | "report_ref_doctype": null, 83 | "type": "Link" 84 | }, 85 | { 86 | "dependencies": null, 87 | "description": null, 88 | "hidden": 0, 89 | "icon": null, 90 | "is_query_report": 0, 91 | "label": "Person", 92 | "link_count": 0, 93 | "link_to": "PL Person", 94 | "link_type": "DocType", 95 | "onboard": 0, 96 | "only_for": null, 97 | "parent": "pibiOffline Settings", 98 | "parentfield": "links", 99 | "parenttype": "Workspace", 100 | "report_ref_doctype": null, 101 | "type": "Link" 102 | }, 103 | { 104 | "dependencies": null, 105 | "description": null, 106 | "hidden": 0, 107 | "icon": null, 108 | "is_query_report": 0, 109 | "label": "Employee Assignment", 110 | "link_count": 0, 111 | "link_to": "PL Employee Assignment", 112 | "link_type": "DocType", 113 | "onboard": 0, 114 | "only_for": null, 115 | "parent": "pibiOffline Settings", 116 | "parentfield": "links", 117 | "parenttype": "Workspace", 118 | "report_ref_doctype": null, 119 | "type": "Link" 120 | }, 121 | { 122 | "dependencies": null, 123 | "description": null, 124 | "hidden": 0, 125 | "icon": null, 126 | "is_query_report": 0, 127 | "label": "Resource Attendance", 128 | "link_count": 0, 129 | "link_to": "PL Resource Attendance", 130 | "link_type": "DocType", 131 | "onboard": 0, 132 | "only_for": null, 133 | "parent": "pibiOffline Settings", 134 | "parentfield": "links", 135 | "parenttype": "Workspace", 136 | "report_ref_doctype": null, 137 | "type": "Link" 138 | }, 139 | { 140 | "dependencies": null, 141 | "description": null, 142 | "hidden": 0, 143 | "icon": null, 144 | "is_query_report": 0, 145 | "label": "Work Session", 146 | "link_count": 0, 147 | "link_to": "PL Work Session", 148 | "link_type": "DocType", 149 | "onboard": 0, 150 | "only_for": null, 151 | "parent": "pibiOffline Settings", 152 | "parentfield": "links", 153 | "parenttype": "Workspace", 154 | "report_ref_doctype": null, 155 | "type": "Link" 156 | } 157 | ], 158 | "modified": "2025-07-04 12:13:13.125848", 159 | "module": "pibiOffline", 160 | "name": "pibiOffline Settings", 161 | "number_cards": [], 162 | "parent_page": "pibiSettings", 163 | "public": 1, 164 | "quick_lists": [], 165 | "restrict_to_domain": null, 166 | "roles": [ 167 | { 168 | "parent": "pibiOffline Settings", 169 | "parentfield": "roles", 170 | "parenttype": "Workspace", 171 | "role": "Administrator" 172 | } 173 | ], 174 | "sequence_id": 23.0, 175 | "shortcuts": [], 176 | "title": "pibiOffline Settings" 177 | } 178 | ] -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_work_session/pl_work_session.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe.model.document import Document 3 | from frappe.utils import now_datetime, time_diff_in_hours, format_datetime 4 | 5 | class PLWorkSession(Document): 6 | def validate(self): 7 | self.calculate_duration() 8 | self.update_status() 9 | 10 | def after_insert(self): 11 | # Set auto-generated title 12 | self.set_title() 13 | 14 | def on_update(self): 15 | # Update title when session is updated 16 | if self.has_value_changed("check_out_time") or self.has_value_changed("status"): 17 | self.set_title() 18 | 19 | def set_title(self): 20 | # Get employee name from assignment 21 | if self.employee: 22 | person = frappe.get_value("PL Employee Assignment", self.employee, "person") 23 | person_name = frappe.get_value("PL Person", person, "full_name") if person else "Unknown" 24 | # Try to get abbreviation, fallback to organization code if not available 25 | try: 26 | org_abbr = frappe.get_value("PL Organization", self.organization, "abbr") 27 | except: 28 | org_abbr = None 29 | 30 | if not org_abbr: 31 | org_abbr = frappe.get_value("PL Organization", self.organization, "organization_code") or self.organization_name[:3].upper() 32 | 33 | # Format work date and time 34 | date_str = format_datetime(self.work_date, "dd-MMM") 35 | time_str = format_datetime(self.check_in_time, "HH:mm") 36 | 37 | # Add status indicator 38 | status = "" 39 | if self.status == "Active": 40 | status = " (Active)" 41 | elif self.status == "Completed" and self.check_out_time: 42 | end_time = format_datetime(self.check_out_time, "HH:mm") 43 | status = f" - {end_time}" 44 | 45 | # Create title: "John Doe - ABC - 04-Jan 14:30 - 18:45" 46 | title = f"{person_name} - {org_abbr} - {date_str} {time_str}{status}" 47 | 48 | frappe.db.set_value("PL Work Session", self.name, "title", title, update_modified=False) 49 | 50 | def calculate_duration(self): 51 | """Calculate total duration if both check in and check out are present""" 52 | if self.check_in_time and self.check_out_time: 53 | hours = time_diff_in_hours(self.check_out_time, self.check_in_time) 54 | self.total_duration = hours * 3600 # Convert to seconds for Duration field 55 | 56 | def update_status(self): 57 | """Update status based on check in/out times""" 58 | if self.check_in_time and self.check_out_time: 59 | self.status = "Completed" 60 | elif self.check_in_time and not self.check_out_time: 61 | self.status = "Active" 62 | 63 | @frappe.whitelist() 64 | def sync_check_in(self, attendance_ref=None): 65 | """Mark check-in as synced""" 66 | self.check_in_synced = 1 67 | self.check_in_sync_time = now_datetime() 68 | if attendance_ref: 69 | self.check_in_attendance_ref = attendance_ref 70 | self.save() 71 | 72 | @frappe.whitelist() 73 | def sync_check_out(self, attendance_ref=None): 74 | """Mark check-out as synced""" 75 | self.check_out_synced = 1 76 | self.check_out_sync_time = now_datetime() 77 | if attendance_ref: 78 | self.check_out_attendance_ref = attendance_ref 79 | self.save() 80 | 81 | @staticmethod 82 | def get_active_session(qr_code, work_date=None): 83 | """Get active work session for a QR code""" 84 | filters = { 85 | "qr_code": qr_code, 86 | "status": "Active" 87 | } 88 | if work_date: 89 | filters["work_date"] = work_date 90 | 91 | sessions = frappe.get_all( 92 | "PL Work Session", 93 | filters=filters, 94 | fields=["name", "check_in_time", "check_in_synced", "check_out_synced"], 95 | order_by="check_in_time desc", 96 | limit=1 97 | ) 98 | 99 | return sessions[0] if sessions else None 100 | 101 | @staticmethod 102 | def create_or_update_session(data): 103 | """Create new session or update existing one""" 104 | # Check if it's a check-out (has check_in_local_id reference) 105 | if data.get("check_in_local_id"): 106 | # Find existing session with this check-in local ID 107 | existing = frappe.db.get_value( 108 | "PL Work Session", 109 | {"check_in_local_id": data["check_in_local_id"]}, 110 | "name" 111 | ) 112 | 113 | if existing: 114 | session = frappe.get_doc("PL Work Session", existing) 115 | session.check_out_time = data["check_out_time"] 116 | session.check_out_location = data.get("location") 117 | session.check_out_latitude = data.get("latitude") 118 | session.check_out_longitude = data.get("longitude") 119 | session.check_out_local_id = data.get("local_id") 120 | session.check_out_synced = 0 121 | session.save() 122 | return session 123 | 124 | # Otherwise create new session for check-in 125 | session = frappe.new_doc("PL Work Session") 126 | session.employee = data["employee"] 127 | session.qr_code = data["qr_code"] 128 | session.work_date = data["work_date"] 129 | session.check_in_time = data["check_in_time"] 130 | session.check_in_location = data.get("location") 131 | session.check_in_latitude = data.get("latitude") 132 | session.check_in_longitude = data.get("longitude") 133 | session.check_in_local_id = data.get("local_id") 134 | session.check_in_synced = 0 135 | session.insert() 136 | 137 | return session -------------------------------------------------------------------------------- /pibioffline/pibioffline/doctype/pl_work_session/pl_work_session.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "allow_rename": 1, 4 | "creation": "2025-01-04 12:00:00", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "title", 10 | "organization", 11 | "organization_name", 12 | "employee", 13 | "qr_code", 14 | "work_date", 15 | "column_break_1", 16 | "check_in_time", 17 | "check_out_time", 18 | "total_duration", 19 | "status", 20 | "section_break_1", 21 | "check_in_location", 22 | "check_in_latitude", 23 | "check_in_longitude", 24 | "column_break_2", 25 | "check_out_location", 26 | "check_out_latitude", 27 | "check_out_longitude", 28 | "section_break_2", 29 | "check_in_synced", 30 | "check_out_synced", 31 | "check_in_local_id", 32 | "check_out_local_id", 33 | "column_break_3", 34 | "check_in_sync_time", 35 | "check_out_sync_time", 36 | "check_in_attendance", 37 | "check_out_attendance" 38 | ], 39 | "fields": [ 40 | { 41 | "fieldname": "title", 42 | "fieldtype": "Data", 43 | "label": "Title", 44 | "read_only": 1, 45 | "in_list_view": 1, 46 | "bold": 1 47 | }, 48 | { 49 | "fieldname": "organization", 50 | "fieldtype": "Link", 51 | "in_list_view": 1, 52 | "label": "Organization", 53 | "options": "PL Organization", 54 | "reqd": 1 55 | }, 56 | { 57 | "fetch_from": "organization.organization_name", 58 | "fieldname": "organization_name", 59 | "fieldtype": "Data", 60 | "label": "Organization Name", 61 | "read_only": 1 62 | }, 63 | { 64 | "fieldname": "employee", 65 | "fieldtype": "Link", 66 | "in_list_view": 1, 67 | "label": "Employee Assignment", 68 | "options": "PL Employee Assignment", 69 | "reqd": 1 70 | }, 71 | { 72 | "fieldname": "qr_code", 73 | "fieldtype": "Data", 74 | "label": "QR Code", 75 | "reqd": 1 76 | }, 77 | { 78 | "fieldname": "work_date", 79 | "fieldtype": "Date", 80 | "in_list_view": 1, 81 | "label": "Work Date", 82 | "reqd": 1 83 | }, 84 | { 85 | "fieldname": "column_break_1", 86 | "fieldtype": "Column Break" 87 | }, 88 | { 89 | "fieldname": "check_in_time", 90 | "fieldtype": "Datetime", 91 | "in_list_view": 1, 92 | "label": "Check In Time", 93 | "reqd": 1 94 | }, 95 | { 96 | "fieldname": "check_out_time", 97 | "fieldtype": "Datetime", 98 | "in_list_view": 1, 99 | "label": "Check Out Time" 100 | }, 101 | { 102 | "fieldname": "total_duration", 103 | "fieldtype": "Duration", 104 | "label": "Total Duration", 105 | "read_only": 1 106 | }, 107 | { 108 | "default": "Active", 109 | "fieldname": "status", 110 | "fieldtype": "Select", 111 | "label": "Status", 112 | "options": "Active\nCompleted\nCancelled" 113 | }, 114 | { 115 | "fieldname": "section_break_1", 116 | "fieldtype": "Section Break", 117 | "label": "Location Details" 118 | }, 119 | { 120 | "fieldname": "check_in_location", 121 | "fieldtype": "Data", 122 | "label": "Check In Location" 123 | }, 124 | { 125 | "fieldname": "check_in_latitude", 126 | "fieldtype": "Float", 127 | "label": "Check In Latitude" 128 | }, 129 | { 130 | "fieldname": "check_in_longitude", 131 | "fieldtype": "Float", 132 | "label": "Check In Longitude" 133 | }, 134 | { 135 | "fieldname": "column_break_2", 136 | "fieldtype": "Column Break" 137 | }, 138 | { 139 | "fieldname": "check_out_location", 140 | "fieldtype": "Data", 141 | "label": "Check Out Location" 142 | }, 143 | { 144 | "fieldname": "check_out_latitude", 145 | "fieldtype": "Float", 146 | "label": "Check Out Latitude" 147 | }, 148 | { 149 | "fieldname": "check_out_longitude", 150 | "fieldtype": "Float", 151 | "label": "Check Out Longitude" 152 | }, 153 | { 154 | "fieldname": "section_break_2", 155 | "fieldtype": "Section Break", 156 | "label": "Sync Details" 157 | }, 158 | { 159 | "default": "0", 160 | "fieldname": "check_in_synced", 161 | "fieldtype": "Check", 162 | "label": "Check In Synced" 163 | }, 164 | { 165 | "default": "0", 166 | "fieldname": "check_out_synced", 167 | "fieldtype": "Check", 168 | "label": "Check Out Synced" 169 | }, 170 | { 171 | "fieldname": "check_in_local_id", 172 | "fieldtype": "Data", 173 | "label": "Check In Local ID" 174 | }, 175 | { 176 | "fieldname": "check_out_local_id", 177 | "fieldtype": "Data", 178 | "label": "Check Out Local ID" 179 | }, 180 | { 181 | "fieldname": "column_break_3", 182 | "fieldtype": "Column Break" 183 | }, 184 | { 185 | "fieldname": "check_in_sync_time", 186 | "fieldtype": "Datetime", 187 | "label": "Check In Sync Time", 188 | "read_only": 1 189 | }, 190 | { 191 | "fieldname": "check_out_sync_time", 192 | "fieldtype": "Datetime", 193 | "label": "Check Out Sync Time", 194 | "read_only": 1 195 | }, 196 | { 197 | "fieldname": "check_in_attendance", 198 | "fieldtype": "Link", 199 | "label": "Check In Attendance", 200 | "options": "PL Resource Attendance" 201 | }, 202 | { 203 | "fieldname": "check_out_attendance", 204 | "fieldtype": "Link", 205 | "label": "Check Out Attendance", 206 | "options": "PL Resource Attendance" 207 | } 208 | ], 209 | "index_web_pages_for_search": 1, 210 | "links": [], 211 | "modified": "2025-07-04 13:51:15.743894", 212 | "modified_by": "Administrator", 213 | "module": "pibiOffline", 214 | "name": "PL Work Session", 215 | "owner": "Administrator", 216 | "permissions": [ 217 | { 218 | "create": 1, 219 | "delete": 1, 220 | "email": 1, 221 | "export": 1, 222 | "print": 1, 223 | "read": 1, 224 | "report": 1, 225 | "role": "System Manager", 226 | "share": 1, 227 | "write": 1 228 | }, 229 | { 230 | "create": 1, 231 | "delete": 1, 232 | "email": 1, 233 | "export": 1, 234 | "print": 1, 235 | "read": 1, 236 | "report": 1, 237 | "role": "HR Manager", 238 | "share": 1, 239 | "write": 1 240 | }, 241 | { 242 | "create": 1, 243 | "email": 1, 244 | "export": 1, 245 | "print": 1, 246 | "read": 1, 247 | "report": 1, 248 | "role": "HR User", 249 | "share": 1, 250 | "write": 1 251 | } 252 | ], 253 | "row_format": "Dynamic", 254 | "sort_field": "modified", 255 | "sort_order": "DESC", 256 | "states": [], 257 | "title_field": "title", 258 | "track_changes": 1 259 | } -------------------------------------------------------------------------------- /pibioffline/hooks.py: -------------------------------------------------------------------------------- 1 | app_name = "pibioffline" 2 | app_title = "pibiOffline" 3 | app_publisher = "pibiCo" 4 | app_description = "PWA for Work Attendance" 5 | app_email = "pibico.sl@gmail.com" 6 | app_license = "mit" 7 | 8 | # Apps 9 | # ------------------ 10 | 11 | # required_apps = [] 12 | 13 | # Each item in the list will be shown as an app in the apps page 14 | # add_to_apps_screen = [ 15 | # { 16 | # "name": "pibioffline", 17 | # "logo": "/assets/pibioffline/logo.png", 18 | # "title": "pibiOffline", 19 | # "route": "/pibioffline", 20 | # "has_permission": "pibioffline.api.permission.has_app_permission" 21 | # } 22 | # ] 23 | 24 | # Includes in 25 | # ------------------ 26 | 27 | # include js, css files in header of desk.html 28 | # app_include_css = "/assets/pibioffline/css/pibioffline.css" 29 | # app_include_js = "/assets/pibioffline/js/pibioffline.js" 30 | 31 | # Note: Scripts are loaded directly in the Custom HTML Block 32 | # This keeps them isolated to the pibiOffline workspace only 33 | 34 | # include js, css files in header of web template 35 | # web_include_css = "/assets/pibioffline/css/pibioffline.css" 36 | # web_include_js = "/assets/pibioffline/js/pibioffline.js" 37 | 38 | # include custom scss in every website theme (without file extension ".scss") 39 | # website_theme_scss = "pibioffline/public/scss/website" 40 | 41 | # include js, css files in header of web form 42 | # webform_include_js = {"doctype": "public/js/doctype.js"} 43 | # webform_include_css = {"doctype": "public/css/doctype.css"} 44 | 45 | # include js in page 46 | # page_js = {"page" : "public/js/file.js"} 47 | 48 | # include js in doctype views 49 | # doctype_js = {"doctype" : "public/js/doctype.js"} 50 | # doctype_list_js = {"doctype" : "public/js/doctype_list.js"} 51 | # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} 52 | # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} 53 | 54 | # Svg Icons 55 | # ------------------ 56 | # include app icons in desk 57 | # app_include_icons = "pibioffline/public/icons.svg" 58 | 59 | # Home Pages 60 | # ---------- 61 | 62 | # application home page (will override Website Settings) 63 | # home_page = "login" 64 | 65 | # website user home page (by Role) 66 | # role_home_page = { 67 | # "Role": "home_page" 68 | # } 69 | 70 | # Generators 71 | # ---------- 72 | 73 | # automatically create page for each record of this doctype 74 | # website_generators = ["Web Page"] 75 | 76 | # Jinja 77 | # ---------- 78 | 79 | # add methods and filters to jinja environment 80 | # jinja = { 81 | # "methods": "pibioffline.utils.jinja_methods", 82 | # "filters": "pibioffline.utils.jinja_filters" 83 | # } 84 | 85 | # Installation 86 | # ------------ 87 | 88 | # before_install = "pibioffline.install.before_install" 89 | after_install = "pibioffline.install.after_install" 90 | 91 | # Fixtures 92 | # -------- 93 | # fixtures = [] 94 | 95 | # Uninstallation 96 | # ------------ 97 | 98 | # before_uninstall = "pibioffline.uninstall.before_uninstall" 99 | # after_uninstall = "pibioffline.uninstall.after_uninstall" 100 | 101 | # Integration Setup 102 | # ------------------ 103 | # To set up dependencies/integrations with other apps 104 | # Name of the app being installed is passed as an argument 105 | 106 | # before_app_install = "pibioffline.utils.before_app_install" 107 | # after_app_install = "pibioffline.utils.after_app_install" 108 | 109 | # Integration Cleanup 110 | # ------------------- 111 | # To clean up dependencies/integrations with other apps 112 | # Name of the app being uninstalled is passed as an argument 113 | 114 | # before_app_uninstall = "pibioffline.utils.before_app_uninstall" 115 | # after_app_uninstall = "pibioffline.utils.after_app_uninstall" 116 | 117 | # Desk Notifications 118 | # ------------------ 119 | # See frappe.core.notifications.get_notification_config 120 | 121 | # notification_config = "pibioffline.notifications.get_notification_config" 122 | 123 | # Permissions 124 | # ----------- 125 | # Permissions evaluated in scripted ways 126 | 127 | # permission_query_conditions = { 128 | # "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", 129 | # } 130 | # 131 | # has_permission = { 132 | # "Event": "frappe.desk.doctype.event.event.has_permission", 133 | # } 134 | 135 | # DocType Class 136 | # --------------- 137 | # Override standard doctype classes 138 | 139 | # override_doctype_class = { 140 | # "ToDo": "custom_app.overrides.CustomToDo" 141 | # } 142 | 143 | # Document Events 144 | # --------------- 145 | # Hook on document methods and events 146 | 147 | # doc_events = { 148 | # "*": { 149 | # "on_update": "method", 150 | # "on_cancel": "method", 151 | # "on_trash": "method" 152 | # } 153 | # } 154 | 155 | # Scheduled Tasks 156 | # --------------- 157 | 158 | # scheduler_events = { 159 | # "all": [ 160 | # "pibioffline.tasks.all" 161 | # ], 162 | # "daily": [ 163 | # "pibioffline.tasks.daily" 164 | # ], 165 | # "hourly": [ 166 | # "pibioffline.tasks.hourly" 167 | # ], 168 | # "weekly": [ 169 | # "pibioffline.tasks.weekly" 170 | # ], 171 | # "monthly": [ 172 | # "pibioffline.tasks.monthly" 173 | # ], 174 | # } 175 | 176 | # Testing 177 | # ------- 178 | 179 | # before_tests = "pibioffline.install.before_tests" 180 | 181 | # Overriding Methods 182 | # ------------------------------ 183 | # 184 | # override_whitelisted_methods = { 185 | # "frappe.desk.doctype.event.event.get_events": "pibioffline.event.get_events" 186 | # } 187 | # 188 | # each overriding function accepts a `data` argument; 189 | # generated from the base implementation of the doctype dashboard, 190 | # along with any modifications made in other Frappe apps 191 | # override_doctype_dashboards = { 192 | # "Task": "pibioffline.task.get_dashboard_data" 193 | # } 194 | 195 | # exempt linked doctypes from being automatically cancelled 196 | # 197 | # auto_cancel_exempted_doctypes = ["Auto Repeat"] 198 | 199 | # Ignore links to specified DocTypes when deleting documents 200 | # ----------------------------------------------------------- 201 | 202 | # ignore_links_on_delete = ["Communication", "ToDo"] 203 | 204 | # Request Events 205 | # ---------------- 206 | # before_request = ["pibioffline.utils.before_request"] 207 | # after_request = ["pibioffline.utils.after_request"] 208 | 209 | # Job Events 210 | # ---------- 211 | # before_job = ["pibioffline.utils.before_job"] 212 | # after_job = ["pibioffline.utils.after_job"] 213 | 214 | # User Data Protection 215 | # -------------------- 216 | 217 | # user_data_fields = [ 218 | # { 219 | # "doctype": "{doctype_1}", 220 | # "filter_by": "{filter_by}", 221 | # "redact_fields": ["{field_1}", "{field_2}"], 222 | # "partial": 1, 223 | # }, 224 | # { 225 | # "doctype": "{doctype_2}", 226 | # "filter_by": "{filter_by}", 227 | # "partial": 1, 228 | # }, 229 | # { 230 | # "doctype": "{doctype_3}", 231 | # "strict": False, 232 | # }, 233 | # { 234 | # "doctype": "{doctype_4}" 235 | # } 236 | # ] 237 | 238 | # Authentication and authorization 239 | # -------------------------------- 240 | 241 | # auth_hooks = [ 242 | # "pibioffline.auth.validate" 243 | # ] 244 | 245 | # Automatically update python controller files with type annotations for this app. 246 | # export_python_type_annotations = True 247 | 248 | # default_log_clearing_doctypes = { 249 | # "Logging DocType Name": 30 # days to retain logs 250 | # } 251 | 252 | fixtures = [ 253 | { 254 | "dt": "Custom HTML Block", 255 | "filters": [["name", "like", "PL %"]] 256 | }, 257 | { 258 | "dt": "Workspace", 259 | "filters": [["module", "in", ("pibiOffline")]] 260 | }, 261 | ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pibiOffline - PWA for Work Attendance 2 | 3 | A Progressive Web App (PWA) for QR code-based attendance tracking with offline capabilities, designed for organizations managing field employees and remote workers. 4 | 5 | ## Table of Contents 6 | 7 | - [Features](#features) 8 | - [System Architecture](#system-architecture) 9 | - [Installation](#installation) 10 | - [Setup](#setup) 11 | - [Configuration](#configuration) 12 | - [Usage](#usage) 13 | - [Offline Functionality](#offline-functionality) 14 | - [DocTypes](#doctypes) 15 | - [API Reference](#api-reference) 16 | - [Security & Permissions](#security--permissions) 17 | - [Troubleshooting](#troubleshooting) 18 | - [Contributing](#contributing) 19 | 20 | ## Features 21 | 22 | ### Core Features 23 | - **QR Code Scanning**: High-performance camera-based QR code scanning 24 | - **Offline-First Design**: Full functionality without internet connection 25 | - **Progressive Web App**: Installable on mobile devices as native app 26 | - **Automatic Sync**: Background synchronization when connection restored 27 | - **Geolocation Tracking**: Optional GPS location capture for attendance 28 | - **Photo Verification**: Optional photo requirement for check-ins 29 | - **Multi-Organization Support**: Manage employees across different organizations 30 | 31 | ### Advanced Features 32 | - **Hybrid QR Detection**: Client-side jsQR with server-side OpenCV fallback 33 | - **Smart Caching**: Automatic caching of accessible employee data 34 | - **Session Management**: Tracks work sessions with check-in/out pairs 35 | - **Permission-Based Access**: Role-based visibility of employee data 36 | - **Real-time Status**: Visual indicators for online/offline status 37 | - **Batch Sync**: Efficient synchronization of multiple offline records 38 | 39 | ## System Architecture 40 | 41 | ### Frontend Components 42 | - **Custom HTML Block**: Isolated workspace implementation 43 | - **IndexedDB Storage**: Local database for offline data 44 | - **Service Worker**: PWA functionality and caching 45 | - **WebRTC Camera**: Direct camera access for QR scanning 46 | 47 | ### Backend Components 48 | - **Frappe Framework**: Core application platform 49 | - **Custom DocTypes**: Employee, attendance, and session management 50 | - **REST APIs**: Attendance sync and data retrieval 51 | - **OpenCV Integration**: Advanced QR code processing 52 | 53 | ### Data Flow 54 | 1. **Check-in Process**: 55 | - QR scan → Employee validation → Location/Photo capture → Local storage → Server sync 56 | 2. **Check-out Process**: 57 | - QR scan → Session lookup → Location capture → Session completion → Server sync 58 | 3. **Offline Queue**: 59 | - Failed syncs → IndexedDB queue → Retry on connection → Update status 60 | 61 | ## Installation 62 | 63 | ```bash 64 | # Get the app 65 | bench get-app https://github.com/pibi/pibiOffline 66 | 67 | # Install on your site 68 | bench --site your-site.com install-app pibioffline 69 | 70 | # Run migrations 71 | bench --site your-site.com migrate 72 | 73 | # Clear cache 74 | bench --site your-site.com clear-cache 75 | 76 | # Optional: Install OpenCV for enhanced QR detection 77 | pip install opencv-python numpy 78 | ``` 79 | 80 | ## Setup 81 | 82 | ### 1. Create Master Data 83 | 84 | #### PL Person (Employee Personal Info) 85 | ``` 86 | 1. Go to PL Person list 87 | 2. Create records with: 88 | - Full Name 89 | - User (optional - links to system user) 90 | - Other personal details 91 | ``` 92 | 93 | #### PL Organization 94 | ``` 95 | 1. Go to PL Organization list 96 | 2. Create organization records with: 97 | - Organization Name 98 | - Organization Code 99 | - Address/contact details 100 | ``` 101 | 102 | #### PL Employee Assignment 103 | ``` 104 | 1. Go to PL Employee Assignment list 105 | 2. Create assignments linking person to organization: 106 | - Person (link to PL Person) 107 | - Organization (link to PL Organization) 108 | - Employee Code (unique identifier) 109 | - QR Code (auto-generated or manual) 110 | - Status (Active/Inactive) 111 | - Location Required (checkbox) 112 | - Picture Required (checkbox) 113 | - User (optional - for direct user access) 114 | - Reporting Manager (optional - for hierarchy) 115 | ``` 116 | 117 | ### 2. Configure Workspace 118 | 119 | ``` 120 | 1. Go to Workspace List 121 | 2. Create/edit "pibiOffline" workspace 122 | 3. Add Custom HTML Block named "PL pibiOffline" 123 | 4. Configure block with provided HTML/JS/CSS 124 | ``` 125 | 126 | ### 3. Set Up Permissions 127 | 128 | The app automatically handles permissions based on: 129 | - **System Manager/HR Manager**: Access to all employees 130 | - **Regular Users**: Access to: 131 | - Their own assignments (via user field) 132 | - Employees in their organizations 133 | - Employees they manage 134 | 135 | ## Configuration 136 | 137 | ### Employee Assignment Settings 138 | 139 | ```python 140 | # Location requirement for check-in 141 | location_required = 1 # Enforces GPS capture 142 | 143 | # Photo requirement for check-in 144 | picture_required = 1 # Enforces photo capture 145 | 146 | # Note: Requirements only apply to check-in, not check-out 147 | ``` 148 | 149 | ### Offline Cache Settings 150 | 151 | ```javascript 152 | // Auto-cache interval (default: 30 minutes) 153 | const CACHE_INTERVAL = 30 * 60 * 1000; 154 | 155 | // Cached data includes: 156 | // - Employee assignments accessible to user 157 | // - Organization details 158 | // - Person names 159 | // - QR codes and requirements 160 | ``` 161 | 162 | ## Usage 163 | 164 | ### For Employees 165 | 166 | 1. **First Time Setup**: 167 | - Open pibiOffline workspace while online 168 | - Grant camera and location permissions when prompted 169 | - Install as PWA if desired (Add to Home Screen) 170 | 171 | 2. **Daily Check-in**: 172 | - Open pibiOffline app 173 | - Click "Scan QR Code" 174 | - Scan your employee QR code 175 | - Take photo if required 176 | - Confirm location if required 177 | - View confirmation message 178 | 179 | 3. **Daily Check-out**: 180 | - Scan QR code again 181 | - System automatically detects check-out 182 | - View work duration 183 | - Data syncs when online 184 | 185 | ### For Administrators 186 | 187 | 1. **Monitor Attendance**: 188 | - View PL Resource Attendance list 189 | - Filter by date, employee, organization 190 | - Check sync status for offline records 191 | 192 | 2. **Manage Employees**: 193 | - Update PL Employee Assignment status 194 | - Configure location/photo requirements 195 | - Assign reporting managers 196 | - Link to user accounts 197 | 198 | 3. **View Work Sessions**: 199 | - Access PL Work Session records 200 | - See complete check-in/out pairs 201 | - Track work duration and locations 202 | 203 | ## Offline Functionality 204 | 205 | ### How It Works 206 | 207 | 1. **Initial Cache**: 208 | - On workspace load, caches accessible employees 209 | - Stores in IndexedDB with indexes for fast lookup 210 | - Updates every 30 minutes when online 211 | 212 | 2. **Offline Detection**: 213 | - Visual indicator shows connection status 214 | - All features remain functional 215 | - Data queued for sync 216 | 217 | 3. **Data Storage**: 218 | ```javascript 219 | // IndexedDB structure 220 | { 221 | attendance: { 222 | id: auto-increment, 223 | qrCode: string, 224 | type: 'check-in' | 'check-out', 225 | timestamp: ISO string, 226 | location: string, 227 | latitude: number, 228 | longitude: number, 229 | photo: base64 string, 230 | synced: boolean, 231 | localId: unique string 232 | }, 233 | resources: { 234 | name: docname, 235 | qr_code: string (indexed), 236 | person_name: string, 237 | organization_name: string, 238 | location_required: boolean, 239 | picture_required: boolean 240 | } 241 | } 242 | ``` 243 | 244 | 4. **Sync Process**: 245 | - Automatic on connection restore 246 | - Manual sync button available 247 | - Handles duplicates via localId 248 | - Updates UI on completion 249 | 250 | ## DocTypes 251 | 252 | ### PL Person 253 | - **Purpose**: Store employee personal information 254 | - **Key Fields**: full_name, user, contact details 255 | - **Links**: PL Employee Assignment 256 | 257 | ### PL Organization 258 | - **Purpose**: Define organizational units 259 | - **Key Fields**: organization_name, code, address 260 | - **Links**: PL Employee Assignment 261 | 262 | ### PL Employee Assignment 263 | - **Purpose**: Link persons to organizations with work details 264 | - **Key Fields**: 265 | - person (Link to PL Person) 266 | - organization (Link to PL Organization) 267 | - employee_code (unique) 268 | - qr_code (for scanning) 269 | - status (Active/Inactive) 270 | - location_required (Check field) 271 | - picture_required (Check field) 272 | - user (Link to User - optional) 273 | - reporting_manager (Link - optional) 274 | 275 | ### PL Resource Attendance 276 | - **Purpose**: Store individual attendance events 277 | - **Key Fields**: 278 | - resource (Link to PL Employee Assignment) 279 | - attendance_type (Check In/Check Out) 280 | - timestamp (Datetime) 281 | - location, latitude, longitude 282 | - photo (Attach Image) 283 | - synced (Check) 284 | - local_id (for deduplication) 285 | 286 | ### PL Work Session 287 | - **Purpose**: Track complete work sessions 288 | - **Key Fields**: 289 | - employee (Link to PL Employee Assignment) 290 | - work_date (Date) 291 | - check_in_time, check_out_time 292 | - check_in_location, check_out_location 293 | - status (Active/Completed) 294 | - Duration calculated automatically 295 | 296 | ## API Reference 297 | 298 | ### Attendance APIs 299 | 300 | #### sync_attendance 301 | ```python 302 | @frappe.whitelist() 303 | def sync_attendance(**kwargs): 304 | """ 305 | Create/update attendance record 306 | 307 | Args: 308 | qr_code: Employee QR code 309 | type: 'check-in' or 'check-out' 310 | time: Timestamp 311 | location: Address string 312 | latitude: GPS latitude 313 | longitude: GPS longitude 314 | local_id: Client-side unique ID 315 | photo: Base64 image data 316 | 317 | Returns: 318 | { 319 | success: boolean, 320 | attendance_id: string, 321 | employee_name: string, 322 | organization_name: string, 323 | message: string 324 | } 325 | """ 326 | ``` 327 | 328 | #### get_resource_by_qr 329 | ```python 330 | @frappe.whitelist() 331 | def get_resource_by_qr(qr_code): 332 | """Get employee details by QR code""" 333 | ``` 334 | 335 | #### get_active_work_session 336 | ```python 337 | @frappe.whitelist() 338 | def get_active_work_session(qr_code): 339 | """Get today's active session for employee""" 340 | ``` 341 | 342 | #### get_user_accessible_resources 343 | ```python 344 | @frappe.whitelist() 345 | def get_user_accessible_resources(): 346 | """Get all employees accessible to logged-in user""" 347 | ``` 348 | 349 | ### Advanced APIs 350 | 351 | #### process_qr_with_opencv 352 | ```python 353 | @frappe.whitelist() 354 | def process_qr_with_opencv(image_base64): 355 | """Process difficult QR codes using OpenCV""" 356 | ``` 357 | 358 | ## Security & Permissions 359 | 360 | ### Permission Model 361 | 1. **Role-Based Access**: 362 | - System Manager: Full access 363 | - HR Manager: Full access 364 | - Regular Users: Limited by assignment 365 | 366 | 2. **Data Visibility**: 367 | - Users see their own assignments 368 | - Users see colleagues in same organization 369 | - Managers see their reportees 370 | 371 | 3. **Security Features**: 372 | - Server-side validation 373 | - Duplicate prevention via local_id 374 | - Photo verification option 375 | - Location verification option 376 | 377 | ### Browser Permissions 378 | - **Camera**: Required for QR scanning 379 | - **Location**: Required if location_required is set 380 | - **Storage**: Required for offline functionality 381 | 382 | **Important**: Permissions must be granted while online 383 | 384 | ## Troubleshooting 385 | 386 | ### Common Issues 387 | 388 | 1. **"Camera shows black screen"** 389 | - Solution: Ensure proper cleanup in stopScanning() 390 | - Clear browser cache and reload 391 | 392 | 2. **"Employee not active" in offline mode** 393 | - Solution: Employee not cached, go online to cache 394 | - Check user has access to employee 395 | 396 | 3. **"Location/Camera permission denied"** 397 | - Solution: Grant permissions while online first 398 | - Check browser settings 399 | 400 | 4. **"Unknown column 'ea.user' error"** 401 | - Solution: Run doctype update in UI 402 | - System has backward compatibility 403 | 404 | 5. **Duplicate attendance records** 405 | - Solution: System uses local_id for deduplication 406 | - Check IndexedDB for corrupted data 407 | 408 | ### Debugging Tools 409 | 410 | ```javascript 411 | // Check IndexedDB contents 412 | pibioffline.storage.db 413 | 414 | // View cached resources 415 | await pibioffline.storage.getResourceByQR('EMP001') 416 | 417 | // Check unsynced records 418 | await pibioffline.storage.getUnsyncedAttendance() 419 | 420 | // Force sync 421 | await pibioffline.storage.syncToServer() 422 | ``` 423 | 424 | ### Contributing 425 | 426 | This app uses `pre-commit` for code formatting and linting. Please [install pre-commit](https://pre-commit.com/#installation) and enable it for this repository: 427 | 428 | ```bash 429 | cd apps/pibioffline 430 | pre-commit install 431 | ``` 432 | 433 | Pre-commit is configured to use the following tools for checking and formatting your code: 434 | 435 | - ruff 436 | - eslint 437 | - prettier 438 | - pyupgrade 439 | 440 | ### License 441 | 442 | mit 443 | # pibioffline 444 | -------------------------------------------------------------------------------- /pibioffline/public/js/offline/offline-storage.js: -------------------------------------------------------------------------------- 1 | frappe.provide('pibioffline.storage'); 2 | 3 | pibioffline.storage = { 4 | dbName: 'pibioffline_db', 5 | dbVersion: 4, // Increment version to rebuild indexes 6 | db: null, 7 | 8 | init() { 9 | return new Promise((resolve, reject) => { 10 | try { 11 | const request = indexedDB.open(this.dbName, this.dbVersion); 12 | 13 | request.onerror = () => { 14 | console.error('Failed to open database:', request.error); 15 | reject(request.error); 16 | }; 17 | 18 | request.onsuccess = () => { 19 | this.db = request.result; 20 | console.log('pibiOffline database initialized successfully'); 21 | resolve(); 22 | }; 23 | 24 | request.onupgradeneeded = (event) => { 25 | const db = event.target.result; 26 | const oldVersion = event.oldVersion; 27 | 28 | // Delete old stores if they exist 29 | if (oldVersion < 4) { 30 | if (db.objectStoreNames.contains('attendance')) { 31 | db.deleteObjectStore('attendance'); 32 | } 33 | if (db.objectStoreNames.contains('workSessions')) { 34 | db.deleteObjectStore('workSessions'); 35 | } 36 | if (db.objectStoreNames.contains('resources')) { 37 | db.deleteObjectStore('resources'); 38 | } 39 | } 40 | 41 | // Create new attendance store with proper structure 42 | if (!db.objectStoreNames.contains('attendance')) { 43 | const attendanceStore = db.createObjectStore('attendance', { 44 | keyPath: 'id', 45 | autoIncrement: true 46 | }); 47 | attendanceStore.createIndex('synced', 'synced', { unique: false }); 48 | attendanceStore.createIndex('qrCode', 'qrCode', { unique: false }); 49 | attendanceStore.createIndex('timestamp', 'timestamp', { unique: false }); 50 | attendanceStore.createIndex('localId', 'localId', { unique: true }); 51 | attendanceStore.createIndex('type', 'type', { unique: false }); // 'check-in' or 'check-out' 52 | attendanceStore.createIndex('date', 'date', { unique: false }); 53 | } 54 | 55 | if (!db.objectStoreNames.contains('resources')) { 56 | const resourceStore = db.createObjectStore('resources', { 57 | keyPath: 'name' 58 | }); 59 | resourceStore.createIndex('qr_code', 'qr_code', { unique: true }); 60 | } 61 | }; 62 | } catch (error) { 63 | console.error('Error initializing database:', error); 64 | reject(error); 65 | } 66 | }); 67 | }, 68 | 69 | async saveAttendance(data) { 70 | if (!this.db) { 71 | console.error('Database not initialized'); 72 | return null; 73 | } 74 | 75 | const localId = Date.now().toString(); 76 | const timestamp = new Date().toISOString(); 77 | const date = new Date().toISOString().split('T')[0]; 78 | 79 | // Use the type provided in the data 80 | const type = data.type || 'check-in'; 81 | 82 | const transaction = this.db.transaction(['attendance'], 'readwrite'); 83 | const store = transaction.objectStore('attendance'); 84 | 85 | const attendanceRecord = { 86 | qrCode: data.qr_code || data.qrCode, 87 | resource: data.resource, 88 | resourceName: data.resource_name || data.resourceName || data.employee_name, 89 | organizationName: data.organization_name || data.organizationName || 'Unknown', 90 | type: type, 91 | time: data.timestamp, 92 | location: data.location, 93 | latitude: data.latitude, 94 | longitude: data.longitude, 95 | photo: data.photo || null, 96 | synced: data.synced !== undefined ? data.synced : false, 97 | timestamp: timestamp, 98 | localId: localId, 99 | date: date 100 | }; 101 | 102 | return new Promise((resolve, reject) => { 103 | const request = store.add(attendanceRecord); 104 | request.onsuccess = () => resolve(request.result); 105 | request.onerror = () => { 106 | console.error('Error saving attendance:', request.error); 107 | reject(request.error); 108 | }; 109 | }); 110 | }, 111 | 112 | async getUnsyncedAttendance() { 113 | if (!this.db) { 114 | console.error('Database not initialized'); 115 | return []; 116 | } 117 | 118 | const transaction = this.db.transaction(['attendance'], 'readonly'); 119 | const store = transaction.objectStore('attendance'); 120 | 121 | return new Promise((resolve, reject) => { 122 | const results = []; 123 | const request = store.openCursor(); 124 | 125 | request.onsuccess = (event) => { 126 | const cursor = event.target.result; 127 | if (cursor) { 128 | // Check if the record is not synced 129 | if (!cursor.value.synced) { 130 | results.push(cursor.value); 131 | } 132 | cursor.continue(); 133 | } else { 134 | // No more records 135 | resolve(results); 136 | } 137 | }; 138 | 139 | request.onerror = () => { 140 | console.error('Error getting unsynced attendance:', request.error); 141 | resolve([]); 142 | }; 143 | }); 144 | }, 145 | 146 | async markAsSynced(id) { 147 | const transaction = this.db.transaction(['attendance'], 'readwrite'); 148 | const store = transaction.objectStore('attendance'); 149 | 150 | return new Promise((resolve, reject) => { 151 | const request = store.get(id); 152 | request.onsuccess = () => { 153 | const record = request.result; 154 | record.synced = true; 155 | const updateRequest = store.put(record); 156 | updateRequest.onsuccess = () => resolve(); 157 | updateRequest.onerror = () => reject(updateRequest.error); 158 | }; 159 | request.onerror = () => reject(request.error); 160 | }); 161 | }, 162 | 163 | async saveResource(resource) { 164 | const transaction = this.db.transaction(['resources'], 'readwrite'); 165 | const store = transaction.objectStore('resources'); 166 | 167 | return new Promise((resolve, reject) => { 168 | const request = store.put(resource); 169 | request.onsuccess = () => resolve(); 170 | request.onerror = () => reject(request.error); 171 | }); 172 | }, 173 | 174 | async getResourceByQR(qrCode) { 175 | const transaction = this.db.transaction(['resources'], 'readonly'); 176 | const store = transaction.objectStore('resources'); 177 | const index = store.index('qr_code'); 178 | 179 | return new Promise((resolve, reject) => { 180 | const request = index.get(qrCode); 181 | request.onsuccess = () => resolve(request.result); 182 | request.onerror = () => reject(request.error); 183 | }); 184 | }, 185 | 186 | async syncToServer() { 187 | try { 188 | const unsyncedRecords = await this.getUnsyncedAttendance(); 189 | 190 | if (!unsyncedRecords || unsyncedRecords.length === 0) { 191 | return { success: true, synced: 0 }; 192 | } 193 | 194 | let syncedCount = 0; 195 | 196 | for (const record of unsyncedRecords) { 197 | try { 198 | const response = await frappe.call({ 199 | method: 'pibioffline.api.attendance.sync_attendance', 200 | args: { 201 | qr_code: record.qrCode, 202 | type: record.type, 203 | time: record.time, 204 | location: record.location, 205 | latitude: record.latitude, 206 | longitude: record.longitude, 207 | local_id: record.localId, 208 | client_timestamp: record.timestamp, 209 | photo: record.photo 210 | } 211 | }); 212 | 213 | if (response.message && response.message.success) { 214 | await this.markAsSynced(record.id); 215 | syncedCount++; 216 | } 217 | } catch (error) { 218 | console.error('Failed to sync record:', error); 219 | } 220 | } 221 | 222 | return { success: true, synced: syncedCount }; 223 | } catch (error) { 224 | console.error('Sync to server error:', error); 225 | return { success: false, synced: 0, error: error.message }; 226 | } 227 | }, 228 | 229 | async getTodaysCheckIn(qrCode) { 230 | if (!this.db) { 231 | console.error('Database not initialized'); 232 | return null; 233 | } 234 | 235 | const transaction = this.db.transaction(['attendance'], 'readonly'); 236 | const store = transaction.objectStore('attendance'); 237 | const today = new Date().toISOString().split('T')[0]; 238 | 239 | return new Promise((resolve, reject) => { 240 | const request = store.getAll(); 241 | request.onsuccess = () => { 242 | const records = request.result || []; 243 | // Find today's check-ins for this QR code 244 | const todaysCheckIns = records.filter(record => 245 | record.qrCode === qrCode && 246 | record.date === today && 247 | record.type === 'check-in' 248 | ); 249 | 250 | if (todaysCheckIns.length > 0) { 251 | // Sort by time to get the latest check-in 252 | todaysCheckIns.sort((a, b) => new Date(b.time) - new Date(a.time)); 253 | const latestCheckIn = todaysCheckIns[0]; 254 | 255 | // Check if there's already a check-out after this check-in 256 | const hasCheckOut = records.some(record => 257 | record.qrCode === qrCode && 258 | record.date === today && 259 | record.type === 'check-out' && 260 | new Date(record.time) > new Date(latestCheckIn.time) 261 | ); 262 | 263 | resolve(hasCheckOut ? null : latestCheckIn); 264 | } else { 265 | resolve(null); 266 | } 267 | }; 268 | request.onerror = () => { 269 | console.error('Error getting today\'s check-in:', request.error); 270 | resolve(null); 271 | }; 272 | }); 273 | }, 274 | 275 | async updateAttendance(id, updates) { 276 | if (!this.db) { 277 | console.error('Database not initialized'); 278 | return; 279 | } 280 | 281 | const transaction = this.db.transaction(['attendance'], 'readwrite'); 282 | const store = transaction.objectStore('attendance'); 283 | 284 | return new Promise((resolve, reject) => { 285 | const request = store.get(id); 286 | request.onsuccess = () => { 287 | const record = request.result; 288 | if (record) { 289 | Object.assign(record, updates); 290 | const updateRequest = store.put(record); 291 | updateRequest.onsuccess = () => resolve(); 292 | updateRequest.onerror = () => { 293 | console.error('Error updating attendance:', updateRequest.error); 294 | reject(updateRequest.error); 295 | }; 296 | } else { 297 | console.error('Record not found:', id); 298 | resolve(); 299 | } 300 | }; 301 | request.onerror = () => { 302 | console.error('Error getting attendance:', request.error); 303 | reject(request.error); 304 | }; 305 | }); 306 | }, 307 | 308 | async saveResource(resourceData) { 309 | if (!this.db) { 310 | console.error('Database not initialized'); 311 | return; 312 | } 313 | 314 | // Ensure qr_code field exists 315 | if (!resourceData.qr_code) { 316 | console.error('Resource missing qr_code:', resourceData); 317 | return; 318 | } 319 | 320 | console.log('Saving resource with QR code:', resourceData.qr_code); 321 | 322 | const transaction = this.db.transaction(['resources'], 'readwrite'); 323 | const store = transaction.objectStore('resources'); 324 | 325 | return new Promise((resolve, reject) => { 326 | const request = store.put(resourceData); 327 | request.onsuccess = () => { 328 | console.log('Resource saved successfully:', resourceData.qr_code); 329 | resolve(); 330 | }; 331 | request.onerror = () => { 332 | console.error('Error saving resource:', request.error); 333 | reject(request.error); 334 | }; 335 | }); 336 | }, 337 | 338 | async getResourceByQR(qrCode) { 339 | if (!this.db) { 340 | console.error('Database not initialized'); 341 | return null; 342 | } 343 | 344 | console.log('Looking up resource with QR code:', qrCode); 345 | 346 | const transaction = this.db.transaction(['resources'], 'readonly'); 347 | const store = transaction.objectStore('resources'); 348 | const index = store.index('qr_code'); 349 | 350 | return new Promise((resolve, reject) => { 351 | const request = index.get(qrCode); 352 | request.onsuccess = () => { 353 | const result = request.result; 354 | if (result) { 355 | console.log('Resource found:', result); 356 | } else { 357 | console.log('No resource found for QR code:', qrCode); 358 | } 359 | resolve(result); 360 | }; 361 | request.onerror = () => { 362 | console.error('Error getting resource:', request.error); 363 | reject(request.error); 364 | }; 365 | }); 366 | } 367 | }; -------------------------------------------------------------------------------- /pibioffline/public/js/pibioffline.js: -------------------------------------------------------------------------------- 1 | frappe.provide('pibioffline'); 2 | 3 | pibioffline.AttendanceTracker = class AttendanceTracker { 4 | constructor(container) { 5 | this.container = container; 6 | this.currentAttendance = null; 7 | this.init(); 8 | } 9 | 10 | async init() { 11 | // Initialize offline storage 12 | await pibioffline.storage.init(); 13 | 14 | // Set up sync interval 15 | setInterval(() => this.syncData(), 60000); // Sync every minute 16 | 17 | // Load jsQR library dynamically 18 | await this.loadQRLibrary(); 19 | 20 | // Render UI 21 | this.render(); 22 | 23 | // Check for current attendance 24 | this.checkCurrentAttendance(); 25 | } 26 | 27 | async loadQRLibrary() { 28 | return new Promise((resolve) => { 29 | const script = document.createElement('script'); 30 | script.src = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js'; 31 | script.onload = resolve; 32 | document.head.appendChild(script); 33 | }); 34 | } 35 | 36 | render() { 37 | this.container.innerHTML = ` 38 |
39 |
40 |

Resource Attendance

41 |
42 | 43 | ${navigator.onLine ? 'Online' : 'Offline'} 44 |
45 |
46 | 47 |
48 |
49 |
50 | 51 |
52 |
53 |
Current Status
54 |
Not Checked In
55 |
56 |
57 |
58 | 59 |
60 | 63 |
64 | 65 | 79 | 80 | 84 |
85 | `; 86 | 87 | // Monitor online/offline status 88 | window.addEventListener('online', () => this.updateOnlineStatus(true)); 89 | window.addEventListener('offline', () => this.updateOnlineStatus(false)); 90 | } 91 | 92 | updateOnlineStatus(isOnline) { 93 | const indicator = document.querySelector('.sync-indicator'); 94 | const text = document.querySelector('.sync-text'); 95 | 96 | if (isOnline) { 97 | indicator.classList.remove('offline'); 98 | indicator.classList.add('online'); 99 | text.textContent = 'Online'; 100 | // Trigger sync when back online 101 | this.syncData(); 102 | } else { 103 | indicator.classList.remove('online'); 104 | indicator.classList.add('offline'); 105 | text.textContent = 'Offline'; 106 | } 107 | } 108 | 109 | scanQRCode() { 110 | // Enhanced QR scanner that tries client-side first, then server if needed 111 | pibioffline.qr.scanQRCode(async (result) => { 112 | try { 113 | let qrData = null; 114 | 115 | if (result.success) { 116 | // Client-side scan succeeded 117 | qrData = result.data; 118 | } else if (navigator.onLine && result.imageData) { 119 | // Client-side failed, try server-side with OpenCV 120 | const response = await frappe.call({ 121 | method: 'pibioffline.api.attendance.process_qr_with_opencv', 122 | args: { 123 | image_base64: result.imageData 124 | } 125 | }); 126 | 127 | if (response.message && response.message.success) { 128 | qrData = response.message.data; 129 | } 130 | } 131 | 132 | if (!qrData) { 133 | frappe.throw(__('Could not read QR code. Please try again.')); 134 | return; 135 | } 136 | 137 | await this.processQRCode(qrData); 138 | 139 | } catch (error) { 140 | frappe.msgprint({ 141 | title: __('Error'), 142 | message: error.message, 143 | indicator: 'red' 144 | }); 145 | } 146 | }); 147 | } 148 | 149 | async processQRCode(qrData) { 150 | // QR data now contains just the employee ID 151 | const employeeId = qrData.trim(); 152 | 153 | if (!employeeId) { 154 | frappe.throw(__('Invalid QR Code - empty data')); 155 | return; 156 | } 157 | 158 | if (!this.currentAttendance) { 159 | await this.checkIn(employeeId, qrData); 160 | } else { 161 | await this.checkOut(qrData); 162 | } 163 | } 164 | 165 | async checkIn(employeeId, qrCode) { 166 | const location = await this.getCurrentLocation(); 167 | const now = frappe.datetime.now_datetime(); 168 | 169 | const attendanceData = { 170 | resource: employeeId, 171 | qr_code: qrCode, 172 | check_in: now, 173 | location: location.address || 'Unknown', 174 | latitude: location.latitude, 175 | longitude: location.longitude 176 | }; 177 | 178 | if (navigator.onLine) { 179 | try { 180 | // Online: Validate and save on server 181 | const response = await frappe.call({ 182 | method: 'pibioffline.api.attendance.create_attendance', 183 | args: attendanceData 184 | }); 185 | 186 | if (response.message.success) { 187 | this.currentAttendance = { 188 | ...attendanceData, 189 | name: response.message.name, 190 | resource_name: response.message.resource_name 191 | }; 192 | 193 | frappe.show_alert({ 194 | message: __('Checked in successfully'), 195 | indicator: 'green' 196 | }); 197 | } 198 | } catch (error) { 199 | // If online request fails, fall back to offline 200 | await this.saveOfflineAttendance(attendanceData); 201 | } 202 | } else { 203 | // Offline: Save locally 204 | await this.saveOfflineAttendance(attendanceData); 205 | } 206 | 207 | this.updateUI(); 208 | } 209 | 210 | async saveOfflineAttendance(attendanceData) { 211 | // Save to IndexedDB 212 | const localId = await pibioffline.storage.saveAttendance(attendanceData); 213 | 214 | this.currentAttendance = { 215 | ...attendanceData, 216 | localId: localId, 217 | resource_name: `Employee ${attendanceData.resource}` 218 | }; 219 | 220 | frappe.show_alert({ 221 | message: __('Checked in (Offline mode - will sync when online)'), 222 | indicator: 'blue' 223 | }); 224 | } 225 | 226 | async checkOut(qrCode) { 227 | if (!this.currentAttendance) { 228 | frappe.throw(__('No active check-in found')); 229 | return; 230 | } 231 | 232 | const checkOutTime = frappe.datetime.now_datetime(); 233 | 234 | if (navigator.onLine && this.currentAttendance.name) { 235 | try { 236 | // Online: Update on server 237 | await frappe.call({ 238 | method: 'pibioffline.api.attendance.update_checkout', 239 | args: { 240 | name: this.currentAttendance.name, 241 | check_out: checkOutTime 242 | } 243 | }); 244 | 245 | frappe.show_alert({ 246 | message: __('Checked out successfully'), 247 | indicator: 'green' 248 | }); 249 | } catch (error) { 250 | await this.updateOfflineCheckout(checkOutTime); 251 | } 252 | } else { 253 | await this.updateOfflineCheckout(checkOutTime); 254 | } 255 | 256 | this.currentAttendance.check_out = checkOutTime; 257 | this.updateUI(); 258 | 259 | // Clear current attendance after checkout 260 | setTimeout(() => { 261 | this.currentAttendance = null; 262 | this.updateUI(); 263 | }, 2000); 264 | } 265 | 266 | async updateOfflineCheckout(checkOutTime) { 267 | if (this.currentAttendance.localId) { 268 | // Update in IndexedDB 269 | await pibioffline.storage.updateAttendance(this.currentAttendance.localId, { 270 | check_out: checkOutTime 271 | }); 272 | 273 | frappe.show_alert({ 274 | message: __('Checked out (Offline mode - will sync when online)'), 275 | indicator: 'blue' 276 | }); 277 | } 278 | } 279 | 280 | async getCurrentLocation() { 281 | return new Promise((resolve) => { 282 | if (navigator.geolocation) { 283 | navigator.geolocation.getCurrentPosition( 284 | (position) => { 285 | resolve({ 286 | latitude: position.coords.latitude, 287 | longitude: position.coords.longitude, 288 | address: `Lat: ${position.coords.latitude.toFixed(6)}, Lng: ${position.coords.longitude.toFixed(6)}` 289 | }); 290 | }, 291 | (error) => { 292 | console.error('Geolocation error:', error); 293 | resolve({ 294 | latitude: null, 295 | longitude: null, 296 | address: 'Location not available' 297 | }); 298 | }, 299 | { 300 | timeout: 2000, 301 | enableHighAccuracy: false, 302 | maximumAge: 30000 303 | } 304 | ); 305 | } else { 306 | resolve({ 307 | latitude: null, 308 | longitude: null, 309 | address: 'Geolocation not supported' 310 | }); 311 | } 312 | }); 313 | } 314 | 315 | async checkCurrentAttendance() { 316 | if (navigator.onLine) { 317 | try { 318 | const response = await frappe.call({ 319 | method: 'pibioffline.api.attendance.get_todays_attendance', 320 | args: { resource: frappe.session.user } 321 | }); 322 | 323 | if (response.message && !response.message.check_out) { 324 | this.currentAttendance = response.message; 325 | this.updateUI(); 326 | } 327 | } catch (error) { 328 | console.error('Error checking attendance:', error); 329 | } 330 | } 331 | 332 | // Also check local storage for offline attendance 333 | if (pibioffline.storage.getTodaysCheckIn) { 334 | const localAttendance = await pibioffline.storage.getTodaysCheckIn(this.currentQrCode || ''); 335 | if (localAttendance) { 336 | this.currentAttendance = localAttendance; 337 | this.updateUI(); 338 | } 339 | } 340 | } 341 | 342 | updateUI() { 343 | const statusEl = document.getElementById('current-status'); 344 | const detailsEl = document.getElementById('attendance-details'); 345 | const checkInEl = document.getElementById('check-in-time'); 346 | const locationEl = document.getElementById('location'); 347 | const durationEl = document.getElementById('duration'); 348 | 349 | if (this.currentAttendance) { 350 | statusEl.textContent = 'Checked In'; 351 | statusEl.classList.add('checked-in'); 352 | 353 | checkInEl.textContent = frappe.datetime.str_to_user(this.currentAttendance.check_in); 354 | locationEl.textContent = this.currentAttendance.location || '-'; 355 | 356 | if (this.currentAttendance.check_out) { 357 | statusEl.textContent = 'Checked Out'; 358 | statusEl.classList.remove('checked-in'); 359 | statusEl.classList.add('checked-out'); 360 | 361 | const duration = moment(this.currentAttendance.check_out).diff(moment(this.currentAttendance.check_in), 'minutes'); 362 | durationEl.textContent = `${Math.floor(duration / 60)}h ${duration % 60}m`; 363 | } else { 364 | // Update duration in real-time 365 | setInterval(() => { 366 | if (this.currentAttendance && !this.currentAttendance.check_out) { 367 | const duration = moment().diff(moment(this.currentAttendance.check_in), 'minutes'); 368 | durationEl.textContent = `${Math.floor(duration / 60)}h ${duration % 60}m (ongoing)`; 369 | } 370 | }, 60000); 371 | } 372 | 373 | detailsEl.style.display = 'block'; 374 | } else { 375 | statusEl.textContent = 'Not Checked In'; 376 | statusEl.classList.remove('checked-in', 'checked-out'); 377 | detailsEl.style.display = 'none'; 378 | } 379 | 380 | this.updateOfflineRecords(); 381 | } 382 | 383 | async updateOfflineRecords() { 384 | const pendingRecords = await pibioffline.storage.getUnsyncedAttendance(); 385 | const pendingCount = document.getElementById('pending-count'); 386 | const pendingList = document.getElementById('pending-list'); 387 | const offlineRecordsEl = document.getElementById('offline-records'); 388 | 389 | if (pendingRecords.length > 0) { 390 | pendingCount.textContent = pendingRecords.length; 391 | pendingList.innerHTML = pendingRecords.map(record => ` 392 |
393 | 394 | ${frappe.datetime.str_to_user(record.check_in)} 395 | ${record.resource} 396 |
397 | `).join(''); 398 | offlineRecordsEl.style.display = 'block'; 399 | } else { 400 | offlineRecordsEl.style.display = 'none'; 401 | } 402 | } 403 | 404 | async syncData() { 405 | if (!navigator.onLine) return; 406 | 407 | const syncIndicator = document.querySelector('.sync-indicator'); 408 | const syncText = document.querySelector('.sync-text'); 409 | 410 | syncIndicator.classList.add('syncing'); 411 | syncText.textContent = 'Syncing...'; 412 | 413 | try { 414 | const result = await pibioffline.storage.syncToServer(); 415 | 416 | if (result.synced > 0) { 417 | frappe.show_alert({ 418 | message: __(`Synced ${result.synced} records`), 419 | indicator: 'green' 420 | }); 421 | } 422 | 423 | syncIndicator.classList.remove('syncing'); 424 | syncText.textContent = 'Online'; 425 | 426 | this.updateOfflineRecords(); 427 | } catch (error) { 428 | syncIndicator.classList.remove('syncing'); 429 | syncText.textContent = 'Sync Failed'; 430 | console.error('Sync error:', error); 431 | } 432 | } 433 | }; 434 | 435 | // Initialize when DOM is ready 436 | document.addEventListener('DOMContentLoaded', () => { 437 | // Register PWA manifest 438 | const link = document.createElement('link'); 439 | link.rel = 'manifest'; 440 | link.href = '/assets/pibioffline/manifest.json'; 441 | document.head.appendChild(link); 442 | }); -------------------------------------------------------------------------------- /pibioffline/public/js/offline/qr-scanner.js: -------------------------------------------------------------------------------- 1 | frappe.provide('pibioffline.qr'); 2 | 3 | pibioffline.qr.Scanner = class QRScanner { 4 | constructor(options) { 5 | this.options = options || {}; 6 | this.video = null; 7 | this.canvas = null; 8 | this.context = null; 9 | this.scanning = false; 10 | this.scanAttempts = 0; 11 | this.maxAttempts = 50; // Try for 5 seconds with jsQR 12 | this.scanInterval = 100; // Scan every 100ms instead of 60fps 13 | } 14 | 15 | async init() { 16 | try { 17 | // Check if mediaDevices is available 18 | if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { 19 | console.error('MediaDevices API not available'); 20 | throw new Error('Camera API not supported in this browser or context'); 21 | } 22 | 23 | // Check if we're in a secure context (HTTPS or localhost) 24 | if (!window.isSecureContext) { 25 | console.error('Not in secure context'); 26 | throw new Error('Camera requires HTTPS connection'); 27 | } 28 | 29 | // Log current state for debugging 30 | console.log('Navigator.mediaDevices:', navigator.mediaDevices); 31 | console.log('UserAgent:', navigator.userAgent); 32 | console.log('Secure context:', window.isSecureContext); 33 | console.log('PWA mode:', window.matchMedia('(display-mode: standalone)').matches); 34 | 35 | // iOS-specific constraints 36 | let constraints = { 37 | video: { 38 | facingMode: 'environment', 39 | width: { min: 640, ideal: 1280, max: 1920 }, 40 | height: { min: 480, ideal: 720, max: 1080 } 41 | }, 42 | audio: false 43 | }; 44 | 45 | // For iOS Safari/PWA, try different constraint combinations 46 | const isIOS = /iPhone|iPad|iPod/.test(navigator.userAgent); 47 | if (isIOS) { 48 | // Try simpler constraints for iOS 49 | constraints = { 50 | video: true, 51 | audio: false 52 | }; 53 | } 54 | 55 | console.log('Requesting camera with constraints:', constraints); 56 | 57 | // First, check permissions if available 58 | if (navigator.permissions && navigator.permissions.query) { 59 | try { 60 | const result = await navigator.permissions.query({ name: 'camera' }); 61 | console.log('Camera permission status:', result.state); 62 | 63 | if (result.state === 'denied') { 64 | throw new Error('Camera permission denied. Please enable camera access in your browser settings.'); 65 | } 66 | } catch (e) { 67 | console.log('Permissions API not available or error:', e); 68 | } 69 | } 70 | 71 | // Try to get user media 72 | let stream; 73 | try { 74 | stream = await navigator.mediaDevices.getUserMedia(constraints); 75 | } catch (firstError) { 76 | console.error('First getUserMedia attempt failed:', firstError); 77 | 78 | // If iOS, try with even simpler constraints 79 | if (isIOS && firstError.name === 'OverconstrainedError') { 80 | console.log('Trying fallback constraints for iOS'); 81 | constraints = { video: true }; 82 | stream = await navigator.mediaDevices.getUserMedia(constraints); 83 | } else { 84 | throw firstError; 85 | } 86 | } 87 | 88 | console.log('Camera stream obtained successfully'); 89 | 90 | this.video = document.createElement('video'); 91 | this.video.srcObject = stream; 92 | 93 | // Critical iOS attributes 94 | this.video.setAttribute('playsinline', 'true'); 95 | this.video.setAttribute('webkit-playsinline', 'true'); 96 | this.video.setAttribute('autoplay', 'true'); 97 | this.video.setAttribute('muted', 'true'); 98 | this.video.muted = true; 99 | this.video.playsInline = true; 100 | 101 | // iOS-specific styling to ensure visibility 102 | this.video.style.width = '100%'; 103 | this.video.style.height = '100%'; 104 | this.video.style.objectFit = 'cover'; 105 | this.video.style.position = 'absolute'; 106 | this.video.style.top = '0'; 107 | this.video.style.left = '0'; 108 | this.video.style.zIndex = '1'; 109 | 110 | // Wait for video to be ready with iOS-specific handling 111 | await new Promise((resolve, reject) => { 112 | let attempts = 0; 113 | const maxAttempts = 10; 114 | 115 | const tryPlay = () => { 116 | attempts++; 117 | 118 | if (this.video.readyState >= 2) { // HAVE_CURRENT_DATA 119 | this.video.play() 120 | .then(() => { 121 | console.log('Video playing successfully'); 122 | resolve(); 123 | }) 124 | .catch(err => { 125 | console.error('Play attempt failed:', err); 126 | if (attempts < maxAttempts) { 127 | setTimeout(tryPlay, 100); 128 | } else { 129 | reject(err); 130 | } 131 | }); 132 | } else if (attempts < maxAttempts) { 133 | setTimeout(tryPlay, 100); 134 | } else { 135 | reject(new Error('Video not ready after max attempts')); 136 | } 137 | }; 138 | 139 | this.video.addEventListener('loadedmetadata', () => { 140 | console.log('Video metadata loaded'); 141 | tryPlay(); 142 | }); 143 | 144 | this.video.addEventListener('canplay', () => { 145 | console.log('Video can play'); 146 | }); 147 | 148 | this.video.onerror = (e) => { 149 | console.error('Video error:', e); 150 | reject(e); 151 | }; 152 | 153 | // Start trying to play 154 | tryPlay(); 155 | 156 | // Timeout after 3 seconds 157 | setTimeout(() => reject(new Error('Video loading timeout')), 3000); 158 | }); 159 | 160 | this.canvas = document.createElement('canvas'); 161 | this.context = this.canvas.getContext('2d', { willReadFrequently: true }); 162 | 163 | return true; 164 | } catch (err) { 165 | console.error('Error accessing camera:', err); 166 | console.error('Error name:', err.name); 167 | console.error('Error message:', err.message); 168 | 169 | let errorMessage = 'Camera access failed. '; 170 | 171 | // Provide specific error messages based on error type 172 | if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') { 173 | errorMessage += 'Camera permission was denied. Please allow camera access in your browser settings and try again.'; 174 | } else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') { 175 | errorMessage += 'No camera found on this device.'; 176 | } else if (err.name === 'NotReadableError' || err.name === 'TrackStartError') { 177 | errorMessage += 'Camera is already in use by another application.'; 178 | } else if (err.name === 'OverconstrainedError' || err.name === 'ConstraintNotSatisfiedError') { 179 | errorMessage += 'Camera does not support the requested settings.'; 180 | } else if (err.message.includes('secure')) { 181 | errorMessage += 'Camera requires HTTPS connection. Please ensure you are using HTTPS.'; 182 | } else if (err.message.includes('not supported')) { 183 | errorMessage += 'Camera API is not supported in this browser or PWA context.'; 184 | } else { 185 | errorMessage += err.message || 'Unknown error occurred.'; 186 | } 187 | 188 | frappe.throw(__(errorMessage)); 189 | return false; 190 | } 191 | } 192 | 193 | startScanning(callback) { 194 | this.scanning = true; 195 | this.scanAttempts = 0; 196 | this.lastScanTime = 0; 197 | this.scan(callback); 198 | } 199 | 200 | stopScanning() { 201 | this.scanning = false; 202 | if (this.video && this.video.srcObject) { 203 | // Stop all tracks 204 | this.video.srcObject.getTracks().forEach(track => track.stop()); 205 | // Clear the video source 206 | this.video.srcObject = null; 207 | } 208 | // Remove video element if it exists in DOM 209 | if (this.video && this.video.parentNode) { 210 | this.video.parentNode.removeChild(this.video); 211 | } 212 | // Clear references 213 | this.video = null; 214 | this.canvas = null; 215 | this.context = null; 216 | } 217 | 218 | scan(callback) { 219 | if (!this.scanning) return; 220 | 221 | const now = Date.now(); 222 | 223 | // Only scan at specified interval to reduce CPU usage 224 | if (now - this.lastScanTime < this.scanInterval) { 225 | requestAnimationFrame(() => this.scan(callback)); 226 | return; 227 | } 228 | 229 | this.lastScanTime = now; 230 | 231 | if (this.video.readyState === this.video.HAVE_ENOUGH_DATA) { 232 | this.canvas.height = this.video.videoHeight; 233 | this.canvas.width = this.video.videoWidth; 234 | 235 | // Debug logging 236 | if (this.scanAttempts === 0) { 237 | console.log('Video dimensions:', this.video.videoWidth, 'x', this.video.videoHeight); 238 | console.log('Canvas dimensions:', this.canvas.width, 'x', this.canvas.height); 239 | } 240 | 241 | try { 242 | this.context.drawImage(this.video, 0, 0, this.canvas.width, this.canvas.height); 243 | 244 | // Try jsQR first 245 | const imageData = this.context.getImageData(0, 0, this.canvas.width, this.canvas.height); 246 | const code = typeof jsQR !== 'undefined' ? jsQR(imageData.data, imageData.width, imageData.height, { 247 | inversionAttempts: "dontInvert", 248 | }) : null; 249 | 250 | if (code) { 251 | this.stopScanning(); 252 | callback({ success: true, data: code.data }); 253 | return; 254 | } 255 | } catch (err) { 256 | console.error('Error during scanning:', err); 257 | } 258 | 259 | this.scanAttempts++; 260 | 261 | // After max attempts, stop trying 262 | if (this.scanAttempts >= this.maxAttempts) { 263 | this.stopScanning(); 264 | callback({ 265 | success: false, 266 | message: 'Could not detect QR code. Please ensure QR code is clearly visible.' 267 | }); 268 | return; 269 | } 270 | } 271 | 272 | requestAnimationFrame(() => this.scan(callback)); 273 | } 274 | }; 275 | 276 | pibioffline.qr.scanQRCode = function(callback) { 277 | // Check HTTPS first 278 | if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost') { 279 | frappe.msgprint({ 280 | title: __('HTTPS Required'), 281 | message: __('Camera access requires HTTPS connection. Please access this site using HTTPS.'), 282 | indicator: 'red' 283 | }); 284 | return; 285 | } 286 | 287 | // Check if mediaDevices is available 288 | if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { 289 | frappe.msgprint({ 290 | title: __('Camera Not Available'), 291 | message: __('Camera API is not available in this browser. Please try using Safari on iOS or Chrome on Android.'), 292 | indicator: 'red' 293 | }); 294 | return; 295 | } 296 | 297 | const scanner = new pibioffline.qr.Scanner(); 298 | const containerId = 'qr-scanner-container-' + Date.now(); 299 | 300 | const dialog = new frappe.ui.Dialog({ 301 | title: __('Scan QR Code'), 302 | fields: [ 303 | { 304 | fieldtype: 'HTML', 305 | fieldname: 'scanner_container', 306 | options: ` 307 |
308 |
309 |
310 |
311 |
312 |
313 |
314 |
315 |

Align QR code within frame

316 |

Initializing camera...

317 |
318 | 324 |
325 | ` 326 | } 327 | ], 328 | primary_action_label: __('Cancel'), 329 | primary_action: () => { 330 | scanner.stopScanning(); 331 | dialog.hide(); 332 | } 333 | }); 334 | 335 | dialog.show(); 336 | 337 | // Ensure scanner is stopped when dialog is closed 338 | dialog.on_hide = () => { 339 | scanner.stopScanning(); 340 | }; 341 | 342 | // Small delay to ensure DOM is ready 343 | setTimeout(() => { 344 | scanner.init().then(success => { 345 | if (success) { 346 | const container = document.getElementById(containerId); 347 | if (container) { 348 | // iOS PWA specific: ensure container has proper dimensions 349 | container.style.position = 'relative'; 350 | container.style.overflow = 'hidden'; 351 | 352 | // The video styling is already set in init(), but ensure it's inserted properly 353 | container.insertBefore(scanner.video, container.firstChild); 354 | 355 | // Force a layout update for iOS 356 | container.offsetHeight; 357 | 358 | // Add a click handler for iOS to ensure video plays 359 | if (/iPhone|iPad|iPod/.test(navigator.userAgent)) { 360 | const playVideo = () => { 361 | scanner.video.play().catch(e => console.log('Play on click failed:', e)); 362 | container.removeEventListener('click', playVideo); 363 | }; 364 | container.addEventListener('click', playVideo); 365 | 366 | // Try to play immediately as well 367 | scanner.video.play().catch(e => console.log('Initial play failed:', e)); 368 | } 369 | 370 | scanner.startScanning((result) => { 371 | scanner.stopScanning(); 372 | dialog.hide(); 373 | callback(result); 374 | }); 375 | } 376 | } 377 | }).catch(err => { 378 | console.error('Scanner initialization failed:', err); 379 | scanner.stopScanning(); 380 | dialog.hide(); 381 | 382 | // Show permission helper or manual input fallback 383 | const permissionDiv = document.getElementById(`${containerId}-permission`); 384 | if (permissionDiv && (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError')) { 385 | permissionDiv.style.display = 'block'; 386 | document.querySelector('.scan-overlay').style.display = 'none'; 387 | } else { 388 | // Offer manual input as fallback 389 | frappe.prompt({ 390 | fieldtype: 'Data', 391 | label: 'Enter QR Code', 392 | fieldname: 'qr_code', 393 | description: 'Camera access failed. Please enter the QR code manually.', 394 | reqd: 1 395 | }, (values) => { 396 | callback({ success: true, data: values.qr_code }); 397 | }, 'Manual QR Code Entry'); 398 | } 399 | }); 400 | }, 100); 401 | }; -------------------------------------------------------------------------------- /pibioffline/api/attendance.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe import _ 3 | from frappe.utils import now_datetime, getdate 4 | import base64 5 | import numpy as np 6 | 7 | @frappe.whitelist() 8 | def sync_attendance(**kwargs): 9 | """Create attendance record and manage work sessions""" 10 | # Get parameters 11 | qr_code = frappe.form_dict.get('qr_code') 12 | type = frappe.form_dict.get('type') 13 | time = frappe.form_dict.get('time') 14 | location = frappe.form_dict.get('location') 15 | latitude = frappe.form_dict.get('latitude') 16 | longitude = frappe.form_dict.get('longitude') 17 | local_id = frappe.form_dict.get('local_id') 18 | photo = frappe.form_dict.get('photo') 19 | 20 | if not qr_code or not type or not time: 21 | frappe.throw(_("Missing required parameters: qr_code, type, and time")) 22 | 23 | try: 24 | # Find employee assignment and person info in single query 25 | assignment_data = frappe.db.sql(""" 26 | SELECT 27 | ea.name as assignment_name, 28 | ea.person, 29 | ea.organization, 30 | ea.organization_name, 31 | ea.employee_code, 32 | ea.location_required, 33 | ea.picture_required, 34 | p.full_name as person_name 35 | FROM `tabPL Employee Assignment` ea 36 | LEFT JOIN `tabPL Person` p ON ea.person = p.name 37 | WHERE ea.qr_code = %s AND ea.status = 'Active' 38 | LIMIT 1 39 | """, qr_code, as_dict=1) 40 | 41 | if not assignment_data: 42 | frappe.throw(_("Invalid QR code or inactive employee assignment")) 43 | 44 | assignment = assignment_data[0] 45 | organization = assignment["organization"] 46 | assignment_name = assignment["assignment_name"] 47 | person_name = assignment["person_name"] or "" 48 | location_required = assignment.get("location_required", 0) 49 | picture_required = assignment.get("picture_required", 0) 50 | 51 | # Validate requirements only for check-in 52 | if type == "check-in": 53 | # Validate location requirement 54 | if location_required and (not latitude or not longitude): 55 | frappe.throw(_("Location is required for this employee's check-in")) 56 | 57 | # Validate photo requirement 58 | if picture_required and not photo: 59 | frappe.throw(_("Photo is required for this employee's check-in")) 60 | 61 | # Parse attendance time 62 | attendance_time = frappe.utils.get_datetime(time) 63 | work_date = getdate(attendance_time) 64 | 65 | # Check for duplicate submission if local_id provided 66 | if local_id: 67 | existing = frappe.db.exists("PL Resource Attendance", { 68 | "local_id": local_id, 69 | "resource": assignment_name 70 | }) 71 | if existing: 72 | return { 73 | "success": True, 74 | "message": "Already synced", 75 | "attendance_id": existing, 76 | "employee_name": person_name 77 | } 78 | 79 | # Create attendance record 80 | attendance = frappe.new_doc("PL Resource Attendance") 81 | attendance.resource = assignment_name 82 | attendance.organization = organization 83 | attendance.qr_code = qr_code 84 | attendance.location = location or "Not provided" 85 | attendance.latitude = float(latitude) if latitude else None 86 | attendance.longitude = float(longitude) if longitude else None 87 | attendance.local_id = local_id 88 | attendance.synced = 1 89 | attendance.sync_status = "Synced" 90 | attendance.attendance_type = "Check In" if type == "check-in" else "Check Out" 91 | attendance.timestamp = attendance_time 92 | 93 | # Insert attendance record first 94 | attendance.insert() 95 | 96 | # Handle photo after record is created 97 | if photo and type == "check-in": 98 | try: 99 | # Save base64 photo as file attachment 100 | import datetime 101 | 102 | timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") 103 | filename = f"attendance_{qr_code}_{timestamp}.jpg" 104 | 105 | # Decode base64 image 106 | if photo.startswith('data:image'): 107 | photo = photo.split(',')[1] 108 | 109 | # Save file using Frappe's file handling 110 | from frappe.utils.file_manager import save_file 111 | 112 | file_doc = save_file( 113 | fname=filename, 114 | content=base64.b64decode(photo), 115 | dt="PL Resource Attendance", 116 | dn=attendance.name, # Now we have the attendance record name 117 | is_private=1 118 | ) 119 | 120 | if file_doc: 121 | attendance.photo = file_doc.file_url 122 | attendance.save() 123 | except Exception as e: 124 | frappe.log_error(f"Error saving photo: {str(e)}", "Attendance Photo Error") 125 | # Continue without photo if there's an error 126 | 127 | # Handle work session 128 | if type == "check-in": 129 | # Create new work session 130 | work_session = frappe.new_doc("PL Work Session") 131 | work_session.employee = assignment_name 132 | work_session.organization = organization 133 | work_session.qr_code = qr_code 134 | work_session.work_date = work_date 135 | work_session.check_in_time = attendance_time 136 | work_session.check_in_location = location 137 | work_session.check_in_latitude = float(latitude) if latitude else None 138 | work_session.check_in_longitude = float(longitude) if longitude else None 139 | work_session.check_in_local_id = local_id 140 | work_session.check_in_synced = 1 141 | work_session.check_in_sync_time = now_datetime() 142 | work_session.check_in_attendance = attendance.name 143 | work_session.status = "Active" 144 | work_session.insert() 145 | 146 | # Update attendance with work session reference 147 | attendance.work_session_ref = work_session.name 148 | attendance.save() 149 | 150 | else: # check-out 151 | # Find active work session for today 152 | filters = { 153 | "qr_code": qr_code, 154 | "work_date": work_date, 155 | "status": "Active" 156 | } 157 | filters["organization"] = organization 158 | 159 | active_session = frappe.get_all("PL Work Session", 160 | filters=filters, 161 | fields=["name"], 162 | order_by="check_in_time desc", 163 | limit=1 164 | ) 165 | 166 | if active_session: 167 | work_session = frappe.get_doc("PL Work Session", active_session[0].name) 168 | work_session.check_out_time = attendance_time 169 | work_session.check_out_location = location 170 | work_session.check_out_latitude = float(latitude) if latitude else None 171 | work_session.check_out_longitude = float(longitude) if longitude else None 172 | work_session.check_out_local_id = local_id 173 | work_session.check_out_synced = 1 174 | work_session.check_out_sync_time = now_datetime() 175 | work_session.check_out_attendance = attendance.name 176 | work_session.status = "Completed" 177 | work_session.save() 178 | 179 | # Update attendance with work session reference 180 | attendance.work_session_ref = work_session.name 181 | attendance.save() 182 | 183 | return { 184 | "success": True, 185 | "attendance_id": attendance.name, 186 | "employee_name": person_name, 187 | "organization_name": assignment.get("organization_name") or assignment.get("organization"), 188 | "location_required": location_required, 189 | "picture_required": picture_required, 190 | "message": f"{type} recorded successfully", 191 | "work_session_id": work_session.name if type == "check-in" else (active_session[0].name if active_session else None) 192 | } 193 | 194 | except Exception as e: 195 | frappe.log_error(f"Sync attendance error: {str(e)}", "Attendance Sync Error") 196 | frappe.throw(str(e)) 197 | 198 | 199 | @frappe.whitelist() 200 | def get_resource_by_qr(qr_code): 201 | """Get resource details by QR code""" 202 | # Find employee assignment by QR code 203 | assignment = frappe.db.get_value("PL Employee Assignment", 204 | {"qr_code": qr_code, "status": "Active"}, 205 | ["name", "person", "employee_code", "organization", "organization_name", "location_required", "picture_required"], 206 | as_dict=1) 207 | 208 | if not assignment: 209 | frappe.throw(_("No active employee assignment found with this QR code")) 210 | 211 | # Add person name to the response 212 | if assignment.get("person"): 213 | assignment["person_name"] = frappe.get_value("PL Person", assignment["person"], "full_name") 214 | 215 | return assignment 216 | 217 | @frappe.whitelist() 218 | def get_todays_attendance(resource): 219 | """Get today's attendance for a resource""" 220 | from frappe.utils import today 221 | 222 | # This is typically called with frappe.session.user which is not an employee code 223 | # For now, return None as this function needs redesign 224 | return None 225 | 226 | attendance = frappe.get_all("PL Resource Attendance", 227 | filters={ 228 | "resource": assignment_doc_name, # Use the document name for querying 229 | "timestamp": ["between", [today(), today() + " 23:59:59"]] 230 | }, 231 | fields=["name", "timestamp", "attendance_type", "location", "resource"], 232 | order_by="timestamp desc", 233 | limit=1 234 | ) 235 | 236 | return attendance[0] if attendance else None 237 | 238 | @frappe.whitelist() 239 | def get_active_work_session(qr_code): 240 | """Get active work session for QR code""" 241 | from frappe.utils import today 242 | 243 | active_session = frappe.get_all("PL Work Session", 244 | filters={ 245 | "qr_code": qr_code, 246 | "work_date": today(), 247 | "status": "Active" 248 | }, 249 | fields=["name", "check_in_time", "check_in_location", "employee"], 250 | order_by="check_in_time desc", 251 | limit=1 252 | ) 253 | 254 | if active_session: 255 | # Get employee name and organization from assignment 256 | session = active_session[0] 257 | if session.get("employee"): 258 | assignment_data = frappe.get_value("PL Employee Assignment", 259 | session["employee"], 260 | ["person", "organization_name"], 261 | as_dict=1) 262 | if assignment_data: 263 | if assignment_data.get("person"): 264 | session["employee_name"] = frappe.get_value("PL Person", assignment_data["person"], "full_name") 265 | session["organization_name"] = assignment_data.get("organization_name") 266 | return session 267 | 268 | return None 269 | 270 | @frappe.whitelist() 271 | def get_user_accessible_resources(): 272 | """Get all employee assignments accessible to the logged-in user for offline caching""" 273 | user = frappe.session.user 274 | 275 | # Log the request 276 | frappe.log_error(f"Getting accessible resources for user: {user}", "Resource Cache") 277 | 278 | # Check if user is System Manager or HR Manager (can see all) 279 | roles = frappe.get_roles(user) 280 | 281 | # Check if user field exists in the table 282 | has_user_field = frappe.db.has_column("PL Employee Assignment", "user") 283 | 284 | if "System Manager" in roles or "HR Manager" in roles or "Administrator" in roles: 285 | # Get all active assignments 286 | if has_user_field: 287 | assignments = frappe.db.sql(""" 288 | SELECT 289 | ea.name, 290 | ea.qr_code, 291 | ea.person, 292 | ea.employee_code, 293 | ea.organization, 294 | ea.organization_name, 295 | ea.location_required, 296 | ea.picture_required, 297 | ea.user, 298 | p.full_name as person_name 299 | FROM `tabPL Employee Assignment` ea 300 | LEFT JOIN `tabPL Person` p ON ea.person = p.name 301 | WHERE ea.status = 'Active' 302 | """, as_dict=1) 303 | else: 304 | assignments = frappe.db.sql(""" 305 | SELECT 306 | ea.name, 307 | ea.qr_code, 308 | ea.person, 309 | ea.employee_code, 310 | ea.organization, 311 | ea.organization_name, 312 | ea.location_required, 313 | ea.picture_required, 314 | p.full_name as person_name 315 | FROM `tabPL Employee Assignment` ea 316 | LEFT JOIN `tabPL Person` p ON ea.person = p.name 317 | WHERE ea.status = 'Active' 318 | """, as_dict=1) 319 | else: 320 | # For regular users, we need to use person-based logic if user field doesn't exist 321 | if has_user_field: 322 | assignments = frappe.db.sql(""" 323 | SELECT DISTINCT 324 | ea.name, 325 | ea.qr_code, 326 | ea.person, 327 | ea.employee_code, 328 | ea.organization, 329 | ea.organization_name, 330 | ea.location_required, 331 | ea.picture_required, 332 | ea.user, 333 | p.full_name as person_name 334 | FROM `tabPL Employee Assignment` ea 335 | LEFT JOIN `tabPL Person` p ON ea.person = p.name 336 | WHERE ea.status = 'Active' 337 | AND ( 338 | ea.user = %(user)s -- User's own assignments 339 | OR ea.organization IN ( -- All assignments in user's organizations 340 | SELECT organization 341 | FROM `tabPL Employee Assignment` 342 | WHERE user = %(user)s 343 | AND status = 'Active' 344 | ) 345 | OR ea.reporting_manager IN ( -- Assignments where user is manager 346 | SELECT name 347 | FROM `tabPL Employee Assignment` 348 | WHERE user = %(user)s 349 | ) 350 | ) 351 | """, {"user": user}, as_dict=1) 352 | else: 353 | # Fallback to person-based logic 354 | person = frappe.db.get_value("PL Person", {"user": user}, "name") 355 | 356 | if person: 357 | assignments = frappe.db.sql(""" 358 | SELECT DISTINCT 359 | ea.name, 360 | ea.qr_code, 361 | ea.person, 362 | ea.employee_code, 363 | ea.organization, 364 | ea.organization_name, 365 | ea.location_required, 366 | ea.picture_required, 367 | p.full_name as person_name 368 | FROM `tabPL Employee Assignment` ea 369 | LEFT JOIN `tabPL Person` p ON ea.person = p.name 370 | WHERE ea.status = 'Active' 371 | AND ( 372 | ea.person = %(person)s -- User's own assignments 373 | OR ea.organization IN ( -- All assignments in user's organizations 374 | SELECT organization 375 | FROM `tabPL Employee Assignment` 376 | WHERE person = %(person)s 377 | AND status = 'Active' 378 | ) 379 | OR ea.reporting_manager IN ( -- Assignments where user is manager 380 | SELECT name 381 | FROM `tabPL Employee Assignment` 382 | WHERE person = %(person)s 383 | ) 384 | ) 385 | """, {"person": person}, as_dict=1) 386 | else: 387 | assignments = [] 388 | 389 | return assignments 390 | 391 | @frappe.whitelist() 392 | def get_all_active_resources(): 393 | """Deprecated - use get_user_accessible_resources instead""" 394 | return get_user_accessible_resources() 395 | 396 | @frappe.whitelist() 397 | def user_has_access(): 398 | """Check if current user has access to pibiOffline features""" 399 | user = frappe.session.user 400 | roles = frappe.get_roles(user) 401 | 402 | # Admins always have access 403 | if "System Manager" in roles or "HR Manager" in roles or "Administrator" in roles: 404 | return True 405 | 406 | # Check if user has any employee assignments 407 | has_assignment = frappe.db.exists("PL Employee Assignment", { 408 | "user": user, 409 | "status": "Active" 410 | }) 411 | 412 | if has_assignment: 413 | return True 414 | 415 | # Check if user manages anyone 416 | manages_someone = frappe.db.exists("PL Employee Assignment", { 417 | "reporting_manager": frappe.db.get_value("PL Employee Assignment", {"user": user}, "name"), 418 | "status": "Active" 419 | }) 420 | 421 | return bool(manages_someone) 422 | 423 | @frappe.whitelist() 424 | def process_qr_with_opencv(image_base64): 425 | """Process QR code using OpenCV for better detection""" 426 | try: 427 | import cv2 428 | 429 | # Decode base64 image 430 | image_data = base64.b64decode(image_base64) 431 | nparr = np.frombuffer(image_data, np.uint8) 432 | img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) 433 | 434 | if img is None: 435 | return {"success": False, "message": "Failed to decode image"} 436 | 437 | # Initialize QR code detector 438 | detector = cv2.QRCodeDetector() 439 | 440 | # Try detecting QR code 441 | data, vertices, _ = detector.detectAndDecode(img) 442 | 443 | if data: 444 | return {"success": True, "data": data} 445 | 446 | # If failed, try with preprocessing 447 | gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) 448 | 449 | # Apply adaptive threshold 450 | thresh = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, 451 | cv2.THRESH_BINARY, 11, 2) 452 | 453 | # Try again with processed image 454 | data, vertices, _ = detector.detectAndDecode(thresh) 455 | 456 | if data: 457 | return {"success": True, "data": data} 458 | 459 | # Try with different preprocessing 460 | blurred = cv2.GaussianBlur(gray, (5, 5), 0) 461 | _, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) 462 | 463 | data, vertices, _ = detector.detectAndDecode(binary) 464 | 465 | if data: 466 | return {"success": True, "data": data} 467 | 468 | return {"success": False, "message": "No QR code detected"} 469 | 470 | except ImportError: 471 | frappe.log_error("OpenCV not installed. Install with: pip install opencv-python") 472 | return {"success": False, "message": "OpenCV not available on server"} 473 | except Exception as e: 474 | frappe.log_error(f"QR processing error: {str(e)}") 475 | return {"success": False, "message": str(e)} --------------------------------------------------------------------------------