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:
parent
3f05e3cdc2
commit
34c59c24f5
@ -17,6 +17,11 @@ enum Permission: string
|
|||||||
case EMPLOYEES_RESET_PASSWORD = 'employees.reset-password';
|
case EMPLOYEES_RESET_PASSWORD = 'employees.reset-password';
|
||||||
case EMPLOYEES_TOGGLE_STATUS = 'employees.toggle-status';
|
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_VIEW = 'categories.view';
|
||||||
case CATEGORIES_CREATE = 'categories.create';
|
case CATEGORIES_CREATE = 'categories.create';
|
||||||
case CATEGORIES_UPDATE = 'categories.update';
|
case CATEGORIES_UPDATE = 'categories.update';
|
||||||
@ -78,6 +83,11 @@ public function label(): string
|
|||||||
self::EMPLOYEES_RESET_PASSWORD => 'Reset Kata Sandi Pegawai',
|
self::EMPLOYEES_RESET_PASSWORD => 'Reset Kata Sandi Pegawai',
|
||||||
self::EMPLOYEES_TOGGLE_STATUS => 'Ubah Status 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_VIEW => 'Lihat Kategori',
|
||||||
self::CATEGORIES_CREATE => 'Tambah Kategori',
|
self::CATEGORIES_CREATE => 'Tambah Kategori',
|
||||||
self::CATEGORIES_UPDATE => 'Ubah Kategori',
|
self::CATEGORIES_UPDATE => 'Ubah Kategori',
|
||||||
@ -135,6 +145,8 @@ public function group(): string
|
|||||||
self::DASHBOARD_VIEW => 'Umum',
|
self::DASHBOARD_VIEW => 'Umum',
|
||||||
self::EMPLOYEES_VIEW, self::EMPLOYEES_CREATE, self::EMPLOYEES_UPDATE,
|
self::EMPLOYEES_VIEW, self::EMPLOYEES_CREATE, self::EMPLOYEES_UPDATE,
|
||||||
self::EMPLOYEES_DELETE, self::EMPLOYEES_RESET_PASSWORD, self::EMPLOYEES_TOGGLE_STATUS => 'Pegawai',
|
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_VIEW, self::CATEGORIES_CREATE, self::CATEGORIES_UPDATE,
|
||||||
self::CATEGORIES_DELETE => 'Kategori',
|
self::CATEGORIES_DELETE => 'Kategori',
|
||||||
self::SUPPLIERS_VIEW, self::SUPPLIERS_CREATE, self::SUPPLIERS_UPDATE,
|
self::SUPPLIERS_VIEW, self::SUPPLIERS_CREATE, self::SUPPLIERS_UPDATE,
|
||||||
|
|||||||
@ -44,6 +44,10 @@ public function permissions(): array
|
|||||||
Permission::EMPLOYEES_DELETE,
|
Permission::EMPLOYEES_DELETE,
|
||||||
Permission::EMPLOYEES_RESET_PASSWORD,
|
Permission::EMPLOYEES_RESET_PASSWORD,
|
||||||
Permission::EMPLOYEES_TOGGLE_STATUS,
|
Permission::EMPLOYEES_TOGGLE_STATUS,
|
||||||
|
Permission::ATTENDANCES_VIEW,
|
||||||
|
Permission::ATTENDANCES_CREATE,
|
||||||
|
Permission::ATTENDANCES_DELETE,
|
||||||
|
Permission::ATTENDANCES_MANAGE,
|
||||||
Permission::CATEGORIES_VIEW,
|
Permission::CATEGORIES_VIEW,
|
||||||
Permission::CATEGORIES_CREATE,
|
Permission::CATEGORIES_CREATE,
|
||||||
Permission::CATEGORIES_UPDATE,
|
Permission::CATEGORIES_UPDATE,
|
||||||
@ -91,6 +95,10 @@ public function permissions(): array
|
|||||||
Permission::EMPLOYEES_UPDATE,
|
Permission::EMPLOYEES_UPDATE,
|
||||||
Permission::EMPLOYEES_RESET_PASSWORD,
|
Permission::EMPLOYEES_RESET_PASSWORD,
|
||||||
Permission::EMPLOYEES_TOGGLE_STATUS,
|
Permission::EMPLOYEES_TOGGLE_STATUS,
|
||||||
|
Permission::ATTENDANCES_VIEW,
|
||||||
|
Permission::ATTENDANCES_CREATE,
|
||||||
|
Permission::ATTENDANCES_DELETE,
|
||||||
|
Permission::ATTENDANCES_MANAGE,
|
||||||
Permission::CATEGORIES_VIEW,
|
Permission::CATEGORIES_VIEW,
|
||||||
Permission::CATEGORIES_CREATE,
|
Permission::CATEGORIES_CREATE,
|
||||||
Permission::CATEGORIES_UPDATE,
|
Permission::CATEGORIES_UPDATE,
|
||||||
@ -129,6 +137,8 @@ public function permissions(): array
|
|||||||
self::ADMIN_BAHAN_BAKU => [
|
self::ADMIN_BAHAN_BAKU => [
|
||||||
Permission::DASHBOARD_VIEW,
|
Permission::DASHBOARD_VIEW,
|
||||||
Permission::EMPLOYEES_VIEW,
|
Permission::EMPLOYEES_VIEW,
|
||||||
|
Permission::ATTENDANCES_VIEW,
|
||||||
|
Permission::ATTENDANCES_CREATE,
|
||||||
Permission::CATEGORIES_VIEW,
|
Permission::CATEGORIES_VIEW,
|
||||||
Permission::CATEGORIES_CREATE,
|
Permission::CATEGORIES_CREATE,
|
||||||
Permission::CATEGORIES_UPDATE,
|
Permission::CATEGORIES_UPDATE,
|
||||||
@ -146,6 +156,8 @@ public function permissions(): array
|
|||||||
self::MARKETING => [
|
self::MARKETING => [
|
||||||
Permission::DASHBOARD_VIEW,
|
Permission::DASHBOARD_VIEW,
|
||||||
Permission::EMPLOYEES_VIEW,
|
Permission::EMPLOYEES_VIEW,
|
||||||
|
Permission::ATTENDANCES_VIEW,
|
||||||
|
Permission::ATTENDANCES_CREATE,
|
||||||
Permission::CATEGORIES_VIEW,
|
Permission::CATEGORIES_VIEW,
|
||||||
Permission::CATEGORIES_CREATE,
|
Permission::CATEGORIES_CREATE,
|
||||||
Permission::CATEGORIES_UPDATE,
|
Permission::CATEGORIES_UPDATE,
|
||||||
|
|||||||
80
app/Http/Controllers/Admin/Hr/AttendanceController.php
Normal file
80
app/Http/Controllers/Admin/Hr/AttendanceController.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Requests/Admin/Hr/AttendanceCheckInRequest.php
Normal file
27
app/Http/Requests/Admin/Hr/AttendanceCheckInRequest.php
Normal 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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Requests/Admin/Hr/AttendanceCheckOutRequest.php
Normal file
27
app/Http/Requests/Admin/Hr/AttendanceCheckOutRequest.php
Normal 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
109
app/Models/Attendance.php
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -102,4 +102,9 @@ public function payrolls(): HasMany
|
|||||||
{
|
{
|
||||||
return $this->hasMany(Payroll::class);
|
return $this->hasMany(Payroll::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function attendances(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Attendance::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
209
app/Services/Hr/AttendanceService.php
Normal file
209
app/Services/Hr/AttendanceService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
64
package-lock.json
generated
@ -5,6 +5,11 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"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/vite": "^3.0.0",
|
||||||
"@inertiajs/vue3": "^3.0.0",
|
"@inertiajs/vue3": "^3.0.0",
|
||||||
"@lucide/vue": "^1.17.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": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
@ -8581,6 +8635,16 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
|
|||||||
@ -33,6 +33,11 @@
|
|||||||
"vue-tsc": "^2.2.4"
|
"vue-tsc": "^2.2.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/vite": "^3.0.0",
|
||||||
"@inertiajs/vue3": "^3.0.0",
|
"@inertiajs/vue3": "^3.0.0",
|
||||||
"@lucide/vue": "^1.17.0",
|
"@lucide/vue": "^1.17.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
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 { computed } from 'vue';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@ -21,6 +21,8 @@ const { can } = useCan();
|
|||||||
|
|
||||||
const isDashboardActive = computed(() => page.url.startsWith('/admin/dashboard'));
|
const isDashboardActive = computed(() => page.url.startsWith('/admin/dashboard'));
|
||||||
const isEmployeesActive = computed(() => page.url.startsWith('/admin/hr/employees'));
|
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 isCategoriesActive = computed(() => page.url.startsWith('/admin/master/categories'));
|
||||||
const isProductsActive = computed(() => page.url.startsWith('/admin/master/products'));
|
const isProductsActive = computed(() => page.url.startsWith('/admin/master/products'));
|
||||||
const isRawMaterialsActive = computed(() => page.url.startsWith('/admin/master/raw-materials'));
|
const isRawMaterialsActive = computed(() => page.url.startsWith('/admin/master/raw-materials'));
|
||||||
@ -156,11 +158,11 @@ const showMasterMenu = computed(() => (
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
<SidebarGroup v-if="can('employees.view')">
|
<SidebarGroup v-if="showHrMenu">
|
||||||
<SidebarGroupLabel>HR</SidebarGroupLabel>
|
<SidebarGroupLabel>HR</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem v-if="can('employees.view')">
|
||||||
<SidebarMenuButton as-child tooltip="Pegawai" :is-active="isEmployeesActive">
|
<SidebarMenuButton as-child tooltip="Pegawai" :is-active="isEmployeesActive">
|
||||||
<Link href="/admin/hr/employees">
|
<Link href="/admin/hr/employees">
|
||||||
<Users />
|
<Users />
|
||||||
@ -168,6 +170,14 @@ const showMasterMenu = computed(() => (
|
|||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</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>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|||||||
@ -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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('\'', ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
67
resources/js/components/admin/hr/attendances/columns.ts
Normal file
67
resources/js/components/admin/hr/attendances/columns.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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>
|
||||||
52
resources/js/composables/useGeolocation.ts
Normal file
52
resources/js/composables/useGeolocation.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
68
resources/js/composables/useWebcamCapture.ts
Normal file
68
resources/js/composables/useWebcamCapture.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
140
resources/js/pages/admin/hr/attendances/Index.vue
Normal file
140
resources/js/pages/admin/hr/attendances/Index.vue
Normal 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>
|
||||||
33
resources/js/types/attendance.ts
Normal file
33
resources/js/types/attendance.ts
Normal 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;
|
||||||
|
};
|
||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Http\Controllers\Admin\Finance\ExpenseController;
|
use App\Http\Controllers\Admin\Finance\ExpenseController;
|
||||||
use App\Http\Controllers\Admin\Finance\PayrollController;
|
use App\Http\Controllers\Admin\Finance\PayrollController;
|
||||||
use App\Http\Controllers\Admin\Finance\PayrollPeriodController;
|
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\Hr\EmployeeController;
|
||||||
use App\Http\Controllers\Admin\Master\CategoryController;
|
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||||
use App\Http\Controllers\Admin\Master\CustomerController;
|
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.')
|
Route::prefix('employees')->name('employees.')
|
||||||
->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)
|
->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)
|
||||||
->group(function () {
|
->group(function () {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user