├── 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 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
Current Status
18 |
Not Checked In
19 |
20 |
21 |
22 |
23 |
24 |
27 |
28 |
29 |
30 |
31 | Employee:
32 | -
33 |
34 |
35 | Organization:
36 | -
37 |
38 |
39 | Check In:
40 | -
41 |
42 |
43 | Location:
44 | -
45 |
46 |
47 | Duration:
48 | -
49 |
50 |
51 |
52 |
53 |
Pending Sync (0)
54 |
55 |
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 |

67 |
68 |
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 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
Current Status
54 |
Not Checked In
55 |
56 |
57 |
58 |
59 |
60 |
63 |
64 |
65 |
66 |
67 | Check In:
68 | -
69 |
70 |
71 | Location:
72 | -
73 |
74 |
75 | Duration:
76 | -
77 |
78 |
79 |
80 |
81 |
Pending Sync (0)
82 |
83 |
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 |
319 |
Camera Permission Required
320 |
Please allow camera access when prompted by your browser.
321 |
On iOS: Settings > Safari > Camera > Allow
322 |
323 |
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)}
--------------------------------------------------------------------------------