This commit is contained in:
jouel88 2026-05-14 19:52:58 +07:00
parent 0a18029912
commit a54630ca29
49 changed files with 951 additions and 374 deletions

View File

@ -4,11 +4,11 @@
use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class CutiUpdated implements ShouldBroadcast class CutiUpdated implements ShouldBroadcastNow
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;

View File

@ -4,11 +4,11 @@
use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class FaceEnrollmentUpdated implements ShouldBroadcast class FaceEnrollmentUpdated implements ShouldBroadcastNow
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;

View File

@ -4,11 +4,11 @@
use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class JadwalUpdated implements ShouldBroadcast class JadwalUpdated implements ShouldBroadcastNow
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;

View File

@ -4,17 +4,17 @@
use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class LemburUpdated implements ShouldBroadcast class LemburUpdated implements ShouldBroadcastNow
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct( public function __construct(
public int $idUser, public int $idUser,
public int $idLembur, public string $idLembur,
public string $status, public string $status,
public string $message, public string $message,
) {} ) {}

View File

@ -4,11 +4,11 @@
use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class PengajuanBaru implements ShouldBroadcast class PengajuanBaru implements ShouldBroadcastNow
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;

View File

@ -4,11 +4,11 @@
use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class PengajuanIzinUpdated implements ShouldBroadcast class PengajuanIzinUpdated implements ShouldBroadcastNow
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;

View File

@ -4,11 +4,11 @@
use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class PenggunaanPoinUpdated implements ShouldBroadcast class PenggunaanPoinUpdated implements ShouldBroadcastNow
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;

View File

@ -4,11 +4,11 @@
use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class PresensiMasuk implements ShouldBroadcast class PresensiMasuk implements ShouldBroadcastNow
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;

View File

@ -4,11 +4,11 @@
use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class PresensiUpdated implements ShouldBroadcast class PresensiUpdated implements ShouldBroadcastNow
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;

View File

@ -4,11 +4,11 @@
use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class SuratIzinUpdated implements ShouldBroadcast class SuratIzinUpdated implements ShouldBroadcastNow
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;

View File

@ -4,11 +4,11 @@
use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class TukarShiftUpdated implements ShouldBroadcast class TukarShiftUpdated implements ShouldBroadcastNow
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;

View File

