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_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,
|
||||
|
||||
@ -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,
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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": {
|
||||
"": {
|
||||
"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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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\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 () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user