Add attendance management features including Attendance model, AttendanceController for handling check-in and check-out operations, and AttendanceService for business logic. Implement request validation for attendance actions and enhance UI components for attendance listing, including a calendar view and detail dialog. Update permissions in the Permission and Role enums to manage attendance access, and integrate webcam functionality for capturing attendance photos.

This commit is contained in:
Yoga Pangestu 2026-06-11 15:39:06 +07:00
parent 3f05e3cdc2
commit 34c59c24f5
23 changed files with 1981 additions and 4 deletions

View File

@ -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,

View File

@ -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,

View File

@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Admin\Hr;
use App\Enums\Permission;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Hr\AttendanceCheckInRequest;
use App\Http\Requests\Admin\Hr\AttendanceCheckOutRequest;
use App\Models\Attendance;
use App\Services\Hr\AttendanceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class AttendanceController extends Controller
{
public function __construct(
private readonly AttendanceService $attendanceService,
) {}
public function index(Request $request): Response
{
$user = auth()->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');
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Admin\Hr;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
class AttendanceCheckInRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can(Permission::ATTENDANCES_CREATE->value) ?? false;
}
/**
* @return array<string, mixed>
*/
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'],
];
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Admin\Hr;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
class AttendanceCheckOutRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can(Permission::ATTENDANCES_CREATE->value) ?? false;
}
/**
* @return array<string, mixed>
*/
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'],
];
}
}

109
app/Models/Attendance.php Normal file
View File

@ -0,0 +1,109 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
#[Guarded(['id'])]
#[Appends([
'check_in_at_formatted',
'check_out_at_formatted',
'attendance_date_formatted',
'work_duration_formatted',
'check_in_photo_url',
'check_out_photo_url',
'employee_name',
])]
class Attendance extends Model
{
protected function casts(): array
{
return [
'attendance_date' => '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,
);
}
}

View File

@ -102,4 +102,9 @@ public function payrolls(): HasMany
{
return $this->hasMany(Payroll::class);
}
public function attendances(): HasMany
{
return $this->hasMany(Attendance::class);
}
}

View File

@ -0,0 +1,209 @@
<?php
namespace App\Services\Hr;
use App\Models\Attendance;
use App\Models\Employee;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
class AttendanceService
{
public function listForCalendar(
CarbonInterface $start,
CarbonInterface $end,
?int $scopedEmployeeId = null,
bool $hasScopedAccess = true,
): Collection {
return Attendance::query()
->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<string, mixed>|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;
}
}
}

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('attendances', function (Blueprint $table) {
$table->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');
}
};

