finall
This commit is contained in:
parent
0a18029912
commit
a54630ca29
|
|
@ -4,11 +4,11 @@
|
||||||
|
|
||||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
use Illuminate\Broadcasting\PrivateChannel;
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class CutiUpdated implements ShouldBroadcast
|
class CutiUpdated implements ShouldBroadcastNow
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@
|
||||||
|
|
||||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
use Illuminate\Broadcasting\PrivateChannel;
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class FaceEnrollmentUpdated implements ShouldBroadcast
|
class FaceEnrollmentUpdated implements ShouldBroadcastNow
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@
|
||||||
|
|
||||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
use Illuminate\Broadcasting\PrivateChannel;
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class JadwalUpdated implements ShouldBroadcast
|
class JadwalUpdated implements ShouldBroadcastNow
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,17 @@
|
||||||
|
|
||||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
use Illuminate\Broadcasting\PrivateChannel;
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class LemburUpdated implements ShouldBroadcast
|
class LemburUpdated implements ShouldBroadcastNow
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $idUser,
|
public int $idUser,
|
||||||
public int $idLembur,
|
public string $idLembur,
|
||||||
public string $status,
|
public string $status,
|
||||||
public string $message,
|
public string $message,
|
||||||
) {}
|
) {}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@
|
||||||
|
|
||||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
use Illuminate\Broadcasting\PrivateChannel;
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class PengajuanBaru implements ShouldBroadcast
|
class PengajuanBaru implements ShouldBroadcastNow
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@
|
||||||
|
|
||||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
use Illuminate\Broadcasting\PrivateChannel;
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class PengajuanIzinUpdated implements ShouldBroadcast
|
class PengajuanIzinUpdated implements ShouldBroadcastNow
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@
|
||||||
|
|
||||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
use Illuminate\Broadcasting\PrivateChannel;
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class PenggunaanPoinUpdated implements ShouldBroadcast
|
class PenggunaanPoinUpdated implements ShouldBroadcastNow
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@
|
||||||
|
|
||||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
use Illuminate\Broadcasting\PrivateChannel;
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class PresensiMasuk implements ShouldBroadcast
|
class PresensiMasuk implements ShouldBroadcastNow
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@
|
||||||
|
|
||||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
use Illuminate\Broadcasting\PrivateChannel;
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class PresensiUpdated implements ShouldBroadcast
|
class PresensiUpdated implements ShouldBroadcastNow
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@
|
||||||
|
|
||||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
use Illuminate\Broadcasting\PrivateChannel;
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class SuratIzinUpdated implements ShouldBroadcast
|
class SuratIzinUpdated implements ShouldBroadcastNow
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@
|
||||||
|
|
||||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
use Illuminate\Broadcasting\PrivateChannel;
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class TukarShiftUpdated implements ShouldBroadcast
|
class TukarShiftUpdated implements ShouldBroadcastNow
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,57 @@ public function store(StoreLemburRequest $request)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
$data = $request->validated();
|
||||||
|
$tglLembur = $data['tanggal_lembur'];
|
||||||
|
$jamMulai = $data['jam_mulai'];
|
||||||
|
$jamSelesai = $data['jam_selesai'];
|
||||||
|
|
||||||
$this->lemburService->createLembur($user, $request->validated());
|
// 1. Cek bentrok dengan pengajuan lembur lain (Pending/Approved)
|
||||||
|
$overlappingLembur = Lembur::where('id_user', $user->id)
|
||||||
|
->where('tanggal_lembur', $tglLembur)
|
||||||
|
->whereIn('id_status', [StatusPengajuan::PENDING, StatusPengajuan::DISETUJUI])
|
||||||
|
->where(function ($q) use ($jamMulai, $jamSelesai) {
|
||||||
|
$q->where(function ($q2) use ($jamMulai, $jamSelesai) {
|
||||||
|
$q2->where('jam_mulai', '<', $jamSelesai)
|
||||||
|
->where('jam_selesai', '>', $jamMulai);
|
||||||
|
});
|
||||||
|
})->exists();
|
||||||
|
|
||||||
|
if ($overlappingLembur) {
|
||||||
|
return ApiResponse::error('Anda sudah memiliki pengajuan lembur lain yang bentrok dengan waktu tersebut.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Cek bentrok dengan jam kerja (Shift)
|
||||||
|
$jadwal = \App\Models\JadwalKerja::with('shift')
|
||||||
|
->where('id_user', $user->id)
|
||||||
|
->where('tanggal', $tglLembur)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($jadwal && $jadwal->shift) {
|
||||||
|
$shiftMulai = $jadwal->shift->jam_mulai;
|
||||||
|
$shiftSelesai = $jadwal->shift->jam_pulang;
|
||||||
|
|
||||||
|
// Cek apakah jam lembur masuk ke dalam range jam kerja
|
||||||
|
// Syarat lembur: Tidak boleh beririsan dengan jam kerja
|
||||||
|
$isInsideShift = ($jamMulai < $shiftSelesai && $jamSelesai > $shiftMulai);
|
||||||
|
|
||||||
|
if ($isInsideShift) {
|
||||||
|
return ApiResponse::error("Waktu lembur tidak boleh bentrok dengan jam kerja reguler ({$shiftMulai} - {$shiftSelesai}).", 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Cek apakah ada Izin/Cuti pada hari tersebut
|
||||||
|
$isIzin = \App\Models\PengajuanIzin::where('id_user', $user->id)
|
||||||
|
->whereIn('id_status', [StatusPengajuan::PENDING, StatusPengajuan::DISETUJUI])
|
||||||
|
->where('tanggal_mulai', '<=', $tglLembur)
|
||||||
|
->where('tanggal_selesai', '>=', $tglLembur)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($isIzin) {
|
||||||
|
return ApiResponse::error('Anda tidak bisa mengajukan lembur di hari saat Anda sedang Izin/Cuti.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->lemburService->createLembur($user, $data);
|
||||||
|
|
||||||
app(NotifikasiService::class)->kirimKeRole(
|
app(NotifikasiService::class)->kirimKeRole(
|
||||||
'hrd',
|
'hrd',
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ public function index()
|
||||||
try {
|
try {
|
||||||
$pengumuman = Pengumuman::with('pembuat.jabatan')
|
$pengumuman = Pengumuman::with('pembuat.jabatan')
|
||||||
->orderBy('tanggal', 'desc')
|
->orderBy('tanggal', 'desc')
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
->limit(5)
|
->limit(5)
|
||||||
->get()
|
->get()
|
||||||
->map(function ($item) {
|
->map(function ($item) {
|
||||||
|
|
|
||||||
|
|
@ -101,10 +101,40 @@ public function redeem(Request $request)
|
||||||
return ApiResponse::error('Jadwal kerja tidak ditemukan untuk tanggal ' . Carbon::parse($tanggalPenggunaan)->format('d/m/Y') . '.', 400);
|
return ApiResponse::error('Jadwal kerja tidak ditemukan untuk tanggal ' . Carbon::parse($tanggalPenggunaan)->format('d/m/Y') . '.', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
$waktuBatasPengajuan = Carbon::parse($tanggalPenggunaan . ' ' . $jadwal->shift->jam_mulai)->subHour();
|
// --- ⚡ VALIDASI PENGGUNAAN POIN GANDA ⚡ ---
|
||||||
|
$existingPoin = PenggunaanPoin::where('id_user', $userId)
|
||||||
|
->where('tanggal_penggunaan', $tanggalPenggunaan)
|
||||||
|
->whereIn('id_status', [StatusPengajuan::PENDING, StatusPengajuan::DISETUJUI])
|
||||||
|
->exists();
|
||||||
|
|
||||||
if (now()->greaterThan($waktuBatasPengajuan)) {
|
if ($existingPoin) {
|
||||||
return ApiResponse::error('Pengajuan poin harus dilakukan maksimal 1 jam sebelum jam kerja dimulai (Batas: ' . $waktuBatasPengajuan->format('d/m/Y H:i') . ').', 400);
|
return ApiResponse::error('Anda sudah memiliki pengajuan penggunaan poin (Pending/Disetujui) untuk tanggal ini.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ⚡ VALIDASI STATUS PRESENSI ⚡ ---
|
||||||
|
$presensi = \App\Models\Presensi::where('id_user', $userId)
|
||||||
|
->where('tanggal', $tanggalPenggunaan)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($presensi && $presensi->jam_pulang) {
|
||||||
|
return ApiResponse::error('Anda sudah melakukan absen pulang pada tanggal tersebut. Penggunaan poin tidak diperbolehkan secara retroaktif.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($presensi && $presensi->jam_masuk && $request->id_pengurangan != JenisPengurangan::PULANG_CEPAT_POIN) {
|
||||||
|
return ApiResponse::error('Anda sudah tercatat melakukan absen masuk. Anda hanya diperbolehkan menggunakan poin untuk "Pulang Cepat".', 400);
|
||||||
|
}
|
||||||
|
// -------------------------------------
|
||||||
|
|
||||||
|
if ($request->id_pengurangan == JenisPengurangan::PULANG_CEPAT_POIN) {
|
||||||
|
$waktuBatasPulang = Carbon::parse($tanggalPenggunaan . ' ' . $request->jam_pulang_custom)->subHour();
|
||||||
|
if (now()->greaterThan($waktuBatasPulang)) {
|
||||||
|
return ApiResponse::error('Pengajuan Pulang Cepat harus dilakukan maksimal 1 jam sebelum jam kepulangan yang diminta (Batas: ' . $waktuBatasPulang->format('H:i') . ').', 400);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$waktuBatasPengajuan = Carbon::parse($tanggalPenggunaan . ' ' . $jadwal->shift->jam_mulai)->subHour();
|
||||||
|
if (now()->greaterThan($waktuBatasPengajuan)) {
|
||||||
|
return ApiResponse::error('Pengajuan poin harus dilakukan maksimal 1 jam sebelum jam kerja dimulai (Batas: ' . $waktuBatasPengajuan->format('d/m/Y H:i') . ').', 400);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PenggunaanPoin::create([
|
PenggunaanPoin::create([
|
||||||
|
|
|
||||||
|
|
@ -263,7 +263,7 @@ public function history(Request $request)
|
||||||
$pulang->addDay();
|
$pulang->addDay();
|
||||||
}
|
}
|
||||||
|
|
||||||
$diffMinutes = $pulang->diffInMinutes($masuk);
|
$diffMinutes = abs($pulang->diffInMinutes($masuk));
|
||||||
$hours = floor($diffMinutes / 60);
|
$hours = floor($diffMinutes / 60);
|
||||||
$minutes = $diffMinutes % 60;
|
$minutes = $diffMinutes % 60;
|
||||||
$totalJam = $hours . 'j ' . $minutes . 'm';
|
$totalJam = $hours . 'j ' . $minutes . 'm';
|
||||||
|
|
|
||||||
|
|
@ -74,12 +74,9 @@ public function destroy($id)
|
||||||
return ApiResponse::notFound('Tanda tangan tidak ditemukan.');
|
return ApiResponse::notFound('Tanda tangan tidak ditemukan.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($ttd->file_ttd && Storage::disk('public')->exists($ttd->file_ttd)) {
|
// Jangan hapus file fisik dan record untuk menjaga integritas history surat
|
||||||
Storage::disk('public')->delete($ttd->file_ttd);
|
$ttd->update(['is_active' => false]);
|
||||||
}
|
|
||||||
|
|
||||||
$ttd->delete();
|
return ApiResponse::success(null, 'Tanda tangan berhasil dinonaktifkan.');
|
||||||
|
|
||||||
return ApiResponse::success(null, 'Tanda tangan berhasil dihapus.');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,22 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request)
|
||||||
try {
|
try {
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
|
$ttdAktif = TandaTangan::where('id_user', $user->id)->active()->first();
|
||||||
|
if (!$ttdAktif) {
|
||||||
|
return ApiResponse::error('Anda belum memiliki tanda tangan digital. Silakan buat di menu Profil terlebih dahulu.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
$jenisIzin = JenisIzin::find($request->id_jenis_izin);
|
$jenisIzin = JenisIzin::find($request->id_jenis_izin);
|
||||||
|
|
||||||
|
$existingPending = PengajuanIzin::where('id_user', $user->id)
|
||||||
|
->where('id_jenis_izin', $request->id_jenis_izin)
|
||||||
|
->where('id_status', StatusPengajuan::PENDING)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($existingPending) {
|
||||||
|
$namaIzin = $jenisIzin->nama_izin ?? 'Izin';
|
||||||
|
return ApiResponse::error("Anda masih memiliki pengajuan {$namaIzin} yang menunggu persetujuan. Tunggu hingga diproses.", 400);
|
||||||
|
}
|
||||||
if ($jenisIzin && $jenisIzin->id_jenis_izin == JenisIzinEnum::CUTI) {
|
if ($jenisIzin && $jenisIzin->id_jenis_izin == JenisIzinEnum::CUTI) {
|
||||||
$tanggalMulai = Carbon::parse($request->tanggal_mulai);
|
$tanggalMulai = Carbon::parse($request->tanggal_mulai);
|
||||||
$tanggalSelesai = Carbon::parse($request->tanggal_selesai);
|
$tanggalSelesai = Carbon::parse($request->tanggal_selesai);
|
||||||
|
|
@ -123,7 +138,7 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request)
|
||||||
'id_status' => StatusPengajuan::PENDING
|
'id_status' => StatusPengajuan::PENDING
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$ttdAktif = TandaTangan::where('id_user', $user->id)->active()->first();
|
// TTD sudah dicek di awal, gunakan variable yang sama
|
||||||
|
|
||||||
$namaJenisIzin = $jenisIzin->nama_izin ?? 'Izin';
|
$namaJenisIzin = $jenisIzin->nama_izin ?? 'Izin';
|
||||||
$tglMulai = Carbon::parse($request->tanggal_mulai)->translatedFormat('d F Y');
|
$tglMulai = Carbon::parse($request->tanggal_mulai)->translatedFormat('d F Y');
|
||||||
|
|
@ -153,8 +168,9 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request)
|
||||||
|
|
||||||
DB::commit();
|
DB::commit();
|
||||||
|
|
||||||
|
$targetRole = ($jenisIzin && $jenisIzin->id_jenis_izin == JenisIzinEnum::CUTI) ? 'manager' : 'hrd';
|
||||||
app(NotifikasiService::class)->kirimKeRole(
|
app(NotifikasiService::class)->kirimKeRole(
|
||||||
'hrd',
|
$targetRole,
|
||||||
'pengajuan_baru',
|
'pengajuan_baru',
|
||||||
'Pengajuan Baru: ' . ($jenisIzin->nama_izin ?? 'Izin'),
|
'Pengajuan Baru: ' . ($jenisIzin->nama_izin ?? 'Izin'),
|
||||||
$user->nama_lengkap . ' mengajukan ' . ($jenisIzin->nama_izin ?? 'izin') . '.',
|
$user->nama_lengkap . ' mengajukan ' . ($jenisIzin->nama_izin ?? 'izin') . '.',
|
||||||
|
|
|
||||||
|
|
@ -33,12 +33,16 @@ public function update(UpdateDivisiRequest $request, $id)
|
||||||
|
|
||||||
public function destroy($id)
|
public function destroy($id)
|
||||||
{
|
{
|
||||||
|
if (\App\Models\User::where('id_divisi', $id)->exists()) {
|
||||||
|
return redirect()->back()->with('error', 'Divisi tidak bisa dihapus karena masih digunakan oleh data pegawai.');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Divisi::findOrFail($id)->delete();
|
Divisi::findOrFail($id)->delete();
|
||||||
return redirect()->back()->with('success', 'Divisi berhasil dihapus.');
|
return redirect()->back()->with('success', 'Divisi berhasil dihapus.');
|
||||||
} catch (\Illuminate\Database\QueryException $e) {
|
} catch (\Illuminate\Database\QueryException $e) {
|
||||||
if ($e->getCode() === '23000') {
|
if ($e->getCode() === '23000') {
|
||||||
return redirect()->back()->with('error', 'Divisi tidak bisa dihapus karena masih digunakan oleh data pegawai.');
|
return redirect()->back()->with('error', 'Divisi tidak bisa dihapus karena masih terhubung dengan data lain.');
|
||||||
}
|
}
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
use App\Services\NotifikasiService;
|
use App\Services\NotifikasiService;
|
||||||
|
|
||||||
use App\Jobs\ReextractAllFrames;
|
use App\Jobs\ReextractAllFrames;
|
||||||
|
use App\Jobs\RetrainAllModels;
|
||||||
use App\Jobs\MigrateExistingEmbeddings;
|
use App\Jobs\MigrateExistingEmbeddings;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
@ -86,6 +87,8 @@ public function approve($id)
|
||||||
|
|
||||||
$user->dataWajah->update(['is_verified' => StatusVerifikasiWajah::APPROVED]);
|
$user->dataWajah->update(['is_verified' => StatusVerifikasiWajah::APPROVED]);
|
||||||
|
|
||||||
|
RetrainAllModels::dispatch();
|
||||||
|
|
||||||
app(NotifikasiService::class)->kirim(
|
app(NotifikasiService::class)->kirim(
|
||||||
$user->id,
|
$user->id,
|
||||||
'face_disetujui',
|
'face_disetujui',
|
||||||
|
|
@ -95,7 +98,7 @@ public function approve($id)
|
||||||
|
|
||||||
broadcast(new FaceEnrollmentUpdated($user->id, 'approved', 'Data wajah diverifikasi.'));
|
broadcast(new FaceEnrollmentUpdated($user->id, 'approved', 'Data wajah diverifikasi.'));
|
||||||
|
|
||||||
return redirect()->back()->with('success', 'Wajah berhasil diverifikasi.');
|
return redirect()->back()->with('success', 'Wajah berhasil diverifikasi. Model SVM sedang di-training ulang.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function reject($id)
|
public function reject($id)
|
||||||
|
|
@ -208,6 +211,12 @@ private function cleanupUserFaceData($userId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function trainingStatus()
|
||||||
|
{
|
||||||
|
$status = \Illuminate\Support\Facades\Cache::get('face_training_status', ['phase' => 'idle']);
|
||||||
|
return response()->json($status);
|
||||||
|
}
|
||||||
|
|
||||||
public function reextractAll()
|
public function reextractAll()
|
||||||
{
|
{
|
||||||
dispatch(new ReextractAllFrames());
|
dispatch(new ReextractAllFrames());
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,16 @@ public function update(UpdateJabatanRequest $request, $id)
|
||||||
|
|
||||||
public function destroy($id)
|
public function destroy($id)
|
||||||
{
|
{
|
||||||
|
if (\App\Models\User::where('id_jabatan', $id)->exists()) {
|
||||||
|
return redirect()->back()->with('error', 'Jabatan tidak bisa dihapus karena masih digunakan oleh data pegawai.');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Jabatan::findOrFail($id)->delete();
|
Jabatan::findOrFail($id)->delete();
|
||||||
return redirect()->back()->with('success', 'Jabatan berhasil dihapus.');
|
return redirect()->back()->with('success', 'Jabatan berhasil dihapus.');
|
||||||
} catch (\Illuminate\Database\QueryException $e) {
|
} catch (\Illuminate\Database\QueryException $e) {
|
||||||
if ($e->getCode() === '23000') {
|
if ($e->getCode() === '23000') {
|
||||||
return redirect()->back()->with('error', 'Jabatan tidak bisa dihapus karena masih digunakan oleh data pegawai.');
|
return redirect()->back()->with('error', 'Jabatan tidak bisa dihapus karena masih terhubung dengan data lain.');
|
||||||
}
|
}
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ public function index()
|
||||||
return $s;
|
return $s;
|
||||||
});
|
});
|
||||||
|
|
||||||
$pegawaiQuery = User::where('status_aktif', 1)->bukanHrd();
|
$pegawaiQuery = User::where('status_aktif', 1)->bukanHrd()->with(['kantor', 'jabatan']);
|
||||||
if (!$isGlobalAdmin) {
|
if (!$isGlobalAdmin) {
|
||||||
$pegawaiQuery->where('id_kantor', $user->id_kantor);
|
$pegawaiQuery->where('id_kantor', $user->id_kantor);
|
||||||
}
|
}
|
||||||
|
|
@ -254,25 +254,66 @@ public function store(StoreJadwalRequest $request)
|
||||||
|
|
||||||
$count = 0;
|
$count = 0;
|
||||||
$refundCount = 0;
|
$refundCount = 0;
|
||||||
|
$liburCount = 0;
|
||||||
|
$skipPresensiCount = 0;
|
||||||
|
$skipTukarShiftCount = 0;
|
||||||
|
$skipIzinCount = 0;
|
||||||
$poinService = new PoinService();
|
$poinService = new PoinService();
|
||||||
|
|
||||||
$usersData = \App\Models\User::whereIn('id', $userIds)->get(['id', 'id_kantor'])->keyBy('id');
|
$usersData = \App\Models\User::whereIn('id', $userIds)->get(['id', 'id_kantor'])->keyBy('id');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
DB::transaction(function () use ($period, $userIds, $shiftId, &$count, &$refundCount, $poinService, $usersData) {
|
DB::transaction(function () use ($period, $userIds, $shiftId, &$count, &$refundCount, &$liburCount, &$skipPresensiCount, &$skipTukarShiftCount, &$skipIzinCount, $poinService, $usersData) {
|
||||||
foreach ($period as $date) {
|
foreach ($period as $date) {
|
||||||
$dateStr = $date->format('Y-m-d');
|
$dateStr = $date->format('Y-m-d');
|
||||||
$libursDate = \App\Models\HariLibur::where('tanggal', $dateStr)->get();
|
$libursDate = \App\Models\HariLibur::where('tanggal', $dateStr)->get();
|
||||||
|
|
||||||
foreach ($userIds as $userId) {
|
foreach ($userIds as $userId) {
|
||||||
|
// 1. Cek Presensi (Sudah absen masuk atau pulang)
|
||||||
|
$hasPresensi = \App\Models\Presensi::where('id_user', $userId)
|
||||||
|
->where('tanggal', $dateStr)
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNotNull('jam_masuk')->orWhereNotNull('jam_pulang');
|
||||||
|
})->exists();
|
||||||
|
|
||||||
|
if ($hasPresensi) {
|
||||||
|
$skipPresensiCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Cek Riwayat Tukar Shift
|
||||||
|
$existingJadwal = JadwalKerja::where('id_user', $userId)
|
||||||
|
->where('tanggal', $dateStr)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existingJadwal) {
|
||||||
|
$hasTukarShift = \App\Models\RiwayatTukarShift::where('id_jadwal_1', $existingJadwal->id_jadwal)
|
||||||
|
->orWhere('id_jadwal_2', $existingJadwal->id_jadwal)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($hasTukarShift) {
|
||||||
|
$skipTukarShiftCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Cek Pengajuan Izin / Cuti / Sakit yang sudah disetujui
|
||||||
|
$hasIzin = \App\Models\PengajuanIzin::where('id_user', $userId)
|
||||||
|
->where('id_status', \App\Enums\StatusPengajuan::DISETUJUI)
|
||||||
|
->whereDate('tanggal_mulai', '<=', $dateStr)
|
||||||
|
->whereDate('tanggal_selesai', '>=', $dateStr)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($hasIzin) {
|
||||||
|
$skipIzinCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$user = $usersData->get($userId);
|
$user = $usersData->get($userId);
|
||||||
$isLibur = $libursDate->contains(function ($libur) use ($user) {
|
$isLibur = $libursDate->contains(function ($libur) use ($user) {
|
||||||
return is_null($libur->id_kantor) || ($user && $libur->id_kantor == $user->id_kantor);
|
return is_null($libur->id_kantor) || ($user && $libur->id_kantor == $user->id_kantor);
|
||||||
});
|
});
|
||||||
|
|
||||||
if ($isLibur) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$conflictPoin = PenggunaanPoin::where('id_user', $userId)
|
$conflictPoin = PenggunaanPoin::where('id_user', $userId)
|
||||||
->where('tanggal_penggunaan', $dateStr)
|
->where('tanggal_penggunaan', $dateStr)
|
||||||
->where('id_status', 2)
|
->where('id_status', 2)
|
||||||
|
|
@ -288,17 +329,33 @@ public function store(StoreJadwalRequest $request)
|
||||||
['id_shift' => $shiftId]
|
['id_shift' => $shiftId]
|
||||||
);
|
);
|
||||||
$count++;
|
$count++;
|
||||||
|
|
||||||
|
if ($isLibur) {
|
||||||
|
$liburCount++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$message = "Berhasil memproses $count jadwal kerja!";
|
$message = "Berhasil memproses $count jadwal kerja!";
|
||||||
|
if ($liburCount > 0) {
|
||||||
|
$message .= " ⚠️ INFO: $liburCount jadwal dibuat pada tanggal hari libur.";
|
||||||
|
}
|
||||||
|
if ($skipPresensiCount > 0) {
|
||||||
|
$message .= " 🛑 $skipPresensiCount jadwal dilewati karena pegawai sudah melakukan presensi.";
|
||||||
|
}
|
||||||
|
if ($skipTukarShiftCount > 0) {
|
||||||
|
$message .= " 🛑 $skipTukarShiftCount jadwal dilewati karena terkait dengan riwayat tukar shift.";
|
||||||
|
}
|
||||||
|
if ($skipIzinCount > 0) {
|
||||||
|
$message .= " 🛑 $skipIzinCount jadwal dilewati karena pegawai sedang izin/cuti/sakit.";
|
||||||
|
}
|
||||||
if ($refundCount > 0) {
|
if ($refundCount > 0) {
|
||||||
$message .= " ⚠️ PERINGATAN: $refundCount penggunaan poin dibatalkan otomatis & saldo dikembalikan karena konflik jadwal.";
|
$message .= " ⚠️ PERINGATAN: $refundCount penggunaan poin dibatalkan otomatis & saldo dikembalikan karena konflik jadwal.";
|
||||||
return redirect()->route('jadwal.index')->with('warning', $message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->route('jadwal.index')->with('success', $message);
|
$hasWarning = $liburCount > 0 || $skipPresensiCount > 0 || $skipTukarShiftCount > 0 || $skipIzinCount > 0 || $refundCount > 0;
|
||||||
|
return redirect()->route('jadwal.index')->with($hasWarning ? 'warning' : 'success', $message);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return redirect()->back()->with('error', 'Terjadi kesalahan: ' . $e->getMessage());
|
return redirect()->back()->with('error', 'Terjadi kesalahan: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
@ -378,13 +435,53 @@ public function bulkDestroy(BulkDestroyJadwalRequest $request)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
DB::transaction(function () use ($data) {
|
$countDeleted = 0;
|
||||||
JadwalKerja::whereIn('id_user', $data['user_ids'])
|
$skipPresensiCount = 0;
|
||||||
|
$skipTukarShiftCount = 0;
|
||||||
|
|
||||||
|
DB::transaction(function () use ($data, &$countDeleted, &$skipPresensiCount, &$skipTukarShiftCount) {
|
||||||
|
$jadwals = JadwalKerja::whereIn('id_user', $data['user_ids'])
|
||||||
->whereBetween('tanggal', [$data['tanggal_mulai'], $data['tanggal_selesai']])
|
->whereBetween('tanggal', [$data['tanggal_mulai'], $data['tanggal_selesai']])
|
||||||
->delete();
|
->get();
|
||||||
|
|
||||||
|
foreach ($jadwals as $jadwal) {
|
||||||
|
// Cek Presensi (Sudah absen masuk atau pulang)
|
||||||
|
$hasPresensi = \App\Models\Presensi::where('id_user', $jadwal->id_user)
|
||||||
|
->where('tanggal', $jadwal->tanggal)
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNotNull('jam_masuk')->orWhereNotNull('jam_pulang');
|
||||||
|
})->exists();
|
||||||
|
|
||||||
|
if ($hasPresensi) {
|
||||||
|
$skipPresensiCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cek Riwayat Tukar Shift
|
||||||
|
$hasTukarShift = \App\Models\RiwayatTukarShift::where('id_jadwal_1', $jadwal->id_jadwal)
|
||||||
|
->orWhere('id_jadwal_2', $jadwal->id_jadwal)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($hasTukarShift) {
|
||||||
|
$skipTukarShiftCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$jadwal->delete();
|
||||||
|
$countDeleted++;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return redirect()->route('jadwal.index')->with('success', 'Jadwal kerja berhasil dihapus pada rentang tanggal tersebut.');
|
$message = "Berhasil menghapus $countDeleted jadwal kerja!";
|
||||||
|
if ($skipPresensiCount > 0) {
|
||||||
|
$message .= " 🛑 $skipPresensiCount jadwal tidak dihapus karena pegawai sudah presensi.";
|
||||||
|
}
|
||||||
|
if ($skipTukarShiftCount > 0) {
|
||||||
|
$message .= " 🛑 $skipTukarShiftCount jadwal tidak dihapus karena terkait riwayat tukar shift.";
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasWarning = $skipPresensiCount > 0 || $skipTukarShiftCount > 0;
|
||||||
|
return redirect()->route('jadwal.index')->with($hasWarning ? 'warning' : 'success', $message);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return redirect()->route('jadwal.index')->with('error', 'Terjadi kesalahan saat menghapus jadwal: ' . $e->getMessage());
|
return redirect()->route('jadwal.index')->with('error', 'Terjadi kesalahan saat menghapus jadwal: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
@ -397,26 +494,49 @@ public function checkConflicts(Request $request)
|
||||||
$endDate = $request->tanggal_selesai;
|
$endDate = $request->tanggal_selesai;
|
||||||
|
|
||||||
if (empty($userIds) || !$startDate || !$endDate) {
|
if (empty($userIds) || !$startDate || !$endDate) {
|
||||||
return response()->json(['has_conflict' => false]);
|
return response()->json(['has_conflict' => false, 'has_holiday' => false]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cek konflik poin
|
||||||
$conflicts = PenggunaanPoin::with('user')
|
$conflicts = PenggunaanPoin::with('user')
|
||||||
->whereIn('id_user', $userIds)
|
->whereIn('id_user', $userIds)
|
||||||
->whereBetween('tanggal_penggunaan', [$startDate, $endDate])
|
->whereBetween('tanggal_penggunaan', [$startDate, $endDate])
|
||||||
->where('id_status', 2)
|
->where('id_status', 2)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
$conflictData = null;
|
||||||
if ($conflicts->count() > 0) {
|
if ($conflicts->count() > 0) {
|
||||||
$details = $conflicts->map(function ($item) {
|
$details = $conflicts->map(function ($item) {
|
||||||
return "- " . $item->user->nama_lengkap . " (" . date('d M Y', strtotime($item->tanggal_penggunaan)) . ")";
|
return "- " . $item->user->nama_lengkap . " (" . date('d M Y', strtotime($item->tanggal_penggunaan)) . ")";
|
||||||
})->join('<br>');
|
})->join('<br>');
|
||||||
|
|
||||||
return response()->json([
|
$conflictData = [
|
||||||
'has_conflict' => true,
|
'has_conflict' => true,
|
||||||
'message' => "Ditemukan <b>{$conflicts->count()} karyawan</b> yang sedang menggunakan Poin (Cuti/Pulang Cepat) pada tanggal ini:<br><br><small>$details</small><br><br>Jika dilanjutkan, <b>Penggunaan Poin akan DIBATALKAN otomatis</b> dan saldo dikembalikan."
|
'message' => "Ditemukan <b>{$conflicts->count()} karyawan</b> yang sedang menggunakan Poin (Cuti/Pulang Cepat) pada tanggal ini:<br><br><small>$details</small><br><br>Jika dilanjutkan, <b>Penggunaan Poin akan DIBATALKAN otomatis</b> dan saldo dikembalikan."
|
||||||
]);
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json(['has_conflict' => false]);
|
// Cek hari libur dalam rentang tanggal
|
||||||
|
$hariLiburs = \App\Models\HariLibur::whereBetween('tanggal', [$startDate, $endDate])->get();
|
||||||
|
$holidayData = null;
|
||||||
|
if ($hariLiburs->count() > 0) {
|
||||||
|
$holidayDetails = $hariLiburs->map(function ($hl) {
|
||||||
|
return "- " . \Carbon\Carbon::parse($hl->tanggal)->translatedFormat('d M Y') . " (" . $hl->keterangan . ")";
|
||||||
|
})->join('<br>');
|
||||||
|
|
||||||
|
$holidayData = [
|
||||||
|
'has_holiday' => true,
|
||||||
|
'holiday_count' => $hariLiburs->count(),
|
||||||
|
'message' => "Terdapat <b>{$hariLiburs->count()} hari libur</b> dalam rentang tanggal ini:<br><br><small>$holidayDetails</small><br><br>Jadwal tetap akan dibuat pada tanggal tersebut."
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'has_conflict' => $conflictData ? true : false,
|
||||||
|
'conflict_message' => $conflictData['message'] ?? null,
|
||||||
|
'has_holiday' => $holidayData ? true : false,
|
||||||
|
'holiday_count' => $holidayData['holiday_count'] ?? 0,
|
||||||
|
'holiday_message' => $holidayData['message'] ?? null,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -29,12 +29,20 @@ public function update(UpdateKantorRequest $request, $id)
|
||||||
|
|
||||||
public function destroy($id)
|
public function destroy($id)
|
||||||
{
|
{
|
||||||
|
if (\App\Models\User::where('id_kantor', $id)->exists()) {
|
||||||
|
return redirect()->back()->with('error', 'Kantor tidak bisa dihapus karena masih digunakan oleh data pegawai.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\App\Models\HariLibur::where('id_kantor', $id)->exists()) {
|
||||||
|
return redirect()->back()->with('error', 'Kantor tidak bisa dihapus karena masih terkait dengan data hari libur.');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Kantor::findOrFail($id)->delete();
|
Kantor::findOrFail($id)->delete();
|
||||||
return redirect()->back()->with('success', 'Data kantor berhasil dihapus.');
|
return redirect()->back()->with('success', 'Data kantor berhasil dihapus.');
|
||||||
} catch (\Illuminate\Database\QueryException $e) {
|
} catch (\Illuminate\Database\QueryException $e) {
|
||||||
if ($e->getCode() === '23000') {
|
if ($e->getCode() === '23000') {
|
||||||
return redirect()->back()->with('error', 'Kantor tidak bisa dihapus karena masih digunakan oleh data pegawai.');
|
return redirect()->back()->with('error', 'Kantor tidak bisa dihapus karena masih terhubung dengan data lain.');
|
||||||
}
|
}
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ public function index(Request $request)
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$isGlobalAdmin = $user->isGlobalAdmin();
|
$isGlobalAdmin = $user->isGlobalAdmin();
|
||||||
|
|
||||||
$query = User::with(['divisi', 'jabatan', 'kantor', 'roles'])->bukanHrd();
|
$query = User::with(['divisi', 'jabatan', 'kantor', 'roles']);
|
||||||
|
|
||||||
if (!$isGlobalAdmin) {
|
if (!$isGlobalAdmin) {
|
||||||
$query->where('id_kantor', $user->id_kantor);
|
$query->where('id_kantor', $user->id_kantor);
|
||||||
|
|
@ -30,6 +30,12 @@ public function index(Request $request)
|
||||||
$query->where('id_jabatan', $request->filter_jabatan);
|
$query->where('id_jabatan', $request->filter_jabatan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($request->filled('filter_role')) {
|
||||||
|
$query->whereHas('roles', function ($q) use ($request) {
|
||||||
|
$q->where('detail_user_roles.id_role', $request->filter_role);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if ($request->filled('filter_kantor')) {
|
if ($request->filled('filter_kantor')) {
|
||||||
$query->where('id_kantor', $request->filter_kantor);
|
$query->where('id_kantor', $request->filter_kantor);
|
||||||
}
|
}
|
||||||
|
|
@ -50,18 +56,14 @@ public function index(Request $request)
|
||||||
|
|
||||||
$pegawai = $query->latest()->paginate(10)->withQueryString();
|
$pegawai = $query->latest()->paginate(10)->withQueryString();
|
||||||
|
|
||||||
$allKantorQuery = Kantor::withCount([
|
$allKantorQuery = Kantor::withCount('users as total_pegawai');
|
||||||
'users as total_pegawai' => function ($q) {
|
|
||||||
$q->bukanHrd();
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
if (!$isGlobalAdmin) {
|
if (!$isGlobalAdmin) {
|
||||||
$allKantorQuery->where('id_kantor', $user->id_kantor);
|
$allKantorQuery->where('id_kantor', $user->id_kantor);
|
||||||
$totalActive = User::bukanHrd()->where('id_kantor', $user->id_kantor)->where('status_aktif', 1)->count();
|
$totalActive = User::where('id_kantor', $user->id_kantor)->where('status_aktif', 1)->count();
|
||||||
$totalPegawaiStats = User::bukanHrd()->where('id_kantor', $user->id_kantor)->count();
|
$totalPegawaiStats = User::where('id_kantor', $user->id_kantor)->count();
|
||||||
} else {
|
} else {
|
||||||
$totalActive = User::bukanHrd()->where('status_aktif', 1)->count();
|
$totalActive = User::where('status_aktif', 1)->count();
|
||||||
$totalPegawaiStats = User::bukanHrd()->count();
|
$totalPegawaiStats = User::count();
|
||||||
}
|
}
|
||||||
$allKantor = $allKantorQuery->get();
|
$allKantor = $allKantorQuery->get();
|
||||||
|
|
||||||
|
|
@ -72,8 +74,9 @@ public function index(Request $request)
|
||||||
];
|
];
|
||||||
|
|
||||||
$allJabatan = Jabatan::all();
|
$allJabatan = Jabatan::all();
|
||||||
|
$allRoles = Role::all();
|
||||||
|
|
||||||
return view('pegawai.index', compact('pegawai', 'stats', 'allJabatan', 'allKantor'));
|
return view('pegawai.index', compact('pegawai', 'stats', 'allJabatan', 'allKantor', 'allRoles'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateIdKaryawan($tglBergabung, $idDivisi, $idJabatan)
|
private function generateIdKaryawan($tglBergabung, $idDivisi, $idJabatan)
|
||||||
|
|
@ -129,27 +132,40 @@ private function generateIdKaryawan($tglBergabung, $idDivisi, $idJabatan)
|
||||||
|
|
||||||
public function store(StorePegawaiRequest $request)
|
public function store(StorePegawaiRequest $request)
|
||||||
{
|
{
|
||||||
$data = $request->validated();
|
try {
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
if ($request->hasFile('foto')) {
|
if ($request->hasFile('foto')) {
|
||||||
$data['foto'] = $request->file('foto')->store('foto-profil', 'public');
|
$data['foto'] = $request->file('foto')->store('foto-profil', 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tglBergabung = $data['tgl_bergabung'] ?? now()->toDateString();
|
||||||
|
$data['nik'] = $this->generateIdKaryawan($tglBergabung, $data['id_divisi'], $data['id_jabatan']);
|
||||||
|
|
||||||
|
$existingNik = User::withTrashed()->where('nik', $data['nik'])->exists();
|
||||||
|
if ($existingNik) {
|
||||||
|
\Illuminate\Support\Facades\Log::warning("NIK duplikat terdeteksi: {$data['nik']}");
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Gagal generate NIK unik. Silakan coba lagi.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['password'] = Hash::make('Mpg123!');
|
||||||
|
$data['status_aktif'] = 1;
|
||||||
|
$data['sisa_cuti'] = 12;
|
||||||
|
|
||||||
|
$user = User::create($data);
|
||||||
|
|
||||||
|
if ($request->filled('id_role')) {
|
||||||
|
$user->roles()->attach($request->id_role);
|
||||||
|
}
|
||||||
|
|
||||||
|
\Illuminate\Support\Facades\Log::info("Pegawai baru berhasil dibuat: ID={$user->id}, NIK={$data['nik']}, Nama={$data['nama_lengkap']}");
|
||||||
|
|
||||||
|
return redirect()->route('pegawai.index')->with('success', 'Pegawai baru berhasil ditambahkan.');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
\Illuminate\Support\Facades\Log::error("Gagal menambahkan pegawai: " . $e->getMessage());
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Gagal menambahkan pegawai: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate ID Karyawan Otomatis
|
|
||||||
$tglBergabung = $data['tgl_bergabung'] ?? now()->toDateString();
|
|
||||||
$data['nik'] = $this->generateIdKaryawan($tglBergabung, $data['id_divisi'], $data['id_jabatan']);
|
|
||||||
|
|
||||||
$data['password'] = Hash::make('Mpg123!');
|
|
||||||
$data['status_aktif'] = 1;
|
|
||||||
$data['sisa_cuti'] = 12;
|
|
||||||
|
|
||||||
$user = User::create($data);
|
|
||||||
|
|
||||||
if ($request->filled('id_role')) {
|
|
||||||
$user->roles()->attach($request->id_role);
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->route('pegawai.index')->with('success', 'Pegawai baru berhasil ditambahkan.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(UpdatePegawaiRequest $request, $id)
|
public function update(UpdatePegawaiRequest $request, $id)
|
||||||
|
|
|
||||||
|
|
@ -32,13 +32,26 @@ public function index(Request $request)
|
||||||
$statusId = $request->get('status');
|
$statusId = $request->get('status');
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
$isGlobalAdmin = $user->isGlobalAdmin();
|
$isGlobalAdmin = $user->isGlobalAdmin();
|
||||||
|
$isSuperAdmin = $user->isSuperAdmin();
|
||||||
|
|
||||||
$query = PengajuanIzin::with(['user', 'jenisIzin', 'statusPengajuan', 'suratIzin']);
|
$query = PengajuanIzin::with(['user', 'jenisIzin', 'statusPengajuan', 'suratIzin']);
|
||||||
|
|
||||||
if (!$isGlobalAdmin) {
|
if (!$isSuperAdmin) {
|
||||||
$query->whereHas('user', function ($q) use ($user) {
|
$isManager = $user->roles->contains('nama_role', 'manager');
|
||||||
$q->where('id_kantor', $user->id_kantor);
|
if (!$isManager) {
|
||||||
});
|
$query->where(function ($q) {
|
||||||
|
$q->where('id_jenis_izin', '!=', JenisIzinEnum::CUTI)
|
||||||
|
->orWhereHas('suratIzin', function ($sq) {
|
||||||
|
$sq->where('status_surat', '!=', StatusSurat::MENUNGGU_MANAJER);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$isGlobalAdmin) {
|
||||||
|
$query->whereHas('user', function ($q) use ($user) {
|
||||||
|
$q->where('id_kantor', $user->id_kantor);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($statusId) {
|
if ($statusId) {
|
||||||
|
|
@ -75,8 +88,18 @@ public function index(Request $request)
|
||||||
|
|
||||||
public function store(StorePengajuanIzinRequest $request)
|
public function store(StorePengajuanIzinRequest $request)
|
||||||
{
|
{
|
||||||
|
|
||||||
$jenisIzinData = JenisIzin::find($request->id_jenis_izin);
|
$jenisIzinData = JenisIzin::find($request->id_jenis_izin);
|
||||||
|
|
||||||
|
$existingPending = PengajuanIzin::where('id_user', $request->id_user)
|
||||||
|
->where('id_jenis_izin', $request->id_jenis_izin)
|
||||||
|
->where('id_status', StatusPengajuan::PENDING)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($existingPending) {
|
||||||
|
$namaIzin = $jenisIzinData->nama_izin ?? 'Izin';
|
||||||
|
return back()->withInput()->with('error', "Pegawai masih memiliki pengajuan {$namaIzin} yang menunggu persetujuan.");
|
||||||
|
}
|
||||||
|
|
||||||
if ($jenisIzinData && $jenisIzinData->id_jenis_izin == JenisIzinEnum::CUTI) {
|
if ($jenisIzinData && $jenisIzinData->id_jenis_izin == JenisIzinEnum::CUTI) {
|
||||||
$pegawai = User::find($request->id_user);
|
$pegawai = User::find($request->id_user);
|
||||||
$tanggalMulai = Carbon::parse($request->tanggal_mulai);
|
$tanggalMulai = Carbon::parse($request->tanggal_mulai);
|
||||||
|
|
@ -190,6 +213,17 @@ public function store(StorePengajuanIzinRequest $request)
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::commit();
|
DB::commit();
|
||||||
|
|
||||||
|
$pegawai = User::find($request->id_user);
|
||||||
|
$targetRole = ($izin->id_jenis_izin == JenisIzinEnum::CUTI) ? 'manager' : 'hrd';
|
||||||
|
app(NotifikasiService::class)->kirimKeRole(
|
||||||
|
$targetRole,
|
||||||
|
'pengajuan_baru',
|
||||||
|
'Pengajuan Baru: ' . ($jenisIzinData->nama_izin ?? 'Izin'),
|
||||||
|
($pegawai->nama_lengkap ?? 'Pegawai') . ' mengajukan ' . ($jenisIzinData->nama_izin ?? 'izin') . '.',
|
||||||
|
['id_izin' => $izin->id_izin]
|
||||||
|
);
|
||||||
|
|
||||||
return redirect()->back()->with('success', 'Pengajuan izin berhasil dibuat.');
|
return redirect()->back()->with('success', 'Pengajuan izin berhasil dibuat.');
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|
@ -260,8 +294,8 @@ public function approve($id)
|
||||||
);
|
);
|
||||||
|
|
||||||
broadcast(new PengajuanIzinUpdated(
|
broadcast(new PengajuanIzinUpdated(
|
||||||
$izin->id_user,
|
(int) $izin->id_user,
|
||||||
$izin->id_izin,
|
(int) $izin->id_izin,
|
||||||
'disetujui',
|
'disetujui',
|
||||||
$izin->jenisIzin->nama_izin,
|
$izin->jenisIzin->nama_izin,
|
||||||
'Pengajuan ' . $izin->jenisIzin->nama_izin . ' disetujui.'
|
'Pengajuan ' . $izin->jenisIzin->nama_izin . ' disetujui.'
|
||||||
|
|
@ -311,8 +345,8 @@ public function reject(Request $request, $id)
|
||||||
);
|
);
|
||||||
|
|
||||||
broadcast(new PengajuanIzinUpdated(
|
broadcast(new PengajuanIzinUpdated(
|
||||||
$izin->id_user,
|
(int) $izin->id_user,
|
||||||
$izin->id_izin,
|
(int) $izin->id_izin,
|
||||||
'ditolak',
|
'ditolak',
|
||||||
$izin->jenisIzin->nama_izin ?? 'Izin',
|
$izin->jenisIzin->nama_izin ?? 'Izin',
|
||||||
'Pengajuan ' . ($izin->jenisIzin->nama_izin ?? 'Izin') . ' ditolak.'
|
'Pengajuan ' . ($izin->jenisIzin->nama_izin ?? 'Izin') . ' ditolak.'
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,13 @@ public function update(Request $request, $id)
|
||||||
['id_penggunaan' => $penggunaan->id_penggunaan]
|
['id_penggunaan' => $penggunaan->id_penggunaan]
|
||||||
);
|
);
|
||||||
|
|
||||||
broadcast(new PenggunaanPoinUpdated($penggunaan->id_user, $penggunaan->id_penggunaan, 'disetujui', $penggunaan->jumlah_poin, 'Penggunaan poin disetujui.'));
|
broadcast(new PenggunaanPoinUpdated(
|
||||||
|
(int)$penggunaan->id_user,
|
||||||
|
(int)$penggunaan->id_penggunaan,
|
||||||
|
'disetujui',
|
||||||
|
(int)$penggunaan->jumlah_poin,
|
||||||
|
'Penggunaan poin disetujui.'
|
||||||
|
));
|
||||||
|
|
||||||
return redirect()->back()->with('success', 'Pengajuan berhasil disetujui dan poin telah dipotong.');
|
return redirect()->back()->with('success', 'Pengajuan berhasil disetujui dan poin telah dipotong.');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -111,7 +117,13 @@ public function update(Request $request, $id)
|
||||||
['id_penggunaan' => $penggunaan->id_penggunaan]
|
['id_penggunaan' => $penggunaan->id_penggunaan]
|
||||||
);
|
);
|
||||||
|
|
||||||
broadcast(new PenggunaanPoinUpdated($penggunaan->id_user, $penggunaan->id_penggunaan, 'ditolak', $penggunaan->jumlah_poin, 'Penggunaan poin ditolak.'));
|
broadcast(new PenggunaanPoinUpdated(
|
||||||
|
(int)$penggunaan->id_user,
|
||||||
|
(int)$penggunaan->id_penggunaan,
|
||||||
|
'ditolak',
|
||||||
|
(int)$penggunaan->jumlah_poin,
|
||||||
|
'Penggunaan poin ditolak.'
|
||||||
|
));
|
||||||
|
|
||||||
return redirect()->back()->with('success', 'Pengajuan berhasil ditolak.');
|
return redirect()->back()->with('success', 'Pengajuan berhasil ditolak.');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ public function index()
|
||||||
{
|
{
|
||||||
$pengumuman = Pengumuman::with('pembuat')
|
$pengumuman = Pengumuman::with('pembuat')
|
||||||
->orderBy('tanggal', 'desc')
|
->orderBy('tanggal', 'desc')
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
->paginate(10);
|
->paginate(10);
|
||||||
|
|
||||||
$title = 'Pengumuman';
|
$title = 'Pengumuman';
|
||||||
|
|
|
||||||
|
|
@ -24,16 +24,20 @@ public function index(Request $request)
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
$isGlobalAdmin = $user->isGlobalAdmin();
|
$isGlobalAdmin = $user->isGlobalAdmin();
|
||||||
|
$isSuperAdmin = $user->isSuperAdmin();
|
||||||
|
|
||||||
$query = SuratIzin::with(['user', 'pengajuanIzin.jenisIzin', 'approvals.approver']);
|
$query = SuratIzin::with(['user', 'pengajuanIzin.jenisIzin', 'approvals.approver']);
|
||||||
|
|
||||||
if (!$isGlobalAdmin) {
|
if (!$isSuperAdmin) {
|
||||||
if (!$user->roles->contains('nama_role', 'manajer')) {
|
$isManager = $user->roles->contains('nama_role', 'manager');
|
||||||
|
if (!$isManager) {
|
||||||
$query->where('status_surat', '!=', StatusSurat::MENUNGGU_MANAJER);
|
$query->where('status_surat', '!=', StatusSurat::MENUNGGU_MANAJER);
|
||||||
}
|
}
|
||||||
$query->whereHas('user', function ($q) use ($user) {
|
if (!$isGlobalAdmin) {
|
||||||
$q->where('id_kantor', $user->id_kantor);
|
$query->whereHas('user', function ($q) use ($user) {
|
||||||
});
|
$q->where('id_kantor', $user->id_kantor);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->filled('status')) {
|
if ($request->filled('status')) {
|
||||||
|
|
@ -76,7 +80,7 @@ public function show($id)
|
||||||
// Cegah user yang sama menandatangani di dua tahap berbeda
|
// Cegah user yang sama menandatangani di dua tahap berbeda
|
||||||
$sudahApprove = $surat->approvals->contains('id_approver', $user->id);
|
$sudahApprove = $surat->approvals->contains('id_approver', $user->id);
|
||||||
|
|
||||||
if (!$sudahApprove && $surat->status_surat === StatusSurat::MENUNGGU_MANAJER && ($user->roles->contains('nama_role', 'manajer') || $isGlobalAdmin)) {
|
if (!$sudahApprove && $surat->status_surat === StatusSurat::MENUNGGU_MANAJER && ($user->roles->contains('nama_role', 'manager') || $isGlobalAdmin)) {
|
||||||
$canApprove = true;
|
$canApprove = true;
|
||||||
$tahapApproval = 1;
|
$tahapApproval = 1;
|
||||||
} elseif (!$sudahApprove && $surat->status_surat === StatusSurat::MENUNGGU_HRD && ($user->roles->contains('nama_role', 'hrd') || $isGlobalAdmin)) {
|
} elseif (!$sudahApprove && $surat->status_surat === StatusSurat::MENUNGGU_HRD && ($user->roles->contains('nama_role', 'hrd') || $isGlobalAdmin)) {
|
||||||
|
|
@ -116,7 +120,7 @@ public function approve(Request $request, $id)
|
||||||
return redirect()->back()->with('error', 'Anda sudah menyetujui surat ini sebelumnya dan tidak dapat menandatangani di posisi lain.');
|
return redirect()->back()->with('error', 'Anda sudah menyetujui surat ini sebelumnya dan tidak dapat menandatangani di posisi lain.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($surat->status_surat === StatusSurat::MENUNGGU_MANAJER && ($user->roles->contains('nama_role', 'manajer') || $isGlobalAdmin)) {
|
if ($surat->status_surat === StatusSurat::MENUNGGU_MANAJER && ($user->roles->contains('nama_role', 'manager') || $isGlobalAdmin)) {
|
||||||
$tahap = 1;
|
$tahap = 1;
|
||||||
} elseif ($surat->status_surat === StatusSurat::MENUNGGU_HRD && ($user->roles->contains('nama_role', 'hrd') || $isGlobalAdmin)) {
|
} elseif ($surat->status_surat === StatusSurat::MENUNGGU_HRD && ($user->roles->contains('nama_role', 'hrd') || $isGlobalAdmin)) {
|
||||||
$tahap = 2;
|
$tahap = 2;
|
||||||
|
|
@ -160,6 +164,14 @@ public function approve(Request $request, $id)
|
||||||
'Surat izin Anda telah disetujui Manajer dan sedang menunggu persetujuan HRD.',
|
'Surat izin Anda telah disetujui Manajer dan sedang menunggu persetujuan HRD.',
|
||||||
['id_surat' => $surat->id_surat]
|
['id_surat' => $surat->id_surat]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app(NotifikasiService::class)->kirimKeRole(
|
||||||
|
'hrd',
|
||||||
|
'izin_proses',
|
||||||
|
'Surat Cuti Baru',
|
||||||
|
'Surat cuti ' . $surat->user->nama_lengkap . ' telah disetujui Manajer dan menunggu persetujuan HRD.',
|
||||||
|
['id_surat' => $surat->id_surat]
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
$statusLabel = 'Disetujui';
|
$statusLabel = 'Disetujui';
|
||||||
app(NotifikasiService::class)->kirim(
|
app(NotifikasiService::class)->kirim(
|
||||||
|
|
@ -172,10 +184,10 @@ public function approve(Request $request, $id)
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcast(new SuratIzinUpdated(
|
broadcast(new SuratIzinUpdated(
|
||||||
$surat->id_user,
|
(int) $surat->id_user,
|
||||||
$surat->id_surat,
|
(int) $surat->id_surat,
|
||||||
$surat->status_surat,
|
$surat->status_surat,
|
||||||
$tahap,
|
(int) $tahap,
|
||||||
'Surat izin ' . ($tahap === 1 ? 'disetujui Manajer' : 'disetujui sepenuhnya') . '.'
|
'Surat izin ' . ($tahap === 1 ? 'disetujui Manajer' : 'disetujui sepenuhnya') . '.'
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
@ -214,7 +226,7 @@ public function reject(Request $request, $id)
|
||||||
return redirect()->back()->with('error', 'Anda sudah menyetujui surat ini sebelumnya dan tidak dapat menolak di posisi lain.');
|
return redirect()->back()->with('error', 'Anda sudah menyetujui surat ini sebelumnya dan tidak dapat menolak di posisi lain.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($surat->status_surat === StatusSurat::MENUNGGU_MANAJER && ($user->roles->contains('nama_role', 'manajer') || $isGlobalAdmin)) {
|
if ($surat->status_surat === StatusSurat::MENUNGGU_MANAJER && ($user->roles->contains('nama_role', 'manager') || $isGlobalAdmin)) {
|
||||||
$tahap = 1;
|
$tahap = 1;
|
||||||
} elseif ($surat->status_surat === StatusSurat::MENUNGGU_HRD && ($user->roles->contains('nama_role', 'hrd') || $isGlobalAdmin)) {
|
} elseif ($surat->status_surat === StatusSurat::MENUNGGU_HRD && ($user->roles->contains('nama_role', 'hrd') || $isGlobalAdmin)) {
|
||||||
$tahap = 2;
|
$tahap = 2;
|
||||||
|
|
@ -252,10 +264,10 @@ public function reject(Request $request, $id)
|
||||||
);
|
);
|
||||||
|
|
||||||
broadcast(new SuratIzinUpdated(
|
broadcast(new SuratIzinUpdated(
|
||||||
$surat->id_user,
|
(int) $surat->id_user,
|
||||||
$surat->id_surat,
|
(int) $surat->id_surat,
|
||||||
StatusSurat::DITOLAK,
|
StatusSurat::DITOLAK,
|
||||||
$tahap,
|
(int) $tahap,
|
||||||
'Surat izin ditolak.'
|
'Surat izin ditolak.'
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ public function authorize(): bool
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'tanggal_mulai' => ['required', 'date'],
|
'tanggal_mulai' => ['required', 'date', 'after_or_equal:today'],
|
||||||
'tanggal_selesai' => ['required', 'date', 'after_or_equal:tanggal_mulai'],
|
'tanggal_selesai' => ['required', 'date', 'after_or_equal:tanggal_mulai'],
|
||||||
'user_ids' => ['required', 'array', 'min:1'],
|
'user_ids' => ['required', 'array', 'min:1'],
|
||||||
'user_ids.*' => ['exists:users,id'],
|
'user_ids.*' => ['exists:users,id'],
|
||||||
|
|
@ -25,6 +25,7 @@ public function messages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'tanggal_mulai.required' => 'Tanggal mulai wajib diisi.',
|
'tanggal_mulai.required' => 'Tanggal mulai wajib diisi.',
|
||||||
|
'tanggal_mulai.after_or_equal' => 'Tanggal mulai tidak boleh di masa lalu (sebelum hari ini).',
|
||||||
'tanggal_selesai.required' => 'Tanggal selesai wajib diisi.',
|
'tanggal_selesai.required' => 'Tanggal selesai wajib diisi.',
|
||||||
'tanggal_selesai.after_or_equal' => 'Tanggal selesai tidak boleh sebelum tanggal mulai.',
|
'tanggal_selesai.after_or_equal' => 'Tanggal selesai tidak boleh sebelum tanggal mulai.',
|
||||||
'user_ids.required' => 'Pilih minimal 1 pegawai.',
|
'user_ids.required' => 'Pilih minimal 1 pegawai.',
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ public function authorize(): bool
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'tanggal_mulai' => ['required', 'date'],
|
'tanggal_mulai' => ['required', 'date', 'after_or_equal:today'],
|
||||||
'tanggal_selesai' => ['required', 'date', 'after_or_equal:tanggal_mulai'],
|
'tanggal_selesai' => ['required', 'date', 'after_or_equal:tanggal_mulai'],
|
||||||
'id_shift' => ['required', 'exists:shift_kerja,id_shift'],
|
'id_shift' => ['required', 'exists:shift_kerja,id_shift'],
|
||||||
'user_ids' => ['required', 'array', 'min:1'],
|
'user_ids' => ['required', 'array', 'min:1'],
|
||||||
|
|
@ -26,6 +26,7 @@ public function messages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'tanggal_mulai.required' => 'Tanggal mulai wajib diisi.',
|
'tanggal_mulai.required' => 'Tanggal mulai wajib diisi.',
|
||||||
|
'tanggal_mulai.after_or_equal' => 'Tanggal mulai tidak boleh di masa lalu (sebelum hari ini).',
|
||||||
'tanggal_selesai.required' => 'Tanggal selesai wajib diisi.',
|
'tanggal_selesai.required' => 'Tanggal selesai wajib diisi.',
|
||||||
'tanggal_selesai.after_or_equal' => 'Tanggal selesai tidak boleh sebelum tanggal mulai.',
|
'tanggal_selesai.after_or_equal' => 'Tanggal selesai tidak boleh sebelum tanggal mulai.',
|
||||||
'id_shift.required' => 'Shift kerja wajib dipilih.',
|
'id_shift.required' => 'Shift kerja wajib dipilih.',
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
fopen($absVideoPath, 'r'),
|
||||||
'enrollment.mp4'
|
'enrollment.mp4'
|
||||||
)
|
)
|
||||||
->post(config('services.flask.url') . '/extract-and-embed', [
|
->post(config('services.flask.url') . '/extract-frames', [
|
||||||
'user_id' => (string) $this->userId,
|
'user_id' => (string) $this->userId,
|
||||||
'target_frames' => 200,
|
'target_frames' => 200,
|
||||||
]);
|
]);
|
||||||
|
|
@ -63,29 +63,17 @@ public function handle(): void
|
||||||
$output = $response->json();
|
$output = $response->json();
|
||||||
|
|
||||||
if (!isset($output['status']) || $output['status'] !== 'success') {
|
if (!isset($output['status']) || $output['status'] !== 'success') {
|
||||||
throw new \Exception("Extract & embed error: " . ($output['message'] ?? 'Unknown'));
|
throw new \Exception("Extract frames error: " . ($output['message'] ?? 'Unknown'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$jumlahFrame = $output['total_extracted'] ?? 0;
|
$jumlahFrame = $output['total_extracted'] ?? 0;
|
||||||
$embedding = $output['embedding'] ?? null;
|
|
||||||
|
|
||||||
$updateData = [
|
DB::table('data_wajah')->where('id_user', $this->userId)->update([
|
||||||
'jumlah_frame' => $jumlahFrame,
|
'jumlah_frame' => $jumlahFrame,
|
||||||
'is_verified' => StatusVerifikasiWajah::PENDING,
|
'is_verified' => StatusVerifikasiWajah::PENDING,
|
||||||
];
|
]);
|
||||||
|
|
||||||
if ($embedding && is_array($embedding)) {
|
Log::info("Face enrollment: extract frames berhasil untuk user {$this->userId}. Frames: {$jumlahFrame}. Menunggu HRD approve.");
|
||||||
$updateData['face_embeddings'] = json_encode($embedding);
|
|
||||||
$updateData['jumlah_embedding'] = $jumlahFrame;
|
|
||||||
$updateData['embedding_generated_at'] = now();
|
|
||||||
Log::info("Face enrollment: embedding berhasil di-generate untuk user {$this->userId}. Dimensi: " . count($embedding));
|
|
||||||
} else {
|
|
||||||
Log::warning("Face enrollment: frame berhasil di-extract tapi embedding tidak tersedia untuk user {$this->userId}.");
|
|
||||||
}
|
|
||||||
|
|
||||||
DB::table('data_wajah')->where('id_user', $this->userId)->update($updateData);
|
|
||||||
|
|
||||||
Log::info("Face enrollment: extract & embed berhasil untuk user {$this->userId}. Frames: {$jumlahFrame}. Menunggu HRD approve.");
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error("Face enrollment GAGAL untuk user {$this->userId}: " . $e->getMessage());
|
Log::error("Face enrollment GAGAL untuk user {$this->userId}: " . $e->getMessage());
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
@ -20,22 +21,56 @@ class ReextractAllFrames implements ShouldQueue
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
Log::info("Memulai re-extract frames & embedding untuk semua user...");
|
Log::info("Memulai sync & retrain untuk semua user...");
|
||||||
|
|
||||||
$users = DB::table('data_wajah')
|
$users = DB::table('data_wajah')
|
||||||
->whereNotNull('path_video')
|
->whereNotNull('path_video')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
if ($users->isEmpty()) {
|
if ($users->isEmpty()) {
|
||||||
Log::info("Tidak ada user dengan video. Skip re-extract.");
|
Log::info("Tidak ada user dengan video. Skip sync.");
|
||||||
|
Cache::put('face_training_status', ['phase' => 'idle'], 60);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$successCount = 0;
|
$extractCount = 0;
|
||||||
|
$skipCount = 0;
|
||||||
$failCount = 0;
|
$failCount = 0;
|
||||||
|
$total = $users->count();
|
||||||
|
$current = 0;
|
||||||
|
|
||||||
|
Cache::put('face_training_status', [
|
||||||
|
'phase' => 'extracting',
|
||||||
|
'current' => 0,
|
||||||
|
'total' => $total,
|
||||||
|
'message' => "Mengecek frames 0/{$total} user...",
|
||||||
|
], 3600);
|
||||||
|
|
||||||
foreach ($users as $data) {
|
foreach ($users as $data) {
|
||||||
$userId = $data->id_user;
|
$userId = $data->id_user;
|
||||||
|
$current++;
|
||||||
|
|
||||||
|
$frameDir = Storage::disk('local')->path("face_datasets/{$userId}");
|
||||||
|
$existingFrames = 0;
|
||||||
|
|
||||||
|
if (is_dir($frameDir)) {
|
||||||
|
$existingFrames = count(glob($frameDir . '/frame_*.jpg'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existingFrames >= 50) {
|
||||||
|
Log::info("User {$userId}: sudah punya {$existingFrames} frame. Skip extract.");
|
||||||
|
$skipCount++;
|
||||||
|
|
||||||
|
Cache::put('face_training_status', [
|
||||||
|
'phase' => 'extracting',
|
||||||
|
'current' => $current,
|
||||||
|
'total' => $total,
|
||||||
|
'message' => "User {$current}/{$total}: skip (sudah ada {$existingFrames} frame)",
|
||||||
|
], 3600);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$videoPath = Storage::disk('local')->path($data->path_video);
|
$videoPath = Storage::disk('local')->path($data->path_video);
|
||||||
|
|
||||||
if (!file_exists($videoPath)) {
|
if (!file_exists($videoPath)) {
|
||||||
|
|
@ -45,6 +80,13 @@ public function handle(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
Cache::put('face_training_status', [
|
||||||
|
'phase' => 'extracting',
|
||||||
|
'current' => $current,
|
||||||
|
'total' => $total,
|
||||||
|
'message' => "Extract frames user {$current}/{$total}...",
|
||||||
|
], 3600);
|
||||||
|
|
||||||
$response = Http::timeout(120)
|
$response = Http::timeout(120)
|
||||||
->withHeaders([
|
->withHeaders([
|
||||||
'X-API-Key' => config('services.flask.api_key'),
|
'X-API-Key' => config('services.flask.api_key'),
|
||||||
|
|
@ -54,7 +96,7 @@ public function handle(): void
|
||||||
fopen($videoPath, 'r'),
|
fopen($videoPath, 'r'),
|
||||||
'enrollment.mp4'
|
'enrollment.mp4'
|
||||||
)
|
)
|
||||||
->post(config('services.flask.url') . '/extract-and-embed', [
|
->post(config('services.flask.url') . '/extract-frames', [
|
||||||
'user_id' => (string) $userId,
|
'user_id' => (string) $userId,
|
||||||
'target_frames' => 200,
|
'target_frames' => 200,
|
||||||
]);
|
]);
|
||||||
|
|
@ -70,36 +112,36 @@ public function handle(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
$jumlahFrame = $output['total_extracted'] ?? 0;
|
$jumlahFrame = $output['total_extracted'] ?? 0;
|
||||||
$embedding = $output['embedding'] ?? null;
|
|
||||||
|
|
||||||
$updateData = [
|
DB::table('data_wajah')->where('id_user', $userId)->update([
|
||||||
'jumlah_frame' => $jumlahFrame,
|
'jumlah_frame' => $jumlahFrame,
|
||||||
];
|
]);
|
||||||
|
|
||||||
if ($embedding && is_array($embedding)) {
|
Log::info("Extract frames berhasil untuk user {$userId}. Frames: {$jumlahFrame}");
|
||||||
$updateData['face_embeddings'] = json_encode($embedding);
|
$extractCount++;
|
||||||
$updateData['jumlah_embedding'] = $jumlahFrame;
|
|
||||||
$updateData['embedding_generated_at'] = now();
|
|
||||||
}
|
|
||||||
|
|
||||||
DB::table('data_wajah')->where('id_user', $userId)->update($updateData);
|
|
||||||
|
|
||||||
Log::info("Re-extract & embed berhasil untuk user {$userId}. Frames: {$jumlahFrame}");
|
|
||||||
$successCount++;
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error("Re-extract GAGAL untuk user {$userId}: " . $e->getMessage());
|
Log::error("Extract GAGAL untuk user {$userId}: " . $e->getMessage());
|
||||||
$failCount++;
|
$failCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log::info("Re-extract selesai. Berhasil: {$successCount}, Gagal: {$failCount}");
|
Log::info("Sync selesai. Extracted: {$extractCount}, Skipped: {$skipCount}, Gagal: {$failCount}. Memulai retrain SVM...");
|
||||||
|
|
||||||
|
Cache::put('face_training_status', [
|
||||||
|
'phase' => 'training',
|
||||||
|
'current' => $total,
|
||||||
|
'total' => $total,
|
||||||
|
'message' => "Sync selesai (Extract: {$extractCount}, Skip: {$skipCount}). Memulai training model SVM...",
|
||||||
|
], 3600);
|
||||||
|
|
||||||
|
RetrainAllModels::dispatch();
|
||||||
|
|
||||||
app(\App\Services\NotifikasiService::class)->kirimKeRole(
|
app(\App\Services\NotifikasiService::class)->kirimKeRole(
|
||||||
'hrd',
|
'hrd',
|
||||||
'pengumuman',
|
'pengumuman',
|
||||||
'Ekstraksi & Embedding Wajah Selesai',
|
'Sync & Retrain Selesai',
|
||||||
"Proses re-extract video wajah selesai. Berhasil: {$successCount}, Gagal: {$failCount}."
|
"Sync frame selesai (Extract: {$extractCount}, Skip: {$skipCount}, Gagal: {$failCount}). Model SVM sedang di-training ulang."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
@ -24,6 +25,11 @@ public function handle(): void
|
||||||
{
|
{
|
||||||
Log::info("Memulai training model SVM multi-class global...");
|
Log::info("Memulai training model SVM multi-class global...");
|
||||||
|
|
||||||
|
Cache::put('face_training_status', [
|
||||||
|
'phase' => 'training',
|
||||||
|
'message' => 'Memulai training model SVM...',
|
||||||
|
], 3600);
|
||||||
|
|
||||||
$approvedUsers = DB::table('data_wajah')
|
$approvedUsers = DB::table('data_wajah')
|
||||||
->where('is_verified', StatusVerifikasiWajah::APPROVED)
|
->where('is_verified', StatusVerifikasiWajah::APPROVED)
|
||||||
->pluck('id_user')
|
->pluck('id_user')
|
||||||
|
|
@ -56,8 +62,14 @@ public function handle(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
$totalUsers = $output['total_users'] ?? 0;
|
$totalUsers = $output['total_users'] ?? 0;
|
||||||
|
$testAcc = $output['test_accuracy'] ?? 0;
|
||||||
Log::info("Training model global selesai. Total user: {$totalUsers}");
|
Log::info("Training model global selesai. Total user: {$totalUsers}");
|
||||||
|
|
||||||
|
Cache::put('face_training_status', [
|
||||||
|
'phase' => 'done',
|
||||||
|
'message' => "Training selesai! {$totalUsers} user, Akurasi: " . round($testAcc * 100, 2) . '%',
|
||||||
|
], 300);
|
||||||
|
|
||||||
app(\App\Services\NotifikasiService::class)->kirimKeRole(
|
app(\App\Services\NotifikasiService::class)->kirimKeRole(
|
||||||
'hrd',
|
'hrd',
|
||||||
'pengumuman',
|
'pengumuman',
|
||||||
|
|
@ -67,6 +79,11 @@ public function handle(): void
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error("Training model global GAGAL: " . $e->getMessage());
|
Log::error("Training model global GAGAL: " . $e->getMessage());
|
||||||
|
|
||||||
|
Cache::put('face_training_status', [
|
||||||
|
'phase' => 'error',
|
||||||
|
'message' => 'Training gagal: ' . $e->getMessage(),
|
||||||
|
], 300);
|
||||||
|
|
||||||
app(\App\Services\NotifikasiService::class)->kirimKeRole(
|
app(\App\Services\NotifikasiService::class)->kirimKeRole(
|
||||||
'hrd',
|
'hrd',
|
||||||
|
|
|
||||||
|
|
@ -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.
|
* Verifikasi wajah menggunakan SVM (Jalur 2).
|
||||||
* File dikirim ke Flask untuk generate embedding, lalu dibandingkan dengan database.
|
* File dikirim ke Flask /verify-face untuk prediksi SVM.
|
||||||
*/
|
*/
|
||||||
public function verifyFace($userId, UploadedFile $file)
|
public function verifyFace($userId, UploadedFile $file)
|
||||||
{
|
|
||||||
$probeEmbedding = $this->getEmbedding($file);
|
|
||||||
|
|
||||||
$allEmbeddings = DB::table('data_wajah')
|
|
||||||
->where('is_verified', StatusVerifikasiWajah::APPROVED)
|
|
||||||
->whereNotNull('face_embeddings')
|
|
||||||
->get(['id_user', 'face_embeddings']);
|
|
||||||
|
|
||||||
if ($allEmbeddings->isEmpty()) {
|
|
||||||
throw new \Exception('Belum ada data wajah terverifikasi di sistem.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$bestMatch = null;
|
|
||||||
$bestScore = -1;
|
|
||||||
|
|
||||||
foreach ($allEmbeddings as $data) {
|
|
||||||
$storedEmbedding = json_decode($data->face_embeddings, true);
|
|
||||||
if (!is_array($storedEmbedding)) continue;
|
|
||||||
|
|
||||||
$similarity = $this->cosineSimilarity($probeEmbedding, $storedEmbedding);
|
|
||||||
|
|
||||||
if ($similarity > $bestScore) {
|
|
||||||
$bestScore = $similarity;
|
|
||||||
$bestMatch = $data->id_user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$threshold = 0.6;
|
|
||||||
$isMatch = $bestScore >= $threshold && $bestMatch == $userId;
|
|
||||||
|
|
||||||
$verificationStatus = 'REJECTED';
|
|
||||||
if ($isMatch) {
|
|
||||||
$verificationStatus = 'MATCH';
|
|
||||||
} elseif ($bestScore >= $threshold && $bestMatch != $userId) {
|
|
||||||
$verificationStatus = 'MISMATCH';
|
|
||||||
}
|
|
||||||
|
|
||||||
Log::info("[FaceVerify] User {$userId}: best_match={$bestMatch}, score={$bestScore}, status={$verificationStatus}");
|
|
||||||
|
|
||||||
return [
|
|
||||||
'verified' => $isMatch,
|
|
||||||
'confidence' => round($bestScore, 4),
|
|
||||||
'svm_df' => null,
|
|
||||||
'verification_status' => $verificationStatus,
|
|
||||||
'blur_score' => $probeEmbedding['blur_score'] ?? null,
|
|
||||||
'predicted_user' => $bestMatch,
|
|
||||||
'expected_user' => $userId,
|
|
||||||
'message' => $isMatch ? 'Wajah cocok' : 'Wajah tidak cocok',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getEmbedding(UploadedFile $file): array
|
|
||||||
{
|
{
|
||||||
$isVideo = str_starts_with($file->getMimeType() ?? '', 'video/');
|
$isVideo = str_starts_with($file->getMimeType() ?? '', 'video/');
|
||||||
|
|
||||||
|
|
@ -143,52 +91,44 @@ private function getEmbedding(UploadedFile $file): array
|
||||||
fopen($file->getRealPath(), 'r'),
|
fopen($file->getRealPath(), 'r'),
|
||||||
$isVideo ? 'verify.mp4' : 'verify.jpg'
|
$isVideo ? 'verify.mp4' : 'verify.jpg'
|
||||||
)
|
)
|
||||||
->post($this->getFlaskUrl() . '/get-embedding', [
|
->post($this->getFlaskUrl() . '/verify-face', [
|
||||||
|
'user_id' => (string) $userId,
|
||||||
'is_video' => $isVideo ? 'true' : 'false',
|
'is_video' => $isVideo ? 'true' : 'false',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!$response->successful()) {
|
if (!$response->successful()) {
|
||||||
$body = $response->json();
|
$body = $response->json();
|
||||||
$msg = $body['message'] ?? 'Flask ML API tidak merespons.';
|
$msg = $body['message'] ?? 'Flask ML API tidak merespons.';
|
||||||
Log::error("[FaceVerify] Flask /get-embedding error: HTTP {$response->status()} - {$msg}");
|
Log::error("[FaceVerify] Flask /verify-face error: HTTP {$response->status()} - {$msg}");
|
||||||
throw new \Exception("Gagal mengekstrak embedding wajah. {$msg}");
|
throw new \Exception("Gagal memverifikasi wajah. {$msg}");
|
||||||
}
|
}
|
||||||
|
|
||||||
$output = $response->json();
|
$result = $response->json();
|
||||||
|
|
||||||
if (!$output || $output['status'] !== 'success' || !isset($output['embedding'])) {
|
if (!$result || $result['status'] !== 'success') {
|
||||||
throw new \Exception('Respons embedding tidak valid.');
|
throw new \Exception($result['message'] ?? 'Respons verifikasi tidak valid.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $output;
|
Log::info("[FaceVerify] User {$userId}: " . json_encode([
|
||||||
}
|
'match' => $result['match'],
|
||||||
|
'verification_status' => $result['verification_status'],
|
||||||
|
'confidence' => $result['confidence'],
|
||||||
|
'svm_df' => $result['svm_df'] ?? null,
|
||||||
|
'predicted_user' => $result['predicted_user'] ?? null,
|
||||||
|
'blur_score' => $result['blur_score'] ?? null,
|
||||||
|
]));
|
||||||
|
|
||||||
private function cosineSimilarity(array $embeddingResponse, array $storedEmbedding): float
|
return [
|
||||||
{
|
'verified' => $result['match'],
|
||||||
$a = $embeddingResponse['embedding'] ?? $embeddingResponse;
|
'confidence' => $result['confidence'],
|
||||||
$b = $storedEmbedding;
|
'svm_df' => $result['svm_df'] ?? null,
|
||||||
|
'verification_status' => $result['verification_status'],
|
||||||
if (count($a) !== count($b) || count($a) === 0) {
|
'blur_score' => $result['blur_score'] ?? null,
|
||||||
return 0.0;
|
'predicted_user' => $result['predicted_user'] ?? null,
|
||||||
}
|
'expected_user' => (string) $userId,
|
||||||
|
'approved_ratio' => $result['approved_ratio'] ?? null,
|
||||||
$dotProduct = 0.0;
|
'message' => $result['message'] ?? ($result['match'] ? 'Wajah cocok' : 'Wajah tidak cocok'),
|
||||||
$normA = 0.0;
|
];
|
||||||
$normB = 0.0;
|
|
||||||
|
|
||||||
for ($i = 0; $i < count($a); $i++) {
|
|
||||||
$dotProduct += $a[$i] * $b[$i];
|
|
||||||
$normA += $a[$i] * $a[$i];
|
|
||||||
$normB += $b[$i] * $b[$i];
|
|
||||||
}
|
|
||||||
|
|
||||||
$normA = sqrt($normA);
|
|
||||||
$normB = sqrt($normB);
|
|
||||||
|
|
||||||
if ($normA == 0 || $normB == 0) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $dotProduct / ($normA * $normB);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,8 @@ public function kirim(int $idUser, string $tipe, string $judul, string $pesan, a
|
||||||
$unreadCount = Notifikasi::where('id_user', $idUser)->where('is_read', false)->count();
|
$unreadCount = Notifikasi::where('id_user', $idUser)->where('is_read', false)->count();
|
||||||
broadcast(new NotifikasiCreated($idUser, $judul, $pesan, $tipe, $unreadCount));
|
broadcast(new NotifikasiCreated($idUser, $judul, $pesan, $tipe, $unreadCount));
|
||||||
|
|
||||||
$this->sendPushNotification($idUser, $judul, $pesan, $data);
|
$data['tipe'] = $tipe;
|
||||||
|
\App\Jobs\SendFcmPushNotification::dispatch($idUser, $judul, $pesan, $data);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('NotifikasiService::kirim gagal: ' . $e->getMessage());
|
Log::error('NotifikasiService::kirim gagal: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
@ -39,15 +40,7 @@ public function kirim(int $idUser, string $tipe, string $judul, string $pesan, a
|
||||||
*/
|
*/
|
||||||
public function kirimBroadcast(string $tipe, string $judul, string $pesan, array $data = []): void
|
public function kirimBroadcast(string $tipe, string $judul, string $pesan, array $data = []): void
|
||||||
{
|
{
|
||||||
$users = User::where('status_aktif', 1)
|
\App\Jobs\BroadcastNotifikasiJob::dispatch($tipe, $judul, $pesan, $data);
|
||||||
->whereDoesntHave('roles', function ($q) {
|
|
||||||
$q->whereIn('nama_role', ['super_admin']);
|
|
||||||
})
|
|
||||||
->pluck('id');
|
|
||||||
|
|
||||||
foreach ($users as $idUser) {
|
|
||||||
$this->kirim($idUser, $tipe, $judul, $pesan, $data);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -70,7 +63,7 @@ public function kirimKeRole(string $namaRole, string $tipe, string $judul, strin
|
||||||
* Kirim push notification ke device FCM user via FCM HTTP v1 API.
|
* Kirim push notification ke device FCM user via FCM HTTP v1 API.
|
||||||
* Token yang sudah tidak valid (UNREGISTERED) otomatis dihapus dari DB.
|
* Token yang sudah tidak valid (UNREGISTERED) otomatis dihapus dari DB.
|
||||||
*/
|
*/
|
||||||
private function sendPushNotification(int $idUser, string $judul, string $pesan, array $data = []): void
|
public function sendPushNotification(int $idUser, string $judul, string $pesan, array $data = []): void
|
||||||
{
|
{
|
||||||
$tokens = DeviceToken::where('id_user', $idUser)->get();
|
$tokens = DeviceToken::where('id_user', $idUser)->get();
|
||||||
if ($tokens->isEmpty()) {
|
if ($tokens->isEmpty()) {
|
||||||
|
|
@ -97,6 +90,7 @@ private function sendPushNotification(int $idUser, string $judul, string $pesan,
|
||||||
'notification' => ['title' => $judul, 'body' => $pesan],
|
'notification' => ['title' => $judul, 'body' => $pesan],
|
||||||
'data' => array_map('strval', $data),
|
'data' => array_map('strval', $data),
|
||||||
'android' => [
|
'android' => [
|
||||||
|
'priority' => 'HIGH',
|
||||||
'notification' => [
|
'notification' => [
|
||||||
'channel_id' => 'hris_channel',
|
'channel_id' => 'hris_channel',
|
||||||
'click_action' => 'FLUTTER_NOTIFICATION_CLICK',
|
'click_action' => 'FLUTTER_NOTIFICATION_CLICK',
|
||||||
|
|
@ -125,35 +119,38 @@ private function sendPushNotification(int $idUser, string $judul, string $pesan,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ambil access token Google OAuth2 menggunakan Service Account JWT.
|
* Ambil access token Google OAuth2 menggunakan Service Account JWT.
|
||||||
|
* Dicache selama 50 menit (token OAuth biasanya berlaku 1 jam).
|
||||||
*/
|
*/
|
||||||
private function getFcmAccessToken(): ?string
|
private function getFcmAccessToken(): ?string
|
||||||
{
|
{
|
||||||
try {
|
return \Illuminate\Support\Facades\Cache::remember('fcm_access_token', 3000, function () {
|
||||||
$credPath = base_path(env('FIREBASE_CREDENTIALS'));
|
try {
|
||||||
$credentials = json_decode(file_get_contents($credPath), true);
|
$credPath = base_path(env('FIREBASE_CREDENTIALS'));
|
||||||
$now = time();
|
$credentials = json_decode(file_get_contents($credPath), true);
|
||||||
|
$now = time();
|
||||||
|
|
||||||
$header = rtrim(strtr(base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])), '+/', '-_'), '=');
|
$header = rtrim(strtr(base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])), '+/', '-_'), '=');
|
||||||
$payload = rtrim(strtr(base64_encode(json_encode([
|
$payload = rtrim(strtr(base64_encode(json_encode([
|
||||||
'iss' => $credentials['client_email'],
|
'iss' => $credentials['client_email'],
|
||||||
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
|
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
|
||||||
'aud' => 'https://oauth2.googleapis.com/token',
|
'aud' => 'https://oauth2.googleapis.com/token',
|
||||||
'iat' => $now,
|
'iat' => $now,
|
||||||
'exp' => $now + 3600,
|
'exp' => $now + 3600,
|
||||||
])), '+/', '-_'), '=');
|
])), '+/', '-_'), '=');
|
||||||
|
|
||||||
openssl_sign("$header.$payload", $sig, $credentials['private_key'], OPENSSL_ALGO_SHA256);
|
openssl_sign("$header.$payload", $sig, $credentials['private_key'], OPENSSL_ALGO_SHA256);
|
||||||
$jwt = "$header.$payload." . rtrim(strtr(base64_encode($sig), '+/', '-_'), '=');
|
$jwt = "$header.$payload." . rtrim(strtr(base64_encode($sig), '+/', '-_'), '=');
|
||||||
|
|
||||||
$resp = \Illuminate\Support\Facades\Http::asForm()->withoutVerifying()->post('https://oauth2.googleapis.com/token', [
|
$resp = \Illuminate\Support\Facades\Http::asForm()->withoutVerifying()->post('https://oauth2.googleapis.com/token', [
|
||||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||||
'assertion' => $jwt,
|
'assertion' => $jwt,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $resp->json('access_token');
|
return $resp->json('access_token');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('FCM Token error: ' . $e->getMessage());
|
Log::error('FCM Token error: ' . $e->getMessage());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
],
|
],
|
||||||
"dev": [
|
"dev": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74,#a3e635\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan reverb:start\" \"npm run dev\" --names=server,queue,reverb,vite --kill-others"
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74,#a3e635\" \"php artisan serve --host=0.0.0.0\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan reverb:start\" \"npm run dev\" --names=server,queue,reverb,vite --kill-others"
|
||||||
],
|
],
|
||||||
"test": [
|
"test": [
|
||||||
"@php artisan config:clear --ansi",
|
"@php artisan config:clear --ansi",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ function initSweetAlerts() {
|
||||||
if (swalData) {
|
if (swalData) {
|
||||||
const successMessage = swalData.getAttribute('data-success');
|
const successMessage = swalData.getAttribute('data-success');
|
||||||
const errorMessage = swalData.getAttribute('data-error');
|
const errorMessage = swalData.getAttribute('data-error');
|
||||||
|
const warningMessage = swalData.getAttribute('data-warning');
|
||||||
|
|
||||||
if (successMessage && successMessage !== '') {
|
if (successMessage && successMessage !== '') {
|
||||||
showSuccess(successMessage);
|
showSuccess(successMessage);
|
||||||
|
|
@ -38,6 +39,17 @@ function initSweetAlerts() {
|
||||||
swalData.setAttribute('data-error', '');
|
swalData.setAttribute('data-error', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (warningMessage && warningMessage !== '') {
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'warning',
|
||||||
|
title: 'Peringatan',
|
||||||
|
text: warningMessage,
|
||||||
|
confirmButtonColor: '#f59e0b',
|
||||||
|
confirmButtonText: 'OK'
|
||||||
|
});
|
||||||
|
swalData.setAttribute('data-warning', '');
|
||||||
|
}
|
||||||
|
|
||||||
const validationErrors = swalData.getAttribute('data-errors');
|
const validationErrors = swalData.getAttribute('data-errors');
|
||||||
if (validationErrors) {
|
if (validationErrors) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -198,28 +210,7 @@ function updateBadgeUI(count) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete(id) {
|
|
||||||
Swal.fire({
|
|
||||||
title: 'Apakah Anda yakin?',
|
|
||||||
text: "Data ini akan dihapus secara permanen.",
|
|
||||||
icon: 'warning',
|
|
||||||
showCancelButton: true,
|
|
||||||
confirmButtonColor: '#d33',
|
|
||||||
cancelButtonColor: '#3085d6',
|
|
||||||
confirmButtonText: 'Ya, hapus saja!',
|
|
||||||
cancelButtonText: 'Batal',
|
|
||||||
reverseButtons: true
|
|
||||||
}).then((result) => {
|
|
||||||
if (result.isConfirmed) {
|
|
||||||
const form = document.getElementById('delete-form-' + id);
|
|
||||||
if (form) {
|
|
||||||
form.submit();
|
|
||||||
} else {
|
|
||||||
console.error('Form hapus tidak ditemukan untuk ID:', id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmAction(event, formId, message, confirmBtnColor = '#3085d6', confirmBtnText = 'Ya, lanjutkan!') {
|
function confirmAction(event, formId, message, confirmBtnColor = '#3085d6', confirmBtnText = 'Ya, lanjutkan!') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -238,7 +229,7 @@ function confirmAction(event, formId, message, confirmBtnColor = '#3085d6', conf
|
||||||
if (result.isConfirmed) {
|
if (result.isConfirmed) {
|
||||||
const form = document.getElementById(formId);
|
const form = document.getElementById(formId);
|
||||||
if (form) {
|
if (form) {
|
||||||
form.submit();
|
form.requestSubmit();
|
||||||
} else {
|
} else {
|
||||||
console.error('Form tidak ditemukan:', formId);
|
console.error('Form tidak ditemukan:', formId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,10 +74,11 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||||
position: 'top-end',
|
position: 'top-end',
|
||||||
icon: 'info',
|
icon: 'info',
|
||||||
title: title,
|
title: title,
|
||||||
text: message,
|
html: '<div style="font-size: 0.875rem; color: #64748b; margin-top: 0.25rem;">' + message + '</div>',
|
||||||
showConfirmButton: false,
|
showConfirmButton: false,
|
||||||
timer: 4000,
|
timer: 4000,
|
||||||
timerProgressBar: true,
|
timerProgressBar: true,
|
||||||
|
padding: '1em',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -227,38 +227,40 @@ class="text-xs text-primary hover:underline font-medium">Lihat</a>
|
||||||
@section('script')
|
@section('script')
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const ctx = document.getElementById('attendanceChart').getContext('2d');
|
(function() {
|
||||||
const attendanceChart = new Chart(ctx, {
|
const canvas = document.getElementById('attendanceChart');
|
||||||
type: 'line',
|
if (!canvas) return;
|
||||||
data: {
|
const ctx = canvas.getContext('2d');
|
||||||
labels: @json($chartLabels),
|
const attendanceChart = new Chart(ctx, {
|
||||||
datasets: [{
|
type: 'bar',
|
||||||
label: 'Jumlah Hadir',
|
data: {
|
||||||
data: @json($chartData),
|
labels: @json($chartLabels),
|
||||||
borderColor: '#10b981',
|
datasets: [{
|
||||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
label: 'Jumlah Hadir',
|
||||||
tension: 0.4,
|
data: @json($chartData),
|
||||||
fill: true,
|
backgroundColor: 'rgba(16, 185, 129, 0.8)',
|
||||||
pointRadius: 4,
|
borderColor: '#10b981',
|
||||||
pointHoverRadius: 6
|
borderWidth: 1,
|
||||||
}]
|
borderRadius: 4
|
||||||
},
|
}]
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: { display: false }
|
|
||||||
},
|
},
|
||||||
scales: {
|
options: {
|
||||||
y: {
|
responsive: true,
|
||||||
beginAtZero: true,
|
maintainAspectRatio: false,
|
||||||
ticks: { stepSize: 1 }
|
plugins: {
|
||||||
|
legend: { display: false }
|
||||||
},
|
},
|
||||||
x: {
|
scales: {
|
||||||
grid: { display: false }
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: { stepSize: 1 }
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: { display: false }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
})();
|
||||||
</script>
|
</script>
|
||||||
@endsection
|
@endsection
|
||||||
|
|
|
||||||
|
|
@ -101,12 +101,28 @@ class="!mb-0" />
|
||||||
<form id="reextract-form" action="{{ route('face.reextract_all') }}" method="POST" class="hidden">
|
<form id="reextract-form" action="{{ route('face.reextract_all') }}" method="POST" class="hidden">
|
||||||
@csrf
|
@csrf
|
||||||
</form>
|
</form>
|
||||||
<button onclick="confirmAction(event, 'reextract-form', 'Apakah Anda yakin ingin mengekstrak ulang seluruh video enrollment lama? Proses ini memakan waktu beberapa menit, dan akan me-retrain model global secara otomatis.', '#3b82f6', 'Ya, Lanjutkan!')"
|
<button id="reextractBtn" onclick="confirmAction(event, 'reextract-form', 'Sistem akan mengecek user yang belum punya frame, extract jika perlu, lalu retrain model global. Proses ini memakan waktu beberapa menit.', '#3b82f6', 'Ya, Lanjutkan!')"
|
||||||
class="inline-flex items-center px-4 py-2 text-sm font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-lg transition-colors shadow-sm">
|
class="inline-flex items-center px-4 py-2 text-sm font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-lg transition-colors shadow-sm">
|
||||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
|
||||||
Re-extract & Retrain All
|
Sync & Retrain All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="trainingProgress" class="mt-3 hidden">
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<svg class="w-5 h-5 text-blue-500 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span id="trainingPhase" class="text-sm font-semibold text-blue-800">Memproses...</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-blue-200 rounded-full h-2.5">
|
||||||
|
<div id="trainingBar" class="bg-blue-600 h-2.5 rounded-full transition-all duration-500" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<p id="trainingMessage" class="text-xs text-blue-600 mt-1.5"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-table>
|
<x-table>
|
||||||
|
|
@ -310,5 +326,84 @@ function closeFacePreview() {
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Escape') closeFacePreview();
|
if (e.key === 'Escape') closeFacePreview();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let pollingInterval = null;
|
||||||
|
|
||||||
|
// Cek status saat halaman pertama kali dimuat
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
fetch('{{ route("face.training_status") }}')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const phase = data.phase || 'idle';
|
||||||
|
if (phase === 'extracting' || phase === 'training') {
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('reextract-form').addEventListener('submit', function() {
|
||||||
|
startPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
const progressEl = document.getElementById('trainingProgress');
|
||||||
|
const btn = document.getElementById('reextractBtn');
|
||||||
|
progressEl.classList.remove('hidden');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||||
|
|
||||||
|
pollingInterval = setInterval(checkStatus, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkStatus() {
|
||||||
|
fetch('{{ route("face.training_status") }}')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const phase = data.phase || 'idle';
|
||||||
|
const message = data.message || '';
|
||||||
|
const current = data.current || 0;
|
||||||
|
const total = data.total || 1;
|
||||||
|
|
||||||
|
document.getElementById('trainingMessage').textContent = message;
|
||||||
|
|
||||||
|
if (phase === 'extracting') {
|
||||||
|
const pct = Math.round((current / total) * 70);
|
||||||
|
document.getElementById('trainingBar').style.width = pct + '%';
|
||||||
|
document.getElementById('trainingPhase').textContent = 'Extracting Frames...';
|
||||||
|
} else if (phase === 'training') {
|
||||||
|
document.getElementById('trainingBar').style.width = '85%';
|
||||||
|
document.getElementById('trainingPhase').textContent = 'Training SVM...';
|
||||||
|
} else if (phase === 'done') {
|
||||||
|
document.getElementById('trainingBar').style.width = '100%';
|
||||||
|
document.getElementById('trainingPhase').textContent = '✅ Selesai!';
|
||||||
|
document.getElementById('trainingProgress').querySelector('div').className =
|
||||||
|
'bg-emerald-50 border border-emerald-200 rounded-lg p-4';
|
||||||
|
document.getElementById('trainingPhase').className = 'text-sm font-semibold text-emerald-800';
|
||||||
|
document.getElementById('trainingMessage').className = 'text-xs text-emerald-600 mt-1.5';
|
||||||
|
document.getElementById('trainingBar').className = 'bg-emerald-600 h-2.5 rounded-full transition-all duration-500';
|
||||||
|
document.querySelector('#trainingProgress svg').classList.remove('animate-spin');
|
||||||
|
stopPolling();
|
||||||
|
} else if (phase === 'error') {
|
||||||
|
document.getElementById('trainingPhase').textContent = '❌ Gagal';
|
||||||
|
document.getElementById('trainingProgress').querySelector('div').className =
|
||||||
|
'bg-red-50 border border-red-200 rounded-lg p-4';
|
||||||
|
document.getElementById('trainingPhase').className = 'text-sm font-semibold text-red-800';
|
||||||
|
document.getElementById('trainingMessage').className = 'text-xs text-red-600 mt-1.5';
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollingInterval) {
|
||||||
|
clearInterval(pollingInterval);
|
||||||
|
pollingInterval = null;
|
||||||
|
}
|
||||||
|
const btn = document.getElementById('reextractBtn');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
@endsection
|
@endsection
|
||||||
|
|
|
||||||
|
|
@ -344,12 +344,32 @@ function openDetailModal(data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitRejectWithAlasan() {
|
function submitRejectWithAlasan() {
|
||||||
const alasan = prompt('Masukkan alasan penolakan (opsional):');
|
// Tutup modal detail terlebih dahulu untuk menghindari focus trap
|
||||||
if (alasan === null) return;
|
window.dispatchEvent(new CustomEvent('close-modal', { detail: 'detail-izin' }));
|
||||||
const inputAlasan = document.getElementById('input-alasan-penolakan');
|
|
||||||
if (inputAlasan) inputAlasan.value = alasan;
|
setTimeout(() => {
|
||||||
const form = document.getElementById('form-reject-izin');
|
Swal.fire({
|
||||||
if (form) form.submit();
|
title: 'Tolak Pengajuan',
|
||||||
|
text: 'Masukkan alasan penolakan (opsional):',
|
||||||
|
input: 'textarea',
|
||||||
|
inputPlaceholder: 'Ketik alasan di sini...',
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonColor: '#ef4444',
|
||||||
|
cancelButtonColor: '#64748b',
|
||||||
|
confirmButtonText: 'Ya, Tolak',
|
||||||
|
cancelButtonText: 'Batal'
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
const inputAlasan = document.getElementById('input-alasan-penolakan');
|
||||||
|
if (inputAlasan) inputAlasan.value = result.value || '';
|
||||||
|
const form = document.getElementById('form-reject-izin');
|
||||||
|
if (form) form.requestSubmit();
|
||||||
|
} else {
|
||||||
|
// Jika dibatalkan, buka kembali modal detail
|
||||||
|
window.dispatchEvent(new CustomEvent('open-modal', { detail: 'detail-izin' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 150); // Jeda singkat agar animasi tutup modal selesai
|
||||||
}
|
}
|
||||||
|
|
||||||
function initCreateIzinForm() {
|
function initCreateIzinForm() {
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,15 @@
|
||||||
</x-page-header>
|
</x-page-header>
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
|
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
|
||||||
<form action="{{ route('jadwal.store') }}" method="POST">
|
<form id="jadwalForm" action="{{ route('jadwal.store') }}" method="POST">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||||
<x-date-input label="Tanggal Mulai" name="tanggal_mulai"
|
<x-date-input label="Tanggal Mulai" name="tanggal_mulai"
|
||||||
value="{{ old('tanggal_mulai', now()->format('Y-m-d')) }}" required />
|
value="{{ old('tanggal_mulai', now()->format('Y-m-d')) }}" required min="{{ date('Y-m-d') }}" />
|
||||||
|
|
||||||
<x-date-input label="Tanggal Selesai" name="tanggal_selesai"
|
<x-date-input label="Tanggal Selesai" name="tanggal_selesai"
|
||||||
value="{{ old('tanggal_selesai', now()->addDays(6)->format('Y-m-d')) }}" required />
|
value="{{ old('tanggal_selesai', now()->addDays(6)->format('Y-m-d')) }}" required min="{{ date('Y-m-d') }}" />
|
||||||
|
|
||||||
<x-select label="Shift" name="id_shift" id="id_shift" class="!mb-0" required>
|
<x-select label="Shift" name="id_shift" id="id_shift" class="!mb-0" required>
|
||||||
<option value="">-- Pilih Shift --</option>
|
<option value="">-- Pilih Shift --</option>
|
||||||
|
|
@ -88,7 +88,7 @@ class="flex items-center gap-3 p-3 bg-slate-50 rounded-lg hover:bg-slate-100 tra
|
||||||
<div class="flex justify-end gap-3 pt-4 border-t border-slate-100">
|
<div class="flex justify-end gap-3 pt-4 border-t border-slate-100">
|
||||||
<a href="{{ route('jadwal.index') }}"
|
<a href="{{ route('jadwal.index') }}"
|
||||||
class="px-4 py-2 text-sm border rounded-xl hover:bg-slate-50">Batal</a>
|
class="px-4 py-2 text-sm border rounded-xl hover:bg-slate-50">Batal</a>
|
||||||
<button type="submit"
|
<button type="button" id="generateBtn"
|
||||||
class="px-6 py-2 text-sm bg-primary text-white rounded-xl hover:bg-primary/90 font-medium">
|
class="px-6 py-2 text-sm bg-primary text-white rounded-xl hover:bg-primary/90 font-medium">
|
||||||
Generate Jadwal
|
Generate Jadwal
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -128,13 +128,11 @@ class="px-6 py-2 text-sm bg-primary text-white rounded-xl hover:bg-primary/90 fo
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
const formObj = document.querySelector('form');
|
const formObj = document.getElementById('jadwalForm');
|
||||||
if (formObj) {
|
const generateBtn = document.getElementById('generateBtn');
|
||||||
formObj.addEventListener('submit', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
let form = this;
|
|
||||||
|
|
||||||
|
if (generateBtn && formObj) {
|
||||||
|
generateBtn.addEventListener('click', function () {
|
||||||
let selectedUserIds = [];
|
let selectedUserIds = [];
|
||||||
document.querySelectorAll('.user-checkbox:checked').forEach((cb) => {
|
document.querySelectorAll('.user-checkbox:checked').forEach((cb) => {
|
||||||
selectedUserIds.push(cb.value);
|
selectedUserIds.push(cb.value);
|
||||||
|
|
@ -154,7 +152,7 @@ class="px-6 py-2 text-sm bg-primary text-white rounded-xl hover:bg-primary/90 fo
|
||||||
|
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
title: 'Memeriksa Jadwal...',
|
title: 'Memeriksa Jadwal...',
|
||||||
text: 'Sedang mengecek bentrok penggunaan poin',
|
text: 'Sedang mengecek bentrok penggunaan poin & hari libur',
|
||||||
allowOutsideClick: false,
|
allowOutsideClick: false,
|
||||||
didOpen: () => { Swal.showLoading() }
|
didOpen: () => { Swal.showLoading() }
|
||||||
});
|
});
|
||||||
|
|
@ -169,23 +167,47 @@ class="px-6 py-2 text-sm bg-primary text-white rounded-xl hover:bg-primary/90 fo
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
let warnings = [];
|
||||||
|
|
||||||
|
if (data.has_holiday) {
|
||||||
|
warnings.push(data.holiday_message);
|
||||||
|
}
|
||||||
if (data.has_conflict) {
|
if (data.has_conflict) {
|
||||||
|
warnings.push(data.conflict_message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warnings.length > 0) {
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
title: '⚠️ Konflik Data Poin!',
|
title: data.has_conflict ? '⚠️ Konflik & Peringatan!' : '📅 Peringatan Hari Libur',
|
||||||
html: data.message,
|
html: warnings.join('<hr class="my-3 border-slate-200">') +
|
||||||
|
'<br><small class="text-slate-500">Apakah Anda yakin ingin melanjutkan pembuatan jadwal?</small>',
|
||||||
icon: 'warning',
|
icon: 'warning',
|
||||||
showCancelButton: true,
|
showCancelButton: true,
|
||||||
confirmButtonColor: '#d33',
|
confirmButtonColor: data.has_conflict ? '#d33' : '#3085d6',
|
||||||
cancelButtonColor: '#3085d6',
|
cancelButtonColor: '#6b7280',
|
||||||
confirmButtonText: 'Ya, Ubah & Batalkan Poin',
|
confirmButtonText: data.has_conflict ? 'Ya, Lanjutkan & Batalkan Poin' : 'Ya, Tetap Buat Jadwal',
|
||||||
cancelButtonText: 'Batal'
|
cancelButtonText: 'Batal'
|
||||||
}).then((result) => {
|
}).then((result) => {
|
||||||
if (result.isConfirmed) {
|
if (result.isConfirmed) {
|
||||||
form.submit();
|
formObj.submit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
form.submit();
|
Swal.fire({
|
||||||
|
title: '📋 Konfirmasi Jadwal',
|
||||||
|
html: `Anda akan membuat jadwal kerja untuk <b>${selectedUserIds.length} pegawai</b> ` +
|
||||||
|
`dari tanggal <b>${payload.tanggal_mulai}</b> s/d <b>${payload.tanggal_selesai}</b>.<br><br>Lanjutkan?`,
|
||||||
|
icon: 'question',
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonColor: '#3085d6',
|
||||||
|
cancelButtonColor: '#6b7280',
|
||||||
|
confirmButtonText: 'Ya, Buat Jadwal',
|
||||||
|
cancelButtonText: 'Batal'
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
formObj.submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
|
|
||||||
|
|
@ -373,15 +373,27 @@ class="text-red-500 text-sm hover:underline font-medium">Hapus Jadwal</button>
|
||||||
x-data="{
|
x-data="{
|
||||||
tglMulai: '',
|
tglMulai: '',
|
||||||
tglSelesai: '',
|
tglSelesai: '',
|
||||||
|
filterKantor: '',
|
||||||
|
filterDivisi: '',
|
||||||
selectAll: true,
|
selectAll: true,
|
||||||
|
getVisibleCheckboxes() {
|
||||||
|
let checkboxes = document.querySelectorAll('#form-bulk-delete .user-checkbox');
|
||||||
|
return Array.from(checkboxes).filter(cb => {
|
||||||
|
let item = cb.closest('.user-checkbox-item');
|
||||||
|
let k = item.getAttribute('data-kantor');
|
||||||
|
let d = item.getAttribute('data-divisi');
|
||||||
|
let showKantor = this.filterKantor === '' || this.filterKantor == k;
|
||||||
|
let showDivisi = this.filterDivisi === '' || this.filterDivisi == d;
|
||||||
|
return showKantor && showDivisi;
|
||||||
|
});
|
||||||
|
},
|
||||||
toggleAll() {
|
toggleAll() {
|
||||||
let checkboxes = document.querySelectorAll('.user-checkbox');
|
this.getVisibleCheckboxes().forEach(cb => cb.checked = this.selectAll);
|
||||||
checkboxes.forEach(cb => cb.checked = this.selectAll);
|
|
||||||
},
|
},
|
||||||
updateSelectAll() {
|
updateSelectAll() {
|
||||||
let checkboxes = document.querySelectorAll('.user-checkbox');
|
let visible = this.getVisibleCheckboxes();
|
||||||
let checkedBoxes = document.querySelectorAll('.user-checkbox:checked');
|
let checked = visible.filter(cb => cb.checked);
|
||||||
this.selectAll = checkboxes.length === checkedBoxes.length;
|
this.selectAll = visible.length > 0 && visible.length === checked.length;
|
||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
|
|
@ -392,24 +404,41 @@ class="text-red-500 text-sm hover:underline font-medium">Hapus Jadwal</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col md:flex-row gap-4">
|
<div class="flex flex-col md:flex-row gap-4">
|
||||||
<div class="flex-1"><x-date-input label="Tanggal Mulai" name="tanggal_mulai" required class="mb-0" x-model="tglMulai" x-on:change="if(tglMulai) { $refs.endInput.min = tglMulai; if(tglSelesai && tglSelesai < tglMulai) tglSelesai = tglMulai; }" x-ref="startInput" /></div>
|
<div class="flex-1"><x-date-input label="Tanggal Mulai" name="tanggal_mulai" required class="mb-0" x-model="tglMulai" x-on:change="if(tglMulai) { $refs.endInput.min = tglMulai; if(tglSelesai && tglSelesai < tglMulai) tglSelesai = tglMulai; }" x-ref="startInput" min="{{ date('Y-m-d') }}" /></div>
|
||||||
<div class="flex-1"><x-date-input label="Tanggal Selesai" name="tanggal_selesai" required class="mb-0" x-model="tglSelesai" x-on:change="if(tglSelesai) { $refs.startInput.max = tglSelesai; if(tglMulai && tglMulai > tglSelesai) tglMulai = tglSelesai; }" x-ref="endInput" /></div>
|
<div class="flex-1"><x-date-input label="Tanggal Selesai" name="tanggal_selesai" required class="mb-0" x-model="tglSelesai" x-on:change="if(tglSelesai) { $refs.startInput.max = tglSelesai; if(tglMulai && tglMulai > tglSelesai) tglMulai = tglSelesai; }" x-ref="endInput" min="{{ date('Y-m-d') }}" /></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex flex-col md:flex-row justify-between md:items-center gap-4 mb-2">
|
||||||
<label class="block text-sm font-semibold text-slate-700">Pilih Pegawai <span class="text-red-500">*</span></label>
|
<label class="block text-sm font-semibold text-slate-700">Pilih Pegawai <span class="text-red-500">*</span></label>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex gap-2">
|
||||||
<label class="flex items-center gap-2 text-sm font-bold text-slate-700 cursor-pointer hover:bg-slate-100 px-3 py-1.5 rounded-lg transition-all active:scale-95">
|
<x-filter-select x-model="filterKantor" @change="setTimeout(() => updateSelectAll(), 50)" class="py-1.5 text-xs">
|
||||||
<x-checkbox x-model="selectAll" @change="toggleAll" class="w-5 h-5 cursor-pointer text-primary border-slate-300 rounded focus:ring-primary" />
|
<option value="">Semua Kantor</option>
|
||||||
<span>Pilih Semua</span>
|
@foreach($kantor as $k)
|
||||||
</label>
|
<option value="{{ $k->id_kantor }}">{{ $k->nama_kantor }}</option>
|
||||||
|
@endforeach
|
||||||
|
</x-filter-select>
|
||||||
|
|
||||||
|
<x-filter-select x-model="filterDivisi" @change="setTimeout(() => updateSelectAll(), 50)" class="py-1.5 text-xs">
|
||||||
|
<option value="">Semua Divisi</option>
|
||||||
|
@foreach($divisi as $d)
|
||||||
|
<option value="{{ $d->id_divisi }}">{{ $d->nama_divisi }}</option>
|
||||||
|
@endforeach
|
||||||
|
</x-filter-select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center justify-end mb-2">
|
||||||
|
<label class="flex items-center gap-2 text-sm font-bold text-slate-700 cursor-pointer hover:bg-slate-100 px-3 py-1.5 rounded-lg transition-all active:scale-95">
|
||||||
|
<x-checkbox x-model="selectAll" @change="toggleAll" class="w-5 h-5 cursor-pointer text-primary border-slate-300 rounded focus:ring-primary" />
|
||||||
|
<span>Pilih Semua (yang tampil)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="border border-slate-200 rounded-xl max-h-[300px] overflow-y-auto bg-slate-50/50 p-2 space-y-2 shadow-inner custom-scrollbar">
|
<div class="border border-slate-200 rounded-xl max-h-[300px] overflow-y-auto bg-slate-50/50 p-2 space-y-2 shadow-inner custom-scrollbar">
|
||||||
@foreach($pegawai as $p)
|
@foreach($pegawai as $p)
|
||||||
<label class="relative flex items-center p-3 rounded-lg border-2 border-transparent bg-white shadow-sm cursor-pointer hover:border-slate-300 hover:bg-slate-50 transition-all [&:has(:checked)]:border-primary [&:has(:checked)]:bg-blue-50/50 group">
|
<label x-show="(filterKantor === '' || filterKantor == '{{ $p->id_kantor }}') && (filterDivisi === '' || filterDivisi == '{{ $p->id_divisi }}')"
|
||||||
|
data-kantor="{{ $p->id_kantor }}" data-divisi="{{ $p->id_divisi }}"
|
||||||
|
class="user-checkbox-item relative flex items-center p-3 rounded-lg border-2 border-transparent bg-white shadow-sm cursor-pointer hover:border-slate-300 hover:bg-slate-50 transition-all [&:has(:checked)]:border-primary [&:has(:checked)]:bg-blue-50/50 group">
|
||||||
<input type="checkbox" name="user_ids[]" value="{{ $p->id }}" checked @change="updateSelectAll"
|
<input type="checkbox" name="user_ids[]" value="{{ $p->id }}" checked @change="updateSelectAll"
|
||||||
class="user-checkbox peer sr-only">
|
class="user-checkbox peer sr-only">
|
||||||
|
|
||||||
|
|
@ -419,7 +448,13 @@ class="user-checkbox peer sr-only">
|
||||||
|
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-bold text-slate-800 text-sm group-has-[:checked]:text-primary">{{ $p->nama_lengkap }}</div>
|
<div class="font-bold text-slate-800 text-sm group-has-[:checked]:text-primary">{{ $p->nama_lengkap }}</div>
|
||||||
<div class="text-xs text-slate-500 font-mono">{{ $p->nik }}</div>
|
<div class="text-xs text-slate-500 mt-0.5">
|
||||||
|
<span class="font-mono bg-slate-100 px-1.5 py-0.5 rounded">{{ $p->nik }}</span>
|
||||||
|
<span class="mx-1">•</span>
|
||||||
|
<span>{{ $p->jabatan->nama_jabatan ?? '-' }}</span>
|
||||||
|
<span class="mx-1">•</span>
|
||||||
|
<span>{{ $p->kantor->nama_kantor ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-shrink-0 ml-3 text-slate-300 group-has-[:checked]:text-primary transition-colors relative flex items-center justify-center">
|
<div class="flex-shrink-0 ml-3 text-slate-300 group-has-[:checked]:text-primary transition-colors relative flex items-center justify-center">
|
||||||
|
|
|
||||||
|
|
@ -596,7 +596,7 @@ class="h-11 w-11 rounded-full bg-gradient-to-br from-[#130F26] to-[#2B2545] text
|
||||||
|
|
||||||
<script src="{{ asset('js/dashboard-script.js') }}"></script>
|
<script src="{{ asset('js/dashboard-script.js') }}"></script>
|
||||||
|
|
||||||
<div id="swal-data" data-success="{{ session('success') }}" data-error="{{ session('error') }}"
|
<div id="swal-data" data-success="{{ session('success') }}" data-error="{{ session('error') }}" data-warning="{{ session('warning') }}"
|
||||||
data-errors='@json($errors->all())'>
|
data-errors='@json($errors->all())'>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ class="flex items-center px-4 py-3 rounded-xl transition-all duration-200 group
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- ════════════════════════════════ SEKSI: MANAJEMEN ════════════════════════════════ --}}
|
{{-- ════════════════════════════════ SEKSI: MANAJEMEN ════════════════════════════════ --}}
|
||||||
@if($canManage || $canViewPegawai)
|
@if($canManage || $canViewPegawai || $canViewJadwal)
|
||||||
<div class="pt-6 pb-3 px-4 flex items-center gap-3">
|
<div class="pt-6 pb-3 px-4 flex items-center gap-3">
|
||||||
<div class="h-px bg-slate-200 flex-1"></div>
|
<div class="h-px bg-slate-200 flex-1"></div>
|
||||||
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Manajemen</span>
|
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Manajemen</span>
|
||||||
|
|
@ -177,8 +177,8 @@ class="flex items-center px-4 py-3 rounded-xl transition-all duration-200 group
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- Manajemen Izin – HRD & Manager --}}
|
{{-- Manajemen Izin – HRD Only --}}
|
||||||
@if($canApproveIzin)
|
@if($isHrd)
|
||||||
<a href="{{ route('izin.index') }}"
|
<a href="{{ route('izin.index') }}"
|
||||||
class="flex items-center px-4 py-3 rounded-xl transition-all duration-200 group {{ request()->routeIs('izin.*') ? 'bg-[#130F26] text-white shadow-lg shadow-[#130F26]/30' : 'text-slate-500 hover:bg-slate-50 hover:text-slate-900' }}">
|
class="flex items-center px-4 py-3 rounded-xl transition-all duration-200 group {{ request()->routeIs('izin.*') ? 'bg-[#130F26] text-white shadow-lg shadow-[#130F26]/30' : 'text-slate-500 hover:bg-slate-50 hover:text-slate-900' }}">
|
||||||
<svg class="w-5 h-5 mr-3 {{ request()->routeIs('izin.*') ? 'text-white' : 'text-slate-400 group-hover:text-slate-600' }} group-hover:scale-110 transition-transform duration-300"
|
<svg class="w-5 h-5 mr-3 {{ request()->routeIs('izin.*') ? 'text-white' : 'text-slate-400 group-hover:text-slate-600' }} group-hover:scale-110 transition-transform duration-300"
|
||||||
|
|
@ -197,8 +197,10 @@ class="flex items-center px-4 py-3 rounded-xl transition-all duration-200 group
|
||||||
<span class="ml-auto bg-orange-500 text-white text-[10px] font-bold px-2 py-0.5 rounded-full">{{ $pendingIzin }}</span>
|
<span class="ml-auto bg-orange-500 text-white text-[10px] font-bold px-2 py-0.5 rounded-full">{{ $pendingIzin }}</span>
|
||||||
@endif
|
@endif
|
||||||
</a>
|
</a>
|
||||||
|
@endif
|
||||||
|
|
||||||
{{-- Surat Izin – HRD & Manager --}}
|
{{-- Surat Izin – HRD & Manager --}}
|
||||||
|
@if($canApproveIzin)
|
||||||
<a href="{{ route('surat-izin.index') }}"
|
<a href="{{ route('surat-izin.index') }}"
|
||||||
class="flex items-center px-4 py-3 rounded-xl transition-all duration-200 group {{ request()->routeIs('surat-izin.*') ? 'bg-[#130F26] text-white shadow-lg shadow-[#130F26]/30' : 'text-slate-500 hover:bg-slate-50 hover:text-slate-900' }}">
|
class="flex items-center px-4 py-3 rounded-xl transition-all duration-200 group {{ request()->routeIs('surat-izin.*') ? 'bg-[#130F26] text-white shadow-lg shadow-[#130F26]/30' : 'text-slate-500 hover:bg-slate-50 hover:text-slate-900' }}">
|
||||||
<svg class="w-5 h-5 mr-3 {{ request()->routeIs('surat-izin.*') ? 'text-white' : 'text-slate-400 group-hover:text-slate-600' }} group-hover:scale-110 transition-transform duration-300"
|
<svg class="w-5 h-5 mr-3 {{ request()->routeIs('surat-izin.*') ? 'text-white' : 'text-slate-400 group-hover:text-slate-600' }} group-hover:scale-110 transition-transform duration-300"
|
||||||
|
|
@ -209,7 +211,18 @@ class="flex items-center px-4 py-3 rounded-xl transition-all duration-200 group
|
||||||
</svg>
|
</svg>
|
||||||
<span class="flex-1 font-semibold text-sm">Surat Izin</span>
|
<span class="flex-1 font-semibold text-sm">Surat Izin</span>
|
||||||
@php
|
@php
|
||||||
$suratQuery = \App\Models\SuratIzin::whereIn('status_surat', ['menunggu_manajer', 'menunggu_hrd']);
|
$suratQuery = \App\Models\SuratIzin::query();
|
||||||
|
$statuses = [];
|
||||||
|
if ($isManager) $statuses[] = 'menunggu_manajer';
|
||||||
|
if ($isHrd) $statuses[] = 'menunggu_hrd';
|
||||||
|
if ($isGlobalAdmin && !$isHrd) {
|
||||||
|
$statuses = ['menunggu_manajer', 'menunggu_hrd']; // superadmin
|
||||||
|
}
|
||||||
|
if (count($statuses) > 0) {
|
||||||
|
$suratQuery->whereIn('status_surat', $statuses);
|
||||||
|
} else {
|
||||||
|
$suratQuery->where('id_surat', -1); // don't count if no matching statuses
|
||||||
|
}
|
||||||
if (!$isGlobalAdmin) { $suratQuery->whereHas('user', fn($q) => $q->where('id_kantor', $userKantor)); }
|
if (!$isGlobalAdmin) { $suratQuery->whereHas('user', fn($q) => $q->where('id_kantor', $userKantor)); }
|
||||||
$pendingSurat = $suratQuery->count();
|
$pendingSurat = $suratQuery->count();
|
||||||
@endphp
|
@endphp
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,17 @@ class="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
|
||||||
</x-select>
|
</x-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full md:w-44">
|
||||||
|
<x-select name="filter_role" class="!mb-0 h-10" onchange="this.form.submit()">
|
||||||
|
<option value="">Semua Akses (Role)</option>
|
||||||
|
@foreach($allRoles as $r)
|
||||||
|
<option value="{{ $r->id_role }}" {{ request('filter_role') == $r->id_role ? 'selected' : '' }}>
|
||||||
|
{{ ucwords(str_replace('_', ' ', $r->nama_role)) }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</x-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="w-full md:w-44">
|
<div class="w-full md:w-44">
|
||||||
<x-select name="filter_kantor" class="!mb-0 h-10" onchange="this.form.submit()">
|
<x-select name="filter_kantor" class="!mb-0 h-10" onchange="this.form.submit()">
|
||||||
<option value="">Semua Kantor</option>
|
<option value="">Semua Kantor</option>
|
||||||
|
|
@ -74,7 +85,7 @@ class="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
|
||||||
</x-select>
|
</x-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(request()->hasAny(['filter_jabatan', 'filter_kantor', 'filter_status', 'search']))
|
@if(request()->hasAny(['filter_jabatan', 'filter_role', 'filter_kantor', 'filter_status', 'search']))
|
||||||
<a href="{{ route('pegawai.index') }}"
|
<a href="{{ route('pegawai.index') }}"
|
||||||
class="flex h-10 items-center px-4 bg-slate-100 text-slate-600 text-sm font-semibold rounded-lg hover:bg-slate-200 transition-all border border-slate-200">
|
class="flex h-10 items-center px-4 bg-slate-100 text-slate-600 text-sm font-semibold rounded-lg hover:bg-slate-200 transition-all border border-slate-200">
|
||||||
Reset
|
Reset
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@
|
||||||
|
|
||||||
Route::get('/face-approval', [FaceApprovalController::class, 'index'])->name('face.index');
|
Route::get('/face-approval', [FaceApprovalController::class, 'index'])->name('face.index');
|
||||||
Route::post('/face-approval/reextract-all', [FaceApprovalController::class, 'reextractAll'])->name('face.reextract_all');
|
Route::post('/face-approval/reextract-all', [FaceApprovalController::class, 'reextractAll'])->name('face.reextract_all');
|
||||||
|
Route::get('/face-approval/training-status', [FaceApprovalController::class, 'trainingStatus'])->name('face.training_status');
|
||||||
Route::post('/face-approval/migrate-embeddings', [FaceApprovalController::class, 'migrateEmbeddings'])->name('face.migrate_embeddings');
|
Route::post('/face-approval/migrate-embeddings', [FaceApprovalController::class, 'migrateEmbeddings'])->name('face.migrate_embeddings');
|
||||||
Route::get('/face-approval/photo/{userId}/{pose}', [FaceApprovalController::class, 'showPhoto'])->name('face.photo');
|
Route::get('/face-approval/photo/{userId}/{pose}', [FaceApprovalController::class, 'showPhoto'])->name('face.photo');
|
||||||
Route::get('/face-approval/frame/{userId}/{frameIndex}', [FaceApprovalController::class, 'showFrame'])->name('face.frame');
|
Route::get('/face-approval/frame/{userId}/{frameIndex}', [FaceApprovalController::class, 'showFrame'])->name('face.frame');
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
Loading…
Reference in New Issue