From a54630ca29a661137442c25f05666ad464b6e283 Mon Sep 17 00:00:00 2001 From: jouel88 Date: Thu, 14 May 2026 19:52:58 +0700 Subject: [PATCH] finall --- app/Events/CutiUpdated.php | 4 +- app/Events/FaceEnrollmentUpdated.php | 4 +- app/Events/JadwalUpdated.php | 4 +- app/Events/LemburUpdated.php | 6 +- app/Events/PengajuanBaru.php | 4 +- app/Events/PengajuanIzinUpdated.php | 4 +- app/Events/PenggunaanPoinUpdated.php | 4 +- app/Events/PresensiMasuk.php | 4 +- app/Events/PresensiUpdated.php | 4 +- app/Events/SuratIzinUpdated.php | 4 +- app/Events/TukarShiftUpdated.php | 4 +- app/Http/Controllers/Api/LemburController.php | 51 +++++- .../Api/PengumumanApiController.php | 1 + app/Http/Controllers/Api/PoinController.php | 36 ++++- .../Controllers/Api/PresensiController.php | 2 +- .../Api/SignatureApiController.php | 9 +- .../Controllers/Api/SubmissionController.php | 20 ++- app/Http/Controllers/DivisiController.php | 6 +- .../Controllers/FaceApprovalController.php | 11 +- app/Http/Controllers/JabatanController.php | 6 +- app/Http/Controllers/JadwalController.php | 150 ++++++++++++++++-- app/Http/Controllers/KantorController.php | 10 +- app/Http/Controllers/PegawaiController.php | 76 +++++---- .../Controllers/PengajuanIzinController.php | 52 ++++-- .../Controllers/PenggunaanPoinController.php | 16 +- app/Http/Controllers/PengumumanController.php | 1 + app/Http/Controllers/SuratIzinController.php | 40 +++-- .../Requests/BulkDestroyJadwalRequest.php | 3 +- app/Http/Requests/StoreJadwalRequest.php | 3 +- app/Jobs/BroadcastNotifikasiJob.php | 48 ++++++ app/Jobs/ProcessFaceEnrollment.php | 22 +-- app/Jobs/ReextractAllFrames.php | 84 +++++++--- app/Jobs/RetrainAllModels.php | 17 ++ app/Jobs/SendFcmPushNotification.php | 39 +++++ app/Services/FaceRecognitionService.php | 118 ++++---------- app/Services/NotifikasiService.php | 65 ++++---- composer.json | 2 +- public/js/notifications.js | 37 ++--- public/js/realtime.js | 3 +- resources/views/dashboard/index.blade.php | 60 +++---- resources/views/face-approval/index.blade.php | 99 +++++++++++- resources/views/izin/index.blade.php | 32 +++- resources/views/jadwal/create.blade.php | 58 ++++--- resources/views/jadwal/index.blade.php | 65 ++++++-- resources/views/layouts/app.blade.php | 2 +- resources/views/layouts/sidebar.blade.php | 21 ++- resources/views/pegawai/index.blade.php | 13 +- routes/web.php | 1 + .../app/public/uploads/ttd/69_1775547357.png | Bin 14509 -> 0 bytes 49 files changed, 951 insertions(+), 374 deletions(-) create mode 100644 app/Jobs/BroadcastNotifikasiJob.php create mode 100644 app/Jobs/SendFcmPushNotification.php delete mode 100644 storage/app/public/uploads/ttd/69_1775547357.png diff --git a/app/Events/CutiUpdated.php b/app/Events/CutiUpdated.php index 0c76099d..67111599 100644 --- a/app/Events/CutiUpdated.php +++ b/app/Events/CutiUpdated.php @@ -4,11 +4,11 @@ use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class CutiUpdated implements ShouldBroadcast +class CutiUpdated implements ShouldBroadcastNow { use Dispatchable, InteractsWithSockets, SerializesModels; diff --git a/app/Events/FaceEnrollmentUpdated.php b/app/Events/FaceEnrollmentUpdated.php index 25a16326..b978f06d 100644 --- a/app/Events/FaceEnrollmentUpdated.php +++ b/app/Events/FaceEnrollmentUpdated.php @@ -4,11 +4,11 @@ use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class FaceEnrollmentUpdated implements ShouldBroadcast +class FaceEnrollmentUpdated implements ShouldBroadcastNow { use Dispatchable, InteractsWithSockets, SerializesModels; diff --git a/app/Events/JadwalUpdated.php b/app/Events/JadwalUpdated.php index 233c4774..404e82ee 100644 --- a/app/Events/JadwalUpdated.php +++ b/app/Events/JadwalUpdated.php @@ -4,11 +4,11 @@ use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class JadwalUpdated implements ShouldBroadcast +class JadwalUpdated implements ShouldBroadcastNow { use Dispatchable, InteractsWithSockets, SerializesModels; diff --git a/app/Events/LemburUpdated.php b/app/Events/LemburUpdated.php index f994463b..1069ac88 100644 --- a/app/Events/LemburUpdated.php +++ b/app/Events/LemburUpdated.php @@ -4,17 +4,17 @@ use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class LemburUpdated implements ShouldBroadcast +class LemburUpdated implements ShouldBroadcastNow { use Dispatchable, InteractsWithSockets, SerializesModels; public function __construct( public int $idUser, - public int $idLembur, + public string $idLembur, public string $status, public string $message, ) {} diff --git a/app/Events/PengajuanBaru.php b/app/Events/PengajuanBaru.php index 87a51e36..d9485d22 100644 --- a/app/Events/PengajuanBaru.php +++ b/app/Events/PengajuanBaru.php @@ -4,11 +4,11 @@ use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class PengajuanBaru implements ShouldBroadcast +class PengajuanBaru implements ShouldBroadcastNow { use Dispatchable, InteractsWithSockets, SerializesModels; diff --git a/app/Events/PengajuanIzinUpdated.php b/app/Events/PengajuanIzinUpdated.php index b296eab7..c3cf48c1 100644 --- a/app/Events/PengajuanIzinUpdated.php +++ b/app/Events/PengajuanIzinUpdated.php @@ -4,11 +4,11 @@ use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class PengajuanIzinUpdated implements ShouldBroadcast +class PengajuanIzinUpdated implements ShouldBroadcastNow { use Dispatchable, InteractsWithSockets, SerializesModels; diff --git a/app/Events/PenggunaanPoinUpdated.php b/app/Events/PenggunaanPoinUpdated.php index 05fa6f65..c2d43077 100644 --- a/app/Events/PenggunaanPoinUpdated.php +++ b/app/Events/PenggunaanPoinUpdated.php @@ -4,11 +4,11 @@ use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class PenggunaanPoinUpdated implements ShouldBroadcast +class PenggunaanPoinUpdated implements ShouldBroadcastNow { use Dispatchable, InteractsWithSockets, SerializesModels; diff --git a/app/Events/PresensiMasuk.php b/app/Events/PresensiMasuk.php index 908a5ba4..64b7a627 100644 --- a/app/Events/PresensiMasuk.php +++ b/app/Events/PresensiMasuk.php @@ -4,11 +4,11 @@ use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class PresensiMasuk implements ShouldBroadcast +class PresensiMasuk implements ShouldBroadcastNow { use Dispatchable, InteractsWithSockets, SerializesModels; diff --git a/app/Events/PresensiUpdated.php b/app/Events/PresensiUpdated.php index 569a9719..cf9959c9 100644 --- a/app/Events/PresensiUpdated.php +++ b/app/Events/PresensiUpdated.php @@ -4,11 +4,11 @@ use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class PresensiUpdated implements ShouldBroadcast +class PresensiUpdated implements ShouldBroadcastNow { use Dispatchable, InteractsWithSockets, SerializesModels; diff --git a/app/Events/SuratIzinUpdated.php b/app/Events/SuratIzinUpdated.php index b19ad2a3..f087492a 100644 --- a/app/Events/SuratIzinUpdated.php +++ b/app/Events/SuratIzinUpdated.php @@ -4,11 +4,11 @@ use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class SuratIzinUpdated implements ShouldBroadcast +class SuratIzinUpdated implements ShouldBroadcastNow { use Dispatchable, InteractsWithSockets, SerializesModels; diff --git a/app/Events/TukarShiftUpdated.php b/app/Events/TukarShiftUpdated.php index 7140fd7a..edc7c14f 100644 --- a/app/Events/TukarShiftUpdated.php +++ b/app/Events/TukarShiftUpdated.php @@ -4,11 +4,11 @@ use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class TukarShiftUpdated implements ShouldBroadcast +class TukarShiftUpdated implements ShouldBroadcastNow { use Dispatchable, InteractsWithSockets, SerializesModels; diff --git a/app/Http/Controllers/Api/LemburController.php b/app/Http/Controllers/Api/LemburController.php index 762f9fba..127a4e48 100644 --- a/app/Http/Controllers/Api/LemburController.php +++ b/app/Http/Controllers/Api/LemburController.php @@ -26,8 +26,57 @@ public function store(StoreLemburRequest $request) { try { $user = Auth::user(); + $data = $request->validated(); + $tglLembur = $data['tanggal_lembur']; + $jamMulai = $data['jam_mulai']; + $jamSelesai = $data['jam_selesai']; - $this->lemburService->createLembur($user, $request->validated()); + // 1. Cek bentrok dengan pengajuan lembur lain (Pending/Approved) + $overlappingLembur = Lembur::where('id_user', $user->id) + ->where('tanggal_lembur', $tglLembur) + ->whereIn('id_status', [StatusPengajuan::PENDING, StatusPengajuan::DISETUJUI]) + ->where(function ($q) use ($jamMulai, $jamSelesai) { + $q->where(function ($q2) use ($jamMulai, $jamSelesai) { + $q2->where('jam_mulai', '<', $jamSelesai) + ->where('jam_selesai', '>', $jamMulai); + }); + })->exists(); + + if ($overlappingLembur) { + return ApiResponse::error('Anda sudah memiliki pengajuan lembur lain yang bentrok dengan waktu tersebut.', 400); + } + + // 2. Cek bentrok dengan jam kerja (Shift) + $jadwal = \App\Models\JadwalKerja::with('shift') + ->where('id_user', $user->id) + ->where('tanggal', $tglLembur) + ->first(); + + if ($jadwal && $jadwal->shift) { + $shiftMulai = $jadwal->shift->jam_mulai; + $shiftSelesai = $jadwal->shift->jam_pulang; + + // Cek apakah jam lembur masuk ke dalam range jam kerja + // Syarat lembur: Tidak boleh beririsan dengan jam kerja + $isInsideShift = ($jamMulai < $shiftSelesai && $jamSelesai > $shiftMulai); + + if ($isInsideShift) { + return ApiResponse::error("Waktu lembur tidak boleh bentrok dengan jam kerja reguler ({$shiftMulai} - {$shiftSelesai}).", 400); + } + } + + // 3. Cek apakah ada Izin/Cuti pada hari tersebut + $isIzin = \App\Models\PengajuanIzin::where('id_user', $user->id) + ->whereIn('id_status', [StatusPengajuan::PENDING, StatusPengajuan::DISETUJUI]) + ->where('tanggal_mulai', '<=', $tglLembur) + ->where('tanggal_selesai', '>=', $tglLembur) + ->exists(); + + if ($isIzin) { + return ApiResponse::error('Anda tidak bisa mengajukan lembur di hari saat Anda sedang Izin/Cuti.', 400); + } + + $this->lemburService->createLembur($user, $data); app(NotifikasiService::class)->kirimKeRole( 'hrd', diff --git a/app/Http/Controllers/Api/PengumumanApiController.php b/app/Http/Controllers/Api/PengumumanApiController.php index b7fe3c68..c570d712 100644 --- a/app/Http/Controllers/Api/PengumumanApiController.php +++ b/app/Http/Controllers/Api/PengumumanApiController.php @@ -13,6 +13,7 @@ public function index() try { $pengumuman = Pengumuman::with('pembuat.jabatan') ->orderBy('tanggal', 'desc') + ->orderBy('created_at', 'desc') ->limit(5) ->get() ->map(function ($item) { diff --git a/app/Http/Controllers/Api/PoinController.php b/app/Http/Controllers/Api/PoinController.php index 9c02c8df..5a41b151 100644 --- a/app/Http/Controllers/Api/PoinController.php +++ b/app/Http/Controllers/Api/PoinController.php @@ -101,10 +101,40 @@ public function redeem(Request $request) return ApiResponse::error('Jadwal kerja tidak ditemukan untuk tanggal ' . Carbon::parse($tanggalPenggunaan)->format('d/m/Y') . '.', 400); } - $waktuBatasPengajuan = Carbon::parse($tanggalPenggunaan . ' ' . $jadwal->shift->jam_mulai)->subHour(); + // --- ⚡ VALIDASI PENGGUNAAN POIN GANDA ⚡ --- + $existingPoin = PenggunaanPoin::where('id_user', $userId) + ->where('tanggal_penggunaan', $tanggalPenggunaan) + ->whereIn('id_status', [StatusPengajuan::PENDING, StatusPengajuan::DISETUJUI]) + ->exists(); - if (now()->greaterThan($waktuBatasPengajuan)) { - return ApiResponse::error('Pengajuan poin harus dilakukan maksimal 1 jam sebelum jam kerja dimulai (Batas: ' . $waktuBatasPengajuan->format('d/m/Y H:i') . ').', 400); + if ($existingPoin) { + return ApiResponse::error('Anda sudah memiliki pengajuan penggunaan poin (Pending/Disetujui) untuk tanggal ini.', 400); + } + + // --- ⚡ VALIDASI STATUS PRESENSI ⚡ --- + $presensi = \App\Models\Presensi::where('id_user', $userId) + ->where('tanggal', $tanggalPenggunaan) + ->first(); + + if ($presensi && $presensi->jam_pulang) { + return ApiResponse::error('Anda sudah melakukan absen pulang pada tanggal tersebut. Penggunaan poin tidak diperbolehkan secara retroaktif.', 400); + } + + if ($presensi && $presensi->jam_masuk && $request->id_pengurangan != JenisPengurangan::PULANG_CEPAT_POIN) { + return ApiResponse::error('Anda sudah tercatat melakukan absen masuk. Anda hanya diperbolehkan menggunakan poin untuk "Pulang Cepat".', 400); + } + // ------------------------------------- + + if ($request->id_pengurangan == JenisPengurangan::PULANG_CEPAT_POIN) { + $waktuBatasPulang = Carbon::parse($tanggalPenggunaan . ' ' . $request->jam_pulang_custom)->subHour(); + if (now()->greaterThan($waktuBatasPulang)) { + return ApiResponse::error('Pengajuan Pulang Cepat harus dilakukan maksimal 1 jam sebelum jam kepulangan yang diminta (Batas: ' . $waktuBatasPulang->format('H:i') . ').', 400); + } + } else { + $waktuBatasPengajuan = Carbon::parse($tanggalPenggunaan . ' ' . $jadwal->shift->jam_mulai)->subHour(); + if (now()->greaterThan($waktuBatasPengajuan)) { + return ApiResponse::error('Pengajuan poin harus dilakukan maksimal 1 jam sebelum jam kerja dimulai (Batas: ' . $waktuBatasPengajuan->format('d/m/Y H:i') . ').', 400); + } } PenggunaanPoin::create([ diff --git a/app/Http/Controllers/Api/PresensiController.php b/app/Http/Controllers/Api/PresensiController.php index 7eaadff4..6c790bd5 100644 --- a/app/Http/Controllers/Api/PresensiController.php +++ b/app/Http/Controllers/Api/PresensiController.php @@ -263,7 +263,7 @@ public function history(Request $request) $pulang->addDay(); } - $diffMinutes = $pulang->diffInMinutes($masuk); + $diffMinutes = abs($pulang->diffInMinutes($masuk)); $hours = floor($diffMinutes / 60); $minutes = $diffMinutes % 60; $totalJam = $hours . 'j ' . $minutes . 'm'; diff --git a/app/Http/Controllers/Api/SignatureApiController.php b/app/Http/Controllers/Api/SignatureApiController.php index b6afd220..1ffa0256 100644 --- a/app/Http/Controllers/Api/SignatureApiController.php +++ b/app/Http/Controllers/Api/SignatureApiController.php @@ -74,12 +74,9 @@ public function destroy($id) return ApiResponse::notFound('Tanda tangan tidak ditemukan.'); } - if ($ttd->file_ttd && Storage::disk('public')->exists($ttd->file_ttd)) { - Storage::disk('public')->delete($ttd->file_ttd); - } + // Jangan hapus file fisik dan record untuk menjaga integritas history surat + $ttd->update(['is_active' => false]); - $ttd->delete(); - - return ApiResponse::success(null, 'Tanda tangan berhasil dihapus.'); + return ApiResponse::success(null, 'Tanda tangan berhasil dinonaktifkan.'); } } diff --git a/app/Http/Controllers/Api/SubmissionController.php b/app/Http/Controllers/Api/SubmissionController.php index 5ad1e273..a1f1f6e5 100644 --- a/app/Http/Controllers/Api/SubmissionController.php +++ b/app/Http/Controllers/Api/SubmissionController.php @@ -32,7 +32,22 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request) try { $user = Auth::user(); + $ttdAktif = TandaTangan::where('id_user', $user->id)->active()->first(); + if (!$ttdAktif) { + return ApiResponse::error('Anda belum memiliki tanda tangan digital. Silakan buat di menu Profil terlebih dahulu.', 400); + } + $jenisIzin = JenisIzin::find($request->id_jenis_izin); + + $existingPending = PengajuanIzin::where('id_user', $user->id) + ->where('id_jenis_izin', $request->id_jenis_izin) + ->where('id_status', StatusPengajuan::PENDING) + ->exists(); + + if ($existingPending) { + $namaIzin = $jenisIzin->nama_izin ?? 'Izin'; + return ApiResponse::error("Anda masih memiliki pengajuan {$namaIzin} yang menunggu persetujuan. Tunggu hingga diproses.", 400); + } if ($jenisIzin && $jenisIzin->id_jenis_izin == JenisIzinEnum::CUTI) { $tanggalMulai = Carbon::parse($request->tanggal_mulai); $tanggalSelesai = Carbon::parse($request->tanggal_selesai); @@ -123,7 +138,7 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request) 'id_status' => StatusPengajuan::PENDING ]); - $ttdAktif = TandaTangan::where('id_user', $user->id)->active()->first(); + // TTD sudah dicek di awal, gunakan variable yang sama $namaJenisIzin = $jenisIzin->nama_izin ?? 'Izin'; $tglMulai = Carbon::parse($request->tanggal_mulai)->translatedFormat('d F Y'); @@ -153,8 +168,9 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request) DB::commit(); + $targetRole = ($jenisIzin && $jenisIzin->id_jenis_izin == JenisIzinEnum::CUTI) ? 'manager' : 'hrd'; app(NotifikasiService::class)->kirimKeRole( - 'hrd', + $targetRole, 'pengajuan_baru', 'Pengajuan Baru: ' . ($jenisIzin->nama_izin ?? 'Izin'), $user->nama_lengkap . ' mengajukan ' . ($jenisIzin->nama_izin ?? 'izin') . '.', diff --git a/app/Http/Controllers/DivisiController.php b/app/Http/Controllers/DivisiController.php index b2ebec3b..cd38fec3 100644 --- a/app/Http/Controllers/DivisiController.php +++ b/app/Http/Controllers/DivisiController.php @@ -33,12 +33,16 @@ public function update(UpdateDivisiRequest $request, $id) public function destroy($id) { + if (\App\Models\User::where('id_divisi', $id)->exists()) { + return redirect()->back()->with('error', 'Divisi tidak bisa dihapus karena masih digunakan oleh data pegawai.'); + } + try { Divisi::findOrFail($id)->delete(); return redirect()->back()->with('success', 'Divisi berhasil dihapus.'); } catch (\Illuminate\Database\QueryException $e) { if ($e->getCode() === '23000') { - return redirect()->back()->with('error', 'Divisi tidak bisa dihapus karena masih digunakan oleh data pegawai.'); + return redirect()->back()->with('error', 'Divisi tidak bisa dihapus karena masih terhubung dengan data lain.'); } throw $e; } diff --git a/app/Http/Controllers/FaceApprovalController.php b/app/Http/Controllers/FaceApprovalController.php index 989b15ad..076ed482 100644 --- a/app/Http/Controllers/FaceApprovalController.php +++ b/app/Http/Controllers/FaceApprovalController.php @@ -9,6 +9,7 @@ use App\Services\NotifikasiService; use App\Jobs\ReextractAllFrames; +use App\Jobs\RetrainAllModels; use App\Jobs\MigrateExistingEmbeddings; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; @@ -86,6 +87,8 @@ public function approve($id) $user->dataWajah->update(['is_verified' => StatusVerifikasiWajah::APPROVED]); + RetrainAllModels::dispatch(); + app(NotifikasiService::class)->kirim( $user->id, 'face_disetujui', @@ -95,7 +98,7 @@ public function approve($id) broadcast(new FaceEnrollmentUpdated($user->id, 'approved', 'Data wajah diverifikasi.')); - return redirect()->back()->with('success', 'Wajah berhasil diverifikasi.'); + return redirect()->back()->with('success', 'Wajah berhasil diverifikasi. Model SVM sedang di-training ulang.'); } public function reject($id) @@ -208,6 +211,12 @@ private function cleanupUserFaceData($userId) } } + public function trainingStatus() + { + $status = \Illuminate\Support\Facades\Cache::get('face_training_status', ['phase' => 'idle']); + return response()->json($status); + } + public function reextractAll() { dispatch(new ReextractAllFrames()); diff --git a/app/Http/Controllers/JabatanController.php b/app/Http/Controllers/JabatanController.php index 5c391c9b..b4c25f47 100644 --- a/app/Http/Controllers/JabatanController.php +++ b/app/Http/Controllers/JabatanController.php @@ -29,12 +29,16 @@ public function update(UpdateJabatanRequest $request, $id) public function destroy($id) { + if (\App\Models\User::where('id_jabatan', $id)->exists()) { + return redirect()->back()->with('error', 'Jabatan tidak bisa dihapus karena masih digunakan oleh data pegawai.'); + } + try { Jabatan::findOrFail($id)->delete(); return redirect()->back()->with('success', 'Jabatan berhasil dihapus.'); } catch (\Illuminate\Database\QueryException $e) { if ($e->getCode() === '23000') { - return redirect()->back()->with('error', 'Jabatan tidak bisa dihapus karena masih digunakan oleh data pegawai.'); + return redirect()->back()->with('error', 'Jabatan tidak bisa dihapus karena masih terhubung dengan data lain.'); } throw $e; } diff --git a/app/Http/Controllers/JadwalController.php b/app/Http/Controllers/JadwalController.php index d0012689..30949c96 100644 --- a/app/Http/Controllers/JadwalController.php +++ b/app/Http/Controllers/JadwalController.php @@ -30,7 +30,7 @@ public function index() return $s; }); - $pegawaiQuery = User::where('status_aktif', 1)->bukanHrd(); + $pegawaiQuery = User::where('status_aktif', 1)->bukanHrd()->with(['kantor', 'jabatan']); if (!$isGlobalAdmin) { $pegawaiQuery->where('id_kantor', $user->id_kantor); } @@ -254,25 +254,66 @@ public function store(StoreJadwalRequest $request) $count = 0; $refundCount = 0; + $liburCount = 0; + $skipPresensiCount = 0; + $skipTukarShiftCount = 0; + $skipIzinCount = 0; $poinService = new PoinService(); $usersData = \App\Models\User::whereIn('id', $userIds)->get(['id', 'id_kantor'])->keyBy('id'); try { - DB::transaction(function () use ($period, $userIds, $shiftId, &$count, &$refundCount, $poinService, $usersData) { + DB::transaction(function () use ($period, $userIds, $shiftId, &$count, &$refundCount, &$liburCount, &$skipPresensiCount, &$skipTukarShiftCount, &$skipIzinCount, $poinService, $usersData) { foreach ($period as $date) { $dateStr = $date->format('Y-m-d'); $libursDate = \App\Models\HariLibur::where('tanggal', $dateStr)->get(); foreach ($userIds as $userId) { + // 1. Cek Presensi (Sudah absen masuk atau pulang) + $hasPresensi = \App\Models\Presensi::where('id_user', $userId) + ->where('tanggal', $dateStr) + ->where(function ($q) { + $q->whereNotNull('jam_masuk')->orWhereNotNull('jam_pulang'); + })->exists(); + + if ($hasPresensi) { + $skipPresensiCount++; + continue; + } + + // 2. Cek Riwayat Tukar Shift + $existingJadwal = JadwalKerja::where('id_user', $userId) + ->where('tanggal', $dateStr) + ->first(); + + if ($existingJadwal) { + $hasTukarShift = \App\Models\RiwayatTukarShift::where('id_jadwal_1', $existingJadwal->id_jadwal) + ->orWhere('id_jadwal_2', $existingJadwal->id_jadwal) + ->exists(); + + if ($hasTukarShift) { + $skipTukarShiftCount++; + continue; + } + } + + // 3. Cek Pengajuan Izin / Cuti / Sakit yang sudah disetujui + $hasIzin = \App\Models\PengajuanIzin::where('id_user', $userId) + ->where('id_status', \App\Enums\StatusPengajuan::DISETUJUI) + ->whereDate('tanggal_mulai', '<=', $dateStr) + ->whereDate('tanggal_selesai', '>=', $dateStr) + ->exists(); + + if ($hasIzin) { + $skipIzinCount++; + continue; + } + $user = $usersData->get($userId); $isLibur = $libursDate->contains(function ($libur) use ($user) { return is_null($libur->id_kantor) || ($user && $libur->id_kantor == $user->id_kantor); }); - if ($isLibur) { - continue; - } $conflictPoin = PenggunaanPoin::where('id_user', $userId) ->where('tanggal_penggunaan', $dateStr) ->where('id_status', 2) @@ -288,17 +329,33 @@ public function store(StoreJadwalRequest $request) ['id_shift' => $shiftId] ); $count++; + + if ($isLibur) { + $liburCount++; + } } } }); $message = "Berhasil memproses $count jadwal kerja!"; + if ($liburCount > 0) { + $message .= " ⚠️ INFO: $liburCount jadwal dibuat pada tanggal hari libur."; + } + if ($skipPresensiCount > 0) { + $message .= " 🛑 $skipPresensiCount jadwal dilewati karena pegawai sudah melakukan presensi."; + } + if ($skipTukarShiftCount > 0) { + $message .= " 🛑 $skipTukarShiftCount jadwal dilewati karena terkait dengan riwayat tukar shift."; + } + if ($skipIzinCount > 0) { + $message .= " 🛑 $skipIzinCount jadwal dilewati karena pegawai sedang izin/cuti/sakit."; + } if ($refundCount > 0) { $message .= " ⚠️ PERINGATAN: $refundCount penggunaan poin dibatalkan otomatis & saldo dikembalikan karena konflik jadwal."; - return redirect()->route('jadwal.index')->with('warning', $message); } - return redirect()->route('jadwal.index')->with('success', $message); + $hasWarning = $liburCount > 0 || $skipPresensiCount > 0 || $skipTukarShiftCount > 0 || $skipIzinCount > 0 || $refundCount > 0; + return redirect()->route('jadwal.index')->with($hasWarning ? 'warning' : 'success', $message); } catch (\Exception $e) { return redirect()->back()->with('error', 'Terjadi kesalahan: ' . $e->getMessage()); } @@ -378,13 +435,53 @@ public function bulkDestroy(BulkDestroyJadwalRequest $request) } try { - DB::transaction(function () use ($data) { - JadwalKerja::whereIn('id_user', $data['user_ids']) + $countDeleted = 0; + $skipPresensiCount = 0; + $skipTukarShiftCount = 0; + + DB::transaction(function () use ($data, &$countDeleted, &$skipPresensiCount, &$skipTukarShiftCount) { + $jadwals = JadwalKerja::whereIn('id_user', $data['user_ids']) ->whereBetween('tanggal', [$data['tanggal_mulai'], $data['tanggal_selesai']]) - ->delete(); + ->get(); + + foreach ($jadwals as $jadwal) { + // Cek Presensi (Sudah absen masuk atau pulang) + $hasPresensi = \App\Models\Presensi::where('id_user', $jadwal->id_user) + ->where('tanggal', $jadwal->tanggal) + ->where(function ($q) { + $q->whereNotNull('jam_masuk')->orWhereNotNull('jam_pulang'); + })->exists(); + + if ($hasPresensi) { + $skipPresensiCount++; + continue; + } + + // Cek Riwayat Tukar Shift + $hasTukarShift = \App\Models\RiwayatTukarShift::where('id_jadwal_1', $jadwal->id_jadwal) + ->orWhere('id_jadwal_2', $jadwal->id_jadwal) + ->exists(); + + if ($hasTukarShift) { + $skipTukarShiftCount++; + continue; + } + + $jadwal->delete(); + $countDeleted++; + } }); - return redirect()->route('jadwal.index')->with('success', 'Jadwal kerja berhasil dihapus pada rentang tanggal tersebut.'); + $message = "Berhasil menghapus $countDeleted jadwal kerja!"; + if ($skipPresensiCount > 0) { + $message .= " 🛑 $skipPresensiCount jadwal tidak dihapus karena pegawai sudah presensi."; + } + if ($skipTukarShiftCount > 0) { + $message .= " 🛑 $skipTukarShiftCount jadwal tidak dihapus karena terkait riwayat tukar shift."; + } + + $hasWarning = $skipPresensiCount > 0 || $skipTukarShiftCount > 0; + return redirect()->route('jadwal.index')->with($hasWarning ? 'warning' : 'success', $message); } catch (\Exception $e) { return redirect()->route('jadwal.index')->with('error', 'Terjadi kesalahan saat menghapus jadwal: ' . $e->getMessage()); } @@ -397,26 +494,49 @@ public function checkConflicts(Request $request) $endDate = $request->tanggal_selesai; if (empty($userIds) || !$startDate || !$endDate) { - return response()->json(['has_conflict' => false]); + return response()->json(['has_conflict' => false, 'has_holiday' => false]); } + // Cek konflik poin $conflicts = PenggunaanPoin::with('user') ->whereIn('id_user', $userIds) ->whereBetween('tanggal_penggunaan', [$startDate, $endDate]) ->where('id_status', 2) ->get(); + $conflictData = null; if ($conflicts->count() > 0) { $details = $conflicts->map(function ($item) { return "- " . $item->user->nama_lengkap . " (" . date('d M Y', strtotime($item->tanggal_penggunaan)) . ")"; })->join('
'); - return response()->json([ + $conflictData = [ 'has_conflict' => true, 'message' => "Ditemukan {$conflicts->count()} karyawan yang sedang menggunakan Poin (Cuti/Pulang Cepat) pada tanggal ini:

$details

Jika dilanjutkan, Penggunaan Poin akan DIBATALKAN otomatis dan saldo dikembalikan." - ]); + ]; } - return response()->json(['has_conflict' => false]); + // Cek hari libur dalam rentang tanggal + $hariLiburs = \App\Models\HariLibur::whereBetween('tanggal', [$startDate, $endDate])->get(); + $holidayData = null; + if ($hariLiburs->count() > 0) { + $holidayDetails = $hariLiburs->map(function ($hl) { + return "- " . \Carbon\Carbon::parse($hl->tanggal)->translatedFormat('d M Y') . " (" . $hl->keterangan . ")"; + })->join('
'); + + $holidayData = [ + 'has_holiday' => true, + 'holiday_count' => $hariLiburs->count(), + 'message' => "Terdapat {$hariLiburs->count()} hari libur dalam rentang tanggal ini:

$holidayDetails

Jadwal tetap akan dibuat pada tanggal tersebut." + ]; + } + + return response()->json([ + 'has_conflict' => $conflictData ? true : false, + 'conflict_message' => $conflictData['message'] ?? null, + 'has_holiday' => $holidayData ? true : false, + 'holiday_count' => $holidayData['holiday_count'] ?? 0, + 'holiday_message' => $holidayData['message'] ?? null, + ]); } } \ No newline at end of file diff --git a/app/Http/Controllers/KantorController.php b/app/Http/Controllers/KantorController.php index a60d0453..71f7cc36 100644 --- a/app/Http/Controllers/KantorController.php +++ b/app/Http/Controllers/KantorController.php @@ -29,12 +29,20 @@ public function update(UpdateKantorRequest $request, $id) public function destroy($id) { + if (\App\Models\User::where('id_kantor', $id)->exists()) { + return redirect()->back()->with('error', 'Kantor tidak bisa dihapus karena masih digunakan oleh data pegawai.'); + } + + if (\App\Models\HariLibur::where('id_kantor', $id)->exists()) { + return redirect()->back()->with('error', 'Kantor tidak bisa dihapus karena masih terkait dengan data hari libur.'); + } + try { Kantor::findOrFail($id)->delete(); return redirect()->back()->with('success', 'Data kantor berhasil dihapus.'); } catch (\Illuminate\Database\QueryException $e) { if ($e->getCode() === '23000') { - return redirect()->back()->with('error', 'Kantor tidak bisa dihapus karena masih digunakan oleh data pegawai.'); + return redirect()->back()->with('error', 'Kantor tidak bisa dihapus karena masih terhubung dengan data lain.'); } throw $e; } diff --git a/app/Http/Controllers/PegawaiController.php b/app/Http/Controllers/PegawaiController.php index 7ca3fa30..555a93e9 100644 --- a/app/Http/Controllers/PegawaiController.php +++ b/app/Http/Controllers/PegawaiController.php @@ -20,7 +20,7 @@ public function index(Request $request) $user = auth()->user(); $isGlobalAdmin = $user->isGlobalAdmin(); - $query = User::with(['divisi', 'jabatan', 'kantor', 'roles'])->bukanHrd(); + $query = User::with(['divisi', 'jabatan', 'kantor', 'roles']); if (!$isGlobalAdmin) { $query->where('id_kantor', $user->id_kantor); @@ -30,6 +30,12 @@ public function index(Request $request) $query->where('id_jabatan', $request->filter_jabatan); } + if ($request->filled('filter_role')) { + $query->whereHas('roles', function ($q) use ($request) { + $q->where('detail_user_roles.id_role', $request->filter_role); + }); + } + if ($request->filled('filter_kantor')) { $query->where('id_kantor', $request->filter_kantor); } @@ -50,18 +56,14 @@ public function index(Request $request) $pegawai = $query->latest()->paginate(10)->withQueryString(); - $allKantorQuery = Kantor::withCount([ - 'users as total_pegawai' => function ($q) { - $q->bukanHrd(); - } - ]); + $allKantorQuery = Kantor::withCount('users as total_pegawai'); if (!$isGlobalAdmin) { $allKantorQuery->where('id_kantor', $user->id_kantor); - $totalActive = User::bukanHrd()->where('id_kantor', $user->id_kantor)->where('status_aktif', 1)->count(); - $totalPegawaiStats = User::bukanHrd()->where('id_kantor', $user->id_kantor)->count(); + $totalActive = User::where('id_kantor', $user->id_kantor)->where('status_aktif', 1)->count(); + $totalPegawaiStats = User::where('id_kantor', $user->id_kantor)->count(); } else { - $totalActive = User::bukanHrd()->where('status_aktif', 1)->count(); - $totalPegawaiStats = User::bukanHrd()->count(); + $totalActive = User::where('status_aktif', 1)->count(); + $totalPegawaiStats = User::count(); } $allKantor = $allKantorQuery->get(); @@ -72,8 +74,9 @@ public function index(Request $request) ]; $allJabatan = Jabatan::all(); + $allRoles = Role::all(); - return view('pegawai.index', compact('pegawai', 'stats', 'allJabatan', 'allKantor')); + return view('pegawai.index', compact('pegawai', 'stats', 'allJabatan', 'allKantor', 'allRoles')); } private function generateIdKaryawan($tglBergabung, $idDivisi, $idJabatan) @@ -129,27 +132,40 @@ private function generateIdKaryawan($tglBergabung, $idDivisi, $idJabatan) public function store(StorePegawaiRequest $request) { - $data = $request->validated(); + try { + $data = $request->validated(); - if ($request->hasFile('foto')) { - $data['foto'] = $request->file('foto')->store('foto-profil', 'public'); + if ($request->hasFile('foto')) { + $data['foto'] = $request->file('foto')->store('foto-profil', 'public'); + } + + $tglBergabung = $data['tgl_bergabung'] ?? now()->toDateString(); + $data['nik'] = $this->generateIdKaryawan($tglBergabung, $data['id_divisi'], $data['id_jabatan']); + + $existingNik = User::withTrashed()->where('nik', $data['nik'])->exists(); + if ($existingNik) { + \Illuminate\Support\Facades\Log::warning("NIK duplikat terdeteksi: {$data['nik']}"); + return redirect()->back()->withInput()->with('error', 'Gagal generate NIK unik. Silakan coba lagi.'); + } + + $data['password'] = Hash::make('Mpg123!'); + $data['status_aktif'] = 1; + $data['sisa_cuti'] = 12; + + $user = User::create($data); + + if ($request->filled('id_role')) { + $user->roles()->attach($request->id_role); + } + + \Illuminate\Support\Facades\Log::info("Pegawai baru berhasil dibuat: ID={$user->id}, NIK={$data['nik']}, Nama={$data['nama_lengkap']}"); + + return redirect()->route('pegawai.index')->with('success', 'Pegawai baru berhasil ditambahkan.'); + + } catch (\Exception $e) { + \Illuminate\Support\Facades\Log::error("Gagal menambahkan pegawai: " . $e->getMessage()); + return redirect()->back()->withInput()->with('error', 'Gagal menambahkan pegawai: ' . $e->getMessage()); } - - // Generate ID Karyawan Otomatis - $tglBergabung = $data['tgl_bergabung'] ?? now()->toDateString(); - $data['nik'] = $this->generateIdKaryawan($tglBergabung, $data['id_divisi'], $data['id_jabatan']); - - $data['password'] = Hash::make('Mpg123!'); - $data['status_aktif'] = 1; - $data['sisa_cuti'] = 12; - - $user = User::create($data); - - if ($request->filled('id_role')) { - $user->roles()->attach($request->id_role); - } - - return redirect()->route('pegawai.index')->with('success', 'Pegawai baru berhasil ditambahkan.'); } public function update(UpdatePegawaiRequest $request, $id) diff --git a/app/Http/Controllers/PengajuanIzinController.php b/app/Http/Controllers/PengajuanIzinController.php index 79d96fff..ab332d8d 100644 --- a/app/Http/Controllers/PengajuanIzinController.php +++ b/app/Http/Controllers/PengajuanIzinController.php @@ -32,13 +32,26 @@ public function index(Request $request) $statusId = $request->get('status'); $user = Auth::user(); $isGlobalAdmin = $user->isGlobalAdmin(); + $isSuperAdmin = $user->isSuperAdmin(); $query = PengajuanIzin::with(['user', 'jenisIzin', 'statusPengajuan', 'suratIzin']); - if (!$isGlobalAdmin) { - $query->whereHas('user', function ($q) use ($user) { - $q->where('id_kantor', $user->id_kantor); - }); + if (!$isSuperAdmin) { + $isManager = $user->roles->contains('nama_role', 'manager'); + if (!$isManager) { + $query->where(function ($q) { + $q->where('id_jenis_izin', '!=', JenisIzinEnum::CUTI) + ->orWhereHas('suratIzin', function ($sq) { + $sq->where('status_surat', '!=', StatusSurat::MENUNGGU_MANAJER); + }); + }); + } + + if (!$isGlobalAdmin) { + $query->whereHas('user', function ($q) use ($user) { + $q->where('id_kantor', $user->id_kantor); + }); + } } if ($statusId) { @@ -75,8 +88,18 @@ public function index(Request $request) public function store(StorePengajuanIzinRequest $request) { - $jenisIzinData = JenisIzin::find($request->id_jenis_izin); + + $existingPending = PengajuanIzin::where('id_user', $request->id_user) + ->where('id_jenis_izin', $request->id_jenis_izin) + ->where('id_status', StatusPengajuan::PENDING) + ->exists(); + + if ($existingPending) { + $namaIzin = $jenisIzinData->nama_izin ?? 'Izin'; + return back()->withInput()->with('error', "Pegawai masih memiliki pengajuan {$namaIzin} yang menunggu persetujuan."); + } + if ($jenisIzinData && $jenisIzinData->id_jenis_izin == JenisIzinEnum::CUTI) { $pegawai = User::find($request->id_user); $tanggalMulai = Carbon::parse($request->tanggal_mulai); @@ -190,6 +213,17 @@ public function store(StorePengajuanIzinRequest $request) } DB::commit(); + + $pegawai = User::find($request->id_user); + $targetRole = ($izin->id_jenis_izin == JenisIzinEnum::CUTI) ? 'manager' : 'hrd'; + app(NotifikasiService::class)->kirimKeRole( + $targetRole, + 'pengajuan_baru', + 'Pengajuan Baru: ' . ($jenisIzinData->nama_izin ?? 'Izin'), + ($pegawai->nama_lengkap ?? 'Pegawai') . ' mengajukan ' . ($jenisIzinData->nama_izin ?? 'izin') . '.', + ['id_izin' => $izin->id_izin] + ); + return redirect()->back()->with('success', 'Pengajuan izin berhasil dibuat.'); } catch (\Exception $e) { @@ -260,8 +294,8 @@ public function approve($id) ); broadcast(new PengajuanIzinUpdated( - $izin->id_user, - $izin->id_izin, + (int) $izin->id_user, + (int) $izin->id_izin, 'disetujui', $izin->jenisIzin->nama_izin, 'Pengajuan ' . $izin->jenisIzin->nama_izin . ' disetujui.' @@ -311,8 +345,8 @@ public function reject(Request $request, $id) ); broadcast(new PengajuanIzinUpdated( - $izin->id_user, - $izin->id_izin, + (int) $izin->id_user, + (int) $izin->id_izin, 'ditolak', $izin->jenisIzin->nama_izin ?? 'Izin', 'Pengajuan ' . ($izin->jenisIzin->nama_izin ?? 'Izin') . ' ditolak.' diff --git a/app/Http/Controllers/PenggunaanPoinController.php b/app/Http/Controllers/PenggunaanPoinController.php index e8048847..ef79a257 100644 --- a/app/Http/Controllers/PenggunaanPoinController.php +++ b/app/Http/Controllers/PenggunaanPoinController.php @@ -89,7 +89,13 @@ public function update(Request $request, $id) ['id_penggunaan' => $penggunaan->id_penggunaan] ); - broadcast(new PenggunaanPoinUpdated($penggunaan->id_user, $penggunaan->id_penggunaan, 'disetujui', $penggunaan->jumlah_poin, 'Penggunaan poin disetujui.')); + broadcast(new PenggunaanPoinUpdated( + (int)$penggunaan->id_user, + (int)$penggunaan->id_penggunaan, + 'disetujui', + (int)$penggunaan->jumlah_poin, + 'Penggunaan poin disetujui.' + )); return redirect()->back()->with('success', 'Pengajuan berhasil disetujui dan poin telah dipotong.'); } else { @@ -111,7 +117,13 @@ public function update(Request $request, $id) ['id_penggunaan' => $penggunaan->id_penggunaan] ); - broadcast(new PenggunaanPoinUpdated($penggunaan->id_user, $penggunaan->id_penggunaan, 'ditolak', $penggunaan->jumlah_poin, 'Penggunaan poin ditolak.')); + broadcast(new PenggunaanPoinUpdated( + (int)$penggunaan->id_user, + (int)$penggunaan->id_penggunaan, + 'ditolak', + (int)$penggunaan->jumlah_poin, + 'Penggunaan poin ditolak.' + )); return redirect()->back()->with('success', 'Pengajuan berhasil ditolak.'); } diff --git a/app/Http/Controllers/PengumumanController.php b/app/Http/Controllers/PengumumanController.php index f04f070e..efc48eb5 100644 --- a/app/Http/Controllers/PengumumanController.php +++ b/app/Http/Controllers/PengumumanController.php @@ -16,6 +16,7 @@ public function index() { $pengumuman = Pengumuman::with('pembuat') ->orderBy('tanggal', 'desc') + ->orderBy('created_at', 'desc') ->paginate(10); $title = 'Pengumuman'; diff --git a/app/Http/Controllers/SuratIzinController.php b/app/Http/Controllers/SuratIzinController.php index 314ff76a..95d537e5 100644 --- a/app/Http/Controllers/SuratIzinController.php +++ b/app/Http/Controllers/SuratIzinController.php @@ -24,16 +24,20 @@ public function index(Request $request) { $user = Auth::user(); $isGlobalAdmin = $user->isGlobalAdmin(); + $isSuperAdmin = $user->isSuperAdmin(); $query = SuratIzin::with(['user', 'pengajuanIzin.jenisIzin', 'approvals.approver']); - if (!$isGlobalAdmin) { - if (!$user->roles->contains('nama_role', 'manajer')) { + if (!$isSuperAdmin) { + $isManager = $user->roles->contains('nama_role', 'manager'); + if (!$isManager) { $query->where('status_surat', '!=', StatusSurat::MENUNGGU_MANAJER); } - $query->whereHas('user', function ($q) use ($user) { - $q->where('id_kantor', $user->id_kantor); - }); + if (!$isGlobalAdmin) { + $query->whereHas('user', function ($q) use ($user) { + $q->where('id_kantor', $user->id_kantor); + }); + } } if ($request->filled('status')) { @@ -76,7 +80,7 @@ public function show($id) // Cegah user yang sama menandatangani di dua tahap berbeda $sudahApprove = $surat->approvals->contains('id_approver', $user->id); - if (!$sudahApprove && $surat->status_surat === StatusSurat::MENUNGGU_MANAJER && ($user->roles->contains('nama_role', 'manajer') || $isGlobalAdmin)) { + if (!$sudahApprove && $surat->status_surat === StatusSurat::MENUNGGU_MANAJER && ($user->roles->contains('nama_role', 'manager') || $isGlobalAdmin)) { $canApprove = true; $tahapApproval = 1; } elseif (!$sudahApprove && $surat->status_surat === StatusSurat::MENUNGGU_HRD && ($user->roles->contains('nama_role', 'hrd') || $isGlobalAdmin)) { @@ -116,7 +120,7 @@ public function approve(Request $request, $id) return redirect()->back()->with('error', 'Anda sudah menyetujui surat ini sebelumnya dan tidak dapat menandatangani di posisi lain.'); } - if ($surat->status_surat === StatusSurat::MENUNGGU_MANAJER && ($user->roles->contains('nama_role', 'manajer') || $isGlobalAdmin)) { + if ($surat->status_surat === StatusSurat::MENUNGGU_MANAJER && ($user->roles->contains('nama_role', 'manager') || $isGlobalAdmin)) { $tahap = 1; } elseif ($surat->status_surat === StatusSurat::MENUNGGU_HRD && ($user->roles->contains('nama_role', 'hrd') || $isGlobalAdmin)) { $tahap = 2; @@ -160,6 +164,14 @@ public function approve(Request $request, $id) 'Surat izin Anda telah disetujui Manajer dan sedang menunggu persetujuan HRD.', ['id_surat' => $surat->id_surat] ); + + app(NotifikasiService::class)->kirimKeRole( + 'hrd', + 'izin_proses', + 'Surat Cuti Baru', + 'Surat cuti ' . $surat->user->nama_lengkap . ' telah disetujui Manajer dan menunggu persetujuan HRD.', + ['id_surat' => $surat->id_surat] + ); } else { $statusLabel = 'Disetujui'; app(NotifikasiService::class)->kirim( @@ -172,10 +184,10 @@ public function approve(Request $request, $id) } broadcast(new SuratIzinUpdated( - $surat->id_user, - $surat->id_surat, + (int) $surat->id_user, + (int) $surat->id_surat, $surat->status_surat, - $tahap, + (int) $tahap, 'Surat izin ' . ($tahap === 1 ? 'disetujui Manajer' : 'disetujui sepenuhnya') . '.' )); @@ -214,7 +226,7 @@ public function reject(Request $request, $id) return redirect()->back()->with('error', 'Anda sudah menyetujui surat ini sebelumnya dan tidak dapat menolak di posisi lain.'); } - if ($surat->status_surat === StatusSurat::MENUNGGU_MANAJER && ($user->roles->contains('nama_role', 'manajer') || $isGlobalAdmin)) { + if ($surat->status_surat === StatusSurat::MENUNGGU_MANAJER && ($user->roles->contains('nama_role', 'manager') || $isGlobalAdmin)) { $tahap = 1; } elseif ($surat->status_surat === StatusSurat::MENUNGGU_HRD && ($user->roles->contains('nama_role', 'hrd') || $isGlobalAdmin)) { $tahap = 2; @@ -252,10 +264,10 @@ public function reject(Request $request, $id) ); broadcast(new SuratIzinUpdated( - $surat->id_user, - $surat->id_surat, + (int) $surat->id_user, + (int) $surat->id_surat, StatusSurat::DITOLAK, - $tahap, + (int) $tahap, 'Surat izin ditolak.' )); diff --git a/app/Http/Requests/BulkDestroyJadwalRequest.php b/app/Http/Requests/BulkDestroyJadwalRequest.php index fbf95e41..b396cd1f 100644 --- a/app/Http/Requests/BulkDestroyJadwalRequest.php +++ b/app/Http/Requests/BulkDestroyJadwalRequest.php @@ -14,7 +14,7 @@ public function authorize(): bool public function rules(): array { return [ - 'tanggal_mulai' => ['required', 'date'], + 'tanggal_mulai' => ['required', 'date', 'after_or_equal:today'], 'tanggal_selesai' => ['required', 'date', 'after_or_equal:tanggal_mulai'], 'user_ids' => ['required', 'array', 'min:1'], 'user_ids.*' => ['exists:users,id'], @@ -25,6 +25,7 @@ public function messages(): array { return [ 'tanggal_mulai.required' => 'Tanggal mulai wajib diisi.', + 'tanggal_mulai.after_or_equal' => 'Tanggal mulai tidak boleh di masa lalu (sebelum hari ini).', 'tanggal_selesai.required' => 'Tanggal selesai wajib diisi.', 'tanggal_selesai.after_or_equal' => 'Tanggal selesai tidak boleh sebelum tanggal mulai.', 'user_ids.required' => 'Pilih minimal 1 pegawai.', diff --git a/app/Http/Requests/StoreJadwalRequest.php b/app/Http/Requests/StoreJadwalRequest.php index 942d7719..60a4288c 100644 --- a/app/Http/Requests/StoreJadwalRequest.php +++ b/app/Http/Requests/StoreJadwalRequest.php @@ -14,7 +14,7 @@ public function authorize(): bool public function rules(): array { return [ - 'tanggal_mulai' => ['required', 'date'], + 'tanggal_mulai' => ['required', 'date', 'after_or_equal:today'], 'tanggal_selesai' => ['required', 'date', 'after_or_equal:tanggal_mulai'], 'id_shift' => ['required', 'exists:shift_kerja,id_shift'], 'user_ids' => ['required', 'array', 'min:1'], @@ -26,6 +26,7 @@ public function messages(): array { return [ 'tanggal_mulai.required' => 'Tanggal mulai wajib diisi.', + 'tanggal_mulai.after_or_equal' => 'Tanggal mulai tidak boleh di masa lalu (sebelum hari ini).', 'tanggal_selesai.required' => 'Tanggal selesai wajib diisi.', 'tanggal_selesai.after_or_equal' => 'Tanggal selesai tidak boleh sebelum tanggal mulai.', 'id_shift.required' => 'Shift kerja wajib dipilih.', diff --git a/app/Jobs/BroadcastNotifikasiJob.php b/app/Jobs/BroadcastNotifikasiJob.php new file mode 100644 index 00000000..1f606c50 --- /dev/null +++ b/app/Jobs/BroadcastNotifikasiJob.php @@ -0,0 +1,48 @@ +tipe = $tipe; + $this->judul = $judul; + $this->pesan = $pesan; + $this->data = $data; + } + + /** + * Execute the job. + */ + public function handle(NotifikasiService $notifikasiService): void + { + $users = User::where('status_aktif', 1) + ->whereDoesntHave('roles', function ($q) { + $q->whereIn('nama_role', ['super_admin']); + }) + ->pluck('id'); + + foreach ($users as $idUser) { + $notifikasiService->kirim($idUser, $this->tipe, $this->judul, $this->pesan, $this->data); + } + } +} diff --git a/app/Jobs/ProcessFaceEnrollment.php b/app/Jobs/ProcessFaceEnrollment.php index d3a1a503..0e30caf1 100644 --- a/app/Jobs/ProcessFaceEnrollment.php +++ b/app/Jobs/ProcessFaceEnrollment.php @@ -51,7 +51,7 @@ public function handle(): void fopen($absVideoPath, 'r'), 'enrollment.mp4' ) - ->post(config('services.flask.url') . '/extract-and-embed', [ + ->post(config('services.flask.url') . '/extract-frames', [ 'user_id' => (string) $this->userId, 'target_frames' => 200, ]); @@ -63,29 +63,17 @@ public function handle(): void $output = $response->json(); if (!isset($output['status']) || $output['status'] !== 'success') { - throw new \Exception("Extract & embed error: " . ($output['message'] ?? 'Unknown')); + throw new \Exception("Extract frames error: " . ($output['message'] ?? 'Unknown')); } $jumlahFrame = $output['total_extracted'] ?? 0; - $embedding = $output['embedding'] ?? null; - $updateData = [ + DB::table('data_wajah')->where('id_user', $this->userId)->update([ 'jumlah_frame' => $jumlahFrame, 'is_verified' => StatusVerifikasiWajah::PENDING, - ]; + ]); - if ($embedding && is_array($embedding)) { - $updateData['face_embeddings'] = json_encode($embedding); - $updateData['jumlah_embedding'] = $jumlahFrame; - $updateData['embedding_generated_at'] = now(); - Log::info("Face enrollment: embedding berhasil di-generate untuk user {$this->userId}. Dimensi: " . count($embedding)); - } else { - Log::warning("Face enrollment: frame berhasil di-extract tapi embedding tidak tersedia untuk user {$this->userId}."); - } - - DB::table('data_wajah')->where('id_user', $this->userId)->update($updateData); - - Log::info("Face enrollment: extract & embed berhasil untuk user {$this->userId}. Frames: {$jumlahFrame}. Menunggu HRD approve."); + Log::info("Face enrollment: extract frames berhasil untuk user {$this->userId}. Frames: {$jumlahFrame}. Menunggu HRD approve."); } catch (\Exception $e) { Log::error("Face enrollment GAGAL untuk user {$this->userId}: " . $e->getMessage()); diff --git a/app/Jobs/ReextractAllFrames.php b/app/Jobs/ReextractAllFrames.php index 0463f934..480d73ec 100644 --- a/app/Jobs/ReextractAllFrames.php +++ b/app/Jobs/ReextractAllFrames.php @@ -7,6 +7,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -20,22 +21,56 @@ class ReextractAllFrames implements ShouldQueue public function handle(): void { - Log::info("Memulai re-extract frames & embedding untuk semua user..."); + Log::info("Memulai sync & retrain untuk semua user..."); $users = DB::table('data_wajah') ->whereNotNull('path_video') ->get(); if ($users->isEmpty()) { - Log::info("Tidak ada user dengan video. Skip re-extract."); + Log::info("Tidak ada user dengan video. Skip sync."); + Cache::put('face_training_status', ['phase' => 'idle'], 60); return; } - $successCount = 0; + $extractCount = 0; + $skipCount = 0; $failCount = 0; + $total = $users->count(); + $current = 0; + + Cache::put('face_training_status', [ + 'phase' => 'extracting', + 'current' => 0, + 'total' => $total, + 'message' => "Mengecek frames 0/{$total} user...", + ], 3600); foreach ($users as $data) { $userId = $data->id_user; + $current++; + + $frameDir = Storage::disk('local')->path("face_datasets/{$userId}"); + $existingFrames = 0; + + if (is_dir($frameDir)) { + $existingFrames = count(glob($frameDir . '/frame_*.jpg')); + } + + if ($existingFrames >= 50) { + Log::info("User {$userId}: sudah punya {$existingFrames} frame. Skip extract."); + $skipCount++; + + Cache::put('face_training_status', [ + 'phase' => 'extracting', + 'current' => $current, + 'total' => $total, + 'message' => "User {$current}/{$total}: skip (sudah ada {$existingFrames} frame)", + ], 3600); + + continue; + } + $videoPath = Storage::disk('local')->path($data->path_video); if (!file_exists($videoPath)) { @@ -45,6 +80,13 @@ public function handle(): void } try { + Cache::put('face_training_status', [ + 'phase' => 'extracting', + 'current' => $current, + 'total' => $total, + 'message' => "Extract frames user {$current}/{$total}...", + ], 3600); + $response = Http::timeout(120) ->withHeaders([ 'X-API-Key' => config('services.flask.api_key'), @@ -54,7 +96,7 @@ public function handle(): void fopen($videoPath, 'r'), 'enrollment.mp4' ) - ->post(config('services.flask.url') . '/extract-and-embed', [ + ->post(config('services.flask.url') . '/extract-frames', [ 'user_id' => (string) $userId, 'target_frames' => 200, ]); @@ -70,36 +112,36 @@ public function handle(): void } $jumlahFrame = $output['total_extracted'] ?? 0; - $embedding = $output['embedding'] ?? null; - $updateData = [ + DB::table('data_wajah')->where('id_user', $userId)->update([ 'jumlah_frame' => $jumlahFrame, - ]; + ]); - if ($embedding && is_array($embedding)) { - $updateData['face_embeddings'] = json_encode($embedding); - $updateData['jumlah_embedding'] = $jumlahFrame; - $updateData['embedding_generated_at'] = now(); - } - - DB::table('data_wajah')->where('id_user', $userId)->update($updateData); - - Log::info("Re-extract & embed berhasil untuk user {$userId}. Frames: {$jumlahFrame}"); - $successCount++; + Log::info("Extract frames berhasil untuk user {$userId}. Frames: {$jumlahFrame}"); + $extractCount++; } catch (\Exception $e) { - Log::error("Re-extract GAGAL untuk user {$userId}: " . $e->getMessage()); + Log::error("Extract GAGAL untuk user {$userId}: " . $e->getMessage()); $failCount++; } } - Log::info("Re-extract selesai. Berhasil: {$successCount}, Gagal: {$failCount}"); + Log::info("Sync selesai. Extracted: {$extractCount}, Skipped: {$skipCount}, Gagal: {$failCount}. Memulai retrain SVM..."); + + Cache::put('face_training_status', [ + 'phase' => 'training', + 'current' => $total, + 'total' => $total, + 'message' => "Sync selesai (Extract: {$extractCount}, Skip: {$skipCount}). Memulai training model SVM...", + ], 3600); + + RetrainAllModels::dispatch(); app(\App\Services\NotifikasiService::class)->kirimKeRole( 'hrd', 'pengumuman', - 'Ekstraksi & Embedding Wajah Selesai', - "Proses re-extract video wajah selesai. Berhasil: {$successCount}, Gagal: {$failCount}." + 'Sync & Retrain Selesai', + "Sync frame selesai (Extract: {$extractCount}, Skip: {$skipCount}, Gagal: {$failCount}). Model SVM sedang di-training ulang." ); } } diff --git a/app/Jobs/RetrainAllModels.php b/app/Jobs/RetrainAllModels.php index 759a95c8..bfac3d45 100644 --- a/app/Jobs/RetrainAllModels.php +++ b/app/Jobs/RetrainAllModels.php @@ -9,6 +9,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -24,6 +25,11 @@ public function handle(): void { Log::info("Memulai training model SVM multi-class global..."); + Cache::put('face_training_status', [ + 'phase' => 'training', + 'message' => 'Memulai training model SVM...', + ], 3600); + $approvedUsers = DB::table('data_wajah') ->where('is_verified', StatusVerifikasiWajah::APPROVED) ->pluck('id_user') @@ -56,8 +62,14 @@ public function handle(): void } $totalUsers = $output['total_users'] ?? 0; + $testAcc = $output['test_accuracy'] ?? 0; Log::info("Training model global selesai. Total user: {$totalUsers}"); + Cache::put('face_training_status', [ + 'phase' => 'done', + 'message' => "Training selesai! {$totalUsers} user, Akurasi: " . round($testAcc * 100, 2) . '%', + ], 300); + app(\App\Services\NotifikasiService::class)->kirimKeRole( 'hrd', 'pengumuman', @@ -67,6 +79,11 @@ public function handle(): void } catch (\Exception $e) { Log::error("Training model global GAGAL: " . $e->getMessage()); + + Cache::put('face_training_status', [ + 'phase' => 'error', + 'message' => 'Training gagal: ' . $e->getMessage(), + ], 300); app(\App\Services\NotifikasiService::class)->kirimKeRole( 'hrd', diff --git a/app/Jobs/SendFcmPushNotification.php b/app/Jobs/SendFcmPushNotification.php new file mode 100644 index 00000000..fc2e66ff --- /dev/null +++ b/app/Jobs/SendFcmPushNotification.php @@ -0,0 +1,39 @@ +idUser = $idUser; + $this->judul = $judul; + $this->pesan = $pesan; + $this->data = $data; + } + + /** + * Execute the job. + */ + public function handle(NotifikasiService $notifikasiService): void + { + $notifikasiService->sendPushNotification($this->idUser, $this->judul, $this->pesan, $this->data); + } +} diff --git a/app/Services/FaceRecognitionService.php b/app/Services/FaceRecognitionService.php index 1230c0f2..062a0427 100644 --- a/app/Services/FaceRecognitionService.php +++ b/app/Services/FaceRecognitionService.php @@ -77,62 +77,10 @@ public function enrollFace($userId, UploadedFile $video) } /** - * Verifikasi wajah menggunakan pendekatan embedding + cosine similarity. - * File dikirim ke Flask untuk generate embedding, lalu dibandingkan dengan database. + * Verifikasi wajah menggunakan SVM (Jalur 2). + * File dikirim ke Flask /verify-face untuk prediksi SVM. */ public function verifyFace($userId, UploadedFile $file) - { - $probeEmbedding = $this->getEmbedding($file); - - $allEmbeddings = DB::table('data_wajah') - ->where('is_verified', StatusVerifikasiWajah::APPROVED) - ->whereNotNull('face_embeddings') - ->get(['id_user', 'face_embeddings']); - - if ($allEmbeddings->isEmpty()) { - throw new \Exception('Belum ada data wajah terverifikasi di sistem.'); - } - - $bestMatch = null; - $bestScore = -1; - - foreach ($allEmbeddings as $data) { - $storedEmbedding = json_decode($data->face_embeddings, true); - if (!is_array($storedEmbedding)) continue; - - $similarity = $this->cosineSimilarity($probeEmbedding, $storedEmbedding); - - if ($similarity > $bestScore) { - $bestScore = $similarity; - $bestMatch = $data->id_user; - } - } - - $threshold = 0.6; - $isMatch = $bestScore >= $threshold && $bestMatch == $userId; - - $verificationStatus = 'REJECTED'; - if ($isMatch) { - $verificationStatus = 'MATCH'; - } elseif ($bestScore >= $threshold && $bestMatch != $userId) { - $verificationStatus = 'MISMATCH'; - } - - Log::info("[FaceVerify] User {$userId}: best_match={$bestMatch}, score={$bestScore}, status={$verificationStatus}"); - - return [ - 'verified' => $isMatch, - 'confidence' => round($bestScore, 4), - 'svm_df' => null, - 'verification_status' => $verificationStatus, - 'blur_score' => $probeEmbedding['blur_score'] ?? null, - 'predicted_user' => $bestMatch, - 'expected_user' => $userId, - 'message' => $isMatch ? 'Wajah cocok' : 'Wajah tidak cocok', - ]; - } - - private function getEmbedding(UploadedFile $file): array { $isVideo = str_starts_with($file->getMimeType() ?? '', 'video/'); @@ -143,52 +91,44 @@ private function getEmbedding(UploadedFile $file): array fopen($file->getRealPath(), 'r'), $isVideo ? 'verify.mp4' : 'verify.jpg' ) - ->post($this->getFlaskUrl() . '/get-embedding', [ + ->post($this->getFlaskUrl() . '/verify-face', [ + 'user_id' => (string) $userId, 'is_video' => $isVideo ? 'true' : 'false', ]); if (!$response->successful()) { $body = $response->json(); $msg = $body['message'] ?? 'Flask ML API tidak merespons.'; - Log::error("[FaceVerify] Flask /get-embedding error: HTTP {$response->status()} - {$msg}"); - throw new \Exception("Gagal mengekstrak embedding wajah. {$msg}"); + Log::error("[FaceVerify] Flask /verify-face error: HTTP {$response->status()} - {$msg}"); + throw new \Exception("Gagal memverifikasi wajah. {$msg}"); } - $output = $response->json(); + $result = $response->json(); - if (!$output || $output['status'] !== 'success' || !isset($output['embedding'])) { - throw new \Exception('Respons embedding tidak valid.'); + if (!$result || $result['status'] !== 'success') { + throw new \Exception($result['message'] ?? 'Respons verifikasi tidak valid.'); } - return $output; - } + Log::info("[FaceVerify] User {$userId}: " . json_encode([ + 'match' => $result['match'], + 'verification_status' => $result['verification_status'], + 'confidence' => $result['confidence'], + 'svm_df' => $result['svm_df'] ?? null, + 'predicted_user' => $result['predicted_user'] ?? null, + 'blur_score' => $result['blur_score'] ?? null, + ])); - private function cosineSimilarity(array $embeddingResponse, array $storedEmbedding): float - { - $a = $embeddingResponse['embedding'] ?? $embeddingResponse; - $b = $storedEmbedding; - - if (count($a) !== count($b) || count($a) === 0) { - return 0.0; - } - - $dotProduct = 0.0; - $normA = 0.0; - $normB = 0.0; - - for ($i = 0; $i < count($a); $i++) { - $dotProduct += $a[$i] * $b[$i]; - $normA += $a[$i] * $a[$i]; - $normB += $b[$i] * $b[$i]; - } - - $normA = sqrt($normA); - $normB = sqrt($normB); - - if ($normA == 0 || $normB == 0) { - return 0.0; - } - - return $dotProduct / ($normA * $normB); + return [ + 'verified' => $result['match'], + 'confidence' => $result['confidence'], + 'svm_df' => $result['svm_df'] ?? null, + 'verification_status' => $result['verification_status'], + 'blur_score' => $result['blur_score'] ?? null, + 'predicted_user' => $result['predicted_user'] ?? null, + 'expected_user' => (string) $userId, + 'approved_ratio' => $result['approved_ratio'] ?? null, + 'message' => $result['message'] ?? ($result['match'] ? 'Wajah cocok' : 'Wajah tidak cocok'), + ]; } } + diff --git a/app/Services/NotifikasiService.php b/app/Services/NotifikasiService.php index 25799a74..939575a5 100644 --- a/app/Services/NotifikasiService.php +++ b/app/Services/NotifikasiService.php @@ -28,7 +28,8 @@ public function kirim(int $idUser, string $tipe, string $judul, string $pesan, a $unreadCount = Notifikasi::where('id_user', $idUser)->where('is_read', false)->count(); broadcast(new NotifikasiCreated($idUser, $judul, $pesan, $tipe, $unreadCount)); - $this->sendPushNotification($idUser, $judul, $pesan, $data); + $data['tipe'] = $tipe; + \App\Jobs\SendFcmPushNotification::dispatch($idUser, $judul, $pesan, $data); } catch (\Exception $e) { Log::error('NotifikasiService::kirim gagal: ' . $e->getMessage()); } @@ -39,15 +40,7 @@ public function kirim(int $idUser, string $tipe, string $judul, string $pesan, a */ public function kirimBroadcast(string $tipe, string $judul, string $pesan, array $data = []): void { - $users = User::where('status_aktif', 1) - ->whereDoesntHave('roles', function ($q) { - $q->whereIn('nama_role', ['super_admin']); - }) - ->pluck('id'); - - foreach ($users as $idUser) { - $this->kirim($idUser, $tipe, $judul, $pesan, $data); - } + \App\Jobs\BroadcastNotifikasiJob::dispatch($tipe, $judul, $pesan, $data); } /** @@ -70,7 +63,7 @@ public function kirimKeRole(string $namaRole, string $tipe, string $judul, strin * Kirim push notification ke device FCM user via FCM HTTP v1 API. * Token yang sudah tidak valid (UNREGISTERED) otomatis dihapus dari DB. */ - private function sendPushNotification(int $idUser, string $judul, string $pesan, array $data = []): void + public function sendPushNotification(int $idUser, string $judul, string $pesan, array $data = []): void { $tokens = DeviceToken::where('id_user', $idUser)->get(); if ($tokens->isEmpty()) { @@ -97,6 +90,7 @@ private function sendPushNotification(int $idUser, string $judul, string $pesan, 'notification' => ['title' => $judul, 'body' => $pesan], 'data' => array_map('strval', $data), 'android' => [ + 'priority' => 'HIGH', 'notification' => [ 'channel_id' => 'hris_channel', 'click_action' => 'FLUTTER_NOTIFICATION_CLICK', @@ -125,35 +119,38 @@ private function sendPushNotification(int $idUser, string $judul, string $pesan, /** * Ambil access token Google OAuth2 menggunakan Service Account JWT. + * Dicache selama 50 menit (token OAuth biasanya berlaku 1 jam). */ private function getFcmAccessToken(): ?string { - try { - $credPath = base_path(env('FIREBASE_CREDENTIALS')); - $credentials = json_decode(file_get_contents($credPath), true); - $now = time(); + return \Illuminate\Support\Facades\Cache::remember('fcm_access_token', 3000, function () { + try { + $credPath = base_path(env('FIREBASE_CREDENTIALS')); + $credentials = json_decode(file_get_contents($credPath), true); + $now = time(); - $header = rtrim(strtr(base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])), '+/', '-_'), '='); - $payload = rtrim(strtr(base64_encode(json_encode([ - 'iss' => $credentials['client_email'], - 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', - 'aud' => 'https://oauth2.googleapis.com/token', - 'iat' => $now, - 'exp' => $now + 3600, - ])), '+/', '-_'), '='); + $header = rtrim(strtr(base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])), '+/', '-_'), '='); + $payload = rtrim(strtr(base64_encode(json_encode([ + 'iss' => $credentials['client_email'], + 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', + 'aud' => 'https://oauth2.googleapis.com/token', + 'iat' => $now, + 'exp' => $now + 3600, + ])), '+/', '-_'), '='); - openssl_sign("$header.$payload", $sig, $credentials['private_key'], OPENSSL_ALGO_SHA256); - $jwt = "$header.$payload." . rtrim(strtr(base64_encode($sig), '+/', '-_'), '='); + openssl_sign("$header.$payload", $sig, $credentials['private_key'], OPENSSL_ALGO_SHA256); + $jwt = "$header.$payload." . rtrim(strtr(base64_encode($sig), '+/', '-_'), '='); - $resp = \Illuminate\Support\Facades\Http::asForm()->withoutVerifying()->post('https://oauth2.googleapis.com/token', [ - 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', - 'assertion' => $jwt, - ]); + $resp = \Illuminate\Support\Facades\Http::asForm()->withoutVerifying()->post('https://oauth2.googleapis.com/token', [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt, + ]); - return $resp->json('access_token'); - } catch (\Exception $e) { - Log::error('FCM Token error: ' . $e->getMessage()); - return null; - } + return $resp->json('access_token'); + } catch (\Exception $e) { + Log::error('FCM Token error: ' . $e->getMessage()); + return null; + } + }); } } diff --git a/composer.json b/composer.json index 4a324b4f..6ae1126d 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ ], "dev": [ "Composer\\Config::disableProcessTimeout", - "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74,#a3e635\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan reverb:start\" \"npm run dev\" --names=server,queue,reverb,vite --kill-others" + "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74,#a3e635\" \"php artisan serve --host=0.0.0.0\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan reverb:start\" \"npm run dev\" --names=server,queue,reverb,vite --kill-others" ], "test": [ "@php artisan config:clear --ansi", diff --git a/public/js/notifications.js b/public/js/notifications.js index cfb2da1d..63e248b9 100644 --- a/public/js/notifications.js +++ b/public/js/notifications.js @@ -27,6 +27,7 @@ function initSweetAlerts() { if (swalData) { const successMessage = swalData.getAttribute('data-success'); const errorMessage = swalData.getAttribute('data-error'); + const warningMessage = swalData.getAttribute('data-warning'); if (successMessage && successMessage !== '') { showSuccess(successMessage); @@ -38,6 +39,17 @@ function initSweetAlerts() { swalData.setAttribute('data-error', ''); } + if (warningMessage && warningMessage !== '') { + Swal.fire({ + icon: 'warning', + title: 'Peringatan', + text: warningMessage, + confirmButtonColor: '#f59e0b', + confirmButtonText: 'OK' + }); + swalData.setAttribute('data-warning', ''); + } + const validationErrors = swalData.getAttribute('data-errors'); if (validationErrors) { try { @@ -198,28 +210,7 @@ function updateBadgeUI(count) { } } -function confirmDelete(id) { - Swal.fire({ - title: 'Apakah Anda yakin?', - text: "Data ini akan dihapus secara permanen.", - icon: 'warning', - showCancelButton: true, - confirmButtonColor: '#d33', - cancelButtonColor: '#3085d6', - confirmButtonText: 'Ya, hapus saja!', - cancelButtonText: 'Batal', - reverseButtons: true - }).then((result) => { - if (result.isConfirmed) { - const form = document.getElementById('delete-form-' + id); - if (form) { - form.submit(); - } else { - console.error('Form hapus tidak ditemukan untuk ID:', id); - } - } - }); -} + function confirmAction(event, formId, message, confirmBtnColor = '#3085d6', confirmBtnText = 'Ya, lanjutkan!') { event.preventDefault(); @@ -238,7 +229,7 @@ function confirmAction(event, formId, message, confirmBtnColor = '#3085d6', conf if (result.isConfirmed) { const form = document.getElementById(formId); if (form) { - form.submit(); + form.requestSubmit(); } else { console.error('Form tidak ditemukan:', formId); } diff --git a/public/js/realtime.js b/public/js/realtime.js index eb917635..a4563b24 100644 --- a/public/js/realtime.js +++ b/public/js/realtime.js @@ -74,10 +74,11 @@ document.addEventListener('DOMContentLoaded', function () { position: 'top-end', icon: 'info', title: title, - text: message, + html: '
' + message + '
', showConfirmButton: false, timer: 4000, timerProgressBar: true, + padding: '1em', }); } diff --git a/resources/views/dashboard/index.blade.php b/resources/views/dashboard/index.blade.php index ba21821f..4da6bdf0 100644 --- a/resources/views/dashboard/index.blade.php +++ b/resources/views/dashboard/index.blade.php @@ -227,38 +227,40 @@ class="text-xs text-primary hover:underline font-medium">Lihat @section('script') @endsection diff --git a/resources/views/face-approval/index.blade.php b/resources/views/face-approval/index.blade.php index 871ea98c..f68143de 100644 --- a/resources/views/face-approval/index.blade.php +++ b/resources/views/face-approval/index.blade.php @@ -101,12 +101,28 @@ class="!mb-0" /> - + + @@ -310,5 +326,84 @@ function closeFacePreview() { document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeFacePreview(); }); + + let pollingInterval = null; + + // Cek status saat halaman pertama kali dimuat + document.addEventListener('DOMContentLoaded', function() { + fetch('{{ route("face.training_status") }}') + .then(r => r.json()) + .then(data => { + const phase = data.phase || 'idle'; + if (phase === 'extracting' || phase === 'training') { + startPolling(); + } + }) + .catch(() => {}); + }); + + document.getElementById('reextract-form').addEventListener('submit', function() { + startPolling(); + }); + + function startPolling() { + const progressEl = document.getElementById('trainingProgress'); + const btn = document.getElementById('reextractBtn'); + progressEl.classList.remove('hidden'); + btn.disabled = true; + btn.classList.add('opacity-50', 'cursor-not-allowed'); + + pollingInterval = setInterval(checkStatus, 3000); + } + + function checkStatus() { + fetch('{{ route("face.training_status") }}') + .then(r => r.json()) + .then(data => { + const phase = data.phase || 'idle'; + const message = data.message || ''; + const current = data.current || 0; + const total = data.total || 1; + + document.getElementById('trainingMessage').textContent = message; + + if (phase === 'extracting') { + const pct = Math.round((current / total) * 70); + document.getElementById('trainingBar').style.width = pct + '%'; + document.getElementById('trainingPhase').textContent = 'Extracting Frames...'; + } else if (phase === 'training') { + document.getElementById('trainingBar').style.width = '85%'; + document.getElementById('trainingPhase').textContent = 'Training SVM...'; + } else if (phase === 'done') { + document.getElementById('trainingBar').style.width = '100%'; + document.getElementById('trainingPhase').textContent = '✅ Selesai!'; + document.getElementById('trainingProgress').querySelector('div').className = + 'bg-emerald-50 border border-emerald-200 rounded-lg p-4'; + document.getElementById('trainingPhase').className = 'text-sm font-semibold text-emerald-800'; + document.getElementById('trainingMessage').className = 'text-xs text-emerald-600 mt-1.5'; + document.getElementById('trainingBar').className = 'bg-emerald-600 h-2.5 rounded-full transition-all duration-500'; + document.querySelector('#trainingProgress svg').classList.remove('animate-spin'); + stopPolling(); + } else if (phase === 'error') { + document.getElementById('trainingPhase').textContent = '❌ Gagal'; + document.getElementById('trainingProgress').querySelector('div').className = + 'bg-red-50 border border-red-200 rounded-lg p-4'; + document.getElementById('trainingPhase').className = 'text-sm font-semibold text-red-800'; + document.getElementById('trainingMessage').className = 'text-xs text-red-600 mt-1.5'; + stopPolling(); + } + }) + .catch(() => {}); + } + + function stopPolling() { + if (pollingInterval) { + clearInterval(pollingInterval); + pollingInterval = null; + } + const btn = document.getElementById('reextractBtn'); + btn.disabled = false; + btn.classList.remove('opacity-50', 'cursor-not-allowed'); + } @endsection diff --git a/resources/views/izin/index.blade.php b/resources/views/izin/index.blade.php index a87cc6c2..4c942d4c 100644 --- a/resources/views/izin/index.blade.php +++ b/resources/views/izin/index.blade.php @@ -344,12 +344,32 @@ function openDetailModal(data) { } function submitRejectWithAlasan() { - const alasan = prompt('Masukkan alasan penolakan (opsional):'); - if (alasan === null) return; - const inputAlasan = document.getElementById('input-alasan-penolakan'); - if (inputAlasan) inputAlasan.value = alasan; - const form = document.getElementById('form-reject-izin'); - if (form) form.submit(); + // Tutup modal detail terlebih dahulu untuk menghindari focus trap + window.dispatchEvent(new CustomEvent('close-modal', { detail: 'detail-izin' })); + + setTimeout(() => { + Swal.fire({ + title: 'Tolak Pengajuan', + text: 'Masukkan alasan penolakan (opsional):', + input: 'textarea', + inputPlaceholder: 'Ketik alasan di sini...', + showCancelButton: true, + confirmButtonColor: '#ef4444', + cancelButtonColor: '#64748b', + confirmButtonText: 'Ya, Tolak', + cancelButtonText: 'Batal' + }).then((result) => { + if (result.isConfirmed) { + const inputAlasan = document.getElementById('input-alasan-penolakan'); + if (inputAlasan) inputAlasan.value = result.value || ''; + const form = document.getElementById('form-reject-izin'); + if (form) form.requestSubmit(); + } else { + // Jika dibatalkan, buka kembali modal detail + window.dispatchEvent(new CustomEvent('open-modal', { detail: 'detail-izin' })); + } + }); + }, 150); // Jeda singkat agar animasi tutup modal selesai } function initCreateIzinForm() { diff --git a/resources/views/jadwal/create.blade.php b/resources/views/jadwal/create.blade.php index a60702e4..cef1b1a5 100644 --- a/resources/views/jadwal/create.blade.php +++ b/resources/views/jadwal/create.blade.php @@ -10,15 +10,15 @@
-
+ @csrf
+ value="{{ old('tanggal_mulai', now()->format('Y-m-d')) }}" required min="{{ date('Y-m-d') }}" /> + value="{{ old('tanggal_selesai', now()->addDays(6)->format('Y-m-d')) }}" required min="{{ date('Y-m-d') }}" /> @@ -88,7 +88,7 @@ class="flex items-center gap-3 p-3 bg-slate-50 rounded-lg hover:bg-slate-100 tra
Batal - @@ -128,13 +128,11 @@ class="px-6 py-2 text-sm bg-primary text-white rounded-xl hover:bg-primary/90 fo })(); (function() { - const formObj = document.querySelector('form'); - if (formObj) { - formObj.addEventListener('submit', function (e) { - e.preventDefault(); - - let form = this; + const formObj = document.getElementById('jadwalForm'); + const generateBtn = document.getElementById('generateBtn'); + if (generateBtn && formObj) { + generateBtn.addEventListener('click', function () { let selectedUserIds = []; document.querySelectorAll('.user-checkbox:checked').forEach((cb) => { selectedUserIds.push(cb.value); @@ -154,7 +152,7 @@ class="px-6 py-2 text-sm bg-primary text-white rounded-xl hover:bg-primary/90 fo Swal.fire({ title: 'Memeriksa Jadwal...', - text: 'Sedang mengecek bentrok penggunaan poin', + text: 'Sedang mengecek bentrok penggunaan poin & hari libur', allowOutsideClick: false, didOpen: () => { Swal.showLoading() } }); @@ -169,23 +167,47 @@ class="px-6 py-2 text-sm bg-primary text-white rounded-xl hover:bg-primary/90 fo }) .then(response => response.json()) .then(data => { + let warnings = []; + + if (data.has_holiday) { + warnings.push(data.holiday_message); + } if (data.has_conflict) { + warnings.push(data.conflict_message); + } + + if (warnings.length > 0) { Swal.fire({ - title: '⚠️ Konflik Data Poin!', - html: data.message, + title: data.has_conflict ? '⚠️ Konflik & Peringatan!' : '📅 Peringatan Hari Libur', + html: warnings.join('
') + + '
Apakah Anda yakin ingin melanjutkan pembuatan jadwal?', icon: 'warning', showCancelButton: true, - confirmButtonColor: '#d33', - cancelButtonColor: '#3085d6', - confirmButtonText: 'Ya, Ubah & Batalkan Poin', + confirmButtonColor: data.has_conflict ? '#d33' : '#3085d6', + cancelButtonColor: '#6b7280', + confirmButtonText: data.has_conflict ? 'Ya, Lanjutkan & Batalkan Poin' : 'Ya, Tetap Buat Jadwal', cancelButtonText: 'Batal' }).then((result) => { if (result.isConfirmed) { - form.submit(); + formObj.submit(); } }); } else { - form.submit(); + Swal.fire({ + title: '📋 Konfirmasi Jadwal', + html: `Anda akan membuat jadwal kerja untuk ${selectedUserIds.length} pegawai ` + + `dari tanggal ${payload.tanggal_mulai} s/d ${payload.tanggal_selesai}.

Lanjutkan?`, + icon: 'question', + showCancelButton: true, + confirmButtonColor: '#3085d6', + cancelButtonColor: '#6b7280', + confirmButtonText: 'Ya, Buat Jadwal', + cancelButtonText: 'Batal' + }).then((result) => { + if (result.isConfirmed) { + formObj.submit(); + } + }); } }) .catch(error => { diff --git a/resources/views/jadwal/index.blade.php b/resources/views/jadwal/index.blade.php index b9be7e79..b30d0e38 100644 --- a/resources/views/jadwal/index.blade.php +++ b/resources/views/jadwal/index.blade.php @@ -373,15 +373,27 @@ class="text-red-500 text-sm hover:underline font-medium">Hapus Jadwal x-data="{ tglMulai: '', tglSelesai: '', + filterKantor: '', + filterDivisi: '', selectAll: true, + getVisibleCheckboxes() { + let checkboxes = document.querySelectorAll('#form-bulk-delete .user-checkbox'); + return Array.from(checkboxes).filter(cb => { + let item = cb.closest('.user-checkbox-item'); + let k = item.getAttribute('data-kantor'); + let d = item.getAttribute('data-divisi'); + let showKantor = this.filterKantor === '' || this.filterKantor == k; + let showDivisi = this.filterDivisi === '' || this.filterDivisi == d; + return showKantor && showDivisi; + }); + }, toggleAll() { - let checkboxes = document.querySelectorAll('.user-checkbox'); - checkboxes.forEach(cb => cb.checked = this.selectAll); + this.getVisibleCheckboxes().forEach(cb => cb.checked = this.selectAll); }, updateSelectAll() { - let checkboxes = document.querySelectorAll('.user-checkbox'); - let checkedBoxes = document.querySelectorAll('.user-checkbox:checked'); - this.selectAll = checkboxes.length === checkedBoxes.length; + let visible = this.getVisibleCheckboxes(); + let checked = visible.filter(cb => cb.checked); + this.selectAll = visible.length > 0 && visible.length === checked.length; } }" > @@ -392,24 +404,41 @@ class="text-red-500 text-sm hover:underline font-medium">Hapus Jadwal
-
-
+
+
-
+
-
- +
+ + + @foreach($kantor as $k) + + @endforeach + + + + + @foreach($divisi as $d) + + @endforeach +
+
+ +
@foreach($pegawai as $p) -