225 lines
6.7 KiB
Vue
225 lines
6.7 KiB
Vue
<script setup lang="ts">
|
|
import { useForm } from '@inertiajs/vue3';
|
|
import { Camera, Loader2 } from '@lucide/vue';
|
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
|
import { toast } from 'vue-sonner';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { useGeolocation } from '@/composables/useGeolocation';
|
|
import { useWebcamCapture } from '@/composables/useWebcamCapture';
|
|
import type { AttendanceCaptureFormData } from '@/types/attendance';
|
|
|
|
const open = defineModel<boolean>('open', { default: false });
|
|
|
|
const props = defineProps<{
|
|
mode: 'check-in' | 'check-out';
|
|
}>();
|
|
|
|
const videoRef = ref<HTMLVideoElement | null>(null);
|
|
const cameraReady = ref(false);
|
|
const cameraLoading = ref(false);
|
|
const capturing = ref(false);
|
|
const previewPhoto = ref<string | null>(null);
|
|
|
|
const { getCurrentPosition, buildLocationTag } = useGeolocation();
|
|
const { startCamera, stopCamera, captureWithLocationTag } = useWebcamCapture();
|
|
|
|
const form = useForm<AttendanceCaptureFormData>({
|
|
photo: '',
|
|
latitude: 0,
|
|
longitude: 0,
|
|
location_tag: '',
|
|
});
|
|
|
|
const title = computed(() => (props.mode === 'check-in' ? 'Presensi Masuk' : 'Presensi Pulang'));
|
|
const submitLabel = computed(() => (props.mode === 'check-in' ? 'Konfirmasi Masuk' : 'Konfirmasi Pulang'));
|
|
const submitUrl = computed(() => (
|
|
props.mode === 'check-in'
|
|
? '/admin/hr/attendances/check-in'
|
|
: '/admin/hr/attendances/check-out'
|
|
));
|
|
|
|
async function initializeCamera() {
|
|
if (!videoRef.value) {
|
|
return;
|
|
}
|
|
|
|
cameraLoading.value = true;
|
|
cameraReady.value = false;
|
|
previewPhoto.value = null;
|
|
|
|
try {
|
|
await startCamera(videoRef.value);
|
|
cameraReady.value = true;
|
|
} catch {
|
|
toast.error('Gagal mengakses kamera. Pastikan izin kamera diaktifkan.');
|
|
open.value = false;
|
|
} finally {
|
|
cameraLoading.value = false;
|
|
}
|
|
}
|
|
|
|
function resetState() {
|
|
form.reset();
|
|
form.clearErrors();
|
|
previewPhoto.value = null;
|
|
cameraReady.value = false;
|
|
capturing.value = false;
|
|
stopCamera();
|
|
}
|
|
|
|
async function capturePhoto() {
|
|
if (!videoRef.value || !cameraReady.value) {
|
|
return;
|
|
}
|
|
|
|
capturing.value = true;
|
|
|
|
try {
|
|
const position = await getCurrentPosition();
|
|
const locationTag = buildLocationTag(position.latitude, position.longitude);
|
|
const photo = captureWithLocationTag(videoRef.value, locationTag);
|
|
|
|
stopCamera();
|
|
cameraReady.value = false;
|
|
|
|
previewPhoto.value = photo;
|
|
form.photo = photo;
|
|
form.latitude = position.latitude;
|
|
form.longitude = position.longitude;
|
|
form.location_tag = locationTag;
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : 'Gagal mengambil foto presensi.');
|
|
} finally {
|
|
capturing.value = false;
|
|
}
|
|
}
|
|
|
|
async function retakePhoto() {
|
|
previewPhoto.value = null;
|
|
form.photo = '';
|
|
form.latitude = 0;
|
|
form.longitude = 0;
|
|
form.location_tag = '';
|
|
form.clearErrors();
|
|
|
|
await nextTick();
|
|
await initializeCamera();
|
|
}
|
|
|
|
function submitAttendance() {
|
|
form.post(submitUrl.value, {
|
|
preserveScroll: true,
|
|
onSuccess: () => {
|
|
open.value = false;
|
|
},
|
|
onError: () => {
|
|
toast.error('Gagal menyimpan presensi.');
|
|
},
|
|
});
|
|
}
|
|
|
|
watch(open, async (isOpen) => {
|
|
if (isOpen) {
|
|
resetState();
|
|
await nextTick();
|
|
await initializeCamera();
|
|
return;
|
|
}
|
|
|
|
resetState();
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
stopCamera();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<Dialog v-model:open="open">
|
|
<DialogContent class="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>{{ title }}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div class="space-y-4">
|
|
<p v-if="!previewPhoto" class="text-muted-foreground text-sm">
|
|
Ambil foto langsung dari kamera. Upload dari galeri tidak diizinkan.
|
|
</p>
|
|
|
|
<div v-if="!previewPhoto" class="space-y-2">
|
|
<p class="text-sm font-medium">
|
|
Kamera
|
|
</p>
|
|
<div class="relative aspect-[4/3] overflow-hidden rounded-lg border bg-muted">
|
|
<video
|
|
ref="videoRef"
|
|
class="size-full scale-x-[-1] object-cover"
|
|
autoplay
|
|
muted
|
|
playsinline
|
|
/>
|
|
|
|
<div
|
|
v-if="cameraLoading"
|
|
class="absolute inset-0 flex items-center justify-center bg-background/80"
|
|
>
|
|
<Loader2 class="text-muted-foreground size-8 animate-spin" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="space-y-2">
|
|
<p class="text-sm font-medium">
|
|
Hasil Foto
|
|
</p>
|
|
<div class="relative aspect-[4/3] overflow-hidden rounded-lg border bg-muted">
|
|
<img
|
|
:src="previewPhoto"
|
|
alt="Hasil foto presensi"
|
|
class="size-full object-cover"
|
|
/>
|
|
</div>
|
|
|
|
<p class="text-muted-foreground text-xs whitespace-pre-line">
|
|
{{ form.location_tag }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter class="gap-2 sm:gap-0">
|
|
<Button v-if="previewPhoto" type="button" variant="outline" @click="retakePhoto">
|
|
Ambil Ulang
|
|
</Button>
|
|
|
|
<Button
|
|
v-if="!previewPhoto"
|
|
type="button"
|
|
:disabled="!cameraReady || capturing"
|
|
@click="capturePhoto"
|
|
>
|
|
<Loader2 v-if="capturing" class="size-4 animate-spin" />
|
|
<Camera v-else class="size-4" />
|
|
Ambil Foto
|
|
</Button>
|
|
|
|
<Button
|
|
v-else
|
|
type="button"
|
|
:disabled="form.processing"
|
|
@click="submitAttendance"
|
|
>
|
|
<Loader2 v-if="form.processing" class="size-4 animate-spin" />
|
|
{{ submitLabel }}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</template>
|