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\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CutiUpdated implements ShouldBroadcast
class CutiUpdated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,8 +26,57 @@ public function store(StoreLemburRequest $request)
{
try {
$user = Auth::user();
$data = $request->validated();
$tglLembur = $data['tanggal_lembur'];
$jamMulai = $data['jam_mulai'];
$jamSelesai = $data['jam_selesai'];
$this->lemburService->createLembur($user, $request->validated());
// 1. Cek bentrok dengan pengajuan lembur lain (Pending/Approved)
$overlappingLembur = Lembur::where('id_user', $user->id)
->where('tanggal_lembur', $tglLembur)
->whereIn('id_status', [StatusPengajuan::PENDING, StatusPengajuan::DISETUJUI])
->where(function ($q) use ($jamMulai, $jamSelesai) {
$q->where(function ($q2) use ($jamMulai, $jamSelesai) {
$q2->where('jam_mulai', '<', $jamSelesai)
->where('jam_selesai', '>', $jamMulai);
});
})->exists();
if ($overlappingLembur) {
return ApiResponse::error('Anda sudah memiliki pengajuan lembur lain yang bentrok dengan waktu tersebut.', 400);
}
// 2. Cek bentrok dengan jam kerja (Shift)
$jadwal = \App\Models\JadwalKerja::with('shift')
->where('id_user', $user->id)
->where('tanggal', $tglLembur)
->first();
if ($jadwal && $jadwal->shift) {
$shiftMulai = $jadwal->shift->jam_mulai;
$shiftSelesai = $jadwal->shift->jam_pulang;
// Cek apakah jam lembur masuk ke dalam range jam kerja
// Syarat lembur: Tidak boleh beririsan dengan jam kerja
$isInsideShift = ($jamMulai < $shiftSelesai && $jamSelesai > $shiftMulai);
if ($isInsideShift) {
return ApiResponse::error("Waktu lembur tidak boleh bentrok dengan jam kerja reguler ({$shiftMulai} - {$shiftSelesai}).", 400);
}
}
// 3. Cek apakah ada Izin/Cuti pada hari tersebut
$isIzin = \App\Models\PengajuanIzin::where('id_user', $user->id)
->whereIn('id_status', [StatusPengajuan::PENDING, StatusPengajuan::DISETUJUI])
->where('tanggal_mulai', '<=', $tglLembur)
->where('tanggal_selesai', '>=', $tglLembur)
->exists();
if ($isIzin) {
return ApiResponse::error('Anda tidak bisa mengajukan lembur di hari saat Anda sedang Izin/Cuti.', 400);
}
$this->lemburService->createLembur($user, $data);
app(NotifikasiService::class)->kirimKeRole(
'hrd',

View File

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

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);
}
$waktuBatasPengajuan = Carbon::parse($tanggalPenggunaan . ' ' . $jadwal->shift->jam_mulai)->subHour();
// --- ⚡ VALIDASI PENGGUNAAN POIN GANDA ⚡ ---
$existingPoin = PenggunaanPoin::where('id_user', $userId)
->where('tanggal_penggunaan', $tanggalPenggunaan)
->whereIn('id_status', [StatusPengajuan::PENDING, StatusPengajuan::DISETUJUI])
->exists();
if (now()->greaterThan($waktuBatasPengajuan)) {
return ApiResponse::error('Pengajuan poin harus dilakukan maksimal 1 jam sebelum jam kerja dimulai (Batas: ' . $waktuBatasPengajuan->format('d/m/Y H:i') . ').', 400);
if ($existingPoin) {
return ApiResponse::error('Anda sudah memiliki pengajuan penggunaan poin (Pending/Disetujui) untuk tanggal ini.', 400);
}
// --- ⚡ VALIDASI STATUS PRESENSI ⚡ ---
$presensi = \App\Models\Presensi::where('id_user', $userId)
->where('tanggal', $tanggalPenggunaan)
->first();
if ($presensi && $presensi->jam_pulang) {
return ApiResponse::error('Anda sudah melakukan absen pulang pada tanggal tersebut. Penggunaan poin tidak diperbolehkan secara retroaktif.', 400);
}
if ($presensi && $presensi->jam_masuk && $request->id_pengurangan != JenisPengurangan::PULANG_CEPAT_POIN) {
return ApiResponse::error('Anda sudah tercatat melakukan absen masuk. Anda hanya diperbolehkan menggunakan poin untuk "Pulang Cepat".', 400);
}
// -------------------------------------
if ($request->id_pengurangan == JenisPengurangan::PULANG_CEPAT_POIN) {
$waktuBatasPulang = Carbon::parse($tanggalPenggunaan . ' ' . $request->jam_pulang_custom)->subHour();
if (now()->greaterThan($waktuBatasPulang)) {
return ApiResponse::error('Pengajuan Pulang Cepat harus dilakukan maksimal 1 jam sebelum jam kepulangan yang diminta (Batas: ' . $waktuBatasPulang->format('H:i') . ').', 400);
}
} else {
$waktuBatasPengajuan = Carbon::parse($tanggalPenggunaan . ' ' . $jadwal->shift->jam_mulai)->subHour();
if (now()->greaterThan($waktuBatasPengajuan)) {
return ApiResponse::error('Pengajuan poin harus dilakukan maksimal 1 jam sebelum jam kerja dimulai (Batas: ' . $waktuBatasPengajuan->format('d/m/Y H:i') . ').', 400);
}
}
PenggunaanPoin::create([

View File

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

View File

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

View File

@ -32,7 +32,22 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request)
try {
$user = Auth::user();
$ttdAktif = TandaTangan::where('id_user', $user->id)->active()->first();
if (!$ttdAktif) {
return ApiResponse::error('Anda belum memiliki tanda tangan digital. Silakan buat di menu Profil terlebih dahulu.', 400);
}
$jenisIzin = JenisIzin::find($request->id_jenis_izin);
$existingPending = PengajuanIzin::where('id_user', $user->id)
->where('id_jenis_izin', $request->id_jenis_izin)
->where('id_status', StatusPengajuan::PENDING)
->exists();
if ($existingPending) {
$namaIzin = $jenisIzin->nama_izin ?? 'Izin';
return ApiResponse::error("Anda masih memiliki pengajuan {$namaIzin} yang menunggu persetujuan. Tunggu hingga diproses.", 400);
}
if ($jenisIzin && $jenisIzin->id_jenis_izin == JenisIzinEnum::CUTI) {
$tanggalMulai = Carbon::parse($request->tanggal_mulai);
$tanggalSelesai = Carbon::parse($request->tanggal_selesai);
@ -123,7 +138,7 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request)
'id_status' => StatusPengajuan::PENDING
]);
$ttdAktif = TandaTangan::where('id_user', $user->id)->active()->first();
// TTD sudah dicek di awal, gunakan variable yang sama
$namaJenisIzin = $jenisIzin->nama_izin ?? 'Izin';
$tglMulai = Carbon::parse($request->tanggal_mulai)->translatedFormat('d F Y');
@ -153,8 +168,9 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request)
DB::commit();
$targetRole = ($jenisIzin && $jenisIzin->id_jenis_izin == JenisIzinEnum::CUTI) ? 'manager' : 'hrd';
app(NotifikasiService::class)->kirimKeRole(
'hrd',
$targetRole,
'pengajuan_baru',
'Pengajuan Baru: ' . ($jenisIzin->nama_izin ?? 'Izin'),
$user->nama_lengkap . ' mengajukan ' . ($jenisIzin->nama_izin ?? 'izin') . '.',

View File

@ -33,12 +33,16 @@ public function update(UpdateDivisiRequest $request, $id)
public function destroy($id)
{
if (\App\Models\User::where('id_divisi', $id)->exists()) {
return redirect()->back()->with('error', 'Divisi tidak bisa dihapus karena masih digunakan oleh data pegawai.');
}
try {
Divisi::findOrFail($id)->delete();
return redirect()->back()->with('success', 'Divisi berhasil dihapus.');
} catch (\Illuminate\Database\QueryException $e) {
if ($e->getCode() === '23000') {
return redirect()->back()->with('error', 'Divisi tidak bisa dihapus karena masih digunakan oleh data pegawai.');
return redirect()->back()->with('error', 'Divisi tidak bisa dihapus karena masih terhubung dengan data lain.');
}
throw $e;
}

View File

@ -9,6 +9,7 @@
use App\Services\NotifikasiService;
use App\Jobs\ReextractAllFrames;
use App\Jobs\RetrainAllModels;
use App\Jobs\MigrateExistingEmbeddings;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
@ -86,6 +87,8 @@ public function approve($id)
$user->dataWajah->update(['is_verified' => StatusVerifikasiWajah::APPROVED]);
RetrainAllModels::dispatch();
app(NotifikasiService::class)->kirim(
$user->id,
'face_disetujui',
@ -95,7 +98,7 @@ public function approve($id)
broadcast(new FaceEnrollmentUpdated($user->id, 'approved', 'Data wajah diverifikasi.'));
return redirect()->back()->with('success', 'Wajah berhasil diverifikasi.');
return redirect()->back()->with('success', 'Wajah berhasil diverifikasi. Model SVM sedang di-training ulang.');
}
public function reject($id)
@ -208,6 +211,12 @@ private function cleanupUserFaceData($userId)
}
}
public function trainingStatus()
{
$status = \Illuminate\Support\Facades\Cache::get('face_training_status', ['phase' => 'idle']);
return response()->json($status);
}
public function reextractAll()
{
dispatch(new ReextractAllFrames());

View File

@ -29,12 +29,16 @@ public function update(UpdateJabatanRequest $request, $id)
public function destroy($id)
{
if (\App\Models\User::where('id_jabatan', $id)->exists()) {
return redirect()->back()->with('error', 'Jabatan tidak bisa dihapus karena masih digunakan oleh data pegawai.');
}
try {
Jabatan::findOrFail($id)->delete();
return redirect()->back()->with('success', 'Jabatan berhasil dihapus.');
} catch (\Illuminate\Database\QueryException $e) {
if ($e->getCode() === '23000') {
return redirect()->back()->with('error', 'Jabatan tidak bisa dihapus karena masih digunakan oleh data pegawai.');
return redirect()->back()->with('error', 'Jabatan tidak bisa dihapus karena masih terhubung dengan data lain.');
}
throw $e;
}

View File

@ -30,7 +30,7 @@ public function index()
return $s;
});
$pegawaiQuery = User::where('status_aktif', 1)->bukanHrd();
$pegawaiQuery = User::where('status_aktif', 1)->bukanHrd()->with(['kantor', 'jabatan']);
if (!$isGlobalAdmin) {
$pegawaiQuery->where('id_kantor', $user->id_kantor);
}
@ -254,25 +254,66 @@ public function store(StoreJadwalRequest $request)
$count = 0;
$refundCount = 0;
$liburCount = 0;
$skipPresensiCount = 0;
$skipTukarShiftCount = 0;
$skipIzinCount = 0;
$poinService = new PoinService();
$usersData = \App\Models\User::whereIn('id', $userIds)->get(['id', 'id_kantor'])->keyBy('id');
try {
DB::transaction(function () use ($period, $userIds, $shiftId, &$count, &$refundCount, $poinService, $usersData) {
DB::transaction(function () use ($period, $userIds, $shiftId, &$count, &$refundCount, &$liburCount, &$skipPresensiCount, &$skipTukarShiftCount, &$skipIzinCount, $poinService, $usersData) {
foreach ($period as $date) {
$dateStr = $date->format('Y-m-d');
$libursDate = \App\Models\HariLibur::where('tanggal', $dateStr)->get();
foreach ($userIds as $userId) {
// 1. Cek Presensi (Sudah absen masuk atau pulang)
$hasPresensi = \App\Models\Presensi::where('id_user', $userId)
->where('tanggal', $dateStr)
->where(function ($q) {
$q->whereNotNull('jam_masuk')->orWhereNotNull('jam_pulang');
})->exists();
if ($hasPresensi) {
$skipPresensiCount++;
continue;
}
// 2. Cek Riwayat Tukar Shift
$existingJadwal = JadwalKerja::where('id_user', $userId)
->where('tanggal', $dateStr)
->first();
if ($existingJadwal) {
$hasTukarShift = \App\Models\RiwayatTukarShift::where('id_jadwal_1', $existingJadwal->id_jadwal)
->orWhere('id_jadwal_2', $existingJadwal->id_jadwal)
->exists();
if ($hasTukarShift) {
$skipTukarShiftCount++;
continue;
}
}
// 3. Cek Pengajuan Izin / Cuti / Sakit yang sudah disetujui
$hasIzin = \App\Models\PengajuanIzin::where('id_user', $userId)
->where('id_status', \App\Enums\StatusPengajuan::DISETUJUI)
->whereDate('tanggal_mulai', '<=', $dateStr)
->whereDate('tanggal_selesai', '>=', $dateStr)
->exists();
if ($hasIzin) {
$skipIzinCount++;
continue;
}
$user = $usersData->get($userId);
$isLibur = $libursDate->contains(function ($libur) use ($user) {
return is_null($libur->id_kantor) || ($user && $libur->id_kantor == $user->id_kantor);
});
if ($isLibur) {
continue;
}
$conflictPoin = PenggunaanPoin::where('id_user', $userId)
->where('tanggal_penggunaan', $dateStr)
->where('id_status', 2)
@ -288,17 +329,33 @@ public function store(StoreJadwalRequest $request)
['id_shift' => $shiftId]
);
$count++;
if ($isLibur) {
$liburCount++;
}
}
}
});
$message = "Berhasil memproses $count jadwal kerja!";
if ($liburCount > 0) {
$message .= " ⚠️ INFO: $liburCount jadwal dibuat pada tanggal hari libur.";
}
if ($skipPresensiCount > 0) {
$message .= " 🛑 $skipPresensiCount jadwal dilewati karena pegawai sudah melakukan presensi.";
}
if ($skipTukarShiftCount > 0) {
$message .= " 🛑 $skipTukarShiftCount jadwal dilewati karena terkait dengan riwayat tukar shift.";
}
if ($skipIzinCount > 0) {
$message .= " 🛑 $skipIzinCount jadwal dilewati karena pegawai sedang izin/cuti/sakit.";
}
if ($refundCount > 0) {
$message .= " ⚠️ PERINGATAN: $refundCount penggunaan poin dibatalkan otomatis & saldo dikembalikan karena konflik jadwal.";
return redirect()->route('jadwal.index')->with('warning', $message);
}
return redirect()->route('jadwal.index')->with('success', $message);
$hasWarning = $liburCount > 0 || $skipPresensiCount > 0 || $skipTukarShiftCount > 0 || $skipIzinCount > 0 || $refundCount > 0;
return redirect()->route('jadwal.index')->with($hasWarning ? 'warning' : 'success', $message);
} catch (\Exception $e) {
return redirect()->back()->with('error', 'Terjadi kesalahan: ' . $e->getMessage());
}
@ -378,13 +435,53 @@ public function bulkDestroy(BulkDestroyJadwalRequest $request)
}
try {
DB::transaction(function () use ($data) {
JadwalKerja::whereIn('id_user', $data['user_ids'])
$countDeleted = 0;
$skipPresensiCount = 0;
$skipTukarShiftCount = 0;
DB::transaction(function () use ($data, &$countDeleted, &$skipPresensiCount, &$skipTukarShiftCount) {
$jadwals = JadwalKerja::whereIn('id_user', $data['user_ids'])
->whereBetween('tanggal', [$data['tanggal_mulai'], $data['tanggal_selesai']])
->delete();
->get();
foreach ($jadwals as $jadwal) {
// Cek Presensi (Sudah absen masuk atau pulang)
$hasPresensi = \App\Models\Presensi::where('id_user', $jadwal->id_user)
->where('tanggal', $jadwal->tanggal)
->where(function ($q) {
$q->whereNotNull('jam_masuk')->orWhereNotNull('jam_pulang');
})->exists();
if ($hasPresensi) {
$skipPresensiCount++;
continue;
}
// Cek Riwayat Tukar Shift
$hasTukarShift = \App\Models\RiwayatTukarShift::where('id_jadwal_1', $jadwal->id_jadwal)
->orWhere('id_jadwal_2', $jadwal->id_jadwal)
->exists();
if ($hasTukarShift) {
$skipTukarShiftCount++;
continue;
}
$jadwal->delete();
$countDeleted++;
}
});
return redirect()->route('jadwal.index')->with('success', 'Jadwal kerja berhasil dihapus pada rentang tanggal tersebut.');
$message = "Berhasil menghapus $countDeleted jadwal kerja!";
if ($skipPresensiCount > 0) {
$message .= " 🛑 $skipPresensiCount jadwal tidak dihapus karena pegawai sudah presensi.";
}
if ($skipTukarShiftCount > 0) {
$message .= " 🛑 $skipTukarShiftCount jadwal tidak dihapus karena terkait riwayat tukar shift.";
}
$hasWarning = $skipPresensiCount > 0 || $skipTukarShiftCount > 0;
return redirect()->route('jadwal.index')->with($hasWarning ? 'warning' : 'success', $message);
} catch (\Exception $e) {
return redirect()->route('jadwal.index')->with('error', 'Terjadi kesalahan saat menghapus jadwal: ' . $e->getMessage());
}
@ -397,26 +494,49 @@ public function checkConflicts(Request $request)
$endDate = $request->tanggal_selesai;
if (empty($userIds) || !$startDate || !$endDate) {
return response()->json(['has_conflict' => false]);
return response()->json(['has_conflict' => false, 'has_holiday' => false]);
}
// Cek konflik poin
$conflicts = PenggunaanPoin::with('user')
->whereIn('id_user', $userIds)
->whereBetween('tanggal_penggunaan', [$startDate, $endDate])
->where('id_status', 2)
->get();
$conflictData = null;
if ($conflicts->count() > 0) {
$details = $conflicts->map(function ($item) {
return "- " . $item->user->nama_lengkap . " (" . date('d M Y', strtotime($item->tanggal_penggunaan)) . ")";
})->join('<br>');
return response()->json([
$conflictData = [
'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."
]);
];
}
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)
{
if (\App\Models\User::where('id_kantor', $id)->exists()) {
return redirect()->back()->with('error', 'Kantor tidak bisa dihapus karena masih digunakan oleh data pegawai.');
}
if (\App\Models\HariLibur::where('id_kantor', $id)->exists()) {
return redirect()->back()->with('error', 'Kantor tidak bisa dihapus karena masih terkait dengan data hari libur.');
}
try {
Kantor::findOrFail($id)->delete();
return redirect()->back()->with('success', 'Data kantor berhasil dihapus.');
} catch (\Illuminate\Database\QueryException $e) {
if ($e->getCode() === '23000') {
return redirect()->back()->with('error', 'Kantor tidak bisa dihapus karena masih digunakan oleh data pegawai.');
return redirect()->back()->with('error', 'Kantor tidak bisa dihapus karena masih terhubung dengan data lain.');
}
throw $e;
}

View File

@ -20,7 +20,7 @@ public function index(Request $request)
$user = auth()->user();
$isGlobalAdmin = $user->isGlobalAdmin();
$query = User::with(['divisi', 'jabatan', 'kantor', 'roles'])->bukanHrd();
$query = User::with(['divisi', 'jabatan', 'kantor', 'roles']);
if (!$isGlobalAdmin) {
$query->where('id_kantor', $user->id_kantor);
@ -30,6 +30,12 @@ public function index(Request $request)
$query->where('id_jabatan', $request->filter_jabatan);
}
if ($request->filled('filter_role')) {
$query->whereHas('roles', function ($q) use ($request) {
$q->where('detail_user_roles.id_role', $request->filter_role);
});
}
if ($request->filled('filter_kantor')) {
$query->where('id_kantor', $request->filter_kantor);
}
@ -50,18 +56,14 @@ public function index(Request $request)
$pegawai = $query->latest()->paginate(10)->withQueryString();
$allKantorQuery = Kantor::withCount([
'users as total_pegawai' => function ($q) {
$q->bukanHrd();
}
]);
$allKantorQuery = Kantor::withCount('users as total_pegawai');
if (!$isGlobalAdmin) {
$allKantorQuery->where('id_kantor', $user->id_kantor);
$totalActive = User::bukanHrd()->where('id_kantor', $user->id_kantor)->where('status_aktif', 1)->count();
$totalPegawaiStats = User::bukanHrd()->where('id_kantor', $user->id_kantor)->count();
$totalActive = User::where('id_kantor', $user->id_kantor)->where('status_aktif', 1)->count();
$totalPegawaiStats = User::where('id_kantor', $user->id_kantor)->count();
} else {
$totalActive = User::bukanHrd()->where('status_aktif', 1)->count();
$totalPegawaiStats = User::bukanHrd()->count();
$totalActive = User::where('status_aktif', 1)->count();
$totalPegawaiStats = User::count();
}
$allKantor = $allKantorQuery->get();
@ -72,8 +74,9 @@ public function index(Request $request)
];
$allJabatan = Jabatan::all();
$allRoles = Role::all();
return view('pegawai.index', compact('pegawai', 'stats', 'allJabatan', 'allKantor'));
return view('pegawai.index', compact('pegawai', 'stats', 'allJabatan', 'allKantor', 'allRoles'));
}
private function generateIdKaryawan($tglBergabung, $idDivisi, $idJabatan)
@ -129,27 +132,40 @@ private function generateIdKaryawan($tglBergabung, $idDivisi, $idJabatan)
public function store(StorePegawaiRequest $request)
{
$data = $request->validated();
try {
$data = $request->validated();
if ($request->hasFile('foto')) {
$data['foto'] = $request->file('foto')->store('foto-profil', 'public');
if ($request->hasFile('foto')) {
$data['foto'] = $request->file('foto')->store('foto-profil', 'public');
}
$tglBergabung = $data['tgl_bergabung'] ?? now()->toDateString();
$data['nik'] = $this->generateIdKaryawan($tglBergabung, $data['id_divisi'], $data['id_jabatan']);
$existingNik = User::withTrashed()->where('nik', $data['nik'])->exists();
if ($existingNik) {
\Illuminate\Support\Facades\Log::warning("NIK duplikat terdeteksi: {$data['nik']}");
return redirect()->back()->withInput()->with('error', 'Gagal generate NIK unik. Silakan coba lagi.');
}
$data['password'] = Hash::make('Mpg123!');
$data['status_aktif'] = 1;
$data['sisa_cuti'] = 12;
$user = User::create($data);
if ($request->filled('id_role')) {
$user->roles()->attach($request->id_role);
}
\Illuminate\Support\Facades\Log::info("Pegawai baru berhasil dibuat: ID={$user->id}, NIK={$data['nik']}, Nama={$data['nama_lengkap']}");
return redirect()->route('pegawai.index')->with('success', 'Pegawai baru berhasil ditambahkan.');
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error("Gagal menambahkan pegawai: " . $e->getMessage());
return redirect()->back()->withInput()->with('error', 'Gagal menambahkan pegawai: ' . $e->getMessage());
}
// Generate ID Karyawan Otomatis
$tglBergabung = $data['tgl_bergabung'] ?? now()->toDateString();
$data['nik'] = $this->generateIdKaryawan($tglBergabung, $data['id_divisi'], $data['id_jabatan']);
$data['password'] = Hash::make('Mpg123!');
$data['status_aktif'] = 1;
$data['sisa_cuti'] = 12;
$user = User::create($data);
if ($request->filled('id_role')) {
$user->roles()->attach($request->id_role);
}
return redirect()->route('pegawai.index')->with('success', 'Pegawai baru berhasil ditambahkan.');
}
public function update(UpdatePegawaiRequest $request, $id)

View File

@ -32,13 +32,26 @@ public function index(Request $request)
$statusId = $request->get('status');
$user = Auth::user();
$isGlobalAdmin = $user->isGlobalAdmin();
$isSuperAdmin = $user->isSuperAdmin();
$query = PengajuanIzin::with(['user', 'jenisIzin', 'statusPengajuan', 'suratIzin']);
if (!$isGlobalAdmin) {
$query->whereHas('user', function ($q) use ($user) {
$q->where('id_kantor', $user->id_kantor);
});
if (!$isSuperAdmin) {
$isManager = $user->roles->contains('nama_role', 'manager');
if (!$isManager) {
$query->where(function ($q) {
$q->where('id_jenis_izin', '!=', JenisIzinEnum::CUTI)
->orWhereHas('suratIzin', function ($sq) {
$sq->where('status_surat', '!=', StatusSurat::MENUNGGU_MANAJER);
});
});
}
if (!$isGlobalAdmin) {
$query->whereHas('user', function ($q) use ($user) {
$q->where('id_kantor', $user->id_kantor);
});
}
}
if ($statusId) {
@ -75,8 +88,18 @@ public function index(Request $request)
public function store(StorePengajuanIzinRequest $request)
{
$jenisIzinData = JenisIzin::find($request->id_jenis_izin);
$existingPending = PengajuanIzin::where('id_user', $request->id_user)
->where('id_jenis_izin', $request->id_jenis_izin)
->where('id_status', StatusPengajuan::PENDING)
->exists();
if ($existingPending) {
$namaIzin = $jenisIzinData->nama_izin ?? 'Izin';
return back()->withInput()->with('error', "Pegawai masih memiliki pengajuan {$namaIzin} yang menunggu persetujuan.");
}
if ($jenisIzinData && $jenisIzinData->id_jenis_izin == JenisIzinEnum::CUTI) {
$pegawai = User::find($request->id_user);
$tanggalMulai = Carbon::parse($request->tanggal_mulai);
@ -190,6 +213,17 @@ public function store(StorePengajuanIzinRequest $request)
}
DB::commit();
$pegawai = User::find($request->id_user);
$targetRole = ($izin->id_jenis_izin == JenisIzinEnum::CUTI) ? 'manager' : 'hrd';
app(NotifikasiService::class)->kirimKeRole(
$targetRole,
'pengajuan_baru',
'Pengajuan Baru: ' . ($jenisIzinData->nama_izin ?? 'Izin'),
($pegawai->nama_lengkap ?? 'Pegawai') . ' mengajukan ' . ($jenisIzinData->nama_izin ?? 'izin') . '.',
['id_izin' => $izin->id_izin]
);
return redirect()->back()->with('success', 'Pengajuan izin berhasil dibuat.');
} catch (\Exception $e) {
@ -260,8 +294,8 @@ public function approve($id)
);
broadcast(new PengajuanIzinUpdated(
$izin->id_user,
$izin->id_izin,
(int) $izin->id_user,
(int) $izin->id_izin,
'disetujui',
$izin->jenisIzin->nama_izin,
'Pengajuan ' . $izin->jenisIzin->nama_izin . ' disetujui.'
@ -311,8 +345,8 @@ public function reject(Request $request, $id)
);
broadcast(new PengajuanIzinUpdated(
$izin->id_user,
$izin->id_izin,
(int) $izin->id_user,
(int) $izin->id_izin,
'ditolak',
$izin->jenisIzin->nama_izin ?? 'Izin',
'Pengajuan ' . ($izin->jenisIzin->nama_izin ?? 'Izin') . ' ditolak.'

View File

@ -89,7 +89,13 @@ public function update(Request $request, $id)
['id_penggunaan' => $penggunaan->id_penggunaan]
);
broadcast(new PenggunaanPoinUpdated($penggunaan->id_user, $penggunaan->id_penggunaan, 'disetujui', $penggunaan->jumlah_poin, 'Penggunaan poin disetujui.'));
broadcast(new PenggunaanPoinUpdated(
(int)$penggunaan->id_user,
(int)$penggunaan->id_penggunaan,
'disetujui',
(int)$penggunaan->jumlah_poin,
'Penggunaan poin disetujui.'
));
return redirect()->back()->with('success', 'Pengajuan berhasil disetujui dan poin telah dipotong.');
} else {
@ -111,7 +117,13 @@ public function update(Request $request, $id)
['id_penggunaan' => $penggunaan->id_penggunaan]
);
broadcast(new PenggunaanPoinUpdated($penggunaan->id_user, $penggunaan->id_penggunaan, 'ditolak', $penggunaan->jumlah_poin, 'Penggunaan poin ditolak.'));
broadcast(new PenggunaanPoinUpdated(
(int)$penggunaan->id_user,
(int)$penggunaan->id_penggunaan,
'ditolak',
(int)$penggunaan->jumlah_poin,
'Penggunaan poin ditolak.'
));
return redirect()->back()->with('success', 'Pengajuan berhasil ditolak.');
}

View File

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

View File

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

View File

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

View File

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

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'),
'enrollment.mp4'
)
->post(config('services.flask.url') . '/extract-and-embed', [
->post(config('services.flask.url') . '/extract-frames', [
'user_id' => (string) $this->userId,
'target_frames' => 200,
]);
@ -63,29 +63,17 @@ public function handle(): void
$output = $response->json();
if (!isset($output['status']) || $output['status'] !== 'success') {
throw new \Exception("Extract & embed error: " . ($output['message'] ?? 'Unknown'));
throw new \Exception("Extract frames error: " . ($output['message'] ?? 'Unknown'));
}
$jumlahFrame = $output['total_extracted'] ?? 0;
$embedding = $output['embedding'] ?? null;
$updateData = [
DB::table('data_wajah')->where('id_user', $this->userId)->update([
'jumlah_frame' => $jumlahFrame,
'is_verified' => StatusVerifikasiWajah::PENDING,
];
]);
if ($embedding && is_array($embedding)) {
$updateData['face_embeddings'] = json_encode($embedding);
$updateData['jumlah_embedding'] = $jumlahFrame;
$updateData['embedding_generated_at'] = now();
Log::info("Face enrollment: embedding berhasil di-generate untuk user {$this->userId}. Dimensi: " . count($embedding));
} else {
Log::warning("Face enrollment: frame berhasil di-extract tapi embedding tidak tersedia untuk user {$this->userId}.");
}
DB::table('data_wajah')->where('id_user', $this->userId)->update($updateData);
Log::info("Face enrollment: extract & embed berhasil untuk user {$this->userId}. Frames: {$jumlahFrame}. Menunggu HRD approve.");
Log::info("Face enrollment: extract frames berhasil untuk user {$this->userId}. Frames: {$jumlahFrame}. Menunggu HRD approve.");
} catch (\Exception $e) {
Log::error("Face enrollment GAGAL untuk user {$this->userId}: " . $e->getMessage());

View File

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

View File

@ -9,6 +9,7 @@
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@ -24,6 +25,11 @@ public function handle(): void
{
Log::info("Memulai training model SVM multi-class global...");
Cache::put('face_training_status', [
'phase' => 'training',
'message' => 'Memulai training model SVM...',
], 3600);
$approvedUsers = DB::table('data_wajah')
->where('is_verified', StatusVerifikasiWajah::APPROVED)
->pluck('id_user')
@ -56,8 +62,14 @@ public function handle(): void
}
$totalUsers = $output['total_users'] ?? 0;
$testAcc = $output['test_accuracy'] ?? 0;
Log::info("Training model global selesai. Total user: {$totalUsers}");
Cache::put('face_training_status', [
'phase' => 'done',
'message' => "Training selesai! {$totalUsers} user, Akurasi: " . round($testAcc * 100, 2) . '%',
], 300);
app(\App\Services\NotifikasiService::class)->kirimKeRole(
'hrd',
'pengumuman',
@ -68,6 +80,11 @@ public function handle(): void
} catch (\Exception $e) {
Log::error("Training model global GAGAL: " . $e->getMessage());
Cache::put('face_training_status', [
'phase' => 'error',
'message' => 'Training gagal: ' . $e->getMessage(),
], 300);
app(\App\Services\NotifikasiService::class)->kirimKeRole(
'hrd',
'pengumuman',

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.
* File dikirim ke Flask untuk generate embedding, lalu dibandingkan dengan database.
* Verifikasi wajah menggunakan SVM (Jalur 2).
* File dikirim ke Flask /verify-face untuk prediksi SVM.
*/
public function verifyFace($userId, UploadedFile $file)
{
$probeEmbedding = $this->getEmbedding($file);
$allEmbeddings = DB::table('data_wajah')
->where('is_verified', StatusVerifikasiWajah::APPROVED)
->whereNotNull('face_embeddings')
->get(['id_user', 'face_embeddings']);
if ($allEmbeddings->isEmpty()) {
throw new \Exception('Belum ada data wajah terverifikasi di sistem.');
}
$bestMatch = null;
$bestScore = -1;
foreach ($allEmbeddings as $data) {
$storedEmbedding = json_decode($data->face_embeddings, true);
if (!is_array($storedEmbedding)) continue;
$similarity = $this->cosineSimilarity($probeEmbedding, $storedEmbedding);
if ($similarity > $bestScore) {
$bestScore = $similarity;
$bestMatch = $data->id_user;
}
}
$threshold = 0.6;
$isMatch = $bestScore >= $threshold && $bestMatch == $userId;
$verificationStatus = 'REJECTED';
if ($isMatch) {
$verificationStatus = 'MATCH';
} elseif ($bestScore >= $threshold && $bestMatch != $userId) {
$verificationStatus = 'MISMATCH';
}
Log::info("[FaceVerify] User {$userId}: best_match={$bestMatch}, score={$bestScore}, status={$verificationStatus}");
return [
'verified' => $isMatch,
'confidence' => round($bestScore, 4),
'svm_df' => null,
'verification_status' => $verificationStatus,
'blur_score' => $probeEmbedding['blur_score'] ?? null,
'predicted_user' => $bestMatch,
'expected_user' => $userId,
'message' => $isMatch ? 'Wajah cocok' : 'Wajah tidak cocok',
];
}
private function getEmbedding(UploadedFile $file): array
{
$isVideo = str_starts_with($file->getMimeType() ?? '', 'video/');
@ -143,52 +91,44 @@ private function getEmbedding(UploadedFile $file): array
fopen($file->getRealPath(), 'r'),
$isVideo ? 'verify.mp4' : 'verify.jpg'
)
->post($this->getFlaskUrl() . '/get-embedding', [
->post($this->getFlaskUrl() . '/verify-face', [
'user_id' => (string) $userId,
'is_video' => $isVideo ? 'true' : 'false',
]);
if (!$response->successful()) {
$body = $response->json();
$msg = $body['message'] ?? 'Flask ML API tidak merespons.';
Log::error("[FaceVerify] Flask /get-embedding error: HTTP {$response->status()} - {$msg}");
throw new \Exception("Gagal mengekstrak embedding wajah. {$msg}");
Log::error("[FaceVerify] Flask /verify-face error: HTTP {$response->status()} - {$msg}");
throw new \Exception("Gagal memverifikasi wajah. {$msg}");
}
$output = $response->json();
$result = $response->json();
if (!$output || $output['status'] !== 'success' || !isset($output['embedding'])) {
throw new \Exception('Respons embedding tidak valid.');
if (!$result || $result['status'] !== 'success') {
throw new \Exception($result['message'] ?? 'Respons verifikasi tidak valid.');
}
return $output;
}
Log::info("[FaceVerify] User {$userId}: " . json_encode([
'match' => $result['match'],
'verification_status' => $result['verification_status'],
'confidence' => $result['confidence'],
'svm_df' => $result['svm_df'] ?? null,
'predicted_user' => $result['predicted_user'] ?? null,
'blur_score' => $result['blur_score'] ?? null,
]));
private function cosineSimilarity(array $embeddingResponse, array $storedEmbedding): float
{
$a = $embeddingResponse['embedding'] ?? $embeddingResponse;
$b = $storedEmbedding;
if (count($a) !== count($b) || count($a) === 0) {
return 0.0;
}
$dotProduct = 0.0;
$normA = 0.0;
$normB = 0.0;
for ($i = 0; $i < count($a); $i++) {
$dotProduct += $a[$i] * $b[$i];
$normA += $a[$i] * $a[$i];
$normB += $b[$i] * $b[$i];
}
$normA = sqrt($normA);
$normB = sqrt($normB);
if ($normA == 0 || $normB == 0) {
return 0.0;
}
return $dotProduct / ($normA * $normB);
return [
'verified' => $result['match'],
'confidence' => $result['confidence'],
'svm_df' => $result['svm_df'] ?? null,
'verification_status' => $result['verification_status'],
'blur_score' => $result['blur_score'] ?? null,
'predicted_user' => $result['predicted_user'] ?? null,
'expected_user' => (string) $userId,
'approved_ratio' => $result['approved_ratio'] ?? null,
'message' => $result['message'] ?? ($result['match'] ? 'Wajah cocok' : 'Wajah tidak cocok'),
];
}
}

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

View File

@ -51,7 +51,7 @@
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74,#a3e635\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan reverb:start\" \"npm run dev\" --names=server,queue,reverb,vite --kill-others"
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74,#a3e635\" \"php artisan serve --host=0.0.0.0\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan reverb:start\" \"npm run dev\" --names=server,queue,reverb,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",

View File

@ -27,6 +27,7 @@ function initSweetAlerts() {
if (swalData) {
const successMessage = swalData.getAttribute('data-success');
const errorMessage = swalData.getAttribute('data-error');
const warningMessage = swalData.getAttribute('data-warning');
if (successMessage && successMessage !== '') {
showSuccess(successMessage);
@ -38,6 +39,17 @@ function initSweetAlerts() {
swalData.setAttribute('data-error', '');
}
if (warningMessage && warningMessage !== '') {
Swal.fire({
icon: 'warning',
title: 'Peringatan',
text: warningMessage,
confirmButtonColor: '#f59e0b',
confirmButtonText: 'OK'
});
swalData.setAttribute('data-warning', '');
}
const validationErrors = swalData.getAttribute('data-errors');
if (validationErrors) {
try {
@ -198,28 +210,7 @@ function updateBadgeUI(count) {
}
}
function confirmDelete(id) {
Swal.fire({
title: 'Apakah Anda yakin?',
text: "Data ini akan dihapus secara permanen.",
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#3085d6',
confirmButtonText: 'Ya, hapus saja!',
cancelButtonText: 'Batal',
reverseButtons: true
}).then((result) => {
if (result.isConfirmed) {
const form = document.getElementById('delete-form-' + id);
if (form) {
form.submit();
} else {
console.error('Form hapus tidak ditemukan untuk ID:', id);
}
}
});
}
function confirmAction(event, formId, message, confirmBtnColor = '#3085d6', confirmBtnText = 'Ya, lanjutkan!') {
event.preventDefault();
@ -238,7 +229,7 @@ function confirmAction(event, formId, message, confirmBtnColor = '#3085d6', conf
if (result.isConfirmed) {
const form = document.getElementById(formId);
if (form) {
form.submit();
form.requestSubmit();
} else {
console.error('Form tidak ditemukan:', formId);
}

View File

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

View File

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

View File

@ -101,12 +101,28 @@ class="!mb-0" />
<form id="reextract-form" action="{{ route('face.reextract_all') }}" method="POST" class="hidden">
@csrf
</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">
<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>
</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>
<x-table>
@ -310,5 +326,84 @@ function closeFacePreview() {
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeFacePreview();
});
let pollingInterval = null;
// Cek status saat halaman pertama kali dimuat
document.addEventListener('DOMContentLoaded', function() {
fetch('{{ route("face.training_status") }}')
.then(r => r.json())
.then(data => {
const phase = data.phase || 'idle';
if (phase === 'extracting' || phase === 'training') {
startPolling();
}
})
.catch(() => {});
});
document.getElementById('reextract-form').addEventListener('submit', function() {
startPolling();
});
function startPolling() {
const progressEl = document.getElementById('trainingProgress');
const btn = document.getElementById('reextractBtn');
progressEl.classList.remove('hidden');
btn.disabled = true;
btn.classList.add('opacity-50', 'cursor-not-allowed');
pollingInterval = setInterval(checkStatus, 3000);
}
function checkStatus() {
fetch('{{ route("face.training_status") }}')
.then(r => r.json())
.then(data => {
const phase = data.phase || 'idle';
const message = data.message || '';
const current = data.current || 0;
const total = data.total || 1;
document.getElementById('trainingMessage').textContent = message;
if (phase === 'extracting') {
const pct = Math.round((current / total) * 70);
document.getElementById('trainingBar').style.width = pct + '%';
document.getElementById('trainingPhase').textContent = 'Extracting Frames...';
} else if (phase === 'training') {
document.getElementById('trainingBar').style.width = '85%';
document.getElementById('trainingPhase').textContent = 'Training SVM...';
} else if (phase === 'done') {
document.getElementById('trainingBar').style.width = '100%';
document.getElementById('trainingPhase').textContent = '✅ Selesai!';
document.getElementById('trainingProgress').querySelector('div').className =
'bg-emerald-50 border border-emerald-200 rounded-lg p-4';
document.getElementById('trainingPhase').className = 'text-sm font-semibold text-emerald-800';
document.getElementById('trainingMessage').className = 'text-xs text-emerald-600 mt-1.5';
document.getElementById('trainingBar').className = 'bg-emerald-600 h-2.5 rounded-full transition-all duration-500';
document.querySelector('#trainingProgress svg').classList.remove('animate-spin');
stopPolling();
} else if (phase === 'error') {
document.getElementById('trainingPhase').textContent = '❌ Gagal';
document.getElementById('trainingProgress').querySelector('div').className =
'bg-red-50 border border-red-200 rounded-lg p-4';
document.getElementById('trainingPhase').className = 'text-sm font-semibold text-red-800';
document.getElementById('trainingMessage').className = 'text-xs text-red-600 mt-1.5';
stopPolling();
}
})
.catch(() => {});
}
function stopPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
const btn = document.getElementById('reextractBtn');
btn.disabled = false;
btn.classList.remove('opacity-50', 'cursor-not-allowed');
}
</script>
@endsection

View File

@ -344,12 +344,32 @@ function openDetailModal(data) {
}
function submitRejectWithAlasan() {
const alasan = prompt('Masukkan alasan penolakan (opsional):');
if (alasan === null) return;
const inputAlasan = document.getElementById('input-alasan-penolakan');
if (inputAlasan) inputAlasan.value = alasan;
const form = document.getElementById('form-reject-izin');
if (form) form.submit();
// Tutup modal detail terlebih dahulu untuk menghindari focus trap
window.dispatchEvent(new CustomEvent('close-modal', { detail: 'detail-izin' }));
setTimeout(() => {
Swal.fire({
title: 'Tolak Pengajuan',
text: 'Masukkan alasan penolakan (opsional):',
input: 'textarea',
inputPlaceholder: 'Ketik alasan di sini...',
showCancelButton: true,
confirmButtonColor: '#ef4444',
cancelButtonColor: '#64748b',
confirmButtonText: 'Ya, Tolak',
cancelButtonText: 'Batal'
}).then((result) => {
if (result.isConfirmed) {
const inputAlasan = document.getElementById('input-alasan-penolakan');
if (inputAlasan) inputAlasan.value = result.value || '';
const form = document.getElementById('form-reject-izin');
if (form) form.requestSubmit();
} else {
// Jika dibatalkan, buka kembali modal detail
window.dispatchEvent(new CustomEvent('open-modal', { detail: 'detail-izin' }));
}
});
}, 150); // Jeda singkat agar animasi tutup modal selesai
}
function initCreateIzinForm() {

View File

@ -10,15 +10,15 @@
</x-page-header>
<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
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<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"
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>
<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">
<a href="{{ route('jadwal.index') }}"
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">
Generate Jadwal
</button>
@ -128,13 +128,11 @@ class="px-6 py-2 text-sm bg-primary text-white rounded-xl hover:bg-primary/90 fo
})();
(function() {
const formObj = document.querySelector('form');
if (formObj) {
formObj.addEventListener('submit', function (e) {
e.preventDefault();
let form = this;
const formObj = document.getElementById('jadwalForm');
const generateBtn = document.getElementById('generateBtn');
if (generateBtn && formObj) {
generateBtn.addEventListener('click', function () {
let selectedUserIds = [];
document.querySelectorAll('.user-checkbox:checked').forEach((cb) => {
selectedUserIds.push(cb.value);
@ -154,7 +152,7 @@ class="px-6 py-2 text-sm bg-primary text-white rounded-xl hover:bg-primary/90 fo
Swal.fire({
title: 'Memeriksa Jadwal...',
text: 'Sedang mengecek bentrok penggunaan poin',
text: 'Sedang mengecek bentrok penggunaan poin & hari libur',
allowOutsideClick: false,
didOpen: () => { Swal.showLoading() }
});
@ -169,23 +167,47 @@ class="px-6 py-2 text-sm bg-primary text-white rounded-xl hover:bg-primary/90 fo
})
.then(response => response.json())
.then(data => {
let warnings = [];
if (data.has_holiday) {
warnings.push(data.holiday_message);
}
if (data.has_conflict) {
warnings.push(data.conflict_message);
}
if (warnings.length > 0) {
Swal.fire({
title: '⚠️ Konflik Data Poin!',
html: data.message,
title: data.has_conflict ? '⚠️ Konflik & Peringatan!' : '📅 Peringatan Hari Libur',
html: warnings.join('<hr class="my-3 border-slate-200">') +
'<br><small class="text-slate-500">Apakah Anda yakin ingin melanjutkan pembuatan jadwal?</small>',
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#3085d6',
confirmButtonText: 'Ya, Ubah & Batalkan Poin',
confirmButtonColor: data.has_conflict ? '#d33' : '#3085d6',
cancelButtonColor: '#6b7280',
confirmButtonText: data.has_conflict ? 'Ya, Lanjutkan & Batalkan Poin' : 'Ya, Tetap Buat Jadwal',
cancelButtonText: 'Batal'
}).then((result) => {
if (result.isConfirmed) {
form.submit();
formObj.submit();
}
});
} else {
form.submit();
Swal.fire({
title: '📋 Konfirmasi Jadwal',
html: `Anda akan membuat jadwal kerja untuk <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 => {

View File

@ -373,15 +373,27 @@ class="text-red-500 text-sm hover:underline font-medium">Hapus Jadwal</button>
x-data="{
tglMulai: '',
tglSelesai: '',
filterKantor: '',
filterDivisi: '',
selectAll: true,
getVisibleCheckboxes() {
let checkboxes = document.querySelectorAll('#form-bulk-delete .user-checkbox');
return Array.from(checkboxes).filter(cb => {
let item = cb.closest('.user-checkbox-item');
let k = item.getAttribute('data-kantor');
let d = item.getAttribute('data-divisi');
let showKantor = this.filterKantor === '' || this.filterKantor == k;
let showDivisi = this.filterDivisi === '' || this.filterDivisi == d;
return showKantor && showDivisi;
});
},
toggleAll() {
let checkboxes = document.querySelectorAll('.user-checkbox');
checkboxes.forEach(cb => cb.checked = this.selectAll);
this.getVisibleCheckboxes().forEach(cb => cb.checked = this.selectAll);
},
updateSelectAll() {
let checkboxes = document.querySelectorAll('.user-checkbox');
let checkedBoxes = document.querySelectorAll('.user-checkbox:checked');
this.selectAll = checkboxes.length === checkedBoxes.length;
let visible = this.getVisibleCheckboxes();
let checked = visible.filter(cb => cb.checked);
this.selectAll = visible.length > 0 && visible.length === checked.length;
}
}"
>
@ -392,24 +404,41 @@ class="text-red-500 text-sm hover:underline font-medium">Hapus Jadwal</button>
</div>
<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 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 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" min="{{ date('Y-m-d') }}" /></div>
</div>
<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>
<div class="flex items-center gap-3">
<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</span>
</label>
<div class="flex gap-2">
<x-filter-select x-model="filterKantor" @change="setTimeout(() => updateSelectAll(), 50)" class="py-1.5 text-xs">
<option value="">Semua Kantor</option>
@foreach($kantor as $k)
<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 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">
@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"
class="user-checkbox peer sr-only">
@ -419,7 +448,13 @@ class="user-checkbox peer sr-only">
<div class="flex-1">
<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 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>
<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())'>
</div>

View File

@ -46,7 +46,7 @@ class="flex items-center px-4 py-3 rounded-xl transition-all duration-200 group
</div>
{{-- ════════════════════════════════ SEKSI: MANAJEMEN ════════════════════════════════ --}}
@if($canManage || $canViewPegawai)
@if($canManage || $canViewPegawai || $canViewJadwal)
<div class="pt-6 pb-3 px-4 flex items-center gap-3">
<div class="h-px bg-slate-200 flex-1"></div>
<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>
@endif
{{-- Manajemen Izin HRD & Manager --}}
@if($canApproveIzin)
{{-- Manajemen Izin HRD Only --}}
@if($isHrd)
<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' }}">
<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>
@endif
</a>
@endif
{{-- Surat Izin HRD & Manager --}}
@if($canApproveIzin)
<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' }}">
<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>
<span class="flex-1 font-semibold text-sm">Surat Izin</span>
@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)); }
$pendingSurat = $suratQuery->count();
@endphp

View File

@ -53,6 +53,17 @@ class="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
</x-select>
</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">
<x-select name="filter_kantor" class="!mb-0 h-10" onchange="this.form.submit()">
<option value="">Semua Kantor</option>
@ -74,7 +85,7 @@ class="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
</x-select>
</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') }}"
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

View File

@ -118,6 +118,7 @@
Route::get('/face-approval', [FaceApprovalController::class, 'index'])->name('face.index');
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::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');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB