finall
This commit is contained in:
parent
0a18029912
commit
a54630ca29
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') . '.',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ public function index()
|
|||
{
|
||||
$pengumuman = Pengumuman::with('pembuat')
|
||||
->orderBy('tanggal', 'desc')
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(10);
|
||||
|
||||
$title = 'Pengumuman';
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
));
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
Loading…
Reference in New Issue