diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php index 5cf6357..acbfcb3 100644 --- a/app/Enums/Permission.php +++ b/app/Enums/Permission.php @@ -17,6 +17,11 @@ enum Permission: string case EMPLOYEES_RESET_PASSWORD = 'employees.reset-password'; case EMPLOYEES_TOGGLE_STATUS = 'employees.toggle-status'; + case ATTENDANCES_VIEW = 'attendances.view'; + case ATTENDANCES_CREATE = 'attendances.create'; + case ATTENDANCES_DELETE = 'attendances.delete'; + case ATTENDANCES_MANAGE = 'attendances.manage'; + case CATEGORIES_VIEW = 'categories.view'; case CATEGORIES_CREATE = 'categories.create'; case CATEGORIES_UPDATE = 'categories.update'; @@ -78,6 +83,11 @@ public function label(): string self::EMPLOYEES_RESET_PASSWORD => 'Reset Kata Sandi Pegawai', self::EMPLOYEES_TOGGLE_STATUS => 'Ubah Status Pegawai', + self::ATTENDANCES_VIEW => 'Lihat Presensi', + self::ATTENDANCES_CREATE => 'Presensi Masuk/Pulang', + self::ATTENDANCES_DELETE => 'Hapus Presensi', + self::ATTENDANCES_MANAGE => 'Kelola Semua Presensi', + self::CATEGORIES_VIEW => 'Lihat Kategori', self::CATEGORIES_CREATE => 'Tambah Kategori', self::CATEGORIES_UPDATE => 'Ubah Kategori', @@ -135,6 +145,8 @@ public function group(): string self::DASHBOARD_VIEW => 'Umum', self::EMPLOYEES_VIEW, self::EMPLOYEES_CREATE, self::EMPLOYEES_UPDATE, self::EMPLOYEES_DELETE, self::EMPLOYEES_RESET_PASSWORD, self::EMPLOYEES_TOGGLE_STATUS => 'Pegawai', + self::ATTENDANCES_VIEW, self::ATTENDANCES_CREATE, self::ATTENDANCES_DELETE, + self::ATTENDANCES_MANAGE => 'Presensi', self::CATEGORIES_VIEW, self::CATEGORIES_CREATE, self::CATEGORIES_UPDATE, self::CATEGORIES_DELETE => 'Kategori', self::SUPPLIERS_VIEW, self::SUPPLIERS_CREATE, self::SUPPLIERS_UPDATE, diff --git a/app/Enums/Role.php b/app/Enums/Role.php index a5a2f46..b32899d 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -44,6 +44,10 @@ public function permissions(): array Permission::EMPLOYEES_DELETE, Permission::EMPLOYEES_RESET_PASSWORD, Permission::EMPLOYEES_TOGGLE_STATUS, + Permission::ATTENDANCES_VIEW, + Permission::ATTENDANCES_CREATE, + Permission::ATTENDANCES_DELETE, + Permission::ATTENDANCES_MANAGE, Permission::CATEGORIES_VIEW, Permission::CATEGORIES_CREATE, Permission::CATEGORIES_UPDATE, @@ -91,6 +95,10 @@ public function permissions(): array Permission::EMPLOYEES_UPDATE, Permission::EMPLOYEES_RESET_PASSWORD, Permission::EMPLOYEES_TOGGLE_STATUS, + Permission::ATTENDANCES_VIEW, + Permission::ATTENDANCES_CREATE, + Permission::ATTENDANCES_DELETE, + Permission::ATTENDANCES_MANAGE, Permission::CATEGORIES_VIEW, Permission::CATEGORIES_CREATE, Permission::CATEGORIES_UPDATE, @@ -129,6 +137,8 @@ public function permissions(): array self::ADMIN_BAHAN_BAKU => [ Permission::DASHBOARD_VIEW, Permission::EMPLOYEES_VIEW, + Permission::ATTENDANCES_VIEW, + Permission::ATTENDANCES_CREATE, Permission::CATEGORIES_VIEW, Permission::CATEGORIES_CREATE, Permission::CATEGORIES_UPDATE, @@ -146,6 +156,8 @@ public function permissions(): array self::MARKETING => [ Permission::DASHBOARD_VIEW, Permission::EMPLOYEES_VIEW, + Permission::ATTENDANCES_VIEW, + Permission::ATTENDANCES_CREATE, Permission::CATEGORIES_VIEW, Permission::CATEGORIES_CREATE, Permission::CATEGORIES_UPDATE, diff --git a/app/Http/Controllers/Admin/Hr/AttendanceController.php b/app/Http/Controllers/Admin/Hr/AttendanceController.php new file mode 100644 index 0000000..58889be --- /dev/null +++ b/app/Http/Controllers/Admin/Hr/AttendanceController.php @@ -0,0 +1,80 @@ +user(); + $employee = $user?->employee; + $canManageAll = $user?->can(Permission::ATTENDANCES_MANAGE->value) ?? false; + + $scopedEmployeeId = $canManageAll ? null : $employee?->id; + $hasScopedAccess = $canManageAll || $employee !== null; + + $start = $request->date('start') ?? now()->startOfMonth(); + $end = $request->date('end') ?? now()->endOfMonth(); + + return Inertia::render('admin/hr/attendances/Index', [ + 'attendances' => $this->attendanceService->listForCalendar( + $start, + $end, + $scopedEmployeeId, + $hasScopedAccess, + ), + 'todayAttendance' => $employee + ? $this->attendanceService->todayAttendanceForEmployee($employee) + : null, + 'canCheckIn' => ($user?->can(Permission::ATTENDANCES_CREATE->value) ?? false) + && $employee !== null, + 'canManageAll' => $canManageAll, + 'calendarRange' => [ + 'start' => $start->toDateString(), + 'end' => $end->toDateString(), + ], + ]); + } + + public function checkIn(AttendanceCheckInRequest $request): RedirectResponse + { + $this->attendanceService->checkIn($request->validated()); + + Inertia::flash('success', 'Presensi masuk berhasil dicatat.'); + + return redirect()->route('admin.hr.attendances.index'); + } + + public function checkOut(AttendanceCheckOutRequest $request): RedirectResponse + { + $this->attendanceService->checkOut($request->validated()); + + Inertia::flash('success', 'Presensi pulang berhasil dicatat.'); + + return redirect()->route('admin.hr.attendances.index'); + } + + public function destroy(Attendance $attendance): RedirectResponse + { + $this->attendanceService->delete($attendance); + + Inertia::flash('success', 'Data presensi berhasil dihapus.'); + + return redirect()->route('admin.hr.attendances.index'); + } +} diff --git a/app/Http/Requests/Admin/Hr/AttendanceCheckInRequest.php b/app/Http/Requests/Admin/Hr/AttendanceCheckInRequest.php new file mode 100644 index 0000000..0a551f4 --- /dev/null +++ b/app/Http/Requests/Admin/Hr/AttendanceCheckInRequest.php @@ -0,0 +1,27 @@ +user()?->can(Permission::ATTENDANCES_CREATE->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'photo' => ['required', 'string'], + 'latitude' => ['required', 'numeric', 'between:-90,90'], + 'longitude' => ['required', 'numeric', 'between:-180,180'], + 'location_tag' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Hr/AttendanceCheckOutRequest.php b/app/Http/Requests/Admin/Hr/AttendanceCheckOutRequest.php new file mode 100644 index 0000000..c647893 --- /dev/null +++ b/app/Http/Requests/Admin/Hr/AttendanceCheckOutRequest.php @@ -0,0 +1,27 @@ +user()?->can(Permission::ATTENDANCES_CREATE->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'photo' => ['required', 'string'], + 'latitude' => ['required', 'numeric', 'between:-90,90'], + 'longitude' => ['required', 'numeric', 'between:-180,180'], + 'location_tag' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Models/Attendance.php b/app/Models/Attendance.php new file mode 100644 index 0000000..3bad82e --- /dev/null +++ b/app/Models/Attendance.php @@ -0,0 +1,109 @@ + 'date', + 'check_in_at' => 'datetime', + 'check_out_at' => 'datetime', + 'check_in_latitude' => 'decimal:7', + 'check_in_longitude' => 'decimal:7', + 'check_out_latitude' => 'decimal:7', + 'check_out_longitude' => 'decimal:7', + 'work_duration_minutes' => 'integer', + ]; + } + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function employeeName(): Attribute + { + return Attribute::make( + get: fn () => $this->employee?->user?->profile?->full_name, + ); + } + + public function attendanceDateFormatted(): Attribute + { + return Attribute::make( + get: fn () => Carbon::parse($this->attendance_date)->translatedFormat('l, d F Y'), + ); + } + + public function checkInAtFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->check_in_at?->format('H:i'), + ); + } + + public function checkOutAtFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->check_out_at?->format('H:i'), + ); + } + + public function workDurationFormatted(): Attribute + { + return Attribute::make( + get: function () { + if ($this->work_duration_minutes === null) { + return null; + } + + $hours = intdiv($this->work_duration_minutes, 60); + $minutes = $this->work_duration_minutes % 60; + + if ($hours > 0) { + return "{$hours} jam {$minutes} menit"; + } + + return "{$minutes} menit"; + }, + ); + } + + public function checkInPhotoUrl(): Attribute + { + return Attribute::make( + get: fn () => $this->check_in_photo_path + ? Storage::disk('public')->url($this->check_in_photo_path) + : null, + ); + } + + public function checkOutPhotoUrl(): Attribute + { + return Attribute::make( + get: fn () => $this->check_out_photo_path + ? Storage::disk('public')->url($this->check_out_photo_path) + : null, + ); + } +} diff --git a/app/Models/Employee.php b/app/Models/Employee.php index 21c69cd..a5438ec 100644 --- a/app/Models/Employee.php +++ b/app/Models/Employee.php @@ -102,4 +102,9 @@ public function payrolls(): HasMany { return $this->hasMany(Payroll::class); } + + public function attendances(): HasMany + { + return $this->hasMany(Attendance::class); + } } diff --git a/app/Services/Hr/AttendanceService.php b/app/Services/Hr/AttendanceService.php new file mode 100644 index 0000000..64bf4ab --- /dev/null +++ b/app/Services/Hr/AttendanceService.php @@ -0,0 +1,209 @@ +with(['employee.user.profile']) + ->when(! $hasScopedAccess, function (Builder $query): void { + $query->whereRaw('1 = 0'); + }) + ->when($hasScopedAccess && $scopedEmployeeId !== null, function (Builder $query) use ($scopedEmployeeId): void { + $query->where('employee_id', $scopedEmployeeId); + }) + ->whereDate('attendance_date', '>=', $start->toDateString()) + ->whereDate('attendance_date', '<', $end->toDateString()) + ->orderBy('attendance_date') + ->orderBy('check_in_at') + ->get(); + } + + /** + * @return array|null + */ + public function todayAttendanceForEmployee(Employee $employee): ?array + { + $attendance = Attendance::query() + ->where('employee_id', $employee->id) + ->whereDate('attendance_date', today()) + ->first(); + + return $attendance?->toArray(); + } + + /** + * @param array{photo: string, latitude: float, longitude: float, location_tag: string} $validated + */ + public function checkIn(array $validated): void + { + $employee = $this->resolveAuthEmployee(); + + $existing = Attendance::query() + ->where('employee_id', $employee->id) + ->whereDate('attendance_date', today()) + ->exists(); + + if ($existing) { + throw ValidationException::withMessages([ + 'attendance' => 'Anda sudah melakukan presensi masuk hari ini.', + ]); + } + + $locationTag = $this->resolveLocationTag( + $validated['location_tag'], + (float) $validated['latitude'], + (float) $validated['longitude'], + ); + + Attendance::create([ + 'employee_id' => $employee->id, + 'attendance_date' => today(), + 'check_in_at' => now(), + 'check_in_photo_path' => $this->storePhoto($validated['photo'], $employee->id, 'check-in'), + 'check_in_latitude' => $validated['latitude'], + 'check_in_longitude' => $validated['longitude'], + 'check_in_location_tag' => $locationTag, + ]); + } + + /** + * @param array{photo: string, latitude: float, longitude: float, location_tag: string} $validated + */ + public function checkOut(array $validated): void + { + $employee = $this->resolveAuthEmployee(); + + $attendance = Attendance::query() + ->where('employee_id', $employee->id) + ->whereDate('attendance_date', today()) + ->first(); + + if ($attendance === null) { + throw ValidationException::withMessages([ + 'attendance' => 'Anda belum melakukan presensi masuk hari ini.', + ]); + } + + if ($attendance->check_out_at !== null) { + throw ValidationException::withMessages([ + 'attendance' => 'Anda sudah melakukan presensi pulang hari ini.', + ]); + } + + $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_photo_path = $this->storePhoto($validated['photo'], $employee->id, 'check-out'); + $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(); + } + + public function delete(Attendance $attendance): void + { + if ($attendance->check_in_photo_path) { + Storage::disk('public')->delete($attendance->check_in_photo_path); + } + + if ($attendance->check_out_photo_path) { + Storage::disk('public')->delete($attendance->check_out_photo_path); + } + + $attendance->delete(); + } + + private function resolveAuthEmployee(): Employee + { + $employee = auth()->user()?->employee; + + if ($employee === null) { + throw ValidationException::withMessages([ + 'employee' => 'Akun Anda tidak terhubung ke data pegawai.', + ]); + } + + return $employee; + } + + private function storePhoto(string $base64Photo, int $employeeId, string $type): string + { + $image = base64_decode( + (string) preg_replace('#^data:image/\w+;base64,#i', '', $base64Photo), + true, + ); + + if ($image === false) { + throw ValidationException::withMessages([ + 'photo' => 'Foto presensi tidak valid.', + ]); + } + + $filename = sprintf('%d_%s_%s.jpg', $employeeId, now()->format('Y-m-d_His'), $type); + $path = "attendances/{$filename}"; + + Storage::disk('public')->put($path, $image); + + return $path; + } + + 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; + } + } +} diff --git a/database/migrations/2026_06_11_100000_create_attendances_table.php b/database/migrations/2026_06_11_100000_create_attendances_table.php new file mode 100644 index 0000000..817ab9b --- /dev/null +++ b/database/migrations/2026_06_11_100000_create_attendances_table.php @@ -0,0 +1,38 @@ +id(); + + $table->foreignId('employee_id')->constrained()->cascadeOnDelete(); + + $table->date('attendance_date'); + $table->dateTime('check_in_at'); + $table->dateTime('check_out_at')->nullable(); + $table->string('check_in_photo_path', 255); + $table->string('check_out_photo_path', 255)->nullable(); + $table->decimal('check_in_latitude', 10, 7)->nullable(); + $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(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + }); + } + + public function down(): void + { + Schema::dropIfExists('attendances'); + } +}; diff --git a/package-lock.json b/package-lock.json index fb67a5a..0aab0e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,11 @@ "packages": { "": { "dependencies": { + "@fullcalendar/core": "^6.1.20", + "@fullcalendar/daygrid": "^6.1.20", + "@fullcalendar/interaction": "^6.1.20", + "@fullcalendar/timegrid": "^6.1.20", + "@fullcalendar/vue3": "^6.1.20", "@inertiajs/vite": "^3.0.0", "@inertiajs/vue3": "^3.0.0", "@lucide/vue": "^1.17.0", @@ -1978,6 +1983,55 @@ } } }, + "node_modules/@fullcalendar/core": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", + "integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==", + "license": "MIT", + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz", + "integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, + "node_modules/@fullcalendar/interaction": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz", + "integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, + "node_modules/@fullcalendar/timegrid": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz", + "integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==", + "license": "MIT", + "dependencies": { + "@fullcalendar/daygrid": "~6.1.20" + }, + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, + "node_modules/@fullcalendar/vue3": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.20.tgz", + "integrity": "sha512-8qg6pS27II9QBwFkkJC+7SfflMpWqOe7i3ii5ODq9KpLAjwQAd/zjfq8RvKR1Yryoh5UmMCmvRbMB7i4RGtqog==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20", + "vue": "^3.0.11" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -8581,6 +8635,16 @@ "node": ">=4" } }, + "node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index 4b21686..6566f43 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,11 @@ "vue-tsc": "^2.2.4" }, "dependencies": { + "@fullcalendar/core": "^6.1.20", + "@fullcalendar/daygrid": "^6.1.20", + "@fullcalendar/interaction": "^6.1.20", + "@fullcalendar/timegrid": "^6.1.20", + "@fullcalendar/vue3": "^6.1.20", "@inertiajs/vite": "^3.0.0", "@inertiajs/vue3": "^3.0.0", "@lucide/vue": "^1.17.0", diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 5c40510..99da56e 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,6 @@ + + + + diff --git a/resources/js/components/admin/hr/attendances/AttendanceDetailDialog.vue b/resources/js/components/admin/hr/attendances/AttendanceDetailDialog.vue new file mode 100644 index 0000000..e0b6b4b --- /dev/null +++ b/resources/js/components/admin/hr/attendances/AttendanceDetailDialog.vue @@ -0,0 +1,133 @@ + + + diff --git a/resources/js/components/admin/hr/attendances/AttendanceWebcamModal.vue b/resources/js/components/admin/hr/attendances/AttendanceWebcamModal.vue new file mode 100644 index 0000000..b5eaf09 --- /dev/null +++ b/resources/js/components/admin/hr/attendances/AttendanceWebcamModal.vue @@ -0,0 +1,224 @@ + + + diff --git a/resources/js/components/admin/hr/attendances/attendance-photo-cell.vue b/resources/js/components/admin/hr/attendances/attendance-photo-cell.vue new file mode 100644 index 0000000..a907fc4 --- /dev/null +++ b/resources/js/components/admin/hr/attendances/attendance-photo-cell.vue @@ -0,0 +1,85 @@ + + + diff --git a/resources/js/components/admin/hr/attendances/columns.ts b/resources/js/components/admin/hr/attendances/columns.ts new file mode 100644 index 0000000..1085d89 --- /dev/null +++ b/resources/js/components/admin/hr/attendances/columns.ts @@ -0,0 +1,67 @@ +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'; + +export function createColumns(canManageAll: boolean): ColumnDef[] { + const columns: ColumnDef[] = []; + + if (canManageAll) { + columns.push({ + accessorKey: 'employee_name', + enableSorting: false, + header: () => h(DataTableColumnHeader, { title: 'Pegawai', column: 'employee_name' }), + cell: ({ row }) => row.original.employee_name ?? '-', + }); + } + + columns.push( + { + accessorKey: 'attendance_date', + enableSorting: true, + header: () => h(DataTableColumnHeader, { title: 'Tanggal', column: 'attendance_date' }), + cell: ({ row }) => row.original.attendance_date_formatted, + }, + { + accessorKey: 'check_in_at', + enableSorting: true, + header: () => h(DataTableColumnHeader, { title: 'Masuk', column: 'check_in_at' }), + cell: ({ row }) => row.original.check_in_at_formatted ?? '-', + }, + { + accessorKey: 'check_out_at', + enableSorting: true, + header: () => h(DataTableColumnHeader, { title: 'Pulang', column: 'check_out_at' }), + cell: ({ row }) => row.original.check_out_at_formatted ?? '-', + }, + { + id: 'duration', + enableSorting: false, + header: () => h(DataTableColumnHeader, { title: 'Durasi', column: 'duration' }), + cell: ({ row }) => row.original.work_duration_formatted ?? '-', + }, + { + id: 'photos', + enableSorting: false, + header: () => h(DataTableColumnHeader, { title: 'Foto', column: 'photos' }), + 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, + }), + }, + { + id: 'actions', + enableSorting: false, + enableHiding: false, + cell: ({ row }) => h(DataTableActions, { + attendance: row.original, + }), + }, + ); + + return columns; +} diff --git a/resources/js/components/admin/hr/attendances/data-table-actions.vue b/resources/js/components/admin/hr/attendances/data-table-actions.vue new file mode 100644 index 0000000..b588246 --- /dev/null +++ b/resources/js/components/admin/hr/attendances/data-table-actions.vue @@ -0,0 +1,68 @@ + + + diff --git a/resources/js/composables/useGeolocation.ts b/resources/js/composables/useGeolocation.ts new file mode 100644 index 0000000..1f2478d --- /dev/null +++ b/resources/js/composables/useGeolocation.ts @@ -0,0 +1,52 @@ +export type GeolocationResult = { + latitude: number; + longitude: number; +}; + +export function useGeolocation() { + function getCurrentPosition(): Promise { + return new Promise((resolve, reject) => { + if (!navigator.geolocation) { + reject(new Error('Perangkat tidak mendukung geolokasi.')); + return; + } + + navigator.geolocation.getCurrentPosition( + (position) => { + resolve({ + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }); + }, + (error) => { + const messages: Record = { + 1: 'Izin lokasi ditolak. Aktifkan izin lokasi untuk presensi.', + 2: 'Lokasi tidak tersedia. Coba lagi.', + 3: 'Waktu permintaan lokasi habis. Coba lagi.', + }; + + reject(new Error(messages[error.code] ?? 'Gagal mendapatkan lokasi.')); + }, + { + enableHighAccuracy: true, + timeout: 15000, + maximumAge: 0, + }, + ); + }); + } + + 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, + }; +} diff --git a/resources/js/composables/useWebcamCapture.ts b/resources/js/composables/useWebcamCapture.ts new file mode 100644 index 0000000..32aac3f --- /dev/null +++ b/resources/js/composables/useWebcamCapture.ts @@ -0,0 +1,68 @@ +export function useWebcamCapture() { + let stream: MediaStream | null = null; + + async function startCamera(videoElement: HTMLVideoElement): Promise { + stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: 'user', + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, + audio: false, + }); + + videoElement.srcObject = stream; + await videoElement.play(); + } + + function stopCamera(): void { + stream?.getTracks().forEach((track) => track.stop()); + stream = null; + } + + function captureWithLocationTag(videoElement: HTMLVideoElement, locationTag: string): string { + const canvas = document.createElement('canvas'); + canvas.width = videoElement.videoWidth; + canvas.height = videoElement.videoHeight; + + const context = canvas.getContext('2d'); + + if (!context) { + throw new Error('Gagal menyiapkan kanvas foto.'); + } + + context.save(); + context.translate(canvas.width, 0); + context.scale(-1, 1); + 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, + }; +} diff --git a/resources/js/pages/admin/hr/attendances/Index.vue b/resources/js/pages/admin/hr/attendances/Index.vue new file mode 100644 index 0000000..6337824 --- /dev/null +++ b/resources/js/pages/admin/hr/attendances/Index.vue @@ -0,0 +1,140 @@ + + + diff --git a/resources/js/types/attendance.ts b/resources/js/types/attendance.ts new file mode 100644 index 0000000..7173d85 --- /dev/null +++ b/resources/js/types/attendance.ts @@ -0,0 +1,33 @@ +export type AttendanceListItem = { + id: number; + employee_id: number; + employee_name: string | null; + attendance_date: string; + attendance_date_formatted: string; + check_in_at: string; + check_in_at_formatted: string; + check_out_at: string | null; + check_out_at_formatted: string | null; + check_in_photo_path: string; + check_out_photo_path: 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; + work_duration_minutes: number | null; + work_duration_formatted: string | null; +}; + +export type TodayAttendance = AttendanceListItem | null; + +export type CalendarRange = { + start: string; + end: string; +}; + +export type AttendanceCaptureFormData = { + photo: string; + latitude: number; + longitude: number; + location_tag: string; +}; diff --git a/routes/web.php b/routes/web.php index f6e2b2c..20396f5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,6 +7,7 @@ use App\Http\Controllers\Admin\Finance\ExpenseController; use App\Http\Controllers\Admin\Finance\PayrollController; use App\Http\Controllers\Admin\Finance\PayrollPeriodController; +use App\Http\Controllers\Admin\Hr\AttendanceController; use App\Http\Controllers\Admin\Hr\EmployeeController; use App\Http\Controllers\Admin\Master\CategoryController; use App\Http\Controllers\Admin\Master\CustomerController; @@ -248,7 +249,25 @@ }); }); - Route::prefix('hr')->name('hr.')->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)->group(function () { + Route::prefix('hr')->name('hr.')->group(function () { + Route::prefix('attendances')->name('attendances.') + ->middleware('permission:'.Permission::ATTENDANCES_VIEW->value) + ->group(function () { + Route::get('/', [AttendanceController::class, 'index'])->name('index'); + + Route::post('check-in', [AttendanceController::class, 'checkIn']) + ->middleware('permission:'.Permission::ATTENDANCES_CREATE->value) + ->name('check-in'); + + Route::post('check-out', [AttendanceController::class, 'checkOut']) + ->middleware('permission:'.Permission::ATTENDANCES_CREATE->value) + ->name('check-out'); + + Route::delete('{attendance}', [AttendanceController::class, 'destroy']) + ->middleware('permission:'.Permission::ATTENDANCES_DELETE->value) + ->name('destroy'); + }); + Route::prefix('employees')->name('employees.') ->middleware('permission:'.Permission::EMPLOYEES_VIEW->value) ->group(function () {