@ -26,8 +26,57 @@ public function store(StoreLemburRequest $request)
{ {
try { try {
$user = Auth::user(); $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( app(NotifikasiService::class)->kirimKeRole(
'hrd', 'hrd',

View File

@ -13,6 +13,7 @@ public function index()
try { try {
$pengumuman = Pengumuman::with('pembuat.jabatan') $pengumuman = Pengumuman::with('pembuat.jabatan')
->orderBy('tanggal', 'desc') ->orderBy('tanggal', 'desc')
->orderBy('created_at', 'desc')
->limit(5) ->limit(5)
->get() ->get()
->map(function ($item) { ->map(function ($item) {

View File

@ -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); 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)) { if ($existingPoin) {
return ApiResponse::error('Pengajuan poin harus dilakukan maksimal 1 jam sebelum jam kerja dimulai (Batas: ' . $waktuBatasPengajuan->format('d/m/Y H:i') . ').', 400); 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([ PenggunaanPoin::create([

View File

@ -263,7 +263,7 @@ public function history(Request $request)
$pulang->addDay(); $pulang->addDay();
} }
$diffMinutes = $pulang->diffInMinutes($masuk); $diffMinutes = abs($pulang->diffInMinutes($masuk));
$hours = floor($diffMinutes / 60); $hours = floor($diffMinutes / 60);
$minutes = $diffMinutes % 60; $minutes = $diffMinutes % 60;
$totalJam = $hours . 'j ' . $minutes . 'm'; $totalJam = $hours . 'j ' . $minutes . 'm';

View File

@ -74,12 +74,9 @@ public function destroy($id)
return ApiResponse::notFound('Tanda tangan tidak ditemukan.'); return ApiResponse::notFound('Tanda tangan tidak ditemukan.');
} }
if ($ttd->file_ttd && Storage::disk('public')->exists($ttd->file_ttd)) { // Jangan hapus file fisik dan record untuk menjaga integritas history surat
Storage::disk('public')->delete($ttd->file_ttd); $ttd->update(['is_active' => false]);
}
$ttd->delete(); return ApiResponse::success(null, 'Tanda tangan berhasil dinonaktifkan.');
return ApiResponse::success(null, 'Tanda tangan berhasil dihapus.');
} }
} }

View File

@ -32,7 +32,22 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request)
try { try {
$user = Auth::user(); $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); $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) { if ($jenisIzin && $jenisIzin->id_jenis_izin == JenisIzinEnum::CUTI) {
$tanggalMulai = Carbon::parse($request->tanggal_mulai); $tanggalMulai = Carbon::parse($request->tanggal_mulai);
$tanggalSelesai = Carbon::parse($request->tanggal_selesai); $tanggalSelesai = Carbon::parse($request->tanggal_selesai);
@ -123,7 +138,7 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request)
'id_status' => StatusPengajuan::PENDING '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'; $namaJenisIzin = $jenisIzin->nama_izin ?? 'Izin';
$tglMulai = Carbon::parse($request->tanggal_mulai)->translatedFormat('d F Y'); $tglMulai = Carbon::parse($request->tanggal_mulai)->translatedFormat('d F Y');
@ -153,8 +168,9 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request)
DB::commit(); DB::commit();
$targetRole = ($jenisIzin && $jenisIzin->id_jenis_izin == JenisIzinEnum::CUTI) ? 'manager' : 'hrd';
app(NotifikasiService::class)->kirimKeRole( app(NotifikasiService::class)->kirimKeRole(
'hrd', $targetRole,
'pengajuan_baru', 'pengajuan_baru',
'Pengajuan Baru: ' . ($jenisIzin->nama_izin ?? 'Izin'), 'Pengajuan Baru: ' . ($jenisIzin->nama_izin ?? 'Izin'),
$user->nama_lengkap . ' mengajukan ' . ($jenisIzin->nama_izin ?? 'izin') . '.', $user->nama_lengkap . ' mengajukan ' . ($jenisIzin->nama_izin ?? 'izin') . '.',

View File

@ -33,12 +33,16 @@ public function update(UpdateDivisiRequest $request, $id)
public function destroy($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 { try {
Divisi::findOrFail($id)->delete(); Divisi::findOrFail($id)->delete();
return redirect()->back()->with('success', 'Divisi berhasil dihapus.'); return redirect()->back()->with('success', 'Divisi berhasil dihapus.');
} catch (\Illuminate\Database\QueryException $e) { } catch (\Illuminate\Database\QueryException $e) {
if ($e->getCode() === '23000') { 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; throw $e;
} }

View File

@ -9,6 +9,7 @@
use App\Services\NotifikasiService; use App\Services\NotifikasiService;
use App\Jobs\ReextractAllFrames; use App\Jobs\ReextractAllFrames;
use App\Jobs\RetrainAllModels;
use App\Jobs\MigrateExistingEmbeddings; use App\Jobs\MigrateExistingEmbeddings;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@ -86,6 +87,8 @@ public function approve($id)
$user->dataWajah->update(['is_verified' => StatusVerifikasiWajah::APPROVED]); $user->dataWajah->update(['is_verified' => StatusVerifikasiWajah::APPROVED]);
RetrainAllModels::dispatch();
app(NotifikasiService::class)->kirim( app(NotifikasiService::class)->kirim(
$user->id, $user->id,
'face_disetujui', 'face_disetujui',
@ -95,7 +98,7 @@ public function approve($id)
broadcast(new FaceEnrollmentUpdated($user->id, 'approved', 'Data wajah diverifikasi.')); 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) 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() public function reextractAll()
{ {
dispatch(new ReextractAllFrames()); dispatch(new ReextractAllFrames());

View File

@ -29,12 +29,16 @@ public function update(UpdateJabatanRequest $request, $id)
public function destroy($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 { try {
Jabatan::findOrFail($id)->delete(); Jabatan::findOrFail($id)->delete();
return redirect()->back()->with('success', 'Jabatan berhasil dihapus.'); return redirect()->back()->with('success', 'Jabatan berhasil dihapus.');
} catch (\Illuminate\Database\QueryException $e) { } catch (\Illuminate\Database\QueryException $e) {
if ($e->getCode() === '23000') { 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; throw $e;
} }

View File

@ -30,7 +30,7 @@ public function index()
return $s; return $s;
}); });
$pegawaiQuery = User::where('status_aktif', 1)->bukanHrd(); $pegawaiQuery = User::where('status_aktif', 1)->bukanHrd()->with(['kantor', 'jabatan']);
if (!$isGlobalAdmin) { if (!$isGlobalAdmin) {
$pegawaiQuery->where('id_kantor', $user->id_kantor); $pegawaiQuery->where('id_kantor', $user->id_kantor);
} }
@ -254,25 +254,66 @@ public function store(StoreJadwalRequest $request)
$count = 0; $count = 0;
$refundCount = 0; $refundCount = 0;
$liburCount = 0;
$skipPresensiCount = 0;
$skipTukarShiftCount = 0;
$skipIzinCount = 0;
$poinService = new PoinService(); $poinService = new PoinService();
$usersData = \App\Models\User::whereIn('id', $userIds)->get(['id', 'id_kantor'])->keyBy('id'); $usersData = \App\Models\User::whereIn('id', $userIds)->get(['id', 'id_kantor'])->keyBy('id');
try { 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) { foreach ($period as $date) {
$dateStr = $date->format('Y-m-d'); $dateStr = $date->format('Y-m-d');
$libursDate = \App\Models\HariLibur::where('tanggal', $dateStr)->get(); $libursDate = \App\Models\HariLibur::where('tanggal', $dateStr)->get();
foreach ($userIds as $userId) { 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); $user = $usersData->get($userId);
$isLibur = $libursDate->contains(function ($libur) use ($user) { $isLibur = $libursDate->contains(function ($libur) use ($user) {
return is_null($libur->id_kantor) || ($user && $libur->id_kantor == $user->id_kantor); return is_null($libur->id_kantor) || ($user && $libur->id_kantor == $user->id_kantor);
}); });
if ($isLibur) {
continue;
}
$conflictPoin = PenggunaanPoin::where('id_user', $userId) $conflictPoin = PenggunaanPoin::where('id_user', $userId)
->where('tanggal_penggunaan', $dateStr) ->where('tanggal_penggunaan', $dateStr)
->where('id_status', 2) ->where('id_status', 2)
@ -288,17 +329,33 @@ public function store(StoreJadwalRequest $request)
['id_shift' => $shiftId] ['id_shift' => $shiftId]
); );
$count++; $count++;
if ($isLibur) {
$liburCount++;
}
} }
} }
}); });
$message = "Berhasil memproses $count jadwal kerja!"; $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) { if ($refundCount > 0) {
$message .= " ⚠️ PERINGATAN: $refundCount penggunaan poin dibatalkan otomatis & saldo dikembalikan karena konflik jadwal."; $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) { } catch (\Exception $e) {
return redirect()->back()->with('error', 'Terjadi kesalahan: ' . $e->getMessage()); return redirect()->back()->with('error', 'Terjadi kesalahan: ' . $e->getMessage());
} }
@ -378,13 +435,53 @@ public function bulkDestroy(BulkDestroyJadwalRequest $request)
} }
try { try {
DB::transaction(function () use ($data) { $countDeleted = 0;
JadwalKerja::whereIn('id_user', $data['user_ids']) $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']]) ->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) { } catch (\Exception $e) {
return redirect()->route('jadwal.index')->with('error', 'Terjadi kesalahan saat menghapus jadwal: ' . $e->getMessage()); 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; $endDate = $request->tanggal_selesai;
if (empty($userIds) || !$startDate || !$endDate) { 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') $conflicts = PenggunaanPoin::with('user')
->whereIn('id_user', $userIds) ->whereIn('id_user', $userIds)
->whereBetween('tanggal_penggunaan', [$startDate, $endDate]) ->whereBetween('tanggal_penggunaan', [$startDate, $endDate])
->where('id_status', 2) ->where('id_status', 2)
->get(); ->get();
$conflictData = null;
if ($conflicts->count() > 0) { if ($conflicts->count() > 0) {
$details = $conflicts->map(function ($item) { $details = $conflicts->map(function ($item) {
return "- " . $item->user->nama_lengkap . " (" . date('d M Y', strtotime($item->tanggal_penggunaan)) . ")"; return "- " . $item->user->nama_lengkap . " (" . date('d M Y', strtotime($item->tanggal_penggunaan)) . ")";
})->join('<br>'); })->join('<br>');
return response()->json([ $conflictData = [
'has_conflict' => true, 'has_conflict' => true,
'message' => "Ditemukan <b>{$conflicts->count()} karyawan</b> yang sedang menggunakan Poin (Cuti/Pulang Cepat) pada tanggal ini:<br><br><small>$details</small><br><br>Jika dilanjutkan, <b>Penggunaan Poin akan DIBATALKAN otomatis</b> dan saldo dikembalikan." 'message' => "Ditemukan <b>{$conflicts->count()} karyawan</b> yang sedang menggunakan Poin (Cuti/Pulang Cepat) pada tanggal ini:<br><br><small>$details</small><br><br>Jika dilanjutkan, <b>Penggunaan Poin akan DIBATALKAN otomatis</b> 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('<br>');
$holidayData = [
'has_holiday' => true,
'holiday_count' => $hariLiburs->count(),
'message' => "Terdapat <b>{$hariLiburs->count()} hari libur</b> dalam rentang tanggal ini:<br><br><small>$holidayDetails</small><br><br>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,
]);
} }
} }

View File

@ -29,12 +29,20 @@ public function update(UpdateKantorRequest $request, $id)
public function destroy($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 { try {
Kantor::findOrFail($id)->delete(); Kantor::findOrFail($id)->delete();
return redirect()->back()->with('success', 'Data kantor berhasil dihapus.'); return redirect()->back()->with('success', 'Data kantor berhasil dihapus.');
} catch (\Illuminate\Database\QueryException $e) { } catch (\Illuminate\Database\QueryException $e) {
if ($e->getCode() === '23000') { 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; throw $e;
} }

View File

@ -20,7 +20,7 @@ public function index(Request $request)
$user = auth()->user(); $user = auth()->user();
$isGlobalAdmin = $user->isGlobalAdmin(); $isGlobalAdmin = $user->isGlobalAdmin();
$query = User::with(['divisi', 'jabatan', 'kantor', 'roles'])->bukanHrd(); $query = User::with(['divisi', 'jabatan', 'kantor', 'roles']);
if (!$isGlobalAdmin) { if (!$isGlobalAdmin) {
$query->where('id_kantor', $user->id_kantor); $query->where('id_kantor', $user->id_kantor);
@ -30,6 +30,12 @@ public function index(Request $request)
$query->where('id_jabatan', $request->filter_jabatan); $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')) { if ($request->filled('filter_kantor')) {
$query->where('id_kantor', $request->filter_kantor); $query->where('id_kantor', $request->filter_kantor);
} }
@ -50,18 +56,14 @@ public function index(Request $request)
$pegawai = $query->latest()->paginate(10)->withQueryString(); $pegawai = $query->latest()->paginate(10)->withQueryString();
$allKantorQuery = Kantor::withCount([ $allKantorQuery = Kantor::withCount('users as total_pegawai');
'users as total_pegawai' => function ($q) {
$q->bukanHrd();
}
]);
if (!$isGlobalAdmin) { if (!$isGlobalAdmin) {
$allKantorQuery->where('id_kantor', $user->id_kantor); $allKantorQuery->where('id_kantor', $user->id_kantor);
$totalActive = User::bukanHrd()->where('id_kantor', $user->id_kantor)->where('status_aktif', 1)->count(); $totalActive = User::where('id_kantor', $user->id_kantor)->where('status_aktif', 1)->count();
$totalPegawaiStats = User::bukanHrd()->where('id_kantor', $user->id_kantor)->count(); $totalPegawaiStats = User::where('id_kantor', $user->id_kantor)->count();
} else { } else {
$totalActive = User::bukanHrd()->where('status_aktif', 1)->count(); $totalActive = User::where('status_aktif', 1)->count();
$totalPegawaiStats = User::bukanHrd()->count(); $totalPegawaiStats = User::count();
} }
$allKantor = $allKantorQuery->get(); $allKantor = $allKantorQuery->get();
@ -72,8 +74,9 @@ public function index(Request $request)
]; ];
$allJabatan = Jabatan::all(); $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) private function generateIdKaryawan($tglBergabung, $idDivisi, $idJabatan)
@ -129,27 +132,40 @@ private function generateIdKaryawan($tglBergabung, $idDivisi, $idJabatan)
public function store(StorePegawaiRequest $request) public function store(StorePegawaiRequest $request)
{ {
$data = $request->validated(); try {
$data = $request->validated();
if ($request->hasFile('foto')) { if ($request->hasFile('foto')) {
$data['foto'] = $request->file('foto')->store('foto-profil', 'public'); $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) public function update(UpdatePegawaiRequest $request, $id)

View File

@ -32,13 +32,26 @@ public function index(Request $request)
$statusId = $request->get('status'); $statusId = $request->get('status');
$user = Auth::user(); $user = Auth::user();
$isGlobalAdmin = $user->isGlobalAdmin(); $isGlobalAdmin = $user->isGlobalAdmin();
$isSuperAdmin = $user->isSuperAdmin();
$query = PengajuanIzin::with(['user', 'jenisIzin', 'statusPengajuan', 'suratIzin']); $query = PengajuanIzin::with(['user', 'jenisIzin', 'statusPengajuan', 'suratIzin']);
if (!$isGlobalAdmin) { if (!$isSuperAdmin) {
$query->whereHas('user', function ($q) use ($user) { $isManager = $user->roles->contains('nama_role', 'manager');
$q->where('id_kantor', $user->id_kantor); 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) { if ($statusId) {
@ -75,8 +88,18 @@ public function index(Request $request)
public function store(StorePengajuanIzinRequest $request) public function store(StorePengajuanIzinRequest $request)
{ {
$jenisIzinData = JenisIzin::find($request->id_jenis_izin); $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) { if ($jenisIzinData && $jenisIzinData->id_jenis_izin == JenisIzinEnum::CUTI) {
$pegawai = User::find($request->id_user); $pegawai = User::find($request->id_user);
$tanggalMulai = Carbon::parse($request->tanggal_mulai); $tanggalMulai = Carbon::parse($request->tanggal_mulai);
@ -190,6 +213,17 @@ public function store(StorePengajuanIzinRequest $request)
} }
DB::commit(); 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.'); return redirect()->back()->with('success', 'Pengajuan izin berhasil dibuat.');
} catch (\Exception $e) { } catch (\Exception $e) {
@ -260,8 +294,8 @@ public function approve($id)
); );
broadcast(new PengajuanIzinUpdated( broadcast(new PengajuanIzinUpdated(
$izin->id_user, (int) $izin->id_user,
$izin->id_izin, (int) $izin->id_izin,
'disetujui', 'disetujui',
$izin->jenisIzin->nama_izin, $izin->jenisIzin->nama_izin,
'Pengajuan ' . $izin->jenisIzin->nama_izin . ' disetujui.' 'Pengajuan ' . $izin->jenisIzin->nama_izin . ' disetujui.'
@ -311,8 +345,8 @@ public function reject(Request $request, $id)
); );
broadcast(new PengajuanIzinUpdated( broadcast(new PengajuanIzinUpdated(
$izin->id_user, (int) $izin->id_user,
$izin->id_izin, (int) $izin->id_izin,
'ditolak', 'ditolak',
$izin->jenisIzin->nama_izin ?? 'Izin', $izin->jenisIzin->nama_izin ?? 'Izin',
'Pengajuan ' . ($izin->jenisIzin->nama_izin ?? 'Izin') . ' ditolak.' 'Pengajuan ' . ($izin->jenisIzin->nama_izin ?? 'Izin') . ' ditolak.'

View File

@ -89,7 +89,13 @@ public function update(Request $request, $id)
['id_penggunaan' => $penggunaan->id_penggunaan] ['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.'); return redirect()->back()->with('success', 'Pengajuan berhasil disetujui dan poin telah dipotong.');
} else { } else {
@ -111,7 +117,13 @@ public function update(Request $request, $id)
['id_penggunaan' => $penggunaan->id_penggunaan] ['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.'); return redirect()->back()->with('success', 'Pengajuan berhasil ditolak.');
} }

View File

@ -16,6 +16,7 @@ public function index()
{ {
$pengumuman = Pengumuman::with('pembuat') $pengumuman = Pengumuman::with('pembuat')
->orderBy('tanggal', 'desc') ->orderBy('tanggal', 'desc')
->orderBy('created_at', 'desc')
->paginate(10); ->paginate(10);
$title = 'Pengumuman'; $title = 'Pengumuman';

View File

@ -24,16 +24,20 @@ public function index(Request $request)
{ {
$user = Auth::user(); $user = Auth::user();
$isGlobalAdmin = $user->isGlobalAdmin(); $isGlobalAdmin = $user->isGlobalAdmin();
$isSuperAdmin = $user->isSuperAdmin();
$query = SuratIzin::with(['user', 'pengajuanIzin.jenisIzin', 'approvals.approver']); $query = SuratIzin::with(['user', 'pengajuanIzin.jenisIzin', 'approvals.approver']);
if (!$isGlobalAdmin) { if (!$isSuperAdmin) {
if (!$user->roles->contains('nama_role', 'manajer')) { $isManager = $user->roles->contains('nama_role', 'manager');
if (!$isManager) {
$query->where('status_surat', '!=', StatusSurat::MENUNGGU_MANAJER); $query->where('status_surat', '!=', StatusSurat::MENUNGGU_MANAJER);
} }
$query->whereHas('user', function ($q) use ($user) { if (!$isGlobalAdmin) {
$q->where('id_kantor', $user->id_kantor); $query->whereHas('user', function ($q) use ($user) {
}); $q->where('id_kantor', $user->id_kantor);
});
}
} }
if ($request->filled('status')) { if ($request->filled('status')) {
@ -76,7 +80,7 @@ public function show($id)
// Cegah user yang sama menandatangani di dua tahap berbeda // Cegah user yang sama menandatangani di dua tahap berbeda
$sudahApprove = $surat->approvals->contains('id_approver', $user->id); $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; $canApprove = true;
$tahapApproval = 1; $tahapApproval = 1;
} elseif (!$sudahApprove && $surat->status_surat === StatusSurat::MENUNGGU_HRD && ($user->roles->contains('nama_role', 'hrd') || $isGlobalAdmin)) { } 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.'); 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; $tahap = 1;
} elseif ($surat->status_surat === StatusSurat::MENUNGGU_HRD && ($user->roles->contains('nama_role', 'hrd') || $isGlobalAdmin)) { } elseif ($surat->status_surat === StatusSurat::MENUNGGU_HRD && ($user->roles->contains('nama_role', 'hrd') || $isGlobalAdmin)) {
$tahap = 2; $tahap = 2;
@ -160,6 +164,14 @@ public function approve(Request $request, $id)
'Surat izin Anda telah disetujui Manajer dan sedang menunggu persetujuan HRD.', 'Surat izin Anda telah disetujui Manajer dan sedang menunggu persetujuan HRD.',
['id_surat' => $surat->id_surat] ['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 { } else {
$statusLabel = 'Disetujui'; $statusLabel = 'Disetujui';
app(NotifikasiService::class)->kirim( app(NotifikasiService::class)->kirim(
@ -172,10 +184,10 @@ public function approve(Request $request, $id)
} }
broadcast(new SuratIzinUpdated( broadcast(new SuratIzinUpdated(
$surat->id_user, (int) $surat->id_user,
$surat->id_surat, (int) $surat->id_surat,
$surat->status_surat, $surat->status_surat,
$tahap, (int) $tahap,
'Surat izin ' . ($tahap === 1 ? 'disetujui Manajer' : 'disetujui sepenuhnya') . '.' '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.'); 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; $tahap = 1;
} elseif ($surat->status_surat === StatusSurat::MENUNGGU_HRD && ($user->roles->contains('nama_role', 'hrd') || $isGlobalAdmin)) { } elseif ($surat->status_surat === StatusSurat::MENUNGGU_HRD && ($user->roles->contains('nama_role', 'hrd') || $isGlobalAdmin)) {
$tahap = 2; $tahap = 2;
@ -252,10 +264,10 @@ public function reject(Request $request, $id)
); );
broadcast(new SuratIzinUpdated( broadcast(new SuratIzinUpdated(
$surat->id_user, (int) $surat->id_user,
$surat->id_surat, (int) $surat->id_surat,
StatusSurat::DITOLAK, StatusSurat::DITOLAK,
$tahap, (int) $tahap,
'Surat izin ditolak.' 'Surat izin ditolak.'
)); ));

View File

@ -14,7 +14,7 @@ public function authorize(): bool
public function rules(): array public function rules(): array
{ {
return [ return [
'tanggal_mulai' => ['required', 'date'], 'tanggal_mulai' => ['required', 'date', 'after_or_equal:today'],
'tanggal_selesai' => ['required', 'date', 'after_or_equal:tanggal_mulai'], 'tanggal_selesai' => ['required', 'date', 'after_or_equal:tanggal_mulai'],
'user_ids' => ['required', 'array', 'min:1'], 'user_ids' => ['required', 'array', 'min:1'],
'user_ids.*' => ['exists:users,id'], 'user_ids.*' => ['exists:users,id'],
@ -25,6 +25,7 @@ public function messages(): array
{ {
return [ return [
'tanggal_mulai.required' => 'Tanggal mulai wajib diisi.', '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.required' => 'Tanggal selesai wajib diisi.',
'tanggal_selesai.after_or_equal' => 'Tanggal selesai tidak boleh sebelum tanggal mulai.', 'tanggal_selesai.after_or_equal' => 'Tanggal selesai tidak boleh sebelum tanggal mulai.',
'user_ids.required' => 'Pilih minimal 1 pegawai.', 'user_ids.required' => 'Pilih minimal 1 pegawai.',

View File

@ -14,7 +14,7 @@ public function authorize(): bool
public function rules(): array public function rules(): array
{ {
return [ return [
'tanggal_mulai' => ['required', 'date'], 'tanggal_mulai' => ['required', 'date', 'after_or_equal:today'],
'tanggal_selesai' => ['required', 'date', 'after_or_equal:tanggal_mulai'], 'tanggal_selesai' => ['required', 'date', 'after_or_equal:tanggal_mulai'],
'id_shift' => ['required', 'exists:shift_kerja,id_shift'], 'id_shift' => ['required', 'exists:shift_kerja,id_shift'],
'user_ids' => ['required', 'array', 'min:1'], 'user_ids' => ['required', 'array', 'min:1'],
@ -26,6 +26,7 @@ public function messages(): array
{ {
return [ return [
'tanggal_mulai.required' => 'Tanggal mulai wajib diisi.', '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.required' => 'Tanggal selesai wajib diisi.',
'tanggal_selesai.after_or_equal' => 'Tanggal selesai tidak boleh sebelum tanggal mulai.', 'tanggal_selesai.after_or_equal' => 'Tanggal selesai tidak boleh sebelum tanggal mulai.',
'id_shift.required' => 'Shift kerja wajib dipilih.', 'id_shift.required' => 'Shift kerja wajib dipilih.',

View File

@ -0,0 +1,48 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Models\User;
use App\Services\NotifikasiService;
class BroadcastNotifikasiJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tipe;
public $judul;
public $pesan;
public $data;
/**
* Create a new job instance.
*/
public function __construct(string $tipe, string $judul, string $pesan, array $data = [])
{
$this->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);
}
}
}

View File

@ -51,7 +51,7 @@ public function handle(): void
fopen($absVideoPath, 'r'), fopen($absVideoPath, 'r'),
'enrollment.mp4' 'enrollment.mp4'
) )
->post(config('services.flask.url') . '/extract-and-embed', [ ->post(config('services.flask.url') . '/extract-frames', [
'user_id' => (string) $this->userId, 'user_id' => (string) $this->userId,
'target_frames' => 200, 'target_frames' => 200,
]); ]);
@ -63,29 +63,17 @@ public function handle(): void
$output = $response->json(); $output = $response->json();
if (!isset($output['status']) || $output['status'] !== 'success') { 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; $jumlahFrame = $output['total_extracted'] ?? 0;
$embedding = $output['embedding'] ?? null;
$updateData = [ DB::table('data_wajah')->where('id_user', $this->userId)->update([
'jumlah_frame' => $jumlahFrame, 'jumlah_frame' => $jumlahFrame,
'is_verified' => StatusVerifikasiWajah::PENDING, 'is_verified' => StatusVerifikasiWajah::PENDING,
]; ]);
if ($embedding && is_array($embedding)) { Log::info("Face enrollment: extract frames berhasil untuk user {$this->userId}. Frames: {$jumlahFrame}. Menunggu HRD approve.");
$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.");
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("Face enrollment GAGAL untuk user {$this->userId}: " . $e->getMessage()); Log::error("Face enrollment GAGAL untuk user {$this->userId}: " . $e->getMessage());

View File

@ -7,6 +7,7 @@
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -20,22 +21,56 @@ class ReextractAllFrames implements ShouldQueue
public function handle(): void 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') $users = DB::table('data_wajah')
->whereNotNull('path_video') ->whereNotNull('path_video')
->get(); ->get();
if ($users->isEmpty()) { 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; return;
} }
$successCount = 0; $extractCount = 0;
$skipCount = 0;
$failCount = 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) { foreach ($users as $data) {
$userId = $data->id_user; $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); $videoPath = Storage::disk('local')->path($data->path_video);
if (!file_exists($videoPath)) { if (!file_exists($videoPath)) {
@ -45,6 +80,13 @@ public function handle(): void
} }
try { try {
Cache::put('face_training_status', [
'phase' => 'extracting',
'current' => $current,
'total' => $total,
'message' => "Extract frames user {$current}/{$total}...",
], 3600);
$response = Http::timeout(120) $response = Http::timeout(120)
->withHeaders([ ->withHeaders([
'X-API-Key' => config('services.flask.api_key'), 'X-API-Key' => config('services.flask.api_key'),
@ -54,7 +96,7 @@ public function handle(): void
fopen($videoPath, 'r'), fopen($videoPath, 'r'),
'enrollment.mp4' 'enrollment.mp4'
) )
->post(config('services.flask.url') . '/extract-and-embed', [ ->post(config('services.flask.url') . '/extract-frames', [
'user_id' => (string) $userId, 'user_id' => (string) $userId,
'target_frames' => 200, 'target_frames' => 200,
]); ]);
@ -70,36 +112,36 @@ public function handle(): void
} }
$jumlahFrame = $output['total_extracted'] ?? 0; $jumlahFrame = $output['total_extracted'] ?? 0;
$embedding = $output['embedding'] ?? null;
$updateData = [ DB::table('data_wajah')->where('id_user', $userId)->update([
'jumlah_frame' => $jumlahFrame, 'jumlah_frame' => $jumlahFrame,
]; ]);
if ($embedding && is_array($embedding)) { Log::info("Extract frames berhasil untuk user {$userId}. Frames: {$jumlahFrame}");
$updateData['face_embeddings'] = json_encode($embedding); $extractCount++;
$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++;
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("Re-extract GAGAL untuk user {$userId}: " . $e->getMessage()); Log::error("Extract GAGAL untuk user {$userId}: " . $e->getMessage());
$failCount++; $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( app(\App\Services\NotifikasiService::class)->kirimKeRole(
'hrd', 'hrd',
'pengumuman', 'pengumuman',
'Ekstraksi & Embedding Wajah Selesai', 'Sync & Retrain Selesai',
"Proses re-extract video wajah selesai. Berhasil: {$successCount}, Gagal: {$failCount}." "Sync frame selesai (Extract: {$extractCount}, Skip: {$skipCount}, Gagal: {$failCount}). Model SVM sedang di-training ulang."
); );
} }
} }

View File

@ -9,6 +9,7 @@
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -24,6 +25,11 @@ public function handle(): void
{ {
Log::info("Memulai training model SVM multi-class global..."); 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') $approvedUsers = DB::table('data_wajah')
->where('is_verified', StatusVerifikasiWajah::APPROVED) ->where('is_verified', StatusVerifikasiWajah::APPROVED)
->pluck('id_user') ->pluck('id_user')
@ -56,8 +62,14 @@ public function handle(): void
} }
$totalUsers = $output['total_users'] ?? 0; $totalUsers = $output['total_users'] ?? 0;
$testAcc = $output['test_accuracy'] ?? 0;
Log::info("Training model global selesai. Total user: {$totalUsers}"); 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( app(\App\Services\NotifikasiService::class)->kirimKeRole(
'hrd', 'hrd',
'pengumuman', 'pengumuman',
@ -67,6 +79,11 @@ public function handle(): void
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("Training model global GAGAL: " . $e->getMessage()); 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( app(\App\Services\NotifikasiService::class)->kirimKeRole(
'hrd', 'hrd',

View File

@ -0,0 +1,39 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Services\NotifikasiService;
class SendFcmPushNotification implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $idUser;
public $judul;
public $pesan;
public $data;
/**
* Create a new job instance.
*/
public function __construct(int $idUser, string $judul, string $pesan, array $data = [])
{
$this->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);
}
}

View File

@ -77,62 +77,10 @@ public function enrollFace($userId, UploadedFile $video)
} }
/** /**
* Verifikasi wajah menggunakan pendekatan embedding + cosine similarity. * Verifikasi wajah menggunakan SVM (Jalur 2).
* File dikirim ke Flask untuk generate embedding, lalu dibandingkan dengan database. * File dikirim ke Flask /verify-face untuk prediksi SVM.
*/ */
public function verifyFace($userId, UploadedFile $file) 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/'); $isVideo = str_starts_with($file->getMimeType() ?? '', 'video/');
@ -143,52 +91,44 @@ private function getEmbedding(UploadedFile $file): array
fopen($file->getRealPath(), 'r'), fopen($file->getRealPath(), 'r'),
$isVideo ? 'verify.mp4' : 'verify.jpg' $isVideo ? 'verify.mp4' : 'verify.jpg'
) )
->post($this->getFlaskUrl() . '/get-embedding', [ ->post($this->getFlaskUrl() . '/verify-face', [
'user_id' => (string) $userId,
'is_video' => $isVideo ? 'true' : 'false', 'is_video' => $isVideo ? 'true' : 'false',
]); ]);
if (!$response->successful()) { if (!$response->successful()) {
$body = $response->json(); $body = $response->json();
$msg = $body['message'] ?? 'Flask ML API tidak merespons.'; $msg = $body['message'] ?? 'Flask ML API tidak merespons.';
Log::error("[FaceVerify] Flask /get-embedding error: HTTP {$response->status()} - {$msg}"); Log::error("[FaceVerify] Flask /verify-face error: HTTP {$response->status()} - {$msg}");
throw new \Exception("Gagal mengekstrak embedding wajah. {$msg}"); throw new \Exception("Gagal memverifikasi wajah. {$msg}");
} }
$output = $response->json(); $result = $response->json();
if (!$output || $output['status'] !== 'success' || !isset($output['embedding'])) { if (!$result || $result['status'] !== 'success') {
throw new \Exception('Respons embedding tidak valid.'); 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 return [
{ 'verified' => $result['match'],
$a = $embeddingResponse['embedding'] ?? $embeddingResponse; 'confidence' => $result['confidence'],
$b = $storedEmbedding; 'svm_df' => $result['svm_df'] ?? null,
'verification_status' => $result['verification_status'],
if (count($a) !== count($b) || count($a) === 0) { 'blur_score' => $result['blur_score'] ?? null,
return 0.0; 'predicted_user' => $result['predicted_user'] ?? null,
} 'expected_user' => (string) $userId,
'approved_ratio' => $result['approved_ratio'] ?? null,
$dotProduct = 0.0; 'message' => $result['message'] ?? ($result['match'] ? 'Wajah cocok' : 'Wajah tidak cocok'),
$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);
} }
} }

View File

@ -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(); $unreadCount = Notifikasi::where('id_user', $idUser)->where('is_read', false)->count();
broadcast(new NotifikasiCreated($idUser, $judul, $pesan, $tipe, $unreadCount)); 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) { } catch (\Exception $e) {
Log::error('NotifikasiService::kirim gagal: ' . $e->getMessage()); 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 public function kirimBroadcast(string $tipe, string $judul, string $pesan, array $data = []): void
{ {
$users = User::where('status_aktif', 1) \App\Jobs\BroadcastNotifikasiJob::dispatch($tipe, $judul, $pesan, $data);
->whereDoesntHave('roles', function ($q) {
$q->whereIn('nama_role', ['super_admin']);
})
->pluck('id');
foreach ($users as $idUser) {
$this->kirim($idUser, $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. * Kirim push notification ke device FCM user via FCM HTTP v1 API.
* Token yang sudah tidak valid (UNREGISTERED) otomatis dihapus dari DB. * 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(); $tokens = DeviceToken::where('id_user', $idUser)->get();
if ($tokens->isEmpty()) { if ($tokens->isEmpty()) {
@ -97,6 +90,7 @@ private function sendPushNotification(int $idUser, string $judul, string $pesan,
'notification' => ['title' => $judul, 'body' => $pesan], 'notification' => ['title' => $judul, 'body' => $pesan],
'data' => array_map('strval', $data), 'data' => array_map('strval', $data),
'android' => [ 'android' => [
'priority' => 'HIGH',
'notification' => [ 'notification' => [
'channel_id' => 'hris_channel', 'channel_id' => 'hris_channel',
'click_action' => 'FLUTTER_NOTIFICATION_CLICK', '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. * Ambil access token Google OAuth2 menggunakan Service Account JWT.
* Dicache selama 50 menit (token OAuth biasanya berlaku 1 jam).
*/ */
private function getFcmAccessToken(): ?string private function getFcmAccessToken(): ?string
{ {
try { return \Illuminate\Support\Facades\Cache::remember('fcm_access_token', 3000, function () {
$credPath = base_path(env('FIREBASE_CREDENTIALS')); try {
$credentials = json_decode(file_get_contents($credPath), true); $credPath = base_path(env('FIREBASE_CREDENTIALS'));
$now = time(); $credentials = json_decode(file_get_contents($credPath), true);
$now = time();
$header = rtrim(strtr(base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])), '+/', '-_'), '='); $header = rtrim(strtr(base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])), '+/', '-_'), '=');
$payload = rtrim(strtr(base64_encode(json_encode([ $payload = rtrim(strtr(base64_encode(json_encode([
'iss' => $credentials['client_email'], 'iss' => $credentials['client_email'],
'scope' => 'https://www.googleapis.com/auth/firebase.messaging', 'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
'aud' => 'https://oauth2.googleapis.com/token', 'aud' => 'https://oauth2.googleapis.com/token',
'iat' => $now, 'iat' => $now,
'exp' => $now + 3600, 'exp' => $now + 3600,
])), '+/', '-_'), '='); ])), '+/', '-_'), '=');
openssl_sign("$header.$payload", $sig, $credentials['private_key'], OPENSSL_ALGO_SHA256); openssl_sign("$header.$payload", $sig, $credentials['private_key'], OPENSSL_ALGO_SHA256);
$jwt = "$header.$payload." . rtrim(strtr(base64_encode($sig), '+/', '-_'), '='); $jwt = "$header.$payload." . rtrim(strtr(base64_encode($sig), '+/', '-_'), '=');
$resp = \Illuminate\Support\Facades\Http::asForm()->withoutVerifying()->post('https://oauth2.googleapis.com/token', [ $resp = \Illuminate\Support\Facades\Http::asForm()->withoutVerifying()->post('https://oauth2.googleapis.com/token', [
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt, 'assertion' => $jwt,
]); ]);
return $resp->json('access_token'); return $resp->json('access_token');
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('FCM Token error: ' . $e->getMessage()); Log::error('FCM Token error: ' . $e->getMessage());
return null; return null;
} }
});
} }
} }

View File

@ -51,7 +51,7 @@
], ],
"dev": [ "dev": [
"Composer\\Config::disableProcessTimeout", "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": [ "test": [
"@php artisan config:clear --ansi", "@php artisan config:clear --ansi",

View File

@ -27,6 +27,7 @@ function initSweetAlerts() {
if (swalData) { if (swalData) {
const successMessage = swalData.getAttribute('data-success'); const successMessage = swalData.getAttribute('data-success');
const errorMessage = swalData.getAttribute('data-error'); const errorMessage = swalData.getAttribute('data-error');
const warningMessage = swalData.getAttribute('data-warning');
if (successMessage && successMessage !== '') { if (successMessage && successMessage !== '') {
showSuccess(successMessage); showSuccess(successMessage);
@ -38,6 +39,17 @@ function initSweetAlerts() {
swalData.setAttribute('data-error', ''); 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'); const validationErrors = swalData.getAttribute('data-errors');
if (validationErrors) { if (validationErrors) {
try { 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!') { function confirmAction(event, formId, message, confirmBtnColor = '#3085d6', confirmBtnText = 'Ya, lanjutkan!') {
event.preventDefault(); event.preventDefault();
@ -238,7 +229,7 @@ function confirmAction(event, formId, message, confirmBtnColor = '#3085d6', conf
if (result.isConfirmed) { if (result.isConfirmed) {
const form = document.getElementById(formId); const form = document.getElementById(formId);
if (form) { if (form) {
form.submit(); form.requestSubmit();
} else { } else {
console.error('Form tidak ditemukan:', formId); console.error('Form tidak ditemukan:', formId);
} }

View File

@ -74,10 +74,11 @@ document.addEventListener('DOMContentLoaded', function () {
position: 'top-end', position: 'top-end',
icon: 'info', icon: 'info',
title: title, title: title,
text: message, html: '<div style="font-size: 0.875rem; color: #64748b; margin-top: 0.25rem;">' + message + '</div>',
showConfirmButton: false, showConfirmButton: false,
timer: 4000, timer: 4000,
timerProgressBar: true, timerProgressBar: true,
padding: '1em',
}); });
} }

View File

@ -227,38 +227,40 @@ class="text-xs text-primary hover:underline font-medium">Lihat</a>
@section('script') @section('script')
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script> <script>
const ctx = document.getElementById('attendanceChart').getContext('2d'); (function() {
const attendanceChart = new Chart(ctx, { const canvas = document.getElementById('attendanceChart');
type: 'line', if (!canvas) return;
data: { const ctx = canvas.getContext('2d');
labels: @json($chartLabels), const attendanceChart = new Chart(ctx, {
datasets: [{ type: 'bar',
label: 'Jumlah Hadir', data: {
data: @json($chartData), labels: @json($chartLabels),
borderColor: '#10b981', datasets: [{
backgroundColor: 'rgba(16, 185, 129, 0.1)', label: 'Jumlah Hadir',
tension: 0.4, data: @json($chartData),
fill: true, backgroundColor: 'rgba(16, 185, 129, 0.8)',
pointRadius: 4, borderColor: '#10b981',
pointHoverRadius: 6 borderWidth: 1,
}] borderRadius: 4
}, }]
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
}, },
scales: { options: {
y: { responsive: true,
beginAtZero: true, maintainAspectRatio: false,
ticks: { stepSize: 1 } plugins: {
legend: { display: false }
}, },
x: { scales: {
grid: { display: false } y: {
beginAtZero: true,
ticks: { stepSize: 1 }
},
x: {
grid: { display: false }
}
} }
} }
} });
}); })();
</script> </script>
@endsection @endsection

View File

@ -101,12 +101,28 @@ class="!mb-0" />
<form id="reextract-form" action="{{ route('face.reextract_all') }}" method="POST" class="hidden"> <form id="reextract-form" action="{{ route('face.reextract_all') }}" method="POST" class="hidden">
@csrf @csrf
</form> </form>
<button onclick="confirmAction(event, 'reextract-form', 'Apakah Anda yakin ingin mengekstrak ulang seluruh video enrollment lama? Proses ini memakan waktu beberapa menit, dan akan me-retrain model global secara otomatis.', '#3b82f6', 'Ya, Lanjutkan!')" <button id="reextractBtn" onclick="confirmAction(event, 'reextract-form', 'Sistem akan mengecek user yang belum punya frame, extract jika perlu, lalu retrain model global. Proses ini memakan waktu beberapa menit.', '#3b82f6', 'Ya, Lanjutkan!')"
class="inline-flex items-center px-4 py-2 text-sm font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-lg transition-colors shadow-sm"> class="inline-flex items-center px-4 py-2 text-sm font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-lg transition-colors shadow-sm">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
Re-extract & Retrain All Sync & Retrain All
</button> </button>
</div> </div>
<div id="trainingProgress" class="mt-3 hidden">
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-center gap-3 mb-2">
<svg class="w-5 h-5 text-blue-500 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span id="trainingPhase" class="text-sm font-semibold text-blue-800">Memproses...</span>
</div>
<div class="w-full bg-blue-200 rounded-full h-2.5">
<div id="trainingBar" class="bg-blue-600 h-2.5 rounded-full transition-all duration-500" style="width: 0%"></div>
</div>
<p id="trainingMessage" class="text-xs text-blue-600 mt-1.5"></p>
</div>
</div>
</div> </div>
<x-table> <x-table>
@ -310,5 +326,84 @@ function closeFacePreview() {
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeFacePreview(); 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');
}
</script> </script>
@endsection @endsection

View File

@ -344,12 +344,32 @@ function openDetailModal(data) {
} }
function submitRejectWithAlasan() { function submitRejectWithAlasan() {
const alasan = prompt('Masukkan alasan penolakan (opsional):'); // Tutup modal detail terlebih dahulu untuk menghindari focus trap
if (alasan === null) return; window.dispatchEvent(new CustomEvent('close-modal', { detail: 'detail-izin' }));
const inputAlasan = document.getElementById('input-alasan-penolakan');
if (inputAlasan) inputAlasan.value = alasan; setTimeout(() => {
const form = document.getElementById('form-reject-izin'); Swal.fire({
if (form) form.submit(); 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() { function initCreateIzinForm() {

View File

@ -10,15 +10,15 @@
</x-page-header> </x-page-header>
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6"> <div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
<form action="{{ route('jadwal.store') }}" method="POST"> <form id="jadwalForm" action="{{ route('jadwal.store') }}" method="POST">
@csrf @csrf
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<x-date-input label="Tanggal Mulai" name="tanggal_mulai" <x-date-input label="Tanggal Mulai" name="tanggal_mulai"
value="{{ old('tanggal_mulai', now()->format('Y-m-d')) }}" required /> value="{{ old('tanggal_mulai', now()->format('Y-m-d')) }}" required min="{{ date('Y-m-d') }}" />
<x-date-input label="Tanggal Selesai" name="tanggal_selesai" <x-date-input label="Tanggal Selesai" name="tanggal_selesai"
value="{{ old('tanggal_selesai', now()->addDays(6)->format('Y-m-d')) }}" required /> value="{{ old('tanggal_selesai', now()->addDays(6)->format('Y-m-d')) }}" required min="{{ date('Y-m-d') }}" />
<x-select label="Shift" name="id_shift" id="id_shift" class="!mb-0" required> <x-select label="Shift" name="id_shift" id="id_shift" class="!mb-0" required>
<option value="">-- Pilih Shift --</option> <option value="">-- Pilih Shift --</option>
@ -88,7 +88,7 @@ class="flex items-center gap-3 p-3 bg-slate-50 rounded-lg hover:bg-slate-100 tra
<div class="flex justify-end gap-3 pt-4 border-t border-slate-100"> <div class="flex justify-end gap-3 pt-4 border-t border-slate-100">
<a href="{{ route('jadwal.index') }}" <a href="{{ route('jadwal.index') }}"
class="px-4 py-2 text-sm border rounded-xl hover:bg-slate-50">Batal</a> class="px-4 py-2 text-sm border rounded-xl hover:bg-slate-50">Batal</a>
<button type="submit" <button type="button" id="generateBtn"
class="px-6 py-2 text-sm bg-primary text-white rounded-xl hover:bg-primary/90 font-medium"> class="px-6 py-2 text-sm bg-primary text-white rounded-xl hover:bg-primary/90 font-medium">
Generate Jadwal Generate Jadwal
</button> </button>
@ -128,13 +128,11 @@ class="px-6 py-2 text-sm bg-primary text-white rounded-xl hover:bg-primary/90 fo
})(); })();
(function() { (function() {
const formObj = document.querySelector('form'); const formObj = document.getElementById('jadwalForm');
if (formObj) { const generateBtn = document.getElementById('generateBtn');
formObj.addEventListener('submit', function (e) {
e.preventDefault();
let form = this;
if (generateBtn && formObj) {
generateBtn.addEventListener('click', function () {
let selectedUserIds = []; let selectedUserIds = [];
document.querySelectorAll('.user-checkbox:checked').forEach((cb) => { document.querySelectorAll('.user-checkbox:checked').forEach((cb) => {
selectedUserIds.push(cb.value); 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({ Swal.fire({
title: 'Memeriksa Jadwal...', title: 'Memeriksa Jadwal...',
text: 'Sedang mengecek bentrok penggunaan poin', text: 'Sedang mengecek bentrok penggunaan poin & hari libur',
allowOutsideClick: false, allowOutsideClick: false,
didOpen: () => { Swal.showLoading() } 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(response => response.json())
.then(data => { .then(data => {
let warnings = [];
if (data.has_holiday) {
warnings.push(data.holiday_message);
}
if (data.has_conflict) { if (data.has_conflict) {
warnings.push(data.conflict_message);
}
if (warnings.length > 0) {
Swal.fire({ Swal.fire({
title: '⚠️ Konflik Data Poin!', title: data.has_conflict ? '⚠️ Konflik & Peringatan!' : '📅 Peringatan Hari Libur',
html: data.message, html: warnings.join('<hr class="my-3 border-slate-200">') +
'<br><small class="text-slate-500">Apakah Anda yakin ingin melanjutkan pembuatan jadwal?</small>',
icon: 'warning', icon: 'warning',
showCancelButton: true, showCancelButton: true,
confirmButtonColor: '#d33', confirmButtonColor: data.has_conflict ? '#d33' : '#3085d6',
cancelButtonColor: '#3085d6', cancelButtonColor: '#6b7280',
confirmButtonText: 'Ya, Ubah & Batalkan Poin', confirmButtonText: data.has_conflict ? 'Ya, Lanjutkan & Batalkan Poin' : 'Ya, Tetap Buat Jadwal',
cancelButtonText: 'Batal' cancelButtonText: 'Batal'
}).then((result) => { }).then((result) => {
if (result.isConfirmed) { if (result.isConfirmed) {
form.submit(); formObj.submit();
} }
}); });
} else { } else {
form.submit(); Swal.fire({
title: '📋 Konfirmasi Jadwal',
html: `Anda akan membuat jadwal kerja untuk <b>${selectedUserIds.length} pegawai</b> ` +
`dari tanggal <b>${payload.tanggal_mulai}</b> s/d <b>${payload.tanggal_selesai}</b>.<br><br>Lanjutkan?`,
icon: 'question',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#6b7280',
confirmButtonText: 'Ya, Buat Jadwal',
cancelButtonText: 'Batal'
}).then((result) => {
if (result.isConfirmed) {
formObj.submit();
}
});
} }
}) })
.catch(error => { .catch(error => {

View File

@ -373,15 +373,27 @@ class="text-red-500 text-sm hover:underline font-medium">Hapus Jadwal</button>
x-data="{ x-data="{
tglMulai: '', tglMulai: '',
tglSelesai: '', tglSelesai: '',
filterKantor: '',
filterDivisi: '',
selectAll: true, 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() { toggleAll() {
let checkboxes = document.querySelectorAll('.user-checkbox'); this.getVisibleCheckboxes().forEach(cb => cb.checked = this.selectAll);
checkboxes.forEach(cb => cb.checked = this.selectAll);
}, },
updateSelectAll() { updateSelectAll() {
let checkboxes = document.querySelectorAll('.user-checkbox'); let visible = this.getVisibleCheckboxes();
let checkedBoxes = document.querySelectorAll('.user-checkbox:checked'); let checked = visible.filter(cb => cb.checked);
this.selectAll = checkboxes.length === checkedBoxes.length; 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</button>
</div> </div>
<div class="flex flex-col md:flex-row gap-4"> <div class="flex flex-col md:flex-row gap-4">
<div class="flex-1"><x-date-input label="Tanggal Mulai" name="tanggal_mulai" required class="mb-0" x-model="tglMulai" x-on:change="if(tglMulai) { $refs.endInput.min = tglMulai; if(tglSelesai && tglSelesai < tglMulai) tglSelesai = tglMulai; }" x-ref="startInput" /></div> <div class="flex-1"><x-date-input label="Tanggal Mulai" name="tanggal_mulai" required class="mb-0" x-model="tglMulai" x-on:change="if(tglMulai) { $refs.endInput.min = tglMulai; if(tglSelesai && tglSelesai < tglMulai) tglSelesai = tglMulai; }" x-ref="startInput" min="{{ date('Y-m-d') }}" /></div>
<div class="flex-1"><x-date-input label="Tanggal Selesai" name="tanggal_selesai" required class="mb-0" x-model="tglSelesai" x-on:change="if(tglSelesai) { $refs.startInput.max = tglSelesai; if(tglMulai && tglMulai > tglSelesai) tglMulai = tglSelesai; }" x-ref="endInput" /></div> <div class="flex-1"><x-date-input label="Tanggal Selesai" name="tanggal_selesai" required class="mb-0" x-model="tglSelesai" x-on:change="if(tglSelesai) { $refs.startInput.max = tglSelesai; if(tglMulai && tglMulai > tglSelesai) tglMulai = tglSelesai; }" x-ref="endInput" min="{{ date('Y-m-d') }}" /></div>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between mb-2"> <div class="flex flex-col md:flex-row justify-between md:items-center gap-4 mb-2">
<label class="block text-sm font-semibold text-slate-700">Pilih Pegawai <span class="text-red-500">*</span></label> <label class="block text-sm font-semibold text-slate-700">Pilih Pegawai <span class="text-red-500">*</span></label>
<div class="flex items-center gap-3"> <div class="flex gap-2">
<label class="flex items-center gap-2 text-sm font-bold text-slate-700 cursor-pointer hover:bg-slate-100 px-3 py-1.5 rounded-lg transition-all active:scale-95"> <x-filter-select x-model="filterKantor" @change="setTimeout(() => updateSelectAll(), 50)" class="py-1.5 text-xs">
<x-checkbox x-model="selectAll" @change="toggleAll" class="w-5 h-5 cursor-pointer text-primary border-slate-300 rounded focus:ring-primary" /> <option value="">Semua Kantor</option>
<span>Pilih Semua</span> @foreach($kantor as $k)
</label> <option value="{{ $k->id_kantor }}">{{ $k->nama_kantor }}</option>
@endforeach
</x-filter-select>
<x-filter-select x-model="filterDivisi" @change="setTimeout(() => updateSelectAll(), 50)" class="py-1.5 text-xs">
<option value="">Semua Divisi</option>
@foreach($divisi as $d)
<option value="{{ $d->id_divisi }}">{{ $d->nama_divisi }}</option>
@endforeach
</x-filter-select>
</div> </div>
</div> </div>
<div class="flex items-center justify-end mb-2">
<label class="flex items-center gap-2 text-sm font-bold text-slate-700 cursor-pointer hover:bg-slate-100 px-3 py-1.5 rounded-lg transition-all active:scale-95">
<x-checkbox x-model="selectAll" @change="toggleAll" class="w-5 h-5 cursor-pointer text-primary border-slate-300 rounded focus:ring-primary" />
<span>Pilih Semua (yang tampil)</span>
</label>
</div>
<div class="border border-slate-200 rounded-xl max-h-[300px] overflow-y-auto bg-slate-50/50 p-2 space-y-2 shadow-inner custom-scrollbar"> <div class="border border-slate-200 rounded-xl max-h-[300px] overflow-y-auto bg-slate-50/50 p-2 space-y-2 shadow-inner custom-scrollbar">
@foreach($pegawai as $p) @foreach($pegawai as $p)
<label class="relative flex items-center p-3 rounded-lg border-2 border-transparent bg-white shadow-sm cursor-pointer hover:border-slate-300 hover:bg-slate-50 transition-all [&:has(:checked)]:border-primary [&:has(:checked)]:bg-blue-50/50 group"> <label x-show="(filterKantor === '' || filterKantor == '{{ $p->id_kantor }}') && (filterDivisi === '' || filterDivisi == '{{ $p->id_divisi }}')"
data-kantor="{{ $p->id_kantor }}" data-divisi="{{ $p->id_divisi }}"
class="user-checkbox-item relative flex items-center p-3 rounded-lg border-2 border-transparent bg-white shadow-sm cursor-pointer hover:border-slate-300 hover:bg-slate-50 transition-all [&:has(:checked)]:border-primary [&:has(:checked)]:bg-blue-50/50 group">
<input type="checkbox" name="user_ids[]" value="{{ $p->id }}" checked @change="updateSelectAll" <input type="checkbox" name="user_ids[]" value="{{ $p->id }}" checked @change="updateSelectAll"
class="user-checkbox peer sr-only"> class="user-checkbox peer sr-only">
@ -419,7 +448,13 @@ class="user-checkbox peer sr-only">
<div class="flex-1"> <div class="flex-1">
<div class="font-bold text-slate-800 text-sm group-has-[:checked]:text-primary">{{ $p->nama_lengkap }}</div> <div class="font-bold text-slate-800 text-sm group-has-[:checked]:text-primary">{{ $p->nama_lengkap }}</div>
<div class="text-xs text-slate-500 font-mono">{{ $p->nik }}</div> <div class="text-xs text-slate-500 mt-0.5">
<span class="font-mono bg-slate-100 px-1.5 py-0.5 rounded">{{ $p->nik }}</span>
<span class="mx-1"></span>
<span>{{ $p->jabatan->nama_jabatan ?? '-' }}</span>
<span class="mx-1"></span>
<span>{{ $p->kantor->nama_kantor ?? '-' }}</span>
</div>
</div> </div>
<div class="flex-shrink-0 ml-3 text-slate-300 group-has-[:checked]:text-primary transition-colors relative flex items-center justify-center"> <div class="flex-shrink-0 ml-3 text-slate-300 group-has-[:checked]:text-primary transition-colors relative flex items-center justify-center">

View File

@ -596,7 +596,7 @@ class="h-11 w-11 rounded-full bg-gradient-to-br from-[#130F26] to-[#2B2545] text
<script src="{{ asset('js/dashboard-script.js') }}"></script> <script src="{{ asset('js/dashboard-script.js') }}"></script>
<div id="swal-data" data-success="{{ session('success') }}" data-error="{{ session('error') }}" <div id="swal-data" data-success="{{ session('success') }}" data-error="{{ session('error') }}" data-warning="{{ session('warning') }}"
data-errors='@json($errors->all())'> data-errors='@json($errors->all())'>
</div> </div>

View File

@ -46,7 +46,7 @@ class="flex items-center px-4 py-3 rounded-xl transition-all duration-200 group
</div> </div>
{{-- ════════════════════════════════ SEKSI: MANAJEMEN ════════════════════════════════ --}} {{-- ════════════════════════════════ SEKSI: MANAJEMEN ════════════════════════════════ --}}
@if($canManage || $canViewPegawai) @if($canManage || $canViewPegawai || $canViewJadwal)
<div class="pt-6 pb-3 px-4 flex items-center gap-3"> <div class="pt-6 pb-3 px-4 flex items-center gap-3">
<div class="h-px bg-slate-200 flex-1"></div> <div class="h-px bg-slate-200 flex-1"></div>
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Manajemen</span> <span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Manajemen</span>
@ -177,8 +177,8 @@ class="flex items-center px-4 py-3 rounded-xl transition-all duration-200 group
</a> </a>
@endif @endif
{{-- Manajemen Izin HRD & Manager --}} {{-- Manajemen Izin HRD Only --}}
@if($canApproveIzin) @if($isHrd)
<a href="{{ route('izin.index') }}" <a href="{{ route('izin.index') }}"
class="flex items-center px-4 py-3 rounded-xl transition-all duration-200 group {{ request()->routeIs('izin.*') ? 'bg-[#130F26] text-white shadow-lg shadow-[#130F26]/30' : 'text-slate-500 hover:bg-slate-50 hover:text-slate-900' }}"> class="flex items-center px-4 py-3 rounded-xl transition-all duration-200 group {{ request()->routeIs('izin.*') ? 'bg-[#130F26] text-white shadow-lg shadow-[#130F26]/30' : 'text-slate-500 hover:bg-slate-50 hover:text-slate-900' }}">
<svg class="w-5 h-5 mr-3 {{ request()->routeIs('izin.*') ? 'text-white' : 'text-slate-400 group-hover:text-slate-600' }} group-hover:scale-110 transition-transform duration-300" <svg class="w-5 h-5 mr-3 {{ request()->routeIs('izin.*') ? 'text-white' : 'text-slate-400 group-hover:text-slate-600' }} group-hover:scale-110 transition-transform duration-300"
@ -197,8 +197,10 @@ class="flex items-center px-4 py-3 rounded-xl transition-all duration-200 group
<span class="ml-auto bg-orange-500 text-white text-[10px] font-bold px-2 py-0.5 rounded-full">{{ $pendingIzin }}</span> <span class="ml-auto bg-orange-500 text-white text-[10px] font-bold px-2 py-0.5 rounded-full">{{ $pendingIzin }}</span>
@endif @endif
</a> </a>
@endif
{{-- Surat Izin HRD & Manager --}} {{-- Surat Izin HRD & Manager --}}
@if($canApproveIzin)
<a href="{{ route('surat-izin.index') }}" <a href="{{ route('surat-izin.index') }}"
class="flex items-center px-4 py-3 rounded-xl transition-all duration-200 group {{ request()->routeIs('surat-izin.*') ? 'bg-[#130F26] text-white shadow-lg shadow-[#130F26]/30' : 'text-slate-500 hover:bg-slate-50 hover:text-slate-900' }}"> class="flex items-center px-4 py-3 rounded-xl transition-all duration-200 group {{ request()->routeIs('surat-izin.*') ? 'bg-[#130F26] text-white shadow-lg shadow-[#130F26]/30' : 'text-slate-500 hover:bg-slate-50 hover:text-slate-900' }}">
<svg class="w-5 h-5 mr-3 {{ request()->routeIs('surat-izin.*') ? 'text-white' : 'text-slate-400 group-hover:text-slate-600' }} group-hover:scale-110 transition-transform duration-300" <svg class="w-5 h-5 mr-3 {{ request()->routeIs('surat-izin.*') ? 'text-white' : 'text-slate-400 group-hover:text-slate-600' }} group-hover:scale-110 transition-transform duration-300"
@ -209,7 +211,18 @@ class="flex items-center px-4 py-3 rounded-xl transition-all duration-200 group
</svg> </svg>
<span class="flex-1 font-semibold text-sm">Surat Izin</span> <span class="flex-1 font-semibold text-sm">Surat Izin</span>
@php @php
$suratQuery = \App\Models\SuratIzin::whereIn('status_surat', ['menunggu_manajer', 'menunggu_hrd']); $suratQuery = \App\Models\SuratIzin::query();
$statuses = [];
if ($isManager) $statuses[] = 'menunggu_manajer';
if ($isHrd) $statuses[] = 'menunggu_hrd';
if ($isGlobalAdmin && !$isHrd) {
$statuses = ['menunggu_manajer', 'menunggu_hrd']; // superadmin
}
if (count($statuses) > 0) {
$suratQuery->whereIn('status_surat', $statuses);
} else {
$suratQuery->where('id_surat', -1); // don't count if no matching statuses
}
if (!$isGlobalAdmin) { $suratQuery->whereHas('user', fn($q) => $q->where('id_kantor', $userKantor)); } if (!$isGlobalAdmin) { $suratQuery->whereHas('user', fn($q) => $q->where('id_kantor', $userKantor)); }
$pendingSurat = $suratQuery->count(); $pendingSurat = $suratQuery->count();
@endphp @endphp

View File

@ -53,6 +53,17 @@ class="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
</x-select> </x-select>
</div> </div>
<div class="w-full md:w-44">
<x-select name="filter_role" class="!mb-0 h-10" onchange="this.form.submit()">
<option value="">Semua Akses (Role)</option>
@foreach($allRoles as $r)
<option value="{{ $r->id_role }}" {{ request('filter_role') == $r->id_role ? 'selected' : '' }}>
{{ ucwords(str_replace('_', ' ', $r->nama_role)) }}
</option>
@endforeach
</x-select>
</div>
<div class="w-full md:w-44"> <div class="w-full md:w-44">
<x-select name="filter_kantor" class="!mb-0 h-10" onchange="this.form.submit()"> <x-select name="filter_kantor" class="!mb-0 h-10" onchange="this.form.submit()">
<option value="">Semua Kantor</option> <option value="">Semua Kantor</option>
@ -74,7 +85,7 @@ class="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
</x-select> </x-select>
</div> </div>
@if(request()->hasAny(['filter_jabatan', 'filter_kantor', 'filter_status', 'search'])) @if(request()->hasAny(['filter_jabatan', 'filter_role', 'filter_kantor', 'filter_status', 'search']))
<a href="{{ route('pegawai.index') }}" <a href="{{ route('pegawai.index') }}"
class="flex h-10 items-center px-4 bg-slate-100 text-slate-600 text-sm font-semibold rounded-lg hover:bg-slate-200 transition-all border border-slate-200"> class="flex h-10 items-center px-4 bg-slate-100 text-slate-600 text-sm font-semibold rounded-lg hover:bg-slate-200 transition-all border border-slate-200">
Reset Reset

View File

@ -118,6 +118,7 @@
Route::get('/face-approval', [FaceApprovalController::class, 'index'])->name('face.index'); Route::get('/face-approval', [FaceApprovalController::class, 'index'])->name('face.index');
Route::post('/face-approval/reextract-all', [FaceApprovalController::class, 'reextractAll'])->name('face.reextract_all'); Route::post('/face-approval/reextract-all', [FaceApprovalController::class, 'reextractAll'])->name('face.reextract_all');
Route::get('/face-approval/training-status', [FaceApprovalController::class, 'trainingStatus'])->name('face.training_status');
Route::post('/face-approval/migrate-embeddings', [FaceApprovalController::class, 'migrateEmbeddings'])->name('face.migrate_embeddings'); Route::post('/face-approval/migrate-embeddings', [FaceApprovalController::class, 'migrateEmbeddings'])->name('face.migrate_embeddings');
Route::get('/face-approval/photo/{userId}/{pose}', [FaceApprovalController::class, 'showPhoto'])->name('face.photo'); Route::get('/face-approval/photo/{userId}/{pose}', [FaceApprovalController::class, 'showPhoto'])->name('face.photo');
Route::get('/face-approval/frame/{userId}/{frameIndex}', [FaceApprovalController::class, 'showFrame'])->name('face.frame'); Route::get('/face-approval/frame/{userId}/{frameIndex}', [FaceApprovalController::class, 'showFrame'])->name('face.frame');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB