Add PWA mobile attendance shortcut
This commit is contained in:
@@ -27,6 +27,7 @@ from app.modules.notice_cases.ui import router as notice_cases_router
|
|||||||
from app.modules.wizards.ui import router as wizards_ui_router
|
from app.modules.wizards.ui import router as wizards_ui_router
|
||||||
from app.modules.system_admin_dashboard.ui import router as system_admin_dashboard_router
|
from app.modules.system_admin_dashboard.ui import router as system_admin_dashboard_router
|
||||||
from app.ui.routes.auth import router as auth_router
|
from app.ui.routes.auth import router as auth_router
|
||||||
|
from app.ui.routes.pwa import router as pwa_router
|
||||||
from app.modules.workspace_navigation.ui import router as workspace_navigation_router
|
from app.modules.workspace_navigation.ui import router as workspace_navigation_router
|
||||||
from app.modules.firm_admin_dashboard.ui import router as firm_admin_dashboard_router
|
from app.modules.firm_admin_dashboard.ui import router as firm_admin_dashboard_router
|
||||||
from app.modules.aqmm_dashboard.ui import router as aqmm_dashboard_router
|
from app.modules.aqmm_dashboard.ui import router as aqmm_dashboard_router
|
||||||
@@ -38,6 +39,7 @@ def mount_ui(app: FastAPI) -> None:
|
|||||||
app.mount("/storage", StaticFiles(directory="/app/data/storage"), name="storage")
|
app.mount("/storage", StaticFiles(directory="/app/data/storage"), name="storage")
|
||||||
app.include_router(marketplace_public_router)
|
app.include_router(marketplace_public_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
|
app.include_router(pwa_router)
|
||||||
app.include_router(firm_admin_dashboard_router)
|
app.include_router(firm_admin_dashboard_router)
|
||||||
app.include_router(system_settings_router)
|
app.include_router(system_settings_router)
|
||||||
app.include_router(email_integration_router)
|
app.include_router(email_integration_router)
|
||||||
|
|||||||
+19
-2
@@ -1,4 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
@@ -26,6 +26,21 @@ from app.modules.email_integration.services import send_auth_otp_email, send_pas
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
PENDING_POST_LOGIN_REDIRECT_KEY = "pending_post_login_redirect"
|
||||||
|
SAFE_POST_LOGIN_REDIRECTS = {
|
||||||
|
"/mobile/attendance",
|
||||||
|
"/employee/attendance",
|
||||||
|
"/employee/dashboard",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _consume_safe_post_login_redirect(request: Request) -> str | None:
|
||||||
|
value = request.session.pop(PENDING_POST_LOGIN_REDIRECT_KEY, None)
|
||||||
|
value = (value or "").strip()
|
||||||
|
if value in SAFE_POST_LOGIN_REDIRECTS:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _dev_otp_print_enabled() -> bool:
|
def _dev_otp_print_enabled() -> bool:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -459,9 +474,11 @@ def login_submit(
|
|||||||
if active_financial_year:
|
if active_financial_year:
|
||||||
request.session["active_financial_year"] = active_financial_year
|
request.session["active_financial_year"] = active_financial_year
|
||||||
request.session["must_change_password"] = must_change_password
|
request.session["must_change_password"] = must_change_password
|
||||||
request.session["post_login_redirect"] = _post_login_redirect(
|
default_post_login_redirect = _post_login_redirect(
|
||||||
must_change_password, permissions, roles
|
must_change_password, permissions, roles
|
||||||
)
|
)
|
||||||
|
pending_post_login_redirect = None if must_change_password else _consume_safe_post_login_redirect(request)
|
||||||
|
request.session["post_login_redirect"] = pending_post_login_redirect or default_post_login_redirect
|
||||||
|
|
||||||
if _otp_required(bs, roles):
|
if _otp_required(bs, roles):
|
||||||
code = start_otp(request)
|
code = start_otp(request)
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||||
|
|
||||||
|
from app.core.db.common import CommonSessionLocal
|
||||||
|
from app.core.security.session_auth import SESSION_USER_ID_KEY, get_current_user
|
||||||
|
from app.modules.core.rbac.permission_guard import require_permission
|
||||||
|
|
||||||
|
router = APIRouter(tags=["pwa-ui"])
|
||||||
|
|
||||||
|
PWA_REDIRECT_SESSION_KEY = "pending_post_login_redirect"
|
||||||
|
MOBILE_ATTENDANCE_PATH = "/mobile/attendance"
|
||||||
|
PWA_STATIC_ROOT = Path("app/ui/static/pwa")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_mobile_redirect_path(value: str | None) -> str | None:
|
||||||
|
value = (value or "").strip()
|
||||||
|
if value in {MOBILE_ATTENDANCE_PATH, "/employee/attendance", "/employee/dashboard"}:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _remember_mobile_attendance_redirect(request: Request) -> None:
|
||||||
|
request.session[PWA_REDIRECT_SESSION_KEY] = MOBILE_ATTENDANCE_PATH
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/manifest.webmanifest", include_in_schema=False)
|
||||||
|
def pwa_manifest():
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"name": "ARRR ERP Attendance",
|
||||||
|
"short_name": "ARRR ERP",
|
||||||
|
"description": "Mobile attendance access for ARRR ERP staff users.",
|
||||||
|
"id": "/mobile/attendance",
|
||||||
|
"start_url": "/mobile/attendance",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"theme_color": "#1e3a8a",
|
||||||
|
"background_color": "#f8fafc",
|
||||||
|
"categories": ["business", "productivity"],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/pwa/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/pwa/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/pwa/icons/maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Mark Attendance",
|
||||||
|
"short_name": "Attendance",
|
||||||
|
"description": "Open mobile attendance punch screen.",
|
||||||
|
"url": MOBILE_ATTENDANCE_PATH,
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/pwa/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
media_type="application/manifest+json",
|
||||||
|
headers={"Cache-Control": "no-cache"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sw.js", include_in_schema=False)
|
||||||
|
def pwa_service_worker():
|
||||||
|
return FileResponse(
|
||||||
|
PWA_STATIC_ROOT / "sw.js",
|
||||||
|
media_type="application/javascript",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Service-Worker-Allowed": "/",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mobile", include_in_schema=False)
|
||||||
|
def mobile_root(request: Request):
|
||||||
|
return RedirectResponse(url=MOBILE_ATTENDANCE_PATH, status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(MOBILE_ATTENDANCE_PATH, include_in_schema=False)
|
||||||
|
def mobile_attendance(request: Request):
|
||||||
|
if not request.session.get(SESSION_USER_ID_KEY):
|
||||||
|
_remember_mobile_attendance_redirect(request)
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
|
||||||
|
if not request.session.get("otp_verified", False):
|
||||||
|
_remember_mobile_attendance_redirect(request)
|
||||||
|
return RedirectResponse(url="/otp", status_code=303)
|
||||||
|
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
current_user = get_current_user(request, db=db)
|
||||||
|
if not current_user:
|
||||||
|
request.session.clear()
|
||||||
|
_remember_mobile_attendance_redirect(request)
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
try:
|
||||||
|
require_permission(db, current_user, "employees.attendance.view_self")
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse(url="/employee/dashboard", status_code=303)
|
||||||
|
return RedirectResponse(url="/employee/attendance", status_code=303)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
const CACHE_VERSION = 'arrr-erp-pwa-v1';
|
||||||
|
const CORE_ASSETS = [
|
||||||
|
'/manifest.webmanifest',
|
||||||
|
'/static/css/theme_tokens.css',
|
||||||
|
'/static/pwa/icons/icon-192.png',
|
||||||
|
'/static/pwa/icons/icon-512.png'
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_VERSION).then((cache) => cache.addAll(CORE_ASSETS)).catch(() => undefined)
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE_VERSION).map((key) => caches.delete(key))))
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const request = event.request;
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/static/')) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request).then((cached) => cached || fetch(request).then((response) => {
|
||||||
|
const copy = response.clone();
|
||||||
|
caches.open(CACHE_VERSION).then((cache) => cache.put(request, copy)).catch(() => undefined);
|
||||||
|
return response;
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(fetch(request).catch(() => caches.match('/static/css/theme_tokens.css')));
|
||||||
|
});
|
||||||
@@ -6,6 +6,12 @@
|
|||||||
{% set __title_user = current_user if current_user is defined else None %}
|
{% set __title_user = current_user if current_user is defined else None %}
|
||||||
{% set __firm_branding = get_current_firm_branding(request, __title_user) %}
|
{% set __firm_branding = get_current_firm_branding(request, __title_user) %}
|
||||||
<meta name="theme-color" content="{{ __firm_branding.primary_color or '#1e3a8a' }}" />
|
<meta name="theme-color" content="{{ __firm_branding.primary_color or '#1e3a8a' }}" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="ARRR ERP" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<link rel="apple-touch-icon" href="/static/pwa/icons/icon-192.png" />
|
||||||
{% if __firm_branding.favicon_url %}<link rel="icon" href="{{ __firm_branding.favicon_url }}" />{% endif %}
|
{% if __firm_branding.favicon_url %}<link rel="icon" href="{{ __firm_branding.favicon_url }}" />{% endif %}
|
||||||
{% set __title_auth = __title_user and request.session.get("otp_verified", False) %}
|
{% set __title_auth = __title_user and request.session.get("otp_verified", False) %}
|
||||||
{% if __title_auth %}
|
{% if __title_auth %}
|
||||||
@@ -502,6 +508,17 @@
|
|||||||
{{ flash }}
|
{{ flash }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if current_path == '/employee/attendance' or current_path == '/mobile/attendance' %}
|
||||||
|
<div id="af-pwa-install-banner" class="mb-6 hidden rounded-2xl border border-brand-200 bg-brand-50 px-4 py-3 text-sm text-brand-900 shadow-soft">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold">Install ARRR ERP Attendance</div>
|
||||||
|
<div class="text-xs text-brand-800">Add this attendance screen to your mobile home screen for faster punch-in and punch-out.</div>
|
||||||
|
</div>
|
||||||
|
<button id="af-pwa-install-button" type="button" class="rounded-xl bg-brand-600 px-4 py-2 text-xs font-semibold text-white hover:bg-brand-700">Install</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -627,5 +644,37 @@
|
|||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', function () {
|
||||||
|
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(function () {
|
||||||
|
// PWA remains optional; normal ERP browser use continues without service worker.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var deferredPrompt = null;
|
||||||
|
var banner = null;
|
||||||
|
var button = null;
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', function (event) {
|
||||||
|
deferredPrompt = event;
|
||||||
|
banner = document.getElementById('af-pwa-install-banner');
|
||||||
|
button = document.getElementById('af-pwa-install-button');
|
||||||
|
if (!banner || !button) return;
|
||||||
|
event.preventDefault();
|
||||||
|
banner.classList.remove('hidden');
|
||||||
|
button.addEventListener('click', async function () {
|
||||||
|
if (!deferredPrompt) return;
|
||||||
|
deferredPrompt.prompt();
|
||||||
|
try { await deferredPrompt.userChoice; } catch (err) {}
|
||||||
|
deferredPrompt = null;
|
||||||
|
banner.classList.add('hidden');
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user