refactor: update AttendanceController and related components to use new CheckInRequest and CheckOutRequest classes, streamline attendance handling by removing location tags, and enhance UI components for better user experience

This commit is contained in:
Yoga Pangestu 2026-06-21 01:35:30 +07:00
parent b7fddf0597
commit 0cb93c4e05
19 changed files with 136 additions and 250 deletions

View File

@ -3,10 +3,11 @@
namespace App\Http\Controllers\Admin\Hr;
use App\Enums\Permission;
use App\Enums\Role;
use App\Http\Controllers\Concerns\FlashesEntityMessage;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Hr\AttendanceCheckInRequest;
use App\Http\Requests\Admin\Hr\AttendanceCheckOutRequest;
use App\Http\Requests\Admin\Hr\Attendance\CheckInRequest;
use App\Http\Requests\Admin\Hr\Attendance\CheckOutRequest;
use App\Models\Attendance;
use App\Services\Hr\AttendanceService;
use Illuminate\Http\RedirectResponse;
@ -26,7 +27,7 @@ public function index(Request $request): Response
{
$user = $request->user();
$employee = $user?->employee;
$isManager = $user?->hasAnyRole(['owner', 'developer', 'direktur']) ?? false;
$isManager = $user?->hasAnyRole([Role::OWNER->value, Role::DEVELOPER->value, Role::DIREKTUR->value]) ?? false;
$scopedEmployeeId = $isManager ? null : $employee?->id;
$hasScopedAccess = $isManager || $employee !== null;
@ -57,7 +58,7 @@ public function index(Request $request): Response
]);
}
public function checkIn(AttendanceCheckInRequest $request): RedirectResponse
public function checkIn(CheckInRequest $request): RedirectResponse
{
$this->attendanceService->checkIn($request->validated(), $request->user());
@ -66,7 +67,7 @@ public function checkIn(AttendanceCheckInRequest $request): RedirectResponse
return redirect()->route('admin.hr.attendances.index');
}
public function checkOut(AttendanceCheckOutRequest $request): RedirectResponse
public function checkOut(CheckOutRequest $request): RedirectResponse
{
$this->attendanceService->checkOut($request->validated(), $request->user());
@ -77,16 +78,9 @@ public function checkOut(AttendanceCheckOutRequest $request): RedirectResponse
public function destroy(Attendance $attendance): RedirectResponse
{
$user = auth()->user();
if ($user && ! $user->hasAnyRole(['owner', 'developer', 'direktur'])) {
if ($attendance->employee_id !== $user->employee?->id) {
abort(403, 'Anda tidak memiliki akses untuk menghapus data presensi ini.');
}
}
$this->attendanceService->delete($attendance);
$this->flashDeleted('Data presensi');
$this->flashDeleted('Presensi');
return redirect()->route('admin.hr.attendances.index');
}

View File

@ -1,11 +1,11 @@
<?php
namespace App\Http\Requests\Admin\Hr;
namespace App\Http\Requests\Admin\Hr\Attendance;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
class AttendanceCheckInRequest extends FormRequest
class CheckInRequest extends FormRequest
{
public function authorize(): bool
{
@ -21,7 +21,6 @@ public function rules(): array
'photo' => ['required', 'string'],
'latitude' => ['required', 'numeric', 'between:-90,90'],
'longitude' => ['required', 'numeric', 'between:-180,180'],
'location_tag' => ['required', 'string', 'max:255'],
];
}
@ -34,7 +33,6 @@ public function attributes(): array
'photo' => 'foto',
'latitude' => 'latitude',
'longitude' => 'longitude',
'location_tag' => 'lokasi',
];
}
}

View File

@ -1,11 +1,11 @@
<?php
namespace App\Http\Requests\Admin\Hr;
namespace App\Http\Requests\Admin\Hr\Attendance;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
class AttendanceCheckOutRequest extends FormRequest
class CheckOutRequest extends FormRequest
{
public function authorize(): bool
{
@ -21,7 +21,6 @@ public function rules(): array
'photo' => ['required', 'string'],
'latitude' => ['required', 'numeric', 'between:-90,90'],
'longitude' => ['required', 'numeric', 'between:-180,180'],
'location_tag' => ['required', 'string', 'max:255'],
];
}
@ -34,7 +33,6 @@ public function attributes(): array
'photo' => 'foto',
'latitude' => 'latitude',
'longitude' => 'longitude',
'location_tag' => 'lokasi',
];
}
}

View File

