store/resources/js/components/admin/hr/attendances/AttendanceWebcamModal.vue
2026-06-14 13:55:56 +07:00

246 lines
6.9 KiB
Vue

<script setup lang="ts">
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';
import { useForm } from '@inertiajs/vue3';
import { Camera, Loader2 } from '@lucide/vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
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-sm text-muted-foreground">
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="size-8 animate-spin text-muted-foreground"
/>
</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-xs whitespace-pre-line text-muted-foreground"
>
{{ form.location_tag }}
</p>
</div>
</div>
<DialogFooter>
<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>