2114 lines
70 KiB
Vue
2114 lines
70 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
ref,
|
|
reactive,
|
|
onMounted,
|
|
watch,
|
|
onBeforeUnmount,
|
|
computed,
|
|
nextTick,
|
|
} from 'vue'
|
|
import { useQuasar } from 'quasar'
|
|
import { format } from 'date-fns'
|
|
import Camera from 'simple-vue-camera'
|
|
import { storeToRefs } from 'pinia'
|
|
|
|
import config from '@/app.config'
|
|
import http from '@/plugins/http'
|
|
import { useCounterMixin } from '@/stores/mixin'
|
|
import { usePermissions } from '@/composables/usePermissions'
|
|
import { usePrivacyStore } from '@/stores/privacy'
|
|
import { usePositionKeycloakStore } from '@/stores/positionKeycloak'
|
|
import { useSocketStore } from '@/stores/socket'
|
|
|
|
import type { FormRef, OptionReason } from '@/interface/response/checkin'
|
|
|
|
import MapCheck from '@/components/AscGISMap.vue'
|
|
|
|
const mixin = useCounterMixin()
|
|
const { date2Thai, showLoader, hideLoader, messageError, dialogConfirm } = mixin
|
|
const $q = useQuasar()
|
|
const {
|
|
cameraPermissionState,
|
|
checkPrivacyAccepted,
|
|
syncPermissionStates,
|
|
requestCameraPermission,
|
|
} = usePermissions()
|
|
const socketStore = useSocketStore()
|
|
const { notificationCounter } = storeToRefs(socketStore)
|
|
const privacyStore = usePrivacyStore()
|
|
const positionKeycloakStore = usePositionKeycloakStore()
|
|
const MOCK_CHECK_DELAY_MS = 800
|
|
|
|
// เช็คว่าผู้ใช้มีข้อมูลสังกัดหรือไม่
|
|
const hasOrganization = computed(() => {
|
|
const data = positionKeycloakStore.dataPositionKeycloak
|
|
if (!data || !data.organization) return false
|
|
|
|
const org = data.organization
|
|
// ตรวจสอบว่ามีค่าที่ไม่ใช่ null, undefined หรือ string ว่าง
|
|
const hasValue = (val: string | null | undefined) => val && val.trim() !== ''
|
|
|
|
return !!(
|
|
hasValue(org.root) ||
|
|
hasValue(org.child1) ||
|
|
hasValue(org.child2) ||
|
|
hasValue(org.child3) ||
|
|
hasValue(org.child4)
|
|
)
|
|
})
|
|
|
|
const modalTime = ref<boolean>(false) // Dailog ลงเวลาเข้างานของคุณ
|
|
const checkStatus = ref<string>('')
|
|
const statusCheckin = ref<boolean>(true) // สถานะเวลา เข้า,ออก
|
|
|
|
const checkDate = ref<boolean | null>(null)
|
|
const msgCheckTime = ref<string>('') // ข้อความแจ้งเตือน
|
|
const isDisabledCheckTime = ref<boolean>(true) // ข้อความแจ้งเตือน
|
|
const isErr = ref<boolean | null>(null) // ข้อความแจ้งเตือน
|
|
const endTimeAfternoon = ref<string>('12:00:00') //เวลาเช็คเอาท์ตามรอบ
|
|
const crossDayCheckoutWarning = ref<string>('')
|
|
|
|
const isLoadingCheckTime = ref<boolean>(false) // ตัวแปรสำหรับการโหลด
|
|
const disabledBtn = ref<boolean>(false)
|
|
|
|
function parseApiDateTime(value?: string | null) {
|
|
if (!value) return null
|
|
|
|
const normalizedValue = value.includes('T') ? value : value.replace(' ', 'T')
|
|
const parsedDate = new Date(normalizedValue)
|
|
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate
|
|
}
|
|
|
|
function isSameCalendarDay(firstDate: Date, secondDate: Date) {
|
|
return (
|
|
firstDate.getFullYear() === secondDate.getFullYear() &&
|
|
firstDate.getMonth() === secondDate.getMonth() &&
|
|
firstDate.getDate() === secondDate.getDate()
|
|
)
|
|
}
|
|
|
|
function updateCrossDayCheckoutWarning(checkInTime?: string | null) {
|
|
crossDayCheckoutWarning.value = ''
|
|
|
|
const checkInDate = parseApiDateTime(checkInTime)
|
|
if (!checkInDate) return
|
|
|
|
if (!isSameCalendarDay(checkInDate, new Date())) {
|
|
crossDayCheckoutWarning.value = `คุณยังไม่ได้ลงเวลาออกของวันที่ ${date2Thai(
|
|
checkInDate
|
|
)} กรุณาลงเวลาออกก่อน จึงจะลงเวลาเข้าของวันนี้ได้`
|
|
}
|
|
}
|
|
|
|
/**
|
|
* fetch เช็คเวลาต้องลงเวลาเข้าหรือออกงาน
|
|
*/
|
|
async function fetchCheckTime(load: any = true) {
|
|
if (load) isLoadingCheckTime.value = true
|
|
await http
|
|
.get(config.API.checkTime())
|
|
.then(async (res) => {
|
|
const data = await res.data.result
|
|
|
|
statusCheckin.value = data.checkInId ? false : true
|
|
checkInId.value = data.checkInId ? data.checkInId : ''
|
|
updateCrossDayCheckoutWarning(data.checkInTime)
|
|
endTimeAfternoon.value = data.endTimeAfternoon
|
|
isDisabledCheckTime.value = isDisabledCheckTime.value ? true : false
|
|
})
|
|
.catch((err) => {
|
|
if (err.response.status === 500) {
|
|
isErr.value = true
|
|
isDisabledCheckTime.value = true
|
|
msgCheckTime.value = err.response.data.message
|
|
} else messageError($q, err)
|
|
})
|
|
.finally(() => {
|
|
if (load) isLoadingCheckTime.value = false
|
|
})
|
|
}
|
|
|
|
/** ref อัพเดทเวลา*/
|
|
const dateNow = ref<Date>(new Date())
|
|
const Thai = ref<Date>(dateNow.value)
|
|
const formattedS = ref()
|
|
const formattedM = ref()
|
|
const formattedH = ref()
|
|
const formattedHH = ref()
|
|
const formattedA = ref()
|
|
const clockInterval = ref<ReturnType<typeof setInterval> | undefined>()
|
|
|
|
/** function อัพเดทเวลา*/
|
|
function updateClock() {
|
|
const date = new Date()
|
|
const hh = format(date, 'HH')
|
|
const mm = format(date, 'mm')
|
|
const ss = format(date, 'ss')
|
|
const HH = format(date, 'hh')
|
|
const A = format(date, 'a')
|
|
|
|
formattedS.value = ss
|
|
formattedM.value = mm
|
|
formattedH.value = hh
|
|
formattedHH.value = HH
|
|
formattedA.value = A
|
|
}
|
|
|
|
/** form-data */
|
|
const formLocation = reactive({
|
|
lat: 0, //พิกัดละติจูด
|
|
lng: 0, //พิกัดลองจิจูด
|
|
POI: '', //ชื่อสถานที่
|
|
})
|
|
const workplace = ref<string>('in-place') //ณ สถานที่ตั้ง, นอกสถานที่ตั้ง
|
|
const useLocation = ref<string>('') //กรณีเลือกนอกสถานที่ตั้ง ต้องระบุข้อมูลชื่อสถานะที่
|
|
const fileImg = ref<any>() //รูปถ่ายสถานที่
|
|
const remark = ref<string>('') //ข้อความหมายเหตุที่ต้องการระบุเพิ่ม
|
|
const checkInId = ref<string>('') //Id ลงเวลา check-in ล่าสุดที่ยังไม่ลงเวลาออก
|
|
const MIN_CAPTURE_FILE_SIZE_BYTES = 5 * 1024
|
|
|
|
/**
|
|
* funciton เรียกพิกัดละติจูด พิกัดลองติจูด
|
|
* @param location พิกัดละติจูด พิกัดลองติจูด
|
|
* @param namePOI ชื่อสถานที่ ได้มาจากระบบ ArcGis ของกองสารสนเทศภูมิศาสตร์
|
|
*/
|
|
async function updateLocation(
|
|
latitude: number,
|
|
longitude: number,
|
|
namePOI: string
|
|
) {
|
|
formLocation.lat = latitude
|
|
formLocation.lng = longitude
|
|
formLocation.POI = namePOI
|
|
}
|
|
|
|
function resetLocationForRetry() {
|
|
formLocation.lat = 0
|
|
formLocation.lng = 0
|
|
formLocation.POI = ''
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
function getCameraVideoElement() {
|
|
return camera.value?.video
|
|
}
|
|
|
|
function prepareInlineCameraVideo(videoElement: HTMLVideoElement) {
|
|
videoElement.autoplay = true
|
|
videoElement.muted = true
|
|
videoElement.setAttribute('autoplay', '')
|
|
videoElement.setAttribute('muted', '')
|
|
videoElement.setAttribute('playsinline', '')
|
|
videoElement.setAttribute('webkit-playsinline', 'true')
|
|
}
|
|
|
|
function isInlineCameraPreviewReady(videoElement: HTMLVideoElement) {
|
|
return (
|
|
Boolean(videoElement.srcObject) &&
|
|
videoElement.videoWidth > 0 &&
|
|
videoElement.videoHeight > 0 &&
|
|
videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
|
|
)
|
|
}
|
|
|
|
async function waitForCameraInstance() {
|
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
await nextTick()
|
|
|
|
if (camera.value) {
|
|
return camera.value
|
|
}
|
|
|
|
await wait(50)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
async function stopInlineCamera() {
|
|
camera.value?.stop()
|
|
cameraIsOn.value = false
|
|
}
|
|
|
|
async function remountInlineCameraComponent() {
|
|
await stopInlineCamera()
|
|
cameraMountKey.value += 1
|
|
await nextTick()
|
|
await wait(INLINE_CAMERA_REMOUNT_DELAY_MS)
|
|
}
|
|
|
|
async function ensureInlineCameraPreviewReady() {
|
|
const deadline = Date.now() + INLINE_CAMERA_PREVIEW_TIMEOUT_MS
|
|
let lastPlaybackError: unknown = null
|
|
|
|
while (Date.now() < deadline) {
|
|
const videoElement = getCameraVideoElement()
|
|
|
|
if (videoElement) {
|
|
prepareInlineCameraVideo(videoElement)
|
|
|
|
if (isInlineCameraPreviewReady(videoElement)) {
|
|
return
|
|
}
|
|
|
|
if (videoElement.srcObject) {
|
|
try {
|
|
await videoElement.play()
|
|
} catch (error) {
|
|
lastPlaybackError = error
|
|
}
|
|
}
|
|
}
|
|
|
|
await wait(INLINE_CAMERA_PREVIEW_POLL_MS)
|
|
}
|
|
|
|
if (lastPlaybackError instanceof Error) {
|
|
throw lastPlaybackError
|
|
}
|
|
|
|
throw new Error('Inline camera preview did not become ready in time')
|
|
}
|
|
|
|
async function syncActiveCameraDevices() {
|
|
const devices: any = await camera.value?.devices(['videoinput'])
|
|
if (!devices) {
|
|
return
|
|
}
|
|
|
|
availableCameras.value = devices
|
|
|
|
const activeId = camera.value?.currentDeviceID()
|
|
const activeIdx = activeId
|
|
? devices.findIndex((device: any) => device.deviceId === activeId)
|
|
: -1
|
|
currentCameraIndex.value = activeIdx >= 0 ? activeIdx : 0
|
|
currentCameraType.value = resolveCameraType(
|
|
devices[currentCameraIndex.value]?.label || '',
|
|
'front'
|
|
)
|
|
}
|
|
|
|
async function startInlineCamera(recoveryAttempt = 0): Promise<void> {
|
|
const cameraInstance = await waitForCameraInstance()
|
|
|
|
if (!cameraInstance) {
|
|
throw new Error('Camera component is unavailable')
|
|
}
|
|
|
|
try {
|
|
currentCameraType.value = 'front'
|
|
await cameraInstance.start()
|
|
await ensureInlineCameraPreviewReady()
|
|
await syncActiveCameraDevices()
|
|
cameraIsOn.value = true
|
|
} catch (error) {
|
|
await stopInlineCamera()
|
|
|
|
if (recoveryAttempt < MAX_INLINE_CAMERA_RECOVERY_ATTEMPTS) {
|
|
await remountInlineCameraComponent()
|
|
return startInlineCamera(recoveryAttempt + 1)
|
|
}
|
|
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function getDelayedFreshPosition() {
|
|
const firstPosition = await getCurrentPositionAsync({
|
|
enableHighAccuracy: true,
|
|
timeout: 10000,
|
|
maximumAge: 0,
|
|
})
|
|
|
|
// Only add delay in development mode for testing
|
|
if (import.meta.env.DEV) {
|
|
await wait(MOCK_CHECK_DELAY_MS)
|
|
}
|
|
|
|
try {
|
|
return await getCurrentPositionAsync({
|
|
enableHighAccuracy: true,
|
|
timeout: 10000,
|
|
maximumAge: 0,
|
|
})
|
|
} catch (error) {
|
|
return firstPosition
|
|
}
|
|
}
|
|
|
|
const location = ref<string>('') // พื้นที่ใกล้เคียง
|
|
const model = ref<string>('') // สถานที่ทำงาน
|
|
// ตัวเลือกสถานที่ทำงาน
|
|
const options = ref<OptionReason[]>([
|
|
{ value: 'ปฏิบัติงานที่บ้าน', text: 'ปฏิบัติงานที่บ้าน (WFH)' },
|
|
{ value: 'ปฏิบัติงานนอกสถานที่', text: 'ปฏิบัติงานนอกสถานที่ (WFA)' },
|
|
// { value: 'ลืมลงเวลาเริ่มปฏิบัติราชการ', text: 'ลืมลงเวลาเริ่มปฏิบัติราชการ' },
|
|
// { value: 'ลืมลงเวลาเลิกปฏิบัติราชการ', text: 'ลืมลงเวลาเลิกปฏิบัติราชการ' },
|
|
{
|
|
value: 'ไปประชุม / อบรม / สัมมนา',
|
|
text: 'ไปประชุม / อบรม / สัมมนา',
|
|
},
|
|
// { value: 'ลางานครึ่งวัน (เช้า)', text: 'ลางานครึ่งวัน (เช้า)' },
|
|
// { value: 'ลางานครึ่งวัน (บ่าย)', text: 'ลางานครึ่งวัน (บ่าย)' },
|
|
{ value: 'ขออนุญาตออกนอกสถานที่', text: 'ขออนุญาตออกนอกสถานที่' },
|
|
// {
|
|
// value: 'ประสบภัย เช่น น้ำท่วม มีพายุ ประสบอุบัติเหตุ',
|
|
// text: 'ประสบภัย เช่น น้ำท่วม มีพายุ ประสบอุบัติเหตุ',
|
|
// },
|
|
{
|
|
value: 'ปฏิบัติงานในจุดบริการด่วนมหานคร',
|
|
text: 'ปฏิบัติงานในจุดบริการด่วนมหานคร',
|
|
},
|
|
|
|
{ value: 'อื่นๆ', text: 'อื่นๆ' },
|
|
])
|
|
|
|
/** function เลือกสถานที่*/
|
|
function selectLocation() {
|
|
if (model.value === 'อื่นๆ') {
|
|
useLocation.value = ''
|
|
} else {
|
|
useLocation.value = model.value
|
|
}
|
|
}
|
|
|
|
/** Camera */
|
|
const camera = ref<InstanceType<typeof Camera>>()
|
|
const cameraIsOn = ref<boolean>(false)
|
|
const img = ref<any>(undefined)
|
|
const photoWidth = ref<number>(350)
|
|
const photoHeight = ref<number>(350)
|
|
const cameraMountKey = ref<number>(0)
|
|
const availableCameras = ref<any[]>([])
|
|
const currentCameraIndex = ref<number>(0)
|
|
const currentCameraType = ref<'front' | 'back' | 'unknown'>('unknown')
|
|
|
|
/**
|
|
* Detect real iOS/iPadOS devices. Desktop mobile emulation can spoof the
|
|
* iPhone user-agent, so rely on platform + touch capability instead.
|
|
*/
|
|
const isIOSDevice: boolean = (() => {
|
|
const platform = navigator.platform || ''
|
|
const hasTouchPoints = navigator.maxTouchPoints > 1
|
|
|
|
return (
|
|
/iPhone|iPad|iPod/.test(platform) ||
|
|
(platform === 'MacIntel' && hasTouchPoints)
|
|
)
|
|
})()
|
|
|
|
const isAndroidDevice: boolean = /Android/i.test(navigator.userAgent)
|
|
/**
|
|
* `preferNativePhotoCapture` starts false on every device — inline camera is
|
|
* always attempted first. It is set to true only by
|
|
* `switchToNativePhotoCapture()` / `enableNativePhotoCaptureFallback()` when
|
|
* inline capture is proven to be unsupported or has failed. This covers iOS
|
|
* Safari too: getUserMedia is supported there since iOS 14.3, so we try
|
|
* inline first and fall back gracefully on first failure.
|
|
*/
|
|
const preferNativePhotoCapture = ref<boolean>(false)
|
|
const useNativePhotoCapture = computed(() => preferNativePhotoCapture.value)
|
|
const centeredPreviewImageStyle = Object.freeze({
|
|
objectFit: 'cover',
|
|
objectPosition: 'center center',
|
|
})
|
|
const INLINE_CAMERA_PREVIEW_TIMEOUT_MS = isIOSDevice ? 2500 : 1800
|
|
const INLINE_CAMERA_PREVIEW_POLL_MS = 120
|
|
const INLINE_CAMERA_REMOUNT_DELAY_MS = 120
|
|
const MAX_INLINE_CAMERA_RECOVERY_ATTEMPTS = 1
|
|
|
|
/** Ref for the hidden file input used as native camera fallback */
|
|
const nativePhotoInput = ref<HTMLInputElement | null>(null)
|
|
const DEFAULT_NORMALIZED_IMAGE_QUALITY = 0.92
|
|
|
|
interface NormalizePhotoOptions {
|
|
fileName?: string
|
|
mirrorHorizontally?: boolean
|
|
quality?: number
|
|
}
|
|
|
|
function clearSelectedPhoto() {
|
|
if (img.value) {
|
|
URL.revokeObjectURL(img.value)
|
|
img.value = undefined
|
|
}
|
|
fileImg.value = undefined
|
|
}
|
|
|
|
async function canDecodeImageBlob(blob: Blob) {
|
|
if (!blob.type.startsWith('image/')) {
|
|
return false
|
|
}
|
|
|
|
const imageUrl = URL.createObjectURL(blob)
|
|
|
|
try {
|
|
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
|
const element = new Image()
|
|
element.onload = () => {
|
|
if (element.naturalWidth > 0 && element.naturalHeight > 0) {
|
|
resolve(element)
|
|
} else {
|
|
reject(new Error('Image has invalid dimensions'))
|
|
}
|
|
}
|
|
element.onerror = () => reject(new Error('Image decode failed'))
|
|
element.src = imageUrl
|
|
})
|
|
|
|
return image.naturalWidth > 0 && image.naturalHeight > 0
|
|
} catch (error) {
|
|
console.error('Error decoding captured image:', error)
|
|
return false
|
|
} finally {
|
|
URL.revokeObjectURL(imageUrl)
|
|
}
|
|
}
|
|
|
|
async function isValidCapturedImage(blob: Blob) {
|
|
if (blob.size < MIN_CAPTURE_FILE_SIZE_BYTES) {
|
|
return false
|
|
}
|
|
|
|
return canDecodeImageBlob(blob)
|
|
}
|
|
|
|
async function loadImageFromBlob(blob: Blob) {
|
|
const imageUrl = URL.createObjectURL(blob)
|
|
|
|
try {
|
|
return await new Promise<HTMLImageElement>((resolve, reject) => {
|
|
const image = new Image()
|
|
image.onload = () => resolve(image)
|
|
image.onerror = () => reject(new Error('Image load failed'))
|
|
image.src = imageUrl
|
|
})
|
|
} finally {
|
|
URL.revokeObjectURL(imageUrl)
|
|
}
|
|
}
|
|
|
|
async function normalizePhotoFile(
|
|
source: Blob,
|
|
options: NormalizePhotoOptions = {}
|
|
) {
|
|
const fileName =
|
|
options.fileName ??
|
|
(source instanceof File && source.name
|
|
? source.name
|
|
: `photo_${Date.now()}.jpg`)
|
|
const imageType =
|
|
source.type && source.type.startsWith('image/') ? source.type : 'image/jpeg'
|
|
|
|
if (!options.mirrorHorizontally) {
|
|
if (source instanceof File && source.name === fileName) {
|
|
return source
|
|
}
|
|
|
|
return new File([source], fileName, { type: imageType })
|
|
}
|
|
|
|
const image = await loadImageFromBlob(source)
|
|
const canvas = document.createElement('canvas')
|
|
canvas.width = image.naturalWidth
|
|
canvas.height = image.naturalHeight
|
|
|
|
const context = canvas.getContext('2d')
|
|
if (!context) {
|
|
throw new Error('Canvas context unavailable')
|
|
}
|
|
|
|
context.translate(canvas.width, 0)
|
|
context.scale(-1, 1)
|
|
context.drawImage(image, 0, 0, canvas.width, canvas.height)
|
|
|
|
const normalizedBlob = await new Promise<Blob | null>((resolve) => {
|
|
canvas.toBlob(
|
|
resolve,
|
|
imageType,
|
|
options.quality ?? DEFAULT_NORMALIZED_IMAGE_QUALITY
|
|
)
|
|
})
|
|
|
|
if (!normalizedBlob) {
|
|
throw new Error('Failed to normalize image')
|
|
}
|
|
|
|
return new File([normalizedBlob], fileName, { type: imageType })
|
|
}
|
|
|
|
async function assignSelectedPhoto(file: File) {
|
|
clearSelectedPhoto()
|
|
fileImg.value = file
|
|
img.value = URL.createObjectURL(file)
|
|
}
|
|
|
|
function openNativePhotoCapture() {
|
|
void stopInlineCamera()
|
|
|
|
if (nativePhotoInput.value) {
|
|
nativePhotoInput.value.value = ''
|
|
nativePhotoInput.value.click()
|
|
}
|
|
}
|
|
|
|
function enableNativePhotoCaptureFallback() {
|
|
preferNativePhotoCapture.value = true
|
|
void stopInlineCamera()
|
|
clearSelectedPhoto()
|
|
}
|
|
|
|
function switchToNativePhotoCapture() {
|
|
enableNativePhotoCaptureFallback()
|
|
openNativePhotoCapture()
|
|
}
|
|
|
|
/**
|
|
* Called when the user selects / captures a photo via the native file-input
|
|
* fallback. Keeps the same `img` + `fileImg` state that capturePhoto()
|
|
* sets so the upload flow is identical.
|
|
*/
|
|
async function onNativePhotoSelected(event: Event) {
|
|
const input = event.target as HTMLInputElement
|
|
const file = input.files?.[0]
|
|
input.value = ''
|
|
if (!file) return
|
|
|
|
if (!(await isValidCapturedImage(file))) {
|
|
clearSelectedPhoto()
|
|
messageError($q, '', 'ไม่สามารถอ่านไฟล์รูปภาพได้ กรุณาถ่ายรูปใหม่อีกครั้ง')
|
|
return
|
|
}
|
|
|
|
try {
|
|
const normalizedFile = await normalizePhotoFile(file, {
|
|
fileName: file.name,
|
|
mirrorHorizontally: !isIOSDevice,
|
|
})
|
|
|
|
if (!(await isValidCapturedImage(normalizedFile))) {
|
|
throw new Error('Normalized image is invalid')
|
|
}
|
|
|
|
await assignSelectedPhoto(normalizedFile)
|
|
} catch (error) {
|
|
console.error('Error normalizing native photo:', error)
|
|
clearSelectedPhoto()
|
|
messageError($q, error, 'ไม่สามารถเตรียมรูปภาพได้ กรุณาถ่ายรูปใหม่อีกครั้ง')
|
|
}
|
|
}
|
|
|
|
const intervalId = ref<ReturnType<typeof setInterval> | undefined>(undefined)
|
|
|
|
/**
|
|
* เริ่มจาก onMounted #1 เช็ค status คิว
|
|
*
|
|
*/
|
|
async function startChecking() {
|
|
try {
|
|
// showLoader()
|
|
await fetchCheckStatus()
|
|
await fetchCheckTime()
|
|
} catch (error) {
|
|
console.error('Error in startChecking:', error)
|
|
// hideLoader()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* เริ่มจาก onMounted #2 เช็ค status คิว
|
|
*
|
|
*/
|
|
async function fetchCheckStatus() {
|
|
try {
|
|
const res = await http.get(config.API.checkStatus())
|
|
inQueue.value = res.data.result.inQueue
|
|
if (res.data.result.inQueue) {
|
|
/** inQueue เป็น true */
|
|
isDisabledCheckTime.value = true
|
|
msgCheckTime.value = 'ระบบกำลังประมวลผล'
|
|
if (intervalId.value === undefined) {
|
|
intervalId.value = setInterval(async () => {
|
|
try {
|
|
await fetchCheckStatus()
|
|
} catch (error) {
|
|
console.error('Error in interval fetchCheckStatus:', error)
|
|
// หยุด interval ถ้าเกิด error
|
|
stopChecking()
|
|
}
|
|
}, 3000)
|
|
console.log('startChecking called, intervalId:', intervalId.value)
|
|
}
|
|
// hideLoader()
|
|
} else {
|
|
/** inQueue เป็น false */
|
|
isDisabledCheckTime.value = false
|
|
msgCheckTime.value = ''
|
|
await stopChecking() // หยุดการทำงาน
|
|
}
|
|
} catch (error) {
|
|
console.log('เกิดข้อผิดพลาด', error)
|
|
messageError($q, error)
|
|
stopChecking() // หยุดการทำงาน
|
|
}
|
|
}
|
|
|
|
/** ตัวใหม่ที่เปลี่ยนก่อนเปลี่ยน
|
|
* เริ่มจาก onMounted #3 เช็ค status คิว
|
|
*
|
|
*/
|
|
async function stopChecking() {
|
|
if (intervalId.value !== undefined) {
|
|
await fetchCheckTime(false) // เรียก fetchCheckTime ก่อนหยุด interval
|
|
clearInterval(intervalId.value)
|
|
intervalId.value = undefined // รีเซ็ตค่า interval
|
|
|
|
async function repeatFetch() {
|
|
await fetchCheckTime(false)
|
|
if (statusCheckin.value && checkDate.value === true) {
|
|
setTimeout(repeatFetch, 1000)
|
|
} else {
|
|
checkDate.value = null
|
|
isErr.value = false
|
|
}
|
|
}
|
|
|
|
async function repeatFetchOut() {
|
|
await fetchCheckTime(false)
|
|
if (
|
|
isErr.value === false &&
|
|
statusCheckin.value === false &&
|
|
checkDate.value === true
|
|
) {
|
|
setTimeout(repeatFetchOut, 1000) // เรียกตัวเองซ้ำ
|
|
} else {
|
|
checkDate.value = null
|
|
isErr.value = null
|
|
}
|
|
}
|
|
|
|
// ตรวจสอบเงื่อนไขก่อนเริ่มการ fetch ซ้ำ
|
|
if (statusCheckin.value && checkDate.value === true) {
|
|
repeatFetch()
|
|
} else if (
|
|
isErr.value === false &&
|
|
statusCheckin.value === false &&
|
|
checkDate.value === true
|
|
) {
|
|
repeatFetchOut()
|
|
}
|
|
}
|
|
}
|
|
|
|
function identifyCameraType(label: string): 'front' | 'back' | 'unknown' {
|
|
const lowerLabel = label.toLowerCase()
|
|
if (
|
|
lowerLabel.includes('front') ||
|
|
lowerLabel.includes('user') ||
|
|
lowerLabel.includes('face')
|
|
) {
|
|
return 'front'
|
|
} else if (
|
|
lowerLabel.includes('back') ||
|
|
lowerLabel.includes('environment') ||
|
|
lowerLabel.includes('rear')
|
|
) {
|
|
return 'back'
|
|
}
|
|
return 'unknown'
|
|
}
|
|
|
|
function resolveCameraType(
|
|
label: string,
|
|
fallbackType: 'front' | 'back'
|
|
): 'front' | 'back' {
|
|
const detectedType = identifyCameraType(label)
|
|
return detectedType === 'unknown' ? fallbackType : detectedType
|
|
}
|
|
|
|
/** function เปิดกล้อง*/
|
|
async function openCamera() {
|
|
// เช็คสิทธิ์ privacy ก่อนเปิดกล้อง
|
|
if (!checkPrivacyAccepted()) {
|
|
return
|
|
}
|
|
|
|
// If inline capture previously failed on this device, fall back to the
|
|
// native still-photo picker instead of reopening the live camera.
|
|
if (useNativePhotoCapture.value) {
|
|
openNativePhotoCapture()
|
|
return
|
|
}
|
|
|
|
if (cameraIsOn.value) {
|
|
await stopInlineCamera()
|
|
return
|
|
}
|
|
|
|
if (
|
|
(isIOSDevice || isAndroidDevice) &&
|
|
!useNativePhotoCapture.value &&
|
|
(cameraPermissionState.value === 'denied' ||
|
|
cameraPermissionState.value === 'unsupported')
|
|
) {
|
|
switchToNativePhotoCapture()
|
|
return
|
|
}
|
|
|
|
const hasCameraPermission = await requestCameraPermission()
|
|
if (!hasCameraPermission) {
|
|
if ((isIOSDevice || isAndroidDevice) && !useNativePhotoCapture.value) {
|
|
switchToNativePhotoCapture()
|
|
return
|
|
}
|
|
|
|
messageError(
|
|
$q,
|
|
'',
|
|
'ไม่สามารถเข้าถึงกล้องได้ กรุณาอนุญาตการเข้าถึงกล้องในเบราว์เซอร์ของคุณ'
|
|
)
|
|
return
|
|
}
|
|
|
|
try {
|
|
await startInlineCamera()
|
|
} catch (error) {
|
|
console.error('Error opening camera:', error)
|
|
|
|
if ((isIOSDevice || isAndroidDevice) && !useNativePhotoCapture.value) {
|
|
switchToNativePhotoCapture()
|
|
return
|
|
}
|
|
|
|
messageError($q, error, 'ไม่สามารถเปิดกล้องได้ กรุณาลองใหม่อีกครั้ง')
|
|
}
|
|
}
|
|
|
|
/** change camera device*/
|
|
async function changeCamera(targetCameraType?: 'front' | 'back') {
|
|
try {
|
|
const devices: any = await camera.value?.devices(['videoinput'])
|
|
|
|
if (!devices || devices.length === 0) {
|
|
console.warn('No cameras found')
|
|
return
|
|
}
|
|
|
|
availableCameras.value = devices
|
|
|
|
if (devices.length === 1 || !targetCameraType) {
|
|
const device = devices[0]
|
|
const fallbackType =
|
|
targetCameraType ??
|
|
(currentCameraType.value === 'back' ? 'back' : 'front')
|
|
await camera.value?.changeCamera(device.deviceId)
|
|
await ensureInlineCameraPreviewReady()
|
|
currentCameraIndex.value = 0
|
|
currentCameraType.value = resolveCameraType(
|
|
device.label || '',
|
|
fallbackType
|
|
)
|
|
return
|
|
}
|
|
|
|
const matchingCameras = devices.filter(
|
|
(device: any) =>
|
|
identifyCameraType(device.label || '') === targetCameraType
|
|
)
|
|
|
|
if (matchingCameras.length > 0) {
|
|
const targetDevice = matchingCameras[0]
|
|
await camera.value?.changeCamera(targetDevice.deviceId)
|
|
await ensureInlineCameraPreviewReady()
|
|
currentCameraIndex.value = devices.indexOf(targetDevice)
|
|
currentCameraType.value = resolveCameraType(
|
|
targetDevice.label || '',
|
|
targetCameraType
|
|
)
|
|
} else {
|
|
const nextIndex = (currentCameraIndex.value + 1) % devices.length
|
|
const nextDevice = devices[nextIndex]
|
|
await camera.value?.changeCamera(nextDevice.deviceId)
|
|
await ensureInlineCameraPreviewReady()
|
|
currentCameraIndex.value = nextIndex
|
|
currentCameraType.value = resolveCameraType(
|
|
nextDevice.label || '',
|
|
targetCameraType
|
|
)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error switching camera:', error)
|
|
}
|
|
}
|
|
|
|
/** switch camera device*/
|
|
async function switchCamera() {
|
|
if (availableCameras.value.length <= 1) {
|
|
return
|
|
}
|
|
|
|
// สลับแค่ระหว่างกล้อง 2 ตัวแรก (กล้องหน้าและหลังหลัก)
|
|
const targetIndex = currentCameraIndex.value === 0 ? 1 : 0
|
|
const targetDevice = availableCameras.value[targetIndex]
|
|
const fallbackType = currentCameraType.value === 'back' ? 'front' : 'back'
|
|
|
|
await camera.value?.changeCamera(targetDevice.deviceId)
|
|
await ensureInlineCameraPreviewReady()
|
|
currentCameraIndex.value = targetIndex
|
|
currentCameraType.value = resolveCameraType(
|
|
targetDevice.label || '',
|
|
fallbackType
|
|
)
|
|
}
|
|
|
|
/** function ถ่ายรูป*/
|
|
async function capturePhoto() {
|
|
try {
|
|
const imageBlob: any = await camera.value?.snapshot(
|
|
{ width: photoWidth.value, height: photoHeight.value },
|
|
'image/jpeg',
|
|
0.8
|
|
)
|
|
if (!imageBlob || !(await isValidCapturedImage(imageBlob))) {
|
|
if (isIOSDevice || isAndroidDevice) {
|
|
switchToNativePhotoCapture()
|
|
return
|
|
}
|
|
|
|
messageError($q, '', 'ไม่สามารถถ่ายรูปได้ กรุณาลองใหม่อีกครั้ง')
|
|
return
|
|
}
|
|
const fileName = `photo_${Date.now()}.jpg`
|
|
const normalizedFile = await normalizePhotoFile(imageBlob, {
|
|
fileName,
|
|
mirrorHorizontally: false,
|
|
quality: 0.8,
|
|
})
|
|
|
|
if (!(await isValidCapturedImage(normalizedFile))) {
|
|
throw new Error('Normalized image is invalid')
|
|
}
|
|
|
|
await assignSelectedPhoto(normalizedFile)
|
|
|
|
//แสดงรูป
|
|
await stopInlineCamera()
|
|
} catch (error) {
|
|
console.error('Error capturing photo:', error)
|
|
|
|
if ((isIOSDevice || isAndroidDevice) && !useNativePhotoCapture.value) {
|
|
switchToNativePhotoCapture()
|
|
return
|
|
}
|
|
|
|
messageError($q, error, 'ไม่สามารถถ่ายรูปได้ กรุณาลองใหม่อีกครั้ง')
|
|
}
|
|
}
|
|
|
|
/** function เปลี่ยนรูปภาพ*/
|
|
async function refreshPhoto() {
|
|
try {
|
|
clearSelectedPhoto()
|
|
|
|
if (useNativePhotoCapture.value) {
|
|
openNativePhotoCapture()
|
|
return
|
|
}
|
|
await startInlineCamera()
|
|
} catch (error) {
|
|
console.error('Error refreshing photo:', error)
|
|
messageError($q, error, 'ไม่สามารถเปิดกล้องได้ กรุณาลองใหม่อีกครั้ง')
|
|
}
|
|
}
|
|
|
|
/** ref validate*/
|
|
const useLocationRef = ref<object | null>(null)
|
|
const modelRef = ref<object | null>(null)
|
|
const objectRef: FormRef = {
|
|
model: modelRef,
|
|
useLocation: useLocationRef,
|
|
}
|
|
|
|
/** function ตรวจสอบค่าว่างของ input*/
|
|
async function validateForm() {
|
|
disabledBtn.value = true
|
|
|
|
const hasError = []
|
|
for (const key in objectRef) {
|
|
if (Object.prototype.hasOwnProperty.call(objectRef, key)) {
|
|
const property = objectRef[key]
|
|
if (property.value && typeof property.value.validate === 'function') {
|
|
const isValid = property.value.validate()
|
|
hasError.push(isValid)
|
|
}
|
|
}
|
|
}
|
|
if (hasError.every((result) => result === true)) {
|
|
if (statusCheckin.value == false) {
|
|
getCheck()
|
|
} else if (statusCheckin.value) {
|
|
// dialog ยืนยันการลงเวลาเข้างาน
|
|
dialogConfirm(
|
|
$q,
|
|
() => confirm(),
|
|
'ยืนยันการลงเวลาเข้างาน',
|
|
`แจ้งเตือนการลงเวลาเข้างาน ${
|
|
workplace.value === 'in-place'
|
|
? 'ในสถานที่'
|
|
: `นอกสถานที่ (${
|
|
model.value === 'อื่นๆ' ? useLocation.value : model.value
|
|
})`
|
|
} คุณต้องการยืนยันการลงเวลาเข้างาน?`,
|
|
() => {
|
|
disabledBtn.value = false
|
|
},
|
|
'red',
|
|
'ยืนยัน'
|
|
)
|
|
}
|
|
} else {
|
|
disabledBtn.value = false
|
|
}
|
|
}
|
|
|
|
const mapRefDesktop = ref<InstanceType<typeof MapCheck> | null>(null)
|
|
const mapRefMobile = ref<InstanceType<typeof MapCheck> | null>(null)
|
|
|
|
// Computed ref that returns the correct map ref based on screen size
|
|
const mapRef = computed(() =>
|
|
$q.screen.gt.xs ? mapRefDesktop.value : mapRefMobile.value
|
|
)
|
|
|
|
const timeChickin = ref<string>('') //เวลาเข้างาน,เวลาออกงาน
|
|
const displayedCheckDate = ref<Date>(new Date())
|
|
|
|
/** function ยืนยันการลงเวลาเข้า - ออก*/
|
|
async function confirm() {
|
|
// เช็คสิทธิ์ privacy ก่อนใช้งานแผนที่และกล้อง
|
|
if (!checkPrivacyAccepted()) {
|
|
disabledBtn.value = false
|
|
return
|
|
}
|
|
|
|
if (!formLocation.POI || !formLocation.lat || !formLocation.lng) {
|
|
disabledBtn.value = false
|
|
mapRef.value?.requestLocationPermission()
|
|
return
|
|
}
|
|
|
|
if (!fileImg.value || !(await isValidCapturedImage(fileImg.value))) {
|
|
if ((isIOSDevice || isAndroidDevice) && !useNativePhotoCapture.value) {
|
|
enableNativePhotoCaptureFallback()
|
|
}
|
|
clearSelectedPhoto()
|
|
disabledBtn.value = false
|
|
messageError($q, '', 'รูปภาพไม่สมบูรณ์ กรุณาถ่ายรูปใหม่อีกครั้งก่อนลงเวลา')
|
|
return
|
|
}
|
|
|
|
showLoader()
|
|
const isLocation = workplace.value === 'in-place' //*true คือ ณ สถานที่ตั้ง, false คือ นอกสถานที่ตั้ง
|
|
const locationName = workplace.value === 'in-place' ? '' : useLocation.value
|
|
const formdata = new FormData()
|
|
formdata.append('lat', formLocation.lat.toString())
|
|
formdata.append('lon', formLocation.lng.toString())
|
|
formdata.append('POI', formLocation.POI)
|
|
formdata.append('isLocation', isLocation.toString())
|
|
formdata.append('locationName', locationName)
|
|
formdata.append('img', fileImg.value)
|
|
formdata.append('remark', remark.value)
|
|
formdata.append('checkInId', checkInId.value)
|
|
await http
|
|
.post(config.API.checkin(), formdata)
|
|
.then(async (res) => {
|
|
const data = await res.data.result
|
|
const dateObject = new Date(data.date)
|
|
displayedCheckDate.value = dateObject
|
|
checkDate.value = data.date ? true : false
|
|
const options: Intl.DateTimeFormatOptions = {
|
|
hour12: false,
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
}
|
|
const timeString = new Intl.DateTimeFormat('en-US', options).format(
|
|
dateObject
|
|
)
|
|
timeChickin.value = timeString
|
|
modalTime.value = true
|
|
remark.value = ''
|
|
disabledBtn.value = false
|
|
})
|
|
.catch((err) => {
|
|
messageError($q, err)
|
|
disabledBtn.value = false
|
|
})
|
|
.finally(() => {
|
|
hideLoader()
|
|
})
|
|
}
|
|
|
|
async function getCheck() {
|
|
if (!formLocation.POI || !formLocation.lat || !formLocation.lng) {
|
|
disabledBtn.value = false
|
|
mapRef.value?.requestLocationPermission()
|
|
return
|
|
}
|
|
|
|
showLoader()
|
|
const isSeminar =
|
|
model.value === 'ไปประชุม / อบรม / สัมมนา'
|
|
? 'S'
|
|
: model.value === 'ปฏิบัติงานในจุดบริการด่วนมหานคร'
|
|
? 'O'
|
|
: 'N'
|
|
|
|
await http
|
|
.get(config.API.checkoutCheck + `/${isSeminar}`)
|
|
.then(async (res) => {
|
|
checkStatus.value = await res.data.result.status.toLocaleUpperCase()
|
|
if (checkStatus.value == 'ABSENT') {
|
|
const options: Intl.DateTimeFormatOptions = {
|
|
hour12: false,
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
}
|
|
const timeVal =
|
|
new Intl.DateTimeFormat('en-US', options).format(
|
|
new Date(res.data.result.serverTime)
|
|
) ?? ''
|
|
const endTimeAfternoonVal =
|
|
new Intl.DateTimeFormat('en-US', options).format(
|
|
new Date(res.data.result.endTime)
|
|
) ?? ''
|
|
|
|
// dialog ยืนยันการลงเวลาออกงาน
|
|
dialogConfirm(
|
|
$q,
|
|
() => confirm(),
|
|
'ยืนยันการลงเวลาออกงาน',
|
|
`เวลาออกจากงานของคุณคือ ${endTimeAfternoonVal} แต่ขณะนี้เป็นเวลา ${timeVal} น. หากคุณออกจากงานในเวลานี้สถานะการลงเวลาจะเป็น "${res.data.result.statusText}" คุณแน่ใจว่าจะลงเวลาออกงานในตอนนี้ใช่หรือไม่?`,
|
|
() => {
|
|
disabledBtn.value = false
|
|
},
|
|
'red',
|
|
'ยืนยัน'
|
|
)
|
|
} else if (checkStatus.value == 'NORMAL') {
|
|
confirm()
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
disabledBtn.value = false
|
|
messageError($q, e)
|
|
})
|
|
.finally(() => {
|
|
hideLoader()
|
|
})
|
|
}
|
|
/** ปิด popup แสดงการลงเวลา*/
|
|
async function onClickConfirm() {
|
|
try {
|
|
showLoader()
|
|
cameraIsOn.value = false
|
|
clearSelectedPhoto()
|
|
modalTime.value = false
|
|
if (!statusCheckin.value) {
|
|
statusCheckin.value = true
|
|
}
|
|
await startChecking()
|
|
} finally {
|
|
hideLoader()
|
|
}
|
|
}
|
|
|
|
/** เลือกสถานที่ทำงาน*/
|
|
function updateWorkplace() {
|
|
useLocation.value = ''
|
|
model.value = ''
|
|
}
|
|
|
|
/**
|
|
* รี้เทินร์ class สีพื้นหลัง
|
|
* @param val ค่า statusCheckin
|
|
*/
|
|
const getClass = (val: boolean) => {
|
|
return {
|
|
'bg-primary text-white col-12 row items-center q-px-md q-py-sm ': val,
|
|
'bg-red-8 text-white col-12 row items-center q-px-md q-py-sm ': !val,
|
|
}
|
|
}
|
|
|
|
const getClassXS = (val: boolean) => {
|
|
return {
|
|
'text-white q-pa-lg col-12 row bg-primary': val,
|
|
'text-white q-pa-lg col-12 row bg-red-8': !val,
|
|
}
|
|
}
|
|
const inQueue = ref<boolean>(false)
|
|
|
|
// ฟังก์ชันสำหรับรีเซ็ตรูปและหยุดกล้อง
|
|
function resetCameraAndImage() {
|
|
clearSelectedPhoto()
|
|
void stopInlineCamera()
|
|
}
|
|
|
|
// เพิ่มฟังก์ชันสำหรับจัดการการปิดแอพในมือถือ
|
|
function handleAppClose() {
|
|
resetCameraAndImage()
|
|
|
|
// หยุด interval ถ้ามี
|
|
if (intervalId.value !== undefined) {
|
|
clearInterval(intervalId.value)
|
|
intervalId.value = undefined
|
|
}
|
|
}
|
|
|
|
// จัดการ visibility change สำหรับมือถือ
|
|
function handleVisibilityChange() {
|
|
if (document.visibilityState === 'hidden') {
|
|
handleAppClose()
|
|
} else if (document.visibilityState === 'visible') {
|
|
// เมื่อกลับมาที่แอพ ให้เรียกแผนที่ใหม่
|
|
if (privacyStore.isAccepted) {
|
|
mapRef.value?.requestLocationPermission()
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Hook*/
|
|
onMounted(async () => {
|
|
// เริ่มต้น clock เสมอ
|
|
updateClock()
|
|
clockInterval.value = setInterval(updateClock, 1000)
|
|
|
|
// เพิ่ม event listeners สำหรับมือถือ
|
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
window.addEventListener('pagehide', handleAppClose)
|
|
|
|
// ถ้ามีข้อมูลสังกัดแล้ว เริ่ม checking ได้เลย
|
|
if (hasOrganization.value) {
|
|
isLoadingCheckTime.value = true
|
|
startChecking()
|
|
}
|
|
|
|
await syncPermissionStates()
|
|
|
|
// เรียกแผนที่เฉพาะเมื่อยอมรับ privacy แล้ว
|
|
if (privacyStore.isAccepted) {
|
|
mapRef.value?.requestLocationPermission()
|
|
}
|
|
})
|
|
|
|
// เฝ้าดูการเปลี่ยนแปลงของ hasOrganization
|
|
// เพื่อเริ่ม startChecking เมื่อมีข้อมูลสังกัด
|
|
watch(
|
|
hasOrganization,
|
|
(newValue) => {
|
|
if (newValue && !isLoadingCheckTime.value) {
|
|
isLoadingCheckTime.value = true
|
|
startChecking()
|
|
}
|
|
},
|
|
{ immediate: false }
|
|
)
|
|
|
|
onBeforeUnmount(() => {
|
|
resetCameraAndImage()
|
|
|
|
// หยุด clock interval
|
|
if (clockInterval.value !== undefined) {
|
|
clearInterval(clockInterval.value)
|
|
clockInterval.value = undefined
|
|
}
|
|
|
|
// หยุด interval ถ้ามี
|
|
if (intervalId.value !== undefined) {
|
|
clearInterval(intervalId.value)
|
|
intervalId.value = undefined
|
|
}
|
|
|
|
// ลบ event listeners
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
window.removeEventListener('pagehide', handleAppClose)
|
|
})
|
|
|
|
watch(
|
|
() => privacyStore.isAccepted,
|
|
async (newVal) => {
|
|
if (newVal) {
|
|
await syncPermissionStates()
|
|
mapRef.value?.requestLocationPermission()
|
|
}
|
|
}
|
|
)
|
|
|
|
/** Watch notification counter on socket */
|
|
watch(notificationCounter, () => {
|
|
startChecking()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<q-page :style="$q.screen.xs ? 'padding-top: 90px' : ''">
|
|
<div class="col-12 row justify-center">
|
|
<div class="col-xs-12 col-sm-12 col-md-12">
|
|
<q-card
|
|
flat
|
|
:class="
|
|
$q.screen.gt.xs ? 'row col-12 cardNone' : 'row col-12 bg-grey-2'
|
|
"
|
|
>
|
|
<!-- <q-header elevated class="bg-purple"> -->
|
|
<div
|
|
class="col-12 q-pa-md items-center gt-xs"
|
|
v-if="isLoadingCheckTime"
|
|
>
|
|
<q-skeleton width="100%" height="50px" />
|
|
</div>
|
|
|
|
<div v-else :class="getClass(statusCheckin)" class="gt-xs">
|
|
<div class="col">
|
|
<div class="row col-12 justify-center q-py-sm text-subtitle1">
|
|
<strong v-if="!statusCheckin && inQueue">
|
|
ลงเวลาออกงาน (ระบบกำลังประมวลผล)
|
|
</strong>
|
|
|
|
<strong v-else-if="statusCheckin && inQueue">
|
|
ลงเวลาเข้างาน (ระบบกำลังประมวลผล)
|
|
</strong>
|
|
|
|
<strong v-else-if="statusCheckin && !inQueue">
|
|
ลงเวลาเข้างาน
|
|
</strong>
|
|
|
|
<strong v-else> ลงเวลาออกงาน </strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-12 text-grey-9">
|
|
<div class="col-12 row justify-center">
|
|
<div class="col-12 row q-pt-md justify-center gt-xs">
|
|
<div
|
|
class="col-xs-12 col-sm-10 text-h6 text-center text-weight-bold"
|
|
>
|
|
{{ date2Thai(Thai) }}
|
|
</div>
|
|
<div class="row col-12 justify-center q-py-sm">
|
|
<div class="colunm">
|
|
<div class="text-h3 text-weight-bold">
|
|
{{ formattedH }}<span class="q-ma-md">:</span>
|
|
</div>
|
|
</div>
|
|
<div class="colunm">
|
|
<div class="text-h3 text-weight-bold">
|
|
{{ formattedM }}<span class="q-ma-md">:</span>
|
|
</div>
|
|
</div>
|
|
<div class="colunm">
|
|
<div class="text-h3 text-weight-bold">{{ formattedS }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="isDisabledCheckTime && msgCheckTime">
|
|
<div class="col-12 column items-center justify-center q-pa-xl">
|
|
<q-icon
|
|
name="mdi-map-marker-radius"
|
|
size="8em"
|
|
class="text-primary q-mb-md"
|
|
/>
|
|
<div class="text-h6 text-center text-red q-mt-sm">
|
|
*{{ msgCheckTime }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="col-xs-12 col-md-11 q-pa-md q-col-gutter-md row q-pt-lg"
|
|
v-if="!isDisabledCheckTime"
|
|
>
|
|
<div v-if="crossDayCheckoutWarning" class="col-12">
|
|
<q-banner
|
|
rounded
|
|
class="bg-white text-orange-10 warning-banner"
|
|
>
|
|
<q-icon name="warning" color="warning" size="20px" />
|
|
|
|
{{ crossDayCheckoutWarning }}
|
|
</q-banner>
|
|
</div>
|
|
<div class="col-xs-12 col-sm-8 gt-xs">
|
|
<div class="col-12">
|
|
<MapCheck
|
|
ref="mapRefDesktop"
|
|
:initialPOI="formLocation.POI"
|
|
@update:location="updateLocation"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div v-if="$q.screen.gt.xs" class="col-xs-12 col-sm-4">
|
|
<q-card
|
|
:class="
|
|
$q.screen.xs ? 'card-container-xs' : 'card-container'
|
|
"
|
|
>
|
|
<div
|
|
v-if="!cameraIsOn && img == null"
|
|
class="preview-placeholder"
|
|
@click="() => !isDisabledCheckTime && openCamera()"
|
|
>
|
|
<div class="text-center">
|
|
<q-icon
|
|
name="mdi-camera"
|
|
color="blue-grey-3"
|
|
size="100px"
|
|
class="center-icon"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="col-12 row items-center">
|
|
<!-- แสดงกล้องตอนกดถ่ายภาพ -->
|
|
<Camera
|
|
:key="`desktop-${cameraMountKey}`"
|
|
:resolution="{ width: photoWidth, height: photoHeight }"
|
|
ref="camera"
|
|
:class="['camera-preview']"
|
|
:autoplay="false"
|
|
:playsinline="true"
|
|
:facingMode="'user'"
|
|
/>
|
|
|
|
<!-- แสดงรูปเมื่อกด capture -->
|
|
<div v-if="img" class="image-container">
|
|
<q-img
|
|
:src="img"
|
|
class="image-element"
|
|
:img-style="centeredPreviewImageStyle"
|
|
></q-img>
|
|
<!-- Native capture: cameraIsOn stays false so show retake here
|
|
the refresh button here instead of inside v-if="cameraIsOn" -->
|
|
<div
|
|
v-if="useNativePhotoCapture"
|
|
class="absolute-bottom-right q-ma-md"
|
|
>
|
|
<q-btn
|
|
round
|
|
push
|
|
icon="refresh"
|
|
size="md"
|
|
color="negative"
|
|
@click="refreshPhoto"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="cameraIsOn">
|
|
<div
|
|
v-if="$q.screen.gt.xs"
|
|
class="absolute-bottom-right q-ma-md"
|
|
>
|
|
<q-btn
|
|
v-if="availableCameras.length > 1 && img == null"
|
|
round
|
|
push
|
|
icon="flip_camera_ios"
|
|
size="sm"
|
|
color="secondary"
|
|
class="q-mr-sm"
|
|
@click="switchCamera"
|
|
/>
|
|
<q-btn
|
|
v-if="img == null"
|
|
round
|
|
push
|
|
icon="photo_camera"
|
|
size="md"
|
|
color="positive"
|
|
@click="capturePhoto"
|
|
/>
|
|
<q-btn
|
|
v-else
|
|
round
|
|
push
|
|
icon="refresh"
|
|
size="md"
|
|
color="negative"
|
|
@click="refreshPhoto"
|
|
/>
|
|
</div>
|
|
<div v-else>
|
|
<div
|
|
class="absolute-bottom text-subtitle2 text-center q-py-sm"
|
|
style="background: #00000021"
|
|
>
|
|
<q-btn
|
|
v-if="availableCameras.length > 1 && img == null"
|
|
round
|
|
icon="flip_camera_ios"
|
|
size="16px"
|
|
style="background: #424242; color: white"
|
|
@click="switchCamera"
|
|
unelevated
|
|
class="q-mr-xs"
|
|
/>
|
|
<q-btn
|
|
round
|
|
v-if="img == null"
|
|
icon="photo_camera"
|
|
size="18px"
|
|
style="background: #263238; color: white"
|
|
@click="capturePhoto"
|
|
unelevated
|
|
/>
|
|
<q-btn
|
|
v-else
|
|
round
|
|
icon="refresh"
|
|
size="18px"
|
|
style="background: #263238; color: white"
|
|
@click="refreshPhoto"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</q-card>
|
|
</div>
|
|
|
|
<!-- กรอกข้อมูล หน้ามือถือ -->
|
|
<div class="col-12 row q-col-gutter-y-md" v-if="$q.screen.xs">
|
|
<!-- กล้อง หน้ามือถือ -->
|
|
<div class="col-12">
|
|
<q-card
|
|
:class="
|
|
$q.screen.xs ? 'card-container-xs' : 'card-container'
|
|
"
|
|
>
|
|
<div
|
|
v-if="!cameraIsOn && img == null"
|
|
class="preview-placeholder"
|
|
@click="() => !isDisabledCheckTime && openCamera()"
|
|
>
|
|
<div class="text-center">
|
|
<q-icon
|
|
name="mdi-camera"
|
|
color="blue-grey-3"
|
|
size="100px"
|
|
class="center-icon"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="col-12 row items-center">
|
|
<!-- แสดงกล้องตอนกดถ่ายภาพ -->
|
|
<Camera
|
|
:key="`mobile-${cameraMountKey}`"
|
|
:resolution="{
|
|
width: photoWidth,
|
|
height: photoHeight,
|
|
}"
|
|
ref="camera"
|
|
:class="['camera-preview']"
|
|
:autoplay="false"
|
|
:playsinline="true"
|
|
:facingMode="'user'"
|
|
/>
|
|
|
|
<!-- แสดงรูปเมื่อกด capture -->
|
|
<div v-if="img" class="image-container">
|
|
<q-img
|
|
:src="img"
|
|
class="image-element"
|
|
:img-style="centeredPreviewImageStyle"
|
|
></q-img>
|
|
<!-- Native capture: cameraIsOn stays false so show retake here
|
|
the refresh button here instead of inside v-if="cameraIsOn" -->
|
|
<div
|
|
v-if="useNativePhotoCapture"
|
|
class="absolute-bottom text-subtitle2 text-center q-py-sm"
|
|
style="background: #00000021"
|
|
>
|
|
<q-btn
|
|
round
|
|
icon="refresh"
|
|
size="18px"
|
|
style="background: #263238; color: white"
|
|
@click="refreshPhoto"
|
|
unelevated
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="cameraIsOn">
|
|
<div
|
|
class="absolute-bottom text-subtitle2 text-center q-py-sm"
|
|
style="background: #00000021"
|
|
>
|
|
<q-btn
|
|
v-if="availableCameras.length > 1 && img == null"
|
|
round
|
|
icon="flip_camera_ios"
|
|
size="16px"
|
|
style="background: #424242; color: white"
|
|
@click="switchCamera"
|
|
unelevated
|
|
class="q-mr-xs"
|
|
/>
|
|
<q-btn
|
|
round
|
|
v-if="img == null"
|
|
icon="photo_camera"
|
|
size="18px"
|
|
style="background: #263238; color: white"
|
|
@click="capturePhoto"
|
|
unelevated
|
|
/>
|
|
<q-btn
|
|
v-else
|
|
round
|
|
icon="refresh"
|
|
size="18px"
|
|
style="background: #263238; color: white"
|
|
@click="refreshPhoto"
|
|
unelevated
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</q-card>
|
|
</div>
|
|
|
|
<div class="col-12" v-if="!isDisabledCheckTime">
|
|
<q-card
|
|
flat
|
|
bordered
|
|
class="row col-12"
|
|
style="border-radius: 10px"
|
|
>
|
|
<q-input
|
|
v-model="remark"
|
|
filled
|
|
label="กรอกหมายเหตุ"
|
|
input-style="border-radius: 10px 10px 0 0;"
|
|
class="col-12"
|
|
bg-color="white"
|
|
/>
|
|
<div class="col-12"><q-separator /></div>
|
|
<q-btn-toggle
|
|
v-model="workplace"
|
|
@update:model-value="updateWorkplace"
|
|
spread
|
|
no-caps
|
|
toggle-color="blue-grey-10"
|
|
color="white"
|
|
text-color="black"
|
|
class="col-12"
|
|
style="min-height: 3em"
|
|
size="15px"
|
|
:options="[
|
|
{ label: 'ในสถานที่', value: 'in-place' },
|
|
{ label: 'นอกสถานที่', value: 'off-site' },
|
|
]"
|
|
/>
|
|
</q-card>
|
|
</div>
|
|
<div class="col-12" v-if="workplace == 'off-site'">
|
|
<q-card class="col-12">
|
|
<q-select
|
|
ref="modelRef"
|
|
filled
|
|
v-model="model"
|
|
:options="options"
|
|
label="เลือกสถานที่"
|
|
bg-color="white"
|
|
:rules="[(val:string) => !!val || 'กรุณาระบุสถานที่']"
|
|
lazy-rules
|
|
@update:model-value="selectLocation()"
|
|
hide-bottom-space
|
|
option-value="value"
|
|
option-label="text"
|
|
emit-value
|
|
map-options
|
|
/>
|
|
</q-card>
|
|
</div>
|
|
<div
|
|
class="col-12"
|
|
v-if="model == 'อื่นๆ' && workplace === 'off-site'"
|
|
>
|
|
<q-card class="col-12">
|
|
<q-input
|
|
ref="useLocationRef"
|
|
filled
|
|
v-model="useLocation"
|
|
label="ระบุสถานที่"
|
|
bg-color="white"
|
|
:rules="[(val:string) => !!val || 'กรุณาระบุสถานที่']"
|
|
hide-bottom-space
|
|
lazy-rules
|
|
/>
|
|
</q-card>
|
|
</div>
|
|
|
|
<!-- Map หน้ามือถือ -->
|
|
<div class="col-12">
|
|
<MapCheck
|
|
ref="mapRefMobile"
|
|
:initialPOI="formLocation.POI"
|
|
@update:location="updateLocation"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<!-- กรอกข้อมูล หน้ามือถือ -->
|
|
|
|
<div
|
|
v-if="!isDisabledCheckTime"
|
|
class="col-xs-12 col-sm-12 items-center gt-xs"
|
|
>
|
|
<q-card
|
|
bordered
|
|
flat
|
|
:class="
|
|
$q.screen.gt.xs
|
|
? 'q-px-md q-py-sm row items-center shadow-0'
|
|
: 'q-pa-md row items-center shadow-0'
|
|
"
|
|
>
|
|
<div class="text-weight-bold">สถานที่ทำงาน</div>
|
|
<div
|
|
:class="
|
|
$q.screen.gt.xs
|
|
? 'row q-gutter-md q-pl-md col-sm-6 col-md-3'
|
|
: 'column col-12'
|
|
"
|
|
>
|
|
<q-radio
|
|
v-model="workplace"
|
|
checked-icon="task_alt"
|
|
unchecked-icon="panorama_fish_eye"
|
|
val="in-place"
|
|
label="ในสถานที่"
|
|
@update:model-value="updateWorkplace"
|
|
/>
|
|
<q-radio
|
|
v-model="workplace"
|
|
checked-icon="task_alt"
|
|
unchecked-icon="panorama_fish_eye"
|
|
val="off-site"
|
|
label="นอกสถานที่"
|
|
@update:model-value="updateWorkplace"
|
|
/>
|
|
</div>
|
|
<div class="col-sm-12 col-md-8">
|
|
<div class="row items-top">
|
|
<div
|
|
class="col-xs-12 col-sm-6 col-md-6"
|
|
v-if="workplace == 'off-site'"
|
|
>
|
|
<q-select
|
|
ref="modelRef"
|
|
dense
|
|
class="q-ml-md"
|
|
outlined
|
|
v-model="model"
|
|
:options="options"
|
|
prefix="ระบุสถานที่ :"
|
|
:rules="[(val:string) => !!val || 'กรุณาระบุสถานที่']"
|
|
lazy-rules
|
|
@update:model-value="selectLocation()"
|
|
hide-bottom-space
|
|
option-value="value"
|
|
option-label="text"
|
|
emit-value
|
|
map-options
|
|
/>
|
|
</div>
|
|
<div
|
|
class="col-xs-12 col-sm-6 col-md-6"
|
|
v-if="model == 'อื่นๆ' && workplace === 'off-site'"
|
|
>
|
|
<q-input
|
|
ref="useLocationRef"
|
|
dense
|
|
class="q-ml-md"
|
|
outlined
|
|
v-model="useLocation"
|
|
label="ระบุสถานที่"
|
|
hide-bottom-space
|
|
:rules="[(val:string) => !!val || 'กรุณาระบุสถานที่']"
|
|
lazy-rules
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</q-card>
|
|
</div>
|
|
|
|
<div
|
|
v-if="!isDisabledCheckTime"
|
|
class="col-xs-12 col-sm-12 gt-xs"
|
|
>
|
|
<q-card
|
|
bordered
|
|
flat
|
|
:class="
|
|
$q.screen.gt.xs
|
|
? 'q-px-md q-py-sm row items-center shadow-0'
|
|
: 'q-pa-md row items-center shadow-0'
|
|
"
|
|
>
|
|
<div class="text-weight-bold">หมายเหตุ</div>
|
|
<div class="col-12 q-pt-sm">
|
|
<q-input outlined v-model="remark" lazy-rules dense />
|
|
</div>
|
|
</q-card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="!isDisabledCheckTime && $q.screen.gt.xs"
|
|
class="col-12 text-right"
|
|
>
|
|
<q-separator />
|
|
<div class="col-12 q-pa-md">
|
|
<!-- <p
|
|
:class="
|
|
$q.screen.gt.xs
|
|
? 'text-red text-caption '
|
|
: 'text-red text-caption text-center'
|
|
"
|
|
>
|
|
*หมายเหตุ คลิกลงเวลาเข้างานแล้วระบบจะลงเวลาทันที
|
|
</p> -->
|
|
<q-btn
|
|
:label="
|
|
!statusCheckin && inQueue
|
|
? 'ลงเวลาเข้างาน (ระบบกำลังประมวลผล)'
|
|
: statusCheckin && inQueue
|
|
? 'ลงเวลาออกงาน (ระบบกำลังประมวลผล)'
|
|
: statusCheckin && !inQueue
|
|
? 'ลงเวลาเข้างาน'
|
|
: 'ลงเวลาออกงาน'
|
|
"
|
|
:color="
|
|
img == null
|
|
? 'grey-6'
|
|
: !statusCheckin && inQueue
|
|
? 'primary'
|
|
: statusCheckin && inQueue
|
|
? 'red-8'
|
|
: statusCheckin && !inQueue
|
|
? 'primary'
|
|
: 'red-8'
|
|
"
|
|
push
|
|
size="18px"
|
|
:class="$q.screen.gt.xs ? 'q-px-md' : 'full-width q-pa-sm'"
|
|
:disable="disabledBtn ? true : camera && img ? false : true"
|
|
@click="validateForm"
|
|
:loading="inQueue"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- <div v-if="isDisabledCheckTime && $q.screen.gt.xs" class="col-12">
|
|
<q-separator />
|
|
<div class="text-red q-pa-md">*{{ msgCheckTime }}</div>
|
|
</div> -->
|
|
</div>
|
|
</q-card>
|
|
</div>
|
|
</div>
|
|
</q-page>
|
|
|
|
<!-- top page sticky หน้ามือถือ-->
|
|
<q-page-sticky expand position="top" v-if="$q.screen.xs">
|
|
<div
|
|
class="row q-col-gutter-sm col bg-white col-12 q-pa-md"
|
|
style="height: 100px"
|
|
v-if="isLoadingCheckTime"
|
|
>
|
|
<div class="col-7">
|
|
<div class="row col-12">
|
|
<q-skeleton type="text" width="90%" />
|
|
<q-skeleton type="text" width="50%" class="text-subtitle1" />
|
|
</div>
|
|
</div>
|
|
<div class="col-5">
|
|
<div class="row col-12">
|
|
<q-skeleton type="QBtn" width="100%" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else :class="getClassXS(statusCheckin)">
|
|
<div class="col">
|
|
<div class="row col-12 justify-right items-center text-subtitle1">
|
|
<strong v-if="!statusCheckin && inQueue">
|
|
ลงเวลาออกงาน (ระบบกำลังประมวลผล)
|
|
</strong>
|
|
|
|
<strong v-else-if="statusCheckin && inQueue">
|
|
ลงเวลาเข้างาน (ระบบกำลังประมวลผล)
|
|
</strong>
|
|
|
|
<strong v-else-if="statusCheckin && !inQueue"> ลงเวลาเข้างาน </strong>
|
|
|
|
<strong v-else> ลงเวลาออกงาน </strong>
|
|
</div>
|
|
<div
|
|
class="text-white text-subtitle1 row col-12"
|
|
style="line-height: 1.5rem"
|
|
>
|
|
{{ date2Thai(Thai) }}
|
|
</div>
|
|
</div>
|
|
<div class="col-6 row items-center">
|
|
<div class="row col-12 justify-end">
|
|
<div class="colunm">
|
|
<div class="text-h4 text-weight-bold">
|
|
{{ formattedH }}<span class="q-ma-xs">:</span>
|
|
</div>
|
|
</div>
|
|
<div class="colunm">
|
|
<div class="text-h4 text-weight-bold">
|
|
{{ formattedM }}<span class="q-ma-xs">:</span>
|
|
</div>
|
|
</div>
|
|
<div class="colunm">
|
|
<div class="text-h4 text-weight-bold">{{ formattedS }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</q-page-sticky>
|
|
|
|
<!-- footer หน้ามือถือ-->
|
|
<q-footer reveal v-if="$q.screen.xs" class="bg-grey-2">
|
|
<q-separator />
|
|
<div v-if="!isDisabledCheckTime" class="col-12 q-pa-md">
|
|
<!-- <p
|
|
:class="
|
|
$q.screen.gt.xs
|
|
? 'text-red text-caption '
|
|
: 'text-red text-caption text-center'
|
|
"
|
|
>
|
|
*หมายเหตุ คลิกลงเวลาเข้างานแล้วระบบจะลงเวลาทันที
|
|
</p> -->
|
|
<q-btn
|
|
:label="
|
|
!statusCheckin && inQueue
|
|
? 'ลงเวลาเข้างาน (ระบบกำลังประมวลผล)'
|
|
: statusCheckin && inQueue
|
|
? 'ลงเวลาออกงาน (ระบบกำลังประมวลผล)'
|
|
: statusCheckin && !inQueue
|
|
? 'ลงเวลาเข้างาน'
|
|
: 'ลงเวลาออกงาน'
|
|
"
|
|
:color="
|
|
img == null
|
|
? 'grey-6'
|
|
: !statusCheckin && inQueue
|
|
? 'primary'
|
|
: statusCheckin && inQueue
|
|
? 'red-8'
|
|
: statusCheckin && !inQueue
|
|
? 'primary'
|
|
: 'red-8'
|
|
"
|
|
push
|
|
size="18px"
|
|
:class="$q.screen.gt.xs ? 'q-px-md' : 'full-width q-pa-sm'"
|
|
:disable="disabledBtn ? true : camera && img ? false : true"
|
|
@click="validateForm"
|
|
:loading="inQueue"
|
|
/>
|
|
</div>
|
|
<!-- <div v-if="isDisabledCheckTime" class="col-12">
|
|
<q-separator />
|
|
<div class="text-red q-pa-md text-left">*{{ msgCheckTime }}</div>
|
|
</div> -->
|
|
</q-footer>
|
|
|
|
<!-- แสดงการลงเวลา -->
|
|
<q-dialog v-model="modalTime" persistent>
|
|
<q-card class="full-width cardNone">
|
|
<div class="col-12 row items-center q-px-md q-py-sm bg-grey-2">
|
|
<div class="text-body1 text-center col-12 text-weight-bold">
|
|
<span v-if="statusCheckin">ลงเวลาเข้างานของคุณ</span>
|
|
<span v-else>ลงเวลาออกงานของคุณ</span>
|
|
</div>
|
|
</div>
|
|
<q-card-section class="row col-12 justify-center">
|
|
<div
|
|
class="rounded-borders q-pa-md col-11"
|
|
:class="
|
|
!statusCheckin && inQueue
|
|
? 'bg-primary'
|
|
: statusCheckin && inQueue
|
|
? 'bg-red-8'
|
|
: statusCheckin && !inQueue
|
|
? 'bg-primary'
|
|
: 'bg-red-8'
|
|
"
|
|
>
|
|
<div
|
|
class="col-12 text-subtitle1 text-center text-white text-weight-medium"
|
|
>
|
|
{{ date2Thai(displayedCheckDate) }}
|
|
</div>
|
|
<div class="row col-12 justify-center q-pt-sm">
|
|
<!-- <div class="text-h3 text-white text-weight-bold"></div> -->
|
|
<div class="text-h3 text-white text-weight-bold">
|
|
{{ timeChickin }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-12 text-center row q-pt-md">
|
|
<div class="col-12 text-subtitle1 text-weight-medium text-secondary">
|
|
พื้นที่ใกล้เคียง {{ formLocation.POI }}
|
|
</div>
|
|
<div class="col-12 text-subtitle1 text-weight-medium text-secondary">
|
|
{{ location }}
|
|
</div>
|
|
<div class="col-12 text-grey-7">
|
|
{{ formLocation.lat }} , {{ formLocation.lng }}
|
|
</div>
|
|
</div>
|
|
</q-card-section>
|
|
|
|
<q-card-actions align="center" class="q-mb-md row">
|
|
<q-btn
|
|
class="col-xs-11 col-sm-6"
|
|
push
|
|
label="ตกลง"
|
|
color="secondary"
|
|
v-close-popup
|
|
@click="onClickConfirm"
|
|
/>
|
|
</q-card-actions>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
<!--
|
|
Native still-image fallback: hidden file input.
|
|
Activated only after inline camera is proven unsupported or fails — on any
|
|
platform including iOS. Never used as the first-choice path.
|
|
-->
|
|
<input
|
|
ref="nativePhotoInput"
|
|
type="file"
|
|
accept="image/*"
|
|
capture="user"
|
|
style="display: none"
|
|
@change="onNativePhotoSelected"
|
|
/>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.q-card.cardImg:hover {
|
|
border: 1px solid #02a998 !important;
|
|
}
|
|
|
|
.center-icon {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
}
|
|
.card-container {
|
|
position: relative;
|
|
overflow: hidden;
|
|
height: auto; /* Adjust as needed */
|
|
background: #f5f5f5;
|
|
height: 35vh !important;
|
|
box-shadow: none !important;
|
|
border: 1px solid #ededed;
|
|
}
|
|
.card-container-xs {
|
|
position: relative;
|
|
overflow: hidden;
|
|
height: auto; /* Adjust as needed */
|
|
background: #ffffff;
|
|
height: 35vh !important;
|
|
border-radius: 10px;
|
|
}
|
|
.image-container {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.image-element {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
border-radius: 8px; /* Adjust as needed */
|
|
}
|
|
|
|
.preview-placeholder {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
/* Keep the live camera preview visually aligned with the normalized saved photo. */
|
|
.camera-preview :deep(video) {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
-webkit-object-fit: cover;
|
|
}
|
|
|
|
.bg-topOut {
|
|
background: rgb(39, 50, 56);
|
|
background: linear-gradient(
|
|
175deg,
|
|
rgba(39, 50, 56, 1) 76%,
|
|
rgba(198, 40, 40, 1) 100%
|
|
);
|
|
}
|
|
|
|
.bg-topIn {
|
|
background: rgb(39, 50, 56);
|
|
background: linear-gradient(
|
|
175deg,
|
|
rgba(39, 50, 56, 1) 76%,
|
|
rgba(2, 169, 152, 1) 100%
|
|
);
|
|
}
|
|
|
|
.icon-bounce {
|
|
animation: bounce 2s infinite;
|
|
}
|
|
|
|
.warning-banner {
|
|
border: 1px solid #ffcc80;
|
|
border-left: 4px solid #fb8c00;
|
|
}
|
|
|
|
@keyframes bounce {
|
|
0%,
|
|
100% {
|
|
transform: translateY(0);
|
|
}
|
|
50% {
|
|
transform: translateY(-10px);
|
|
}
|
|
}
|
|
</style>
|