@ -13,7 +13,6 @@
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
class AttendanceService
@ -71,7 +70,7 @@ public function isOnLeaveToday(Employee $employee): bool
}
/**
* @param array{photo: string, latitude: float, longitude: float, location_tag: string} $validated
* @param array{photo: string, latitude: float, longitude: float} $validated
*/
public function checkIn(array $validated, User $user): void
{
@ -88,19 +87,12 @@ public function checkIn(array $validated, User $user): void
]);
}
$locationTag = $this->resolveLocationTag(
$validated['location_tag'],
(float) $validated['latitude'],
(float) $validated['longitude'],
);
$attendance = Attendance::create([
'employee_id' => $employee->id,
'attendance_date' => today(),
'check_in_at' => now(),
'check_in_latitude' => $validated['latitude'],
'check_in_longitude' => $validated['longitude'],
'check_in_location_tag' => $locationTag,
]);
$this->mediaService->addBase64Image(
@ -112,14 +104,14 @@ public function checkIn(array $validated, User $user): void
$this->pushNotificationService->sendToRoles(
'⏰ Presensi Masuk',
"Karyawan {$user->profile?->full_name} melakukan presensi masuk di {$locationTag}.",
"Karyawan {$user->profile?->full_name} melakukan presensi masuk.",
['owner', 'developer'],
'/admin/hr/attendances',
);
}
/**
* @param array{photo: string, latitude: float, longitude: float, location_tag: string} $validated
* @param array{photo: string, latitude: float, longitude: float} $validated
*/
public function checkOut(array $validated, User $user): void
{
@ -145,16 +137,9 @@ public function checkOut(array $validated, User $user): void
$checkOutAt = now();
$workDurationMinutes = (int) $attendance->check_in_at->diffInMinutes($checkOutAt);
$locationTag = $this->resolveLocationTag(
$validated['location_tag'],
(float) $validated['latitude'],
(float) $validated['longitude'],
);
$attendance->check_out_at = $checkOutAt;
$attendance->check_out_latitude = $validated['latitude'];
$attendance->check_out_longitude = $validated['longitude'];
$attendance->check_out_location_tag = $locationTag;
$attendance->work_duration_minutes = $workDurationMinutes;
$attendance->save();
@ -167,7 +152,7 @@ public function checkOut(array $validated, User $user): void
$this->pushNotificationService->sendToRoles(
'⏰ Presensi Pulang',
"Karyawan {$user->profile?->full_name} melakukan presensi pulang di {$locationTag} (Durasi kerja: ".round($workDurationMinutes / 60, 1).' jam).',
"Karyawan {$user->profile?->full_name} melakukan presensi pulang (Durasi kerja: ".round($workDurationMinutes / 60, 1).' jam).',
['owner', 'developer'],
'/admin/hr/attendances',
);
@ -179,40 +164,4 @@ public function delete(Attendance $attendance): void
$attendance->clearMediaCollection('checkout');
$attendance->delete();
}
private function resolveLocationTag(string $clientTag, float $latitude, float $longitude): string
{
if ($clientTag !== '') {
return mb_substr($clientTag, 0, 255);
}
$geocoded = $this->reverseGeocode($latitude, $longitude);
if ($geocoded !== null) {
return mb_substr($geocoded, 0, 255);
}
return mb_substr(sprintf('%s, %s', $latitude, $longitude), 0, 255);
}
private function reverseGeocode(float $latitude, float $longitude): ?string
{
try {
$response = Http::timeout(5)
->withHeaders(['User-Agent' => config('app.name', 'DST Collection')])
->get('https://nominatim.openstreetmap.org/reverse', [
'lat' => $latitude,
'lon' => $longitude,
'format' => 'json',
]);
if (! $response->successful()) {
return null;
}
return $response->json('display_name');
} catch (\Throwable) {
return null;
}
}
}

View File

@ -25,8 +25,6 @@ public function definition(): array
'check_in_longitude' => fake()->longitude(106, 107),
'check_out_latitude' => fake()->latitude(-7, -6),
'check_out_longitude' => fake()->longitude(106, 107),
'check_in_location_tag' => fake()->optional()->city(),
'check_out_location_tag' => fake()->optional()->city(),
'work_duration_minutes' => fake()->numberBetween(240, 540),
];
}
@ -38,7 +36,6 @@ public function checkedInOnly(): static
'check_out_photo_path' => null,
'check_out_latitude' => null,
'check_out_longitude' => null,
'check_out_location_tag' => null,
'work_duration_minutes' => null,
]);
}

