Merge branch 'develop' of https://github.com/Frappet/bma-ehr-checkin into develop
All checks were successful
Build & Deploy on Dev / build (push) Successful in 3m3s

This commit is contained in:
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 2026-04-01 13:48:38 +07:00
commit a45c1cae90
8 changed files with 2028 additions and 333 deletions

View file

@ -5,19 +5,14 @@ import axios from 'axios'
import { useCounterMixin } from '@/stores/mixin'
import { useQuasar } from 'quasar'
import { usePrivacyStore } from '@/stores/privacy'
import { useLocationValidation } from '@/composables/useLocationValidation'
const mixin = useCounterMixin()
const { messageError } = mixin
const privacyStore = usePrivacyStore()
// import type { LocationObject } from '@/interface/index/Main'
const mapElement = ref<HTMLElement | null>(null)
const emit = defineEmits(['update:location', 'locationStatus', 'mockDetected'])
const emit = defineEmits(['update:location'])
const $q = useQuasar()
const { validateLocation, showMockWarning } = useLocationValidation()
function updateLocation(latitude: number, longitude: number, namePOI: string) {
// event parent component props
emit('update:location', latitude, longitude, namePOI)
@ -226,30 +221,8 @@ const requestLocationPermission = () => {
navigator.geolocation.getCurrentPosition(
async (position) => {
// Validate location first
const validationResult = validateLocation(position)
// Always emit mockDetected event (regardless of result)
if (validationResult.isMockDetected) {
showMockWarning(validationResult)
emit('mockDetected', validationResult)
}
// Check for critical errors (invalid coordinates) that prevent showing location
const hasCriticalErrors = validationResult.errors.some(error =>
error.includes('พิกัดตำแหน่งไม่ถูกต้อง')
)
if (hasCriticalErrors) {
locationGranted.value = false
emit('locationStatus', false)
messageError($q, '', validationResult.errors[0])
return
}
// Permission granted based on mock detection
locationGranted.value = !validationResult.isMockDetected
emit('locationStatus', !validationResult.isMockDetected)
// Permission granted
locationGranted.value = true
const { latitude, longitude } = position.coords
// console.log('Current position:', latitude, longitude)
@ -267,7 +240,6 @@ const requestLocationPermission = () => {
(error) => {
// Permission denied
locationGranted.value = false
emit('locationStatus', false)
switch (error.code) {
case error.PERMISSION_DENIED:
@ -298,7 +270,6 @@ const requestLocationPermission = () => {
defineExpose({
requestLocationPermission,
locationGranted,
})
</script>

View file

@ -176,7 +176,7 @@ function onClose() {
<template>
<q-dialog v-model="modal" persistent>
<q-card style="width: 700px; max-width: 80vw">
<q-card style="width: 700px; max-width: 90vw">
<q-form greedy @submit.prevent @validation-success="onSubmit">
<DialogHeader :tittle="title" :close="onClose" />
<q-separator />

View file

@ -1,169 +0,0 @@
import { ref } from 'vue'
import { useQuasar } from 'quasar'
import { useCounterMixin } from '@/stores/mixin'
export interface LocationValidationResult {
isValid: boolean
isMockDetected: boolean
confidence: 'low' | 'medium' | 'high'
warnings: string[]
errors: string[]
}
export interface PositionSnapshot {
latitude: number
longitude: number
timestamp: number
}
// Configuration constants - exported for documentation and testing purposes
export const VALIDATION_CONFIG = {
MAX_TIMESTAMP_AGE_MS: 60_000, // 60 seconds - maximum acceptable age of location data
MAX_ACCURACY_METERS: 100, // 100 meters - maximum acceptable GPS accuracy
MAX_SPEED_MS: 100, // 100 m/s (~360 km/h) - maximum plausible movement speed
POSITION_HISTORY_SIZE: 5, // number of positions to keep for pattern detection
MOCK_INDICATOR_THRESHOLD: 3, // threshold for mock detection (indicators >= 3 = mock)
} as const
export function useLocationValidation() {
const $q = useQuasar()
const { messageError } = useCounterMixin()
// Thai error messages - exported for i18n consistency
const errorMessages = {
MOCK_DETECTED: 'ตรวจพบว่าตำแหน่ง GPS อาจไม่ถูกต้อง กรุณาปิดแอปจำลองตำแหน่งและลองใหม่',
INVALID_COORDINATES: 'พิกัดตำแหน่งไม่ถูกต้อง กรุณาลองใหม่',
STALE_TIMESTAMP: 'ข้อมูลตำแหน่งเก่าเกินไป กรุณารับสัญญาณ GPS ใหม่',
POOR_ACCURACY: 'ความแม่นยำตำแหน่งต่ำเกินไป กรุณาตรวจสอบการรับสัญญาณ GPS',
IMPOSSIBLE_SPEED: 'ตรวจพบการเคลื่อนที่ด้วยความเร็วผิดปกติ อาจเป็นการจำลองตำแหน่ง',
}
const previousPositions = ref<PositionSnapshot[]>([])
// คำนวณระยะห่างระหว่าง 2 จุด (Haversine formula)
const haversineDistance = (lat1: number, lon1: number, lat2: number, lon2: number): number => {
const R = 6371e3 // Earth's radius in meters
const φ1 = (lat1 * Math.PI) / 180
const φ2 = (lat2 * Math.PI) / 180
const Δφ = ((lat2 - lat1) * Math.PI) / 180
const Δλ = ((lon2 - lon1) * Math.PI) / 180
const a =
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}
// ตรวจสอบพิกัดถูกต้อง
const validateCoordinates = (lat: number, lon: number): boolean => {
return (
lat >= -90 && lat <= 90 &&
lon >= -180 && lon <= 180 &&
!isNaN(lat) && !isNaN(lon) &&
!(lat === 0 && lon === 0) // Mock มักใช้ 0,0
)
}
// ตรวจสอบความแม่นยำ
const validateAccuracy = (accuracy: number | null): boolean => {
if (accuracy === null) return true
return accuracy <= VALIDATION_CONFIG.MAX_ACCURACY_METERS
}
// ตรวจสอบ Timestamp
const validateTimestamp = (timestamp: number): boolean => {
const now = Date.now()
const age = Math.abs(now - timestamp)
return age <= VALIDATION_CONFIG.MAX_TIMESTAMP_AGE_MS
}
// คำนวณความเร็ว
const calculateSpeed = (pos1: PositionSnapshot, pos2: PositionSnapshot): number => {
const distance = haversineDistance(pos1.latitude, pos1.longitude, pos2.latitude, pos2.longitude)
const timeDiff = Math.abs(pos2.timestamp - pos1.timestamp) / 1000 // seconds
return timeDiff > 0 ? distance / timeDiff : 0
}
// ตรวจสอบความเร็วปกติ
const validateSpeed = (current: PositionSnapshot, previous: PositionSnapshot): boolean => {
const speed = calculateSpeed(previous, current)
return speed <= VALIDATION_CONFIG.MAX_SPEED_MS
}
// Main validation function
const validateLocation = (position: GeolocationPosition): LocationValidationResult => {
const warnings: string[] = []
const errors: string[] = []
let mockIndicators = 0
const { latitude, longitude, accuracy } = position.coords
const { timestamp } = position
// 1. Coordinate validation
if (!validateCoordinates(latitude, longitude)) {
errors.push(errorMessages.INVALID_COORDINATES)
mockIndicators += 3
}
// 2. Timestamp validation
if (!validateTimestamp(timestamp)) {
errors.push(errorMessages.STALE_TIMESTAMP)
mockIndicators += 2
}
// 3. Accuracy validation
if (!validateAccuracy(accuracy)) {
warnings.push(errorMessages.POOR_ACCURACY)
mockIndicators += 1
}
// 4. Compare with previous positions
if (previousPositions.value.length > 0) {
const previous = previousPositions.value[previousPositions.value.length - 1]
if (!validateSpeed({ latitude, longitude, timestamp }, previous)) {
errors.push(errorMessages.IMPOSSIBLE_SPEED)
mockIndicators += 3
}
}
// Store current position
previousPositions.value.push({ latitude, longitude, timestamp })
if (previousPositions.value.length > VALIDATION_CONFIG.POSITION_HISTORY_SIZE) {
previousPositions.value.shift()
}
// Determine result
const isMockDetected = mockIndicators >= VALIDATION_CONFIG.MOCK_INDICATOR_THRESHOLD
const isValid = errors.length === 0
let confidence: 'low' | 'medium' | 'high' = 'low'
if (mockIndicators >= 5) confidence = 'high'
else if (mockIndicators >= 3) confidence = 'medium'
return {
isValid,
isMockDetected,
confidence,
warnings,
errors,
}
}
const showMockWarning = (result: LocationValidationResult) => {
if (!result.isMockDetected) return
messageError($q, null, errorMessages.MOCK_DETECTED)
}
const resetValidation = () => {
previousPositions.value = []
}
return {
validateLocation,
showMockWarning,
resetValidation,
}
}

View file

@ -5,6 +5,56 @@ import { logout } from '@/plugins/auth'
import { format, utcToZonedTime } from 'date-fns-tz'
export const useCounterMixin = defineStore('mixin', () => {
let activeErrorDialog: any = null
const clearActiveErrorDialog = () => {
if (!activeErrorDialog) return
try {
if (typeof activeErrorDialog.hide === 'function') {
activeErrorDialog.hide()
} else if (typeof activeErrorDialog.destroy === 'function') {
activeErrorDialog.destroy()
}
} finally {
activeErrorDialog = null
}
}
const openSingleErrorDialog = (
q: any,
message: string,
onCancel?: () => void | Promise<void>
) => {
clearActiveErrorDialog()
const dialog = q.dialog({
component: CustomComponent,
componentProps: {
title: `พบข้อผิดพลาด`,
message,
icon: 'warning',
color: 'red',
onlycancel: true,
},
})
activeErrorDialog = dialog
dialog.onDismiss(() => {
if (activeErrorDialog === dialog) {
activeErrorDialog = null
}
})
if (onCancel) {
dialog.onCancel(() => {
void onCancel()
})
}
return dialog
}
function date2Thai(srcDate: Date, isFullMonth = false, isTime = false) {
if (srcDate == null) {
return null
@ -133,53 +183,30 @@ export const useCounterMixin = defineStore('mixin', () => {
}
const messageError = (q: any, e: any = '', msg: string = '') => {
// q.dialog.hide();
// Keep only one active warning popup to prevent dialog overlap.
if (e.response !== undefined) {
if (e.response.data.status !== undefined) {
if (e.response.data.status == 401) {
//invalid_token
q.dialog({
component: CustomComponent,
componentProps: {
title: `พบข้อผิดพลาด`,
message: `ล็อกอินหมดอายุ กรุณาล็อกอินใหม่อีกครั้ง`,
icon: 'warning',
color: 'red',
onlycancel: true,
},
}).onCancel(async () => {
showLoader()
await logout()
setTimeout(() => {
hideLoader()
}, 1000)
})
openSingleErrorDialog(
q,
'ล็อกอินหมดอายุ กรุณาล็อกอินใหม่อีกครั้ง',
async () => {
showLoader()
await logout()
setTimeout(() => {
hideLoader()
}, 1000)
}
)
} else {
const message = e.response.data.result ?? e.response.data.message
q.dialog({
component: CustomComponent,
componentProps: {
title: `พบข้อผิดพลาด`,
message: `${message}`,
icon: 'warning',
color: 'red',
onlycancel: true,
},
})
openSingleErrorDialog(q, `${message}`)
}
} else {
if (e.response.status == 401) {
if (msg !== '') {
q.dialog({
component: CustomComponent,
componentProps: {
title: `พบข้อผิดพลาด`,
message: msg,
icon: 'warning',
color: 'red',
onlycancel: true,
},
}).onCancel(async () => {
openSingleErrorDialog(q, msg, async () => {
showLoader()
await logout()
setTimeout(() => {
@ -188,70 +215,35 @@ export const useCounterMixin = defineStore('mixin', () => {
})
} else {
//invalid_token
q.dialog({
component: CustomComponent,
componentProps: {
title: `พบข้อผิดพลาด`,
message: `ล็อกอินหมดอายุ กรุณาล็อกอินใหม่อีกครั้ง`,
icon: 'warning',
color: 'red',
onlycancel: true,
},
}).onCancel(async () => {
showLoader()
await logout()
setTimeout(() => {
hideLoader()
}, 1000)
})
openSingleErrorDialog(
q,
'ล็อกอินหมดอายุ กรุณาล็อกอินใหม่อีกครั้ง',
async () => {
showLoader()
await logout()
setTimeout(() => {
hideLoader()
}, 1000)
}
)
}
} else if (e.response.data.successful === false) {
q.dialog({
component: CustomComponent,
componentProps: {
title: `พบข้อผิดพลาด`,
message: e.response.data.message,
icon: 'warning',
color: 'red',
onlycancel: true,
},
})
openSingleErrorDialog(q, e.response.data.message)
} else {
q.dialog({
component: CustomComponent,
componentProps: {
title: `พบข้อผิดพลาด`,
message: `ข้อมูลผิดพลาดทำให้เกิดการไม่ตอบสนองต่อการเรียกใช้งานดูเว็บไซต์`,
icon: 'warning',
color: 'red',
onlycancel: true,
},
})
openSingleErrorDialog(
q,
'ข้อมูลผิดพลาดทำให้เกิดการไม่ตอบสนองต่อการเรียกใช้งานดูเว็บไซต์'
)
}
}
} else {
if (msg !== '') {
return q.dialog({
component: CustomComponent,
componentProps: {
title: `พบข้อผิดพลาด`,
message: msg,
icon: 'warning',
color: 'red',
onlycancel: true,
},
})
return openSingleErrorDialog(q, msg)
}
q.dialog({
component: CustomComponent,
componentProps: {
title: `พบข้อผิดพลาด`,
message: `ข้อมูลผิดพลาดทำให้เกิดการไม่ตอบสนองต่อการเรียกใช้งานดูเว็บไซต์`,
icon: 'warning',
color: 'red',
onlycancel: true,
},
})
openSingleErrorDialog(
q,
'ข้อมูลผิดพลาดทำให้เกิดการไม่ตอบสนองต่อการเรียกใช้งานดูเว็บไซต์'
)
}
}

View file

@ -19,6 +19,7 @@ const { date2Thai, showLoader, hideLoader, messageError, dialogConfirm } = mixin
const $q = useQuasar()
const { checkPrivacyAccepted } = usePermissions()
const privacyStore = usePrivacyStore()
const MOCK_CHECK_DELAY_MS = 800
const modalTime = ref<boolean>(false) // Dailog
const checkStatus = ref<string>('')
@ -32,8 +33,6 @@ const endTimeAfternoon = ref<string>('12:00:00') //เวลาเช็คเ
const isLoadingCheckTime = ref<boolean>(false) //
const disabledBtn = ref<boolean>(false)
const locationGranted = ref<boolean>(false)
const isMockLocationDetected = ref<boolean>(false)
/**
* fetch เชคเวลาตองลงเวลาเขาหรอออกงาน
@ -115,19 +114,40 @@ async function updateLocation(
formLocation.POI = namePOI
}
/**
* บคาสถานะ location จาก AscGISMap
*/
function onLocationStatus(status: boolean) {
locationGranted.value = status
function resetLocationForRetry() {
formLocation.lat = 0
formLocation.lng = 0
formLocation.POI = ''
}
/**
* บค mock location detection จาก AscGISMap
*/
function onMockDetected(result: any) {
isMockLocationDetected.value = true
disabledBtn.value = true
function getCurrentPositionAsync(options?: PositionOptions) {
return new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, options)
})
}
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function getDelayedFreshPosition() {
const firstPosition = await getCurrentPositionAsync({
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0,
})
await wait(MOCK_CHECK_DELAY_MS)
try {
return await getCurrentPositionAsync({
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0,
})
} catch (error) {
return firstPosition
}
}
const location = ref<string>('') //
@ -348,7 +368,9 @@ const objectRef: FormRef = {
}
/** function ตรวจสอบค่าว่างของ input*/
function validateForm() {
async function validateForm() {
disabledBtn.value = true
const hasError = []
for (const key in objectRef) {
if (Object.prototype.hasOwnProperty.call(objectRef, key)) {
@ -375,11 +397,15 @@ function validateForm() {
model.value === 'อื่นๆ' ? useLocation.value : model.value
})`
} ณตองการยนยนการลงเวลาเขางาน?`,
() => {},
() => {
disabledBtn.value = false
},
'red',
'ยืนยัน'
)
}
} else {
disabledBtn.value = false
}
}
@ -390,14 +416,16 @@ const timeChickin = ref<string>('') //เวลาเข้างาน,เว
async function confirm() {
// privacy
if (!checkPrivacyAccepted()) {
disabledBtn.value = false
return
}
if (!formLocation.POI || !formLocation.lat || !formLocation.lng) {
disabledBtn.value = false
mapRef.value?.requestLocationPermission()
return
}
disabledBtn.value = true
showLoader()
const isLocation = workplace.value === 'in-place' //*true , false
const locationName = workplace.value === 'in-place' ? '' : useLocation.value
@ -440,6 +468,7 @@ async function confirm() {
async function getCheck() {
if (!formLocation.POI || !formLocation.lat || !formLocation.lng) {
disabledBtn.value = false
mapRef.value?.requestLocationPermission()
return
}
@ -472,7 +501,9 @@ async function getCheck() {
() => confirm(),
'ยืนยันการลงเวลาออกงาน',
`เวลาออกจากงานของคุณคือ ${endTimeAfternoonVal} แต่ขณะนี้เป็นเวลา ${timeVal} น. หากคุณออกจากงานในเวลานี้สถานะการลงเวลาจะเป็น "${res.data.result.statusText}" คุณแน่ใจว่าจะลงเวลาออกงานในตอนนี้ใช่หรือไม่?`,
() => {},
() => {
disabledBtn.value = false
},
'red',
'ยืนยัน'
)
@ -481,6 +512,7 @@ async function getCheck() {
}
})
.catch((e) => {
disabledBtn.value = false
messageError($q, e)
})
.finally(() => {
@ -612,16 +644,6 @@ watch(
}
}
)
watch(
() => locationGranted.value,
(newVal) => {
// Removed auto-reset of isMockLocationDetected to prevent
// clearing mock detection state when permission is granted.
// Mock detection state should only be reset after explicit user action
// or after a successful validation without mock indicators.
}
)
</script>
<template>
@ -694,8 +716,6 @@ watch(
v-if="$q.screen.gt.xs"
ref="mapRef"
@update:location="updateLocation"
@location-status="onLocationStatus"
@mock-detected="onMockDetected"
/>
</div>
</div>
@ -862,12 +882,7 @@ watch(
</div>
<div class="col-12" v-if="$q.screen.xs">
<MapCheck
ref="mapRef"
@update:location="updateLocation"
@location-status="onLocationStatus"
@mock-detected="onMockDetected"
/>
<MapCheck ref="mapRef" @update:location="updateLocation" />
</div>
</div>
<!-- กรอกขอม หนามอถ -->
@ -1016,7 +1031,7 @@ watch(
push
size="18px"
:class="$q.screen.gt.xs ? 'q-px-md' : 'full-width q-pa-sm'"
:disable="disabledBtn || !locationGranted || isMockLocationDetected ? true : camera && img ? false : true"
:disable="disabledBtn ? true : camera && img ? false : true"
@click="validateForm"
:loading="inQueue"
/>
@ -1131,7 +1146,7 @@ watch(
push
size="18px"
:class="$q.screen.gt.xs ? 'q-px-md' : 'full-width q-pa-sm'"
:disable="disabledBtn || !locationGranted || isMockLocationDetected ? true : camera && img ? false : true"
:disable="disabledBtn ? true : camera && img ? false : true"
@click="validateForm"
:loading="inQueue"
/>