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:
parent
b7fddf0597
commit
0cb93c4e05
@ -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');
|
||||
}
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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>
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ export const FIELD_LIMITS = {
|
||||
description: 100,
|
||||
email: 100,
|
||||
fullName: 200,
|
||||
locationTag: 255,
|
||||
name: 200,
|
||||
notes: 100,
|
||||
phoneNumber: 20,
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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,
|
||||
}),
|
||||
},
|
||||
{
|
||||
@ -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;
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user