View File

@ -20,8 +20,6 @@ public function up(): void
$table->decimal('check_in_longitude', 10, 7)->nullable();
$table->decimal('check_out_latitude', 10, 7)->nullable();
$table->decimal('check_out_longitude', 10, 7)->nullable();
$table->string('check_in_location_tag', 255)->nullable();
$table->string('check_out_location_tag', 255)->nullable();
$table->unsignedInteger('work_duration_minutes')->nullable();
$table->timestamp('created_at')->useCurrent();

View File

@ -1,78 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
checkInLat: number | null;
checkInLng: number | null;
checkOutLat: number | null;
checkOutLng: number | null;
checkInLocationTag: string | null;
checkOutLocationTag: string | null;
checkInAtFormatted: string;
checkOutAtFormatted: string | null;
attendanceDateFormatted: string;
employeeName: string | null;
}>();
const hasCoordinates = computed(() => props.checkInLat != null && props.checkInLng != null);
const checkInSrc = computed(() => {
if (props.checkInLat == null || props.checkInLng == null) {
return '';
}
return `https://www.google.com/maps?q=${props.checkInLat},${props.checkInLng}&z=18&t=k&output=embed`;
});
const checkOutSrc = computed(() => {
if (props.checkOutLat == null || props.checkOutLng == null) {
return '';
}
return `https://www.google.com/maps?q=${props.checkOutLat},${props.checkOutLng}&z=18&t=k&output=embed`;
});
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function buildLabel(
title: string,
color: string,
locationTag: string | null,
): string {
const lines: string[] = [];
lines.push(`<strong style="color:${color}">${title}</strong>`);
lines.push(`<span>Lokasi: ${escapeHtml(locationTag ?? '-')}</span>`);
return lines.join('<br>');
}
</script>
<template>
<div v-if="hasCoordinates" class="space-y-3">
<p class="text-muted-foreground text-sm">
Lokasi Presensi
</p>
<!-- Check-in map -->
<div class="space-y-1.5">
<div class="text-xs font-medium" v-html="buildLabel('Masuk', '#16a34a', checkInLocationTag)" />
<iframe :src="checkInSrc" class="h-56 w-full overflow-hidden rounded-lg" style="border: 0" allowfullscreen
loading="lazy" referrerpolicy="no-referrer-when-downgrade" />
</div>
<!-- Check-out map -->
<div v-if="checkOutLat != null && checkOutLng != null" class="space-y-1.5">
<div class="text-xs font-medium" v-html="buildLabel('Pulang', '#dc2626', checkOutLocationTag)" />
<iframe :src="checkOutSrc" class="h-56 w-full overflow-hidden rounded-lg" style="border: 0" allowfullscreen
loading="lazy" referrerpolicy="no-referrer-when-downgrade" />
</div>
</div>
</template>

View File

@ -37,17 +37,7 @@ export function useGeolocation() {
});
}
function buildLocationTag(latitude: number, longitude: number): string {
const timestamp = new Intl.DateTimeFormat('id-ID', {
dateStyle: 'medium',
timeStyle: 'medium',
}).format(new Date());
return `${latitude.toFixed(6)}, ${longitude.toFixed(6)}\n${timestamp}`;
}
return {
getCurrentPosition,
buildLocationTag,
};
}

View File