64
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { Banknote, FolderTree, Layers, LayoutDashboard, Package, Receipt, User, UserCheck, Users, Wallet, WalletCards } from '@lucide/vue';
import { Banknote, Clock, FolderTree, Layers, LayoutDashboard, Package, Receipt, User, UserCheck, Users, Wallet, WalletCards } from '@lucide/vue';
import { computed } from 'vue';
import {
Sidebar,
@ -21,6 +21,8 @@ const { can } = useCan();
const isDashboardActive = computed(() => page.url.startsWith('/admin/dashboard'));
const isEmployeesActive = computed(() => page.url.startsWith('/admin/hr/employees'));
const isAttendancesActive = computed(() => page.url.startsWith('/admin/hr/attendances'));
const showHrMenu = computed(() => can('employees.view') || can('attendances.view'));
const isCategoriesActive = computed(() => page.url.startsWith('/admin/master/categories'));
const isProductsActive = computed(() => page.url.startsWith('/admin/master/products'));
const isRawMaterialsActive = computed(() => page.url.startsWith('/admin/master/raw-materials'));
@ -156,11 +158,11 @@ const showMasterMenu = computed(() => (
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup v-if="can('employees.view')">
<SidebarGroup v-if="showHrMenu">
<SidebarGroupLabel>HR</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuItem v-if="can('employees.view')">
<SidebarMenuButton as-child tooltip="Pegawai" :is-active="isEmployeesActive">
<Link href="/admin/hr/employees">
<Users />
@ -168,6 +170,14 @@ const showMasterMenu = computed(() => (
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem v-if="can('attendances.view')">
<SidebarMenuButton as-child tooltip="Presensi" :is-active="isAttendancesActive">
<Link href="/admin/hr/attendances">
<Clock />
<span>Presensi</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>

View File

@ -0,0 +1,490 @@
<script setup lang="ts">
import type { CalendarOptions, DatesSetArg, EventClickArg, EventContentArg, EventInput } from '@fullcalendar/core';
import idLocale from '@fullcalendar/core/locales/id';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
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 { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ui/date-picker';
import { cn } from '@/lib/utils';
import type { AttendanceListItem, CalendarRange } from '@/types/attendance';
const props = defineProps<{
attendances: AttendanceListItem[];
calendarRange: CalendarRange;
canManageAll: boolean;
}>();
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
const detailOpen = ref(false);
const selectedAttendance = ref<AttendanceListItem | null>(null);
const isNavigating = ref(false);
const calendarTitle = ref('');
const pickerDate = ref(props.calendarRange.start);
const isSyncingPicker = ref(false);
const attendanceById = computed(() => new Map(
props.attendances.map((attendance) => [attendance.id, attendance]),
));
function getEventClassNames(attendance: AttendanceListItem): string[] {
return [
'attendance-event',
attendance.check_out_at ? 'attendance-event--complete' : 'attendance-event--active',
];
}
const events = computed<EventInput[]>(() => props.attendances.map((attendance) => ({
id: String(attendance.id),
title: buildEventTitle(attendance),
start: attendance.attendance_date,
allDay: true,
classNames: getEventClassNames(attendance),
extendedProps: {
attendanceId: attendance.id,
},
})));
const calendarOptions = computed<CalendarOptions>(() => ({
plugins: [dayGridPlugin, interactionPlugin],
initialView: 'dayGridMonth',
headerToolbar: false,
locale: idLocale,
height: 'auto',
fixedWeekCount: false,
dayMaxEvents: 3,
moreLinkText: (count) => `+${count} lainnya`,
events: events.value,
eventClick: handleEventClick,
eventContent: renderEventContent,
datesSet: handleDatesSet,
noEventsContent: 'Tidak ada presensi pada periode ini.',
}));
function buildEventTitle(attendance: AttendanceListItem): string {
const timeRange = attendance.check_out_at_formatted
? `${attendance.check_in_at_formatted} ${attendance.check_out_at_formatted}`
: `${attendance.check_in_at_formatted} (belum pulang)`;
if (props.canManageAll && attendance.employee_name) {
return `${attendance.employee_name}: ${timeRange}`;
}
return timeRange;
}
function renderEventContent(arg: EventContentArg) {
const attendance = attendanceById.value.get(Number(arg.event.extendedProps.attendanceId));
if (!attendance) {
return { html: escapeHtml(arg.event.title) };
}
const isComplete = Boolean(attendance.check_out_at);
const timeLabel = isComplete
? `${attendance.check_in_at_formatted} ${attendance.check_out_at_formatted}`
: `${attendance.check_in_at_formatted}`;
const nameMarkup = props.canManageAll && attendance.employee_name
? `<span class="attendance-event__name">${escapeHtml(attendance.employee_name)}</span>`
: '';
const statusMarkup = isComplete
? ''
: '<span class="attendance-event__status">Belum pulang</span>';
return {
html: `
<div class="attendance-event__inner">
${nameMarkup}
<span class="attendance-event__time">${escapeHtml(timeLabel)}</span>
${statusMarkup}
</div>
`,
};
}
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll('\'', '&#39;');
}
function toISODate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function handleEventClick(arg: EventClickArg) {
const attendanceId = Number(arg.event.extendedProps.attendanceId);
const attendance = attendanceById.value.get(attendanceId) ?? null;
if (!attendance) {
return;
}
selectedAttendance.value = attendance;
detailOpen.value = true;
}
function handleDatesSet(arg: DatesSetArg) {
calendarTitle.value = arg.view.title;
isSyncingPicker.value = true;
pickerDate.value = toISODate(arg.view.currentStart);
isSyncingPicker.value = false;
if (isNavigating.value) {
return;
}
const start = arg.startStr.slice(0, 10);
const end = arg.endStr.slice(0, 10);
if (start === props.calendarRange.start && end === props.calendarRange.end) {
return;
}
isNavigating.value = true;
router.get('/admin/hr/attendances', { start, end }, {
preserveState: true,
preserveScroll: true,
only: ['attendances', 'calendarRange'],
onFinish: () => {
isNavigating.value = false;
},
});
}
function navigatePrev() {
calendarRef.value?.getApi().prev();
}
function navigateNext() {
calendarRef.value?.getApi().next();
}
function navigateToday() {
calendarRef.value?.getApi().today();
}
watch(pickerDate, (date) => {
if (!date || isSyncingPicker.value) {
return;
}
calendarRef.value?.getApi().gotoDate(date);
});
watch(
() => props.attendances,
() => {
if (detailOpen.value && selectedAttendance.value) {
selectedAttendance.value = attendanceById.value.get(selectedAttendance.value.id) ?? null;
}
},
);
watch(
() => props.calendarRange.start,
(start) => {
if (!pickerDate.value) {
pickerDate.value = start;
}
},
);
</script>
<template>
<div class="attendance-calendar space-y-4">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-wrap items-center gap-2">
<div class="bg-muted/50 flex items-center rounded-lg border p-1 shadow-xs">
<Button
variant="ghost"
size="icon-sm"
aria-label="Bulan sebelumnya"
@click="navigatePrev"
>
<ChevronLeft class="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
class="px-3"
@click="navigateToday"
>
Hari ini
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Bulan berikutnya"
@click="navigateNext"
>
<ChevronRight class="size-4" />
</Button>
</div>
<DatePicker
v-model="pickerDate"
class="w-auto min-w-[13rem] sm:min-w-[15rem]"
placeholder="Pilih bulan"
/>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-muted-foreground hidden text-sm font-medium sm:inline">
{{ calendarTitle }}
</span>
<Badge variant="outline" class="border-chart-2/50 bg-chart-2/15 text-chart-2 font-normal dark:text-chart-2">
Selesai
</Badge>
<Badge variant="outline" class="border-amber-500/50 bg-amber-400 font-normal text-amber-950">
Belum pulang
</Badge>
</div>
</div>
<div
:class="cn(
'overflow-hidden rounded-xl border bg-background shadow-xs transition-opacity',
isNavigating && 'pointer-events-none opacity-60',
)"
>
<FullCalendar ref="calendarRef" :options="calendarOptions" />
</div>
</div>
<AttendanceDetailDialog
v-model:open="detailOpen"
:attendance="selectedAttendance"
/>
</template>
<style>
.attendance-calendar .fc {
--fc-border-color: var(--border);
--fc-page-bg-color: transparent;
--fc-neutral-bg-color: var(--muted);
--fc-neutral-text-color: var(--muted-foreground);
--fc-today-bg-color: color-mix(in oklch, var(--primary) 6%, transparent);
--fc-now-indicator-color: var(--primary);
font-family: inherit;
}
.attendance-calendar .fc .fc-scrollgrid {
border: 0;
border-radius: 0;
}
.attendance-calendar .fc .fc-scrollgrid-section > td {
border: 0;
}
.attendance-calendar .fc .fc-col-header-cell {
border-color: var(--border);
background: color-mix(in oklch, var(--muted) 55%, transparent);
padding: 0.625rem 0;
}
.attendance-calendar .fc .fc-col-header-cell-cushion {
color: var(--muted-foreground);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
text-transform: uppercase;
}
.attendance-calendar .fc .fc-daygrid-day {
border-color: var(--border);
}
.attendance-calendar .fc .fc-daygrid-day-frame {
min-height: 6.5rem;
padding: 0.375rem;
}
.attendance-calendar .fc .fc-daygrid-day-top {
flex-direction: row;
justify-content: flex-end;
margin-bottom: 0.25rem;
}
.attendance-calendar .fc .fc-daygrid-day-number {
color: var(--foreground);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1;
padding: 0.25rem 0.45rem;
text-decoration: none;
}
.attendance-calendar .fc .fc-day-today .fc-daygrid-day-number {
background: var(--primary);
border-radius: calc(var(--radius) - 2px);
color: var(--primary-foreground);
}
.attendance-calendar .fc .fc-day-other .fc-daygrid-day-number {
color: var(--muted-foreground);
opacity: 0.65;
}
.attendance-calendar .fc .fc-daygrid-day-events {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-top: 0.125rem;
}
.attendance-calendar .fc .fc-daygrid-event-harness {
margin: 0 !important;
}
.attendance-calendar .fc .fc-daygrid-event {
margin: 0 !important;
}
.attendance-calendar .fc .fc-daygrid-more-link {
color: var(--muted-foreground);
font-size: 0.6875rem;
font-weight: 500;
}
.attendance-calendar .fc .fc-daygrid-more-link:hover {
background: var(--accent);
color: var(--accent-foreground);
}
.attendance-calendar .fc .fc-popover {
background: var(--popover);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 10px 30px color-mix(in oklch, var(--foreground) 8%, transparent);
}
.attendance-calendar .fc .fc-popover-header {
background: var(--muted);
color: var(--foreground);
font-size: 0.8125rem;
font-weight: 600;
padding: 0.5rem 0.75rem;
}
.attendance-calendar .fc .attendance-event {
border: 0;
border-radius: calc(var(--radius) - 4px);
box-shadow: none;
cursor: pointer;
margin: 0;
overflow: hidden;
padding: 0.25rem 0.375rem;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.attendance-calendar .fc .attendance-event:hover {
box-shadow: 0 1px 4px color-mix(in oklch, var(--foreground) 10%, transparent);
transform: translateY(-1px);
}
.attendance-calendar .fc .attendance-event--complete,
.attendance-calendar .fc .attendance-event--complete .fc-event-main {
background-color: var(--chart-2) !important;
border-color: color-mix(in oklch, var(--chart-2) 70%, var(--foreground)) !important;
color: oklch(0.99 0 0) !important;
}
.attendance-calendar .fc .attendance-event--active,
.attendance-calendar .fc .attendance-event--active .fc-event-main {
background-color: oklch(0.79 0.16 75) !important;
border-color: oklch(0.7 0.14 70) !important;
color: oklch(0.32 0.07 55) !important;
}
.attendance-calendar .fc .attendance-event__inner {
display: flex;
flex-direction: column;
gap: 0.25rem;
line-height: 1.25;
min-width: 0;
}
.attendance-calendar .fc .attendance-event__name {
font-size: 0.6875rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attendance-calendar .fc .attendance-event__time {
font-size: 0.6875rem;
font-weight: 500;
opacity: 0.95;
}
.attendance-calendar .fc .attendance-event__status {
align-self: flex-start;
background: color-mix(in oklch, currentColor 12%, transparent);
border: 1px solid color-mix(in oklch, currentColor 18%, transparent);
border-radius: calc(var(--radius) - 6px);
font-size: 0.625rem;
font-weight: 600;
line-height: 1;
padding: 0.125rem 0.375rem;
}
.attendance-calendar .fc .attendance-event--complete .attendance-event__time,
.attendance-calendar .fc .attendance-event--complete .attendance-event__name {
color: oklch(0.99 0 0);
opacity: 0.95;
}
.attendance-calendar .fc .attendance-event--active .attendance-event__time,
.attendance-calendar .fc .attendance-event--active .attendance-event__name,
.attendance-calendar .fc .attendance-event--active .attendance-event__status {
color: oklch(0.32 0.07 55);
opacity: 0.95;
}
.dark .attendance-calendar .fc .attendance-event--active,
.dark .attendance-calendar .fc .attendance-event--active .fc-event-main {
background-color: oklch(0.72 0.14 75) !important;
border-color: oklch(0.64 0.12 70) !important;
color: oklch(0.28 0.06 55) !important;
}
.dark .attendance-calendar .fc .attendance-event--active .attendance-event__time,
.dark .attendance-calendar .fc .attendance-event--active .attendance-event__name,
.dark .attendance-calendar .fc .attendance-event--active .attendance-event__status {
color: oklch(0.28 0.06 55);
}
@media (max-width: 640px) {
.attendance-calendar .fc .fc-daygrid-day-frame {
min-height: 5rem;
}
.attendance-calendar .fc .attendance-event__name,
.attendance-calendar .fc .attendance-event__time {
font-size: 0.625rem;
}
}
</style>

View File

@ -0,0 +1,133 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { Trash2 } from '@lucide/vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import AttendancePhotoCell from '@/components/admin/hr/attendances/attendance-photo-cell.vue';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useCan } from '@/composables/useCan';
import type { AttendanceListItem } from '@/types/attendance';
const open = defineModel<boolean>('open', { required: true });
const props = defineProps<{
attendance: AttendanceListItem | null;
}>();
const { can } = useCan();
const deleteConfirmOpen = ref(false);
const deleteProcessing = ref(false);
function destroyAttendance() {
if (!props.attendance) {
return;
}
deleteProcessing.value = true;
router.delete(`/admin/hr/attendances/${props.attendance.id}`, {
preserveScroll: true,
onSuccess: () => {
deleteConfirmOpen.value = false;
open.value = false;
},
onError: () => {
toast.error('Gagal menghapus data presensi.');
},
onFinish: () => {
deleteProcessing.value = false;
},
});
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Detail Presensi</DialogTitle>
<DialogDescription v-if="attendance">
{{ attendance.attendance_date_formatted }}
<template v-if="attendance.employee_name">
{{ attendance.employee_name }}
</template>
</DialogDescription>
</DialogHeader>
<div v-if="attendance" class="space-y-4">
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
<dt class="text-muted-foreground">
Masuk
</dt>
<dd class="font-medium">
{{ attendance.check_in_at_formatted }}
</dd>
</div>
<div>
<dt class="text-muted-foreground">
Pulang
</dt>
<dd class="font-medium">
{{ attendance.check_out_at_formatted ?? '-' }}
</dd>
</div>
<div class="col-span-2">
<dt class="text-muted-foreground">
Durasi
</dt>
<dd class="font-medium">
{{ attendance.work_duration_formatted ?? '-' }}
</dd>
</div>
</dl>
<div>
<p class="text-muted-foreground mb-2 text-sm">
Foto
</p>
<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>
<div v-if="can('attendances.delete')" class="flex justify-end border-t pt-4">
<Button
variant="destructive"
size="sm"
@click="deleteConfirmOpen = true"
>
<Trash2 class="size-4" />
Hapus
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<ConfirmDialog
v-if="can('attendances.delete') && attendance"
v-model:open="deleteConfirmOpen"
title="Hapus data presensi?"
:description="`Presensi tanggal ${attendance.attendance_date_formatted} akan dihapus secara permanen.`"
confirm-label="Hapus"
cancel-label="Batal"
destructive
:loading="deleteProcessing"
@confirm="destroyAttendance"
/>
</template>

View File

@ -0,0 +1,224 @@
<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>

View File

@ -0,0 +1,85 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
const props = 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) {
previewUrl.value = url;
previewTitle.value = title;
previewLocationTag.value = locationTag ?? null;
previewOpen.value = true;
}
</script>
<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)"
>
<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)"
>
<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>
</button>
</div>
<span v-else class="text-muted-foreground">-</span>
<Dialog v-model:open="previewOpen">
<DialogContent class="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{{ previewTitle }}</DialogTitle>
</DialogHeader>
<div class="space-y-3">
<div class="overflow-hidden rounded-lg border bg-muted">
<img
v-if="previewUrl"
:src="previewUrl"
:alt="previewTitle"
class="max-h-[70vh] w-full object-contain"
/>
</div>
<p v-if="previewLocationTag" class="text-muted-foreground text-xs whitespace-pre-line">
{{ previewLocationTag }}
</p>
</div>
</DialogContent>
</Dialog>
</template>

View File

@ -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<AttendanceListItem>[] {
const columns: ColumnDef<AttendanceListItem>[] = [];
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;
}

View File

@ -0,0 +1,68 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { Trash2 } from '@lucide/vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useCan } from '@/composables/useCan';
import type { AttendanceListItem } from '@/types/attendance';
const props = defineProps<{
attendance: AttendanceListItem;
}>();
const { can } = useCan();
const deleteConfirmOpen = ref(false);
const deleteProcessing = ref(false);
function destroyAttendance() {
deleteProcessing.value = true;
router.delete(`/admin/hr/attendances/${props.attendance.id}`, {
preserveScroll: true,
onSuccess: () => {
deleteConfirmOpen.value = false;
},
onError: () => {
toast.error('Gagal menghapus data presensi.');
},
onFinish: () => {
deleteProcessing.value = false;
},
});
}
</script>
<template>
<div class="flex items-center justify-end gap-1">
<Tooltip v-if="can('attendances.delete')">
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="text-destructive hover:text-destructive size-8"
@click="deleteConfirmOpen = true"
>
<Trash2 class="size-4" />
<span class="sr-only">Hapus</span>
</Button>
</TooltipTrigger>
<TooltipContent>Hapus</TooltipContent>
</Tooltip>
</div>
<ConfirmDialog
v-if="can('attendances.delete')"
v-model:open="deleteConfirmOpen"
title="Hapus data presensi?"
:description="`Presensi tanggal ${attendance.attendance_date_formatted} akan dihapus secara permanen.`"
confirm-label="Hapus"
cancel-label="Batal"
destructive
:loading="deleteProcessing"
@confirm="destroyAttendance"
/>
</template>

View File

@ -0,0 +1,52 @@
export type GeolocationResult = {
latitude: number;
longitude: number;
};
export function useGeolocation() {
function getCurrentPosition(): Promise<GeolocationResult> {
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<number, string> = {
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,
};
}

View File

@ -0,0 +1,68 @@
export function useWebcamCapture() {
let stream: MediaStream | null = null;
async function startCamera(videoElement: HTMLVideoElement): Promise<void> {
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,
};
}

View File

@ -0,0 +1,140 @@
<script setup lang="ts">
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';
const props = defineProps<{
attendances: AttendanceListItem[];
todayAttendance: TodayAttendance;
canCheckIn: boolean;
canManageAll: boolean;
calendarRange: CalendarRange;
}>();
const webcamModalOpen = ref(false);
const webcamMode = ref<'check-in' | 'check-out'>('check-in');
const canCheckInToday = computed(() => props.canCheckIn && props.todayAttendance === null);
const canCheckOutToday = computed(() => (
props.canCheckIn
&& props.todayAttendance !== null
&& props.todayAttendance.check_out_at === null
));
const todayStatusLabel = computed(() => {
if (!props.todayAttendance) {
return 'Belum presensi';
}
if (props.todayAttendance.check_out_at) {
return 'Selesai';
}
return 'Sudah masuk';
});
const todayStatusVariant = computed(() => {
if (!props.todayAttendance) {
return 'secondary' as const;
}
if (props.todayAttendance.check_out_at) {
return 'default' as const;
}
return 'outline' as const;
});
function openCheckInModal() {
webcamMode.value = 'check-in';
webcamModalOpen.value = true;
}
function openCheckOutModal() {
webcamMode.value = 'check-out';
webcamModalOpen.value = true;
}
</script>
<template>
<Head title="Presensi" />
<AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
Presensi
</h2>
</div>
</div>
<Card v-if="canCheckIn">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Clock class="size-5" />
Presensi Hari Ini
</CardTitle>
<CardDescription>
Gunakan kamera perangkat untuk presensi masuk dan pulang.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex flex-wrap items-center gap-3">
<Badge :variant="todayStatusVariant">
{{ todayStatusLabel }}
</Badge>
<span v-if="todayAttendance?.check_in_at_formatted" class="text-muted-foreground text-sm">
Masuk: {{ todayAttendance.check_in_at_formatted }}
</span>
<span v-if="todayAttendance?.check_out_at_formatted" class="text-muted-foreground text-sm">
Pulang: {{ todayAttendance.check_out_at_formatted }}
</span>
<span v-if="todayAttendance?.work_duration_formatted" class="text-muted-foreground text-sm">
Durasi: {{ todayAttendance.work_duration_formatted }}
</span>
</div>
<div class="flex flex-wrap gap-2">
<Button :disabled="!canCheckInToday" @click="openCheckInModal">
<LogIn class="size-4" />
Presensi Masuk
</Button>
<Button variant="outline" :disabled="!canCheckOutToday" @click="openCheckOutModal">
<LogOut class="size-4" />
Presensi Pulang
</Button>
</div>
</CardContent>
</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" />
</CardContent>
</Card>
<AttendanceWebcamModal v-if="canCheckIn" v-model:open="webcamModalOpen" :mode="webcamMode" />
</AdminLayout>
</template>

View File

@ -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;
};

View File

@ -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 () {