@ -20,7 +20,7 @@ export function useWebcamCapture() {
stream = null;
}
function captureWithLocationTag(videoElement: HTMLVideoElement, locationTag: string): string {
function capture(videoElement: HTMLVideoElement): string {
const canvas = document.createElement('canvas');
canvas.width = videoElement.videoWidth;
canvas.height = videoElement.videoHeight;
@ -37,32 +37,12 @@ export function useWebcamCapture() {
context.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
context.restore();
const lines = locationTag.split('\n').filter((line) => line.trim() !== '');
const fontSize = Math.max(14, Math.round(canvas.height * 0.028));
const padding = 12;
const lineHeight = fontSize + 8;
const barHeight = lines.length * lineHeight + padding * 2;
context.fillStyle = 'rgba(0, 0, 0, 0.7)';
context.fillRect(0, canvas.height - barHeight, canvas.width, barHeight);
context.fillStyle = '#ffffff';
context.font = `600 ${fontSize}px system-ui, sans-serif`;
lines.forEach((line, index) => {
context.fillText(
line,
padding,
canvas.height - barHeight + padding + fontSize + index * lineHeight,
);
});
return canvas.toDataURL('image/jpeg', 0.85);
}
return {
startCamera,
stopCamera,
captureWithLocationTag,
capture,
};
}

View File

@ -3,7 +3,6 @@ export const FIELD_LIMITS = {
description: 100,
email: 100,
fullName: 200,
locationTag: 255,
name: 200,
notes: 100,
phoneNumber: 20,

View File

@ -2,13 +2,13 @@
import { Head } from '@inertiajs/vue3';
import { Clock, LogIn, LogOut } from '@lucide/vue';
import { computed, ref } from 'vue';
import AttendanceCalendar from '@/components/admin/hr/attendances/AttendanceCalendar.vue';
import AttendanceWebcamModal from '@/components/admin/hr/attendances/AttendanceWebcamModal.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { AttendanceListItem, CalendarRange, TodayAttendance } from '@/types/attendance';
import AttendanceWebcamModal from './form/AttendanceWebcamModal.vue';
import AttendanceCalendar from './table/AttendanceCalendar.vue';
const props = defineProps<{
attendances: AttendanceListItem[];
@ -134,15 +134,6 @@ function openCheckOutModal() {
</Card>
<Card class="min-w-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Riwayat Presensi</CardTitle>
<CardDescription v-if="canManageAll">
Kalender presensi seluruh pegawai. Klik entri untuk melihat detail.
</CardDescription>
<CardDescription v-else>
Kalender riwayat presensi Anda. Klik entri untuk melihat detail.
</CardDescription>
</CardHeader>
<CardContent class="min-w-0">
<AttendanceCalendar :attendances="attendances" :calendar-range="calendarRange"
:can-manage-all="canManageAll" />

View File

@ -27,14 +27,13 @@ const cameraLoading = ref(false);
const capturing = ref(false);
const previewPhoto = ref<string | null>(null);
const { getCurrentPosition, buildLocationTag } = useGeolocation();
const { startCamera, stopCamera, captureWithLocationTag } = useWebcamCapture();
const { getCurrentPosition } = useGeolocation();
const { startCamera, stopCamera, capture } = useWebcamCapture();
const form = useForm<AttendanceCaptureFormData>({
photo: '',
latitude: 0,
longitude: 0,
location_tag: '',
});
const title = computed(() =>
@ -87,11 +86,7 @@ async function capturePhoto() {
try {
const position = await getCurrentPosition();
const locationTag = buildLocationTag(
position.latitude,
position.longitude,
);
const photo = captureWithLocationTag(videoRef.value, locationTag);
const photo = capture(videoRef.value);
stopCamera();
cameraReady.value = false;
@ -100,7 +95,6 @@ async function capturePhoto() {
form.photo = photo;
form.latitude = position.latitude;
form.longitude = position.longitude;
form.location_tag = locationTag;
} catch (error) {
toast.error(
error instanceof Error
@ -117,7 +111,6 @@ async function retakePhoto() {
form.photo = '';
form.latitude = 0;
form.longitude = 0;
form.location_tag = '';
form.clearErrors();
await nextTick();
@ -197,12 +190,6 @@ onBeforeUnmount(() => {
>
<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>

View File

@ -7,7 +7,7 @@ import FullCalendar from '@fullcalendar/vue3';
import { router } from '@inertiajs/vue3';
import { ChevronLeft, ChevronRight } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import AttendanceDetailDialog from '@/components/admin/hr/attendances/AttendanceDetailDialog.vue';
import AttendanceDetailDialog from './AttendanceDetailDialog.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ui/date-picker';
@ -142,6 +142,22 @@ function handleEventClick(arg: EventClickArg) {
detailOpen.value = true;
}
function scrollToToday() {
requestAnimationFrame(() => {
const container = calendarRef.value?.$el?.closest('.overflow-x-auto');
const todayEl = container?.querySelector('.fc-day-today');
if (!container || !todayEl) {
return;
}
const containerRect = container.getBoundingClientRect();
const todayRect = todayEl.getBoundingClientRect();
const offset = todayRect.left - containerRect.left - (containerRect.width / 2) + (todayRect.width / 2);
container.scrollBy({ left: offset, behavior: 'smooth' });
});
}
function handleDatesSet(arg: DatesSetArg) {
calendarTitle.value = arg.view.title;
@ -149,6 +165,8 @@ function handleDatesSet(arg: DatesSetArg) {
pickerDate.value = toISODate(arg.view.currentStart);
isSyncingPicker.value = false;
scrollToToday();
if (isNavigating.value) {
return;
}
@ -182,6 +200,7 @@ function navigateNext() {
function navigateToday() {
calendarRef.value?.getApi().today();
scrollToToday();
}
watch(pickerDate, (date) => {
@ -229,7 +248,7 @@ watch(
</Button>
</div>
<DatePicker v-model="pickerDate" class="w-auto min-w-[13rem] sm:min-w-[15rem]"
<DatePicker v-model="pickerDate" class="w-full sm:w-auto sm:min-w-[15rem]"
placeholder="Pilih bulan" />
</div>
@ -249,10 +268,12 @@ watch(
</div>
<div :class="cn(
'overflow-hidden rounded-xl border bg-background shadow-xs transition-opacity',
'overflow-x-auto rounded-xl border bg-background shadow-xs transition-opacity',
isNavigating && 'pointer-events-none opacity-60',
)">
<FullCalendar ref="calendarRef" :options="calendarOptions" />
<div class="min-w-[640px]">
<FullCalendar ref="calendarRef" :options="calendarOptions" />
</div>
</div>
</div>
@ -483,13 +504,38 @@ watch(
@media (max-width: 640px) {
.attendance-calendar .fc .fc-daygrid-day-frame {
min-height: 5rem;
min-height: 4rem;
padding: 0.25rem;
}
.attendance-calendar .fc .fc-col-header-cell {
padding: 0.375rem 0;
}
.attendance-calendar .fc .fc-col-header-cell-cushion {
font-size: 0.625rem;
}
.attendance-calendar .fc .fc-daygrid-day-number {
font-size: 0.6875rem;
padding: 0.125rem 0.25rem;
}
.attendance-calendar .fc .attendance-event {
padding: 0.125rem 0.25rem;
}
.attendance-calendar .fc .attendance-event__name,
.attendance-calendar .fc .attendance-event__time,
.attendance-calendar .fc .attendance-event__time {
font-size: 0.5625rem;
}
.attendance-calendar .fc .attendance-event__duration {
font-size: 0.625rem;
display: none;
}
.attendance-calendar .fc .fc-daygrid-more-link {
font-size: 0.5625rem;
}
}
</style>

View File

@ -3,8 +3,8 @@ import { router } from '@inertiajs/vue3';
import { Trash2 } from '@lucide/vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import AttendanceMap from '@/components/admin/hr/attendances/AttendanceMap.vue';
import AttendancePhotoCell from '@/components/admin/hr/attendances/attendance-photo-cell.vue';
import AttendanceMap from './AttendanceMap.vue';
import AttendancePhotoCell from './attendance-photo-cell.vue';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
import {
@ -99,12 +99,6 @@ function destroyAttendance() {
:check-in-lng="attendance.check_in_longitude"
:check-out-lat="attendance.check_out_latitude"
:check-out-lng="attendance.check_out_longitude"
:check-in-location-tag="attendance.check_in_location_tag"
:check-out-location-tag="attendance.check_out_location_tag"
:check-in-at-formatted="attendance.check_in_at_formatted"
:check-out-at-formatted="attendance.check_out_at_formatted"
:attendance-date-formatted="attendance.attendance_date_formatted"
:employee-name="attendance.employee_name"
/>
<div>
@ -114,8 +108,6 @@ function destroyAttendance() {
<AttendancePhotoCell
:check-in-photo-url="attendance.check_in_photo_url"
:check-out-photo-url="attendance.check_out_photo_url"
:check-in-location-tag="attendance.check_in_location_tag"
:check-out-location-tag="attendance.check_out_location_tag"
/>
</div>

View File

@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
checkInLat: number | null;
checkInLng: number | null;
checkOutLat: number | null;
checkOutLng: number | null;
}>();
const hasCoordinates = computed(() => props.checkInLat != null && props.checkInLng != null);
const checkInSrc = computed(() => {
if (props.checkInLat == null || props.checkInLng == null) {
return '';
}
return `https://www.google.com/maps?q=${props.checkInLat},${props.checkInLng}&z=18&t=k&output=embed`;
});
const checkOutSrc = computed(() => {
if (props.checkOutLat == null || props.checkOutLng == null) {
return '';
}
return `https://www.google.com/maps?q=${props.checkOutLat},${props.checkOutLng}&z=18&t=k&output=embed`;
});
</script>
<template>
<div v-if="hasCoordinates" class="space-y-3">
<p class="text-muted-foreground text-sm">
Lokasi Presensi
</p>
<!-- Check-in map -->
<div class="space-y-1.5">
<p class="text-xs font-semibold text-emerald-600">Masuk</p>
<div class="relative w-full overflow-hidden rounded-lg" style="padding-top: 56.25%">
<iframe :src="checkInSrc" class="absolute inset-0 h-full w-full" style="border: 0" allowfullscreen
loading="lazy" referrerpolicy="no-referrer-when-downgrade" />
</div>
</div>
<!-- Check-out map -->
<div v-if="checkOutLat != null && checkOutLng != null" class="space-y-1.5">
<p class="text-xs font-semibold text-destructive">Pulang</p>
<div class="relative w-full overflow-hidden rounded-lg" style="padding-top: 56.25%">
<iframe :src="checkOutSrc" class="absolute inset-0 h-full w-full" style="border: 0" allowfullscreen
loading="lazy" referrerpolicy="no-referrer-when-downgrade" />
</div>
</div>
</div>
</template>

View File

@ -10,19 +10,15 @@ import {
defineProps<{
checkInPhotoUrl: string | null;
checkOutPhotoUrl: string | null;
checkInLocationTag?: string | null;
checkOutLocationTag?: string | null;
}>();
const previewOpen = ref(false);
const previewUrl = ref<string | null>(null);
const previewTitle = ref('');
const previewLocationTag = ref<string | null>(null);
function openPreview(url: string, title: string, locationTag: string | null | undefined) {
function openPreview(url: string, title: string) {
previewUrl.value = url;
previewTitle.value = title;
previewLocationTag.value = locationTag ?? null;
previewOpen.value = true;
}
</script>
@ -30,14 +26,14 @@ function openPreview(url: string, title: string, locationTag: string | null | un
<template>
<div v-if="checkInPhotoUrl || checkOutPhotoUrl" class="flex flex-wrap gap-2">
<button v-if="checkInPhotoUrl" type="button" class="group flex flex-col items-center gap-1"
@click="openPreview(checkInPhotoUrl, 'Foto Presensi Masuk', checkInLocationTag)">
@click="openPreview(checkInPhotoUrl, 'Foto Presensi Masuk')">
<img :src="checkInPhotoUrl" alt="Foto presensi masuk"
class="size-12 rounded-md border object-cover transition-opacity group-hover:opacity-80" />
<span class="text-muted-foreground text-[10px] font-medium">Masuk</span>
</button>
<button v-if="checkOutPhotoUrl" type="button" class="group flex flex-col items-center gap-1"
@click="openPreview(checkOutPhotoUrl, 'Foto Presensi Pulang', checkOutLocationTag)">
@click="openPreview(checkOutPhotoUrl, 'Foto Presensi Pulang')">
<img :src="checkOutPhotoUrl" alt="Foto presensi pulang"
class="size-12 rounded-md border object-cover transition-opacity group-hover:opacity-80" />
<span class="text-muted-foreground text-[10px] font-medium">Pulang</span>

View File

@ -1,9 +1,9 @@
import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import AttendancePhotoCell from '@/components/admin/hr/attendances/attendance-photo-cell.vue';
import DataTableActions from '@/components/admin/hr/attendances/data-table-actions.vue';
import { DataTableColumnHeader } from '@/components/data-table';
import type { AttendanceListItem } from '@/types/attendance';
import AttendancePhotoCell from './attendance-photo-cell.vue';
import DataTableActions from './data-table-actions.vue';
export function createColumns(canManageAll: boolean): ColumnDef<AttendanceListItem>[] {
const columns: ColumnDef<AttendanceListItem>[] = [];
@ -49,8 +49,6 @@ export function createColumns(canManageAll: boolean): ColumnDef<AttendanceListIt
cell: ({ row }) => h(AttendancePhotoCell, {
checkInPhotoUrl: row.original.check_in_photo_url,
checkOutPhotoUrl: row.original.check_out_photo_url,
checkInLocationTag: row.original.check_in_location_tag,
checkOutLocationTag: row.original.check_out_location_tag,
}),
},
{

View File

@ -10,8 +10,6 @@ export type AttendanceListItem = {
check_out_at_formatted: string | null;
check_in_photo_url: string | null;
check_out_photo_url: string | null;
check_in_location_tag: string | null;
check_out_location_tag: string | null;
check_in_latitude: number | null;
check_in_longitude: number | null;
check_out_latitude: number | null;
@ -31,5 +29,4 @@ export type AttendanceCaptureFormData = {
photo: string;
latitude: number;
longitude: number;
location_tag: string;
};