From 1f881a6c69a80e70cdece89d81b6fb4d9812d138 Mon Sep 17 00:00:00 2001 From: jouel88 Date: Sat, 28 Mar 2026 15:37:41 +0700 Subject: [PATCH] notifikasi +seeder +migration+penyesuaian --- .gitignore | 1 + app/Console/Commands/AutoPresensiJob.php | 88 +++- app/Exports/LaporanIzinExport.php | 153 +++++++ app/Exports/LaporanLemburExport.php | 172 +++++++- app/Exports/LaporanPresensiExport.php | 209 ++++++++-- .../Controllers/Api/KompensasiController.php | 21 - app/Http/Controllers/Api/LemburController.php | 8 + .../Api/NotifikasiApiController.php | 59 +++ .../Controllers/Api/PresensiController.php | 6 +- .../Controllers/Api/SubmissionController.php | 73 +++- app/Http/Controllers/CutiController.php | 14 + .../Controllers/FaceApprovalController.php | 81 +++- app/Http/Controllers/JadwalController.php | 12 + app/Http/Controllers/LaporanController.php | 40 ++ .../Controllers/LaporanLemburController.php | 22 +- app/Http/Controllers/LemburController.php | 17 + .../Controllers/NotifikasiWebController.php | 59 +++ .../Controllers/PengajuanIzinController.php | 48 ++- .../Controllers/PenggunaanPoinController.php | 18 +- app/Http/Controllers/PengumumanController.php | 10 +- app/Http/Controllers/PermissionController.php | 13 +- app/Http/Controllers/PresensiController.php | 17 + app/Http/Controllers/RoleController.php | 16 +- app/Http/Controllers/SuratIzinController.php | 31 +- app/Http/Controllers/TukarShiftController.php | 43 +- .../Requests/StoreManualPresensiRequest.php | 9 +- app/Http/Requests/StorePegawaiRequest.php | 3 +- app/Http/Requests/UpdatePegawaiRequest.php | 3 +- app/Models/DeviceToken.php | 21 + app/Models/Notifikasi.php | 39 ++ app/Models/User.php | 7 +- app/Services/LemburService.php | 10 +- app/Services/NotifikasiService.php | 134 +++++++ app/Services/PresensiService.php | 19 +- database/base_schema.sql | Bin 0 -> 73006 bytes .../0000_00_00_000000_base_schema.php | 34 ++ ...03_140500_add_sisa_cuti_to_users_table.php | 0 ...6_02_03_140600_create_hari_libur_table.php | 0 ...53_create_personal_access_tokens_table.php | 0 ...112201_create_face_registrations_table.php | 0 ...terangan_luar_radius_to_presensi_table.php | 0 .../2026_02_06_142857_create_poins_table.php | 0 ..._add_fifo_columns_to_poin_lembur_table.php | 0 ...d_custom_time_to_penggunaan_poin_table.php | 0 ...dd_keterangan_pulang_to_presensi_table.php | 0 ...62927_update_structure_for_fifo_points.php | 0 ...6_02_13_073735_update_poin_lembur_fifo.php | 0 ...90000_update_structure_for_fifo_points.php | 0 ...322_update_schema_for_face_recognition.php | 0 ...add_path_model_yml_to_data_wajah_table.php | 0 ...erified_and_last_updated_to_data_wajah.php | 0 ..._encoding_wajah_nullable_in_data_wajah.php | 0 ...48_add_selisih_waktu_to_presensi_table.php | 0 ...6_03_01_060000_add_performance_indexes.php | 0 ...nge_primary_keys_to_string_combination.php | 0 ...03_02_220000_create_tanda_tangan_table.php | 0 ...6_03_02_220100_create_surat_izin_table.php | 0 ..._02_220200_create_approval_surat_table.php | 0 ...1342_create_riwayat_tukar_shifts_table.php | 0 ...5110_add_id_kantor_to_hari_libur_table.php | 0 ...6_03_27_153437_create_notifikasi_table.php | 31 ++ ...3_27_153516_create_device_tokens_table.php | 27 ++ database/seeders/DatabaseSeeder.php | 1 + database/seeders/JenisPenguranganSeeder.php | 5 +- database/seeders/StatusSeeder.php | 27 +- public/js/notifications.js | 120 ++++++ resources/views/components/input.blade.php | 4 +- resources/views/components/select.blade.php | 6 +- resources/views/cuti/index.blade.php | 27 +- resources/views/face-approval/index.blade.php | 212 ++++++++-- resources/views/izin/index.blade.php | 18 +- resources/views/jadwal/index.blade.php | 285 ++++++++++--- resources/views/kantor/index.blade.php | 34 +- .../views/laporan-lembur/index.blade.php | 12 +- resources/views/laporan/index.blade.php | 16 +- resources/views/laporan/izin.blade.php | 20 +- resources/views/laporan/pdf.blade.php | 375 ++++++++++++++++-- resources/views/layouts/app.blade.php | 53 ++- resources/views/layouts/sidebar.blade.php | 4 +- resources/views/notifikasi/index.blade.php | 108 +++++ resources/views/role/index.blade.php | 37 +- resources/views/shift/index.blade.php | 22 +- routes/api.php | 18 +- routes/console.php | 4 + routes/web.php | 11 + 85 files changed, 2651 insertions(+), 336 deletions(-) create mode 100644 app/Exports/LaporanIzinExport.php delete mode 100644 app/Http/Controllers/Api/KompensasiController.php create mode 100644 app/Http/Controllers/Api/NotifikasiApiController.php create mode 100644 app/Http/Controllers/NotifikasiWebController.php create mode 100644 app/Models/DeviceToken.php create mode 100644 app/Models/Notifikasi.php create mode 100644 app/Services/NotifikasiService.php create mode 100644 database/base_schema.sql create mode 100644 database/migrations/0000_00_00_000000_base_schema.php rename database/{migrations => migrations_archive}/2026_02_03_140500_add_sisa_cuti_to_users_table.php (100%) rename database/{migrations => migrations_archive}/2026_02_03_140600_create_hari_libur_table.php (100%) rename database/{migrations => migrations_archive}/2026_02_04_114853_create_personal_access_tokens_table.php (100%) rename database/{migrations => migrations_archive}/2026_02_06_112201_create_face_registrations_table.php (100%) rename database/{migrations => migrations_archive}/2026_02_06_120220_add_keterangan_luar_radius_to_presensi_table.php (100%) rename database/{migrations => migrations_archive}/2026_02_06_142857_create_poins_table.php (100%) rename database/{migrations => migrations_archive}/2026_02_12_051600_add_fifo_columns_to_poin_lembur_table.php (100%) rename database/{migrations => migrations_archive}/2026_02_12_075459_add_custom_time_to_penggunaan_poin_table.php (100%) rename database/{migrations => migrations_archive}/2026_02_12_075803_add_keterangan_pulang_to_presensi_table.php (100%) rename database/{migrations => migrations_archive}/2026_02_13_062927_update_structure_for_fifo_points.php (100%) rename database/{migrations => migrations_archive}/2026_02_13_073735_update_poin_lembur_fifo.php (100%) rename database/{migrations => migrations_archive}/2026_02_13_090000_update_structure_for_fifo_points.php (100%) rename database/{migrations => migrations_archive}/2026_02_16_144322_update_schema_for_face_recognition.php (100%) rename database/{migrations => migrations_archive}/2026_02_16_163810_add_path_model_yml_to_data_wajah_table.php (100%) rename database/{migrations => migrations_archive}/2026_02_16_164216_add_is_verified_and_last_updated_to_data_wajah.php (100%) rename database/{migrations => migrations_archive}/2026_02_16_164449_make_encoding_wajah_nullable_in_data_wajah.php (100%) rename database/{migrations => migrations_archive}/2026_02_21_082548_add_selisih_waktu_to_presensi_table.php (100%) rename database/{migrations => migrations_archive}/2026_03_01_060000_add_performance_indexes.php (100%) rename database/{migrations => migrations_archive}/2026_03_01_141559_change_primary_keys_to_string_combination.php (100%) rename database/{migrations => migrations_archive}/2026_03_02_220000_create_tanda_tangan_table.php (100%) rename database/{migrations => migrations_archive}/2026_03_02_220100_create_surat_izin_table.php (100%) rename database/{migrations => migrations_archive}/2026_03_02_220200_create_approval_surat_table.php (100%) rename database/{migrations => migrations_archive}/2026_03_11_181342_create_riwayat_tukar_shifts_table.php (100%) rename database/{migrations => migrations_archive}/2026_03_16_155110_add_id_kantor_to_hari_libur_table.php (100%) create mode 100644 database/migrations_archive/2026_03_27_153437_create_notifikasi_table.php create mode 100644 database/migrations_archive/2026_03_27_153516_create_device_tokens_table.php create mode 100644 resources/views/notifikasi/index.blade.php diff --git a/.gitignore b/.gitignore index b4e5eec0..8390bc88 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ /public/storage /storage/*.key /storage/pail +/storage/app/firebase/ /vendor Homestead.json Homestead.yaml diff --git a/app/Console/Commands/AutoPresensiJob.php b/app/Console/Commands/AutoPresensiJob.php index 66cdaf07..91901ea2 100644 --- a/app/Console/Commands/AutoPresensiJob.php +++ b/app/Console/Commands/AutoPresensiJob.php @@ -28,20 +28,29 @@ class AutoPresensiJob extends Command public function handle() { - $hariIni = Carbon::today()->format('Y-m-d'); + // Target H-1 (Kemarin) untuk mencegah the Midnight Bug + $hariIni = Carbon::yesterday('Asia/Jakarta')->format('Y-m-d'); + // Target H-2 untuk sweeping pekerja shift malam + $hariLusa = Carbon::now('Asia/Jakarta')->subDays(2)->format('Y-m-d'); - $usersWithJadwal = JadwalKerja::where('tanggal', $hariIni) - ->with('user') - ->get() - ->pluck('id_user'); + // Dapatkan jadwal H-1 + $jadwalH1 = JadwalKerja::where('tanggal', $hariIni) + ->with(['shift', 'user']) + ->get(); + + // Dapatkan jadwal H-2 + $jadwalH2 = JadwalKerja::where('tanggal', $hariLusa) + ->with(['shift', 'user']) + ->get(); - $usersPresensi = Presensi::where('tanggal', $hariIni)->pluck('id_user'); - - $usersAlpha = $usersWithJadwal->diff($usersPresensi); + $usersPresensiH1 = Presensi::where('tanggal', $hariIni)->pluck('id_user'); + $usersH1 = $jadwalH1->pluck('id_user'); + $usersAlphaH1 = $usersH1->diff($usersPresensiH1); $idAlpha = DB::table('status_presensi')->where('nama_status', 'Alpha')->value('id_status') ?? 0; - foreach ($usersAlpha as $userId) { + // 1. Pegawai H-1 yang sama sekali tidak absen masuk + foreach ($usersAlphaH1 as $userId) { Presensi::create([ 'id_user' => $userId, 'tanggal' => $hariIni, @@ -51,8 +60,67 @@ public function handle() ]); } - $this->info('Auto Alpha processed for ' . $usersAlpha->count() . ' users.'); + $this->info('Auto Alpha processed for ' . $usersAlphaH1->count() . ' users (no clock in H-1).'); + // 2. Sweeping pegawai yang lupa absen pulang pada H-1 + $incompleteH1 = Presensi::where('tanggal', $hariIni) + ->whereNotNull('jam_masuk') + ->whereNull('jam_pulang') + ->get(); + + $countIncomplete = 0; + + foreach ($incompleteH1 as $presensi) { + $userJadwal = $jadwalH1->where('id_user', $presensi->id_user)->first(); + $isNightShift = false; + + if ($userJadwal && $userJadwal->shift) { + if ($userJadwal->shift->jam_selesai < $userJadwal->shift->jam_mulai || stripos($userJadwal->shift->nama_shift, 'Malam') !== false) { + $isNightShift = true; + } + } + + // Eksekusi jika bukan shift malam + if (!$isNightShift) { + $presensi->update([ + 'id_status' => $idAlpha, + 'id_validasi' => 1, + 'alasan_telat' => 'Auto Alpha: Lupa Absen Pulang (Shift Normal)' + ]); + $countIncomplete++; + } + } + + // 3. Sweeping pekerja Shift Malam yang lupa absen pulang pada H-2 + $incompleteH2 = Presensi::where('tanggal', $hariLusa) + ->whereNotNull('jam_masuk') + ->whereNull('jam_pulang') + ->get(); + + foreach ($incompleteH2 as $presensi) { + $userJadwal = $jadwalH2->where('id_user', $presensi->id_user)->first(); + $isNightShift = false; + + if ($userJadwal && $userJadwal->shift) { + if ($userJadwal->shift->jam_selesai < $userJadwal->shift->jam_mulai || stripos($userJadwal->shift->nama_shift, 'Malam') !== false) { + $isNightShift = true; + } + } + + // Eksekusi HANYA jika ia benar shift malam + if ($isNightShift) { + $presensi->update([ + 'id_status' => $idAlpha, + 'id_validasi' => 1, + 'alasan_telat' => 'Auto Alpha: Lupa Absen Pulang (Shift Malam)' + ]); + $countIncomplete++; + } + } + + $this->info("Auto Alpha updated for $countIncomplete users (missing clock out with Night Shift Protection)."); + + // 4. Cleanup old photos $dateLimit = Carbon::now()->subMonths(3); $oldPresensi = Presensi::where('tanggal', '<', $dateLimit)->get(); diff --git a/app/Exports/LaporanIzinExport.php b/app/Exports/LaporanIzinExport.php new file mode 100644 index 00000000..128c80f1 --- /dev/null +++ b/app/Exports/LaporanIzinExport.php @@ -0,0 +1,153 @@ +data = $data; + $this->bulan = $bulan; + $this->tahun = $tahun; + } + + public function collection() + { + return $this->data; + } + + public function title(): string + { + return 'Laporan Izin & Cuti'; + } + + public function headings(): array + { + $namaBulan = Carbon::createFromDate($this->tahun, $this->bulan, 1)->translatedFormat('F Y'); + return [ + ["LAPORAN IZIN & CUTI — {$namaBulan}"], + [], + ['No', 'Nama Pegawai', 'NIK', 'Divisi', 'Jenis Izin', 'Tanggal Mulai', 'Tanggal Selesai', 'Alasan', 'Status'], + ]; + } + + public function map($row): array + { + static $no = 0; + $no++; + + $status = match ($row->id_status) { + 2 => 'Disetujui', + 3 => 'Ditolak', + default => 'Menunggu', + }; + + return [ + $no, + $row->user->nama_lengkap ?? '-', + $row->user->nik ?? '-', + $row->user->divisi->nama_divisi ?? '-', + $row->jenisIzin->nama_izin ?? '-', + Carbon::parse($row->tanggal_mulai)->format('d/m/Y'), + Carbon::parse($row->tanggal_selesai)->format('d/m/Y'), + $row->alasan ?? '-', + $status, + ]; + } + + public function columnWidths(): array + { + return [ + 'A' => 6, + 'B' => 28, + 'C' => 16, + 'D' => 20, + 'E' => 14, + 'F' => 16, + 'G' => 16, + 'H' => 40, + 'I' => 14, + ]; + } + + public function styles($sheet) + { + return [ + 1 => [ + 'font' => ['bold' => true, 'size' => 13], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER], + ], + 3 => [ + 'font' => ['bold' => true, 'color' => ['argb' => 'FFFFFFFF']], + 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['argb' => 'FF1E293B']], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER], + ], + ]; + } + + public function registerEvents(): array + { + return [ + AfterSheet::class => function (AfterSheet $event) { + $sheet = $event->sheet->getDelegate(); + $lastRow = $this->data->count() + 3; + $dataRange = "A3:I{$lastRow}"; + + // Merge judul + $sheet->mergeCells('A1:I1'); + + // Border semua sel data + $sheet->getStyle($dataRange)->applyFromArray([ + 'borders' => [ + 'allBorders' => [ + 'borderStyle' => Border::BORDER_THIN, + 'color' => ['argb' => 'FFE2E8F0'], + ], + ], + ]); + + // Zebra striping & highlight status + for ($i = 4; $i <= $lastRow; $i++) { + $status = $sheet->getCell("I{$i}")->getValue(); + + if ($status === 'Menunggu') { + $sheet->getStyle("A{$i}:I{$i}")->getFill() + ->setFillType(Fill::FILL_SOLID) + ->getStartColor()->setARGB('FFFEF9C3'); + } elseif ($status === 'Ditolak') { + $sheet->getStyle("A{$i}:I{$i}")->getFill() + ->setFillType(Fill::FILL_SOLID) + ->getStartColor()->setARGB('FFFEE2E2'); + } elseif ($i % 2 === 0) { + $sheet->getStyle("A{$i}:I{$i}")->getFill() + ->setFillType(Fill::FILL_SOLID) + ->getStartColor()->setARGB('FFF8FAFC'); + } + } + + // Freeze header + $sheet->freezePane('A4'); + + // Auto wrap alasan + $sheet->getStyle("H4:H{$lastRow}")->getAlignment()->setWrapText(true); + }, + ]; + } +} diff --git a/app/Exports/LaporanLemburExport.php b/app/Exports/LaporanLemburExport.php index 69474dbe..5e9291bc 100644 --- a/app/Exports/LaporanLemburExport.php +++ b/app/Exports/LaporanLemburExport.php @@ -7,9 +7,17 @@ use Maatwebsite\Excel\Concerns\WithMapping; use Maatwebsite\Excel\Concerns\ShouldAutoSize; use Maatwebsite\Excel\Concerns\WithStyles; +use Maatwebsite\Excel\Concerns\WithColumnWidths; +use Maatwebsite\Excel\Concerns\WithTitle; +use Maatwebsite\Excel\Concerns\WithEvents; +use Maatwebsite\Excel\Events\AfterSheet; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; +use PhpOffice\PhpSpreadsheet\Style\Alignment; +use PhpOffice\PhpSpreadsheet\Style\Border; +use PhpOffice\PhpSpreadsheet\Style\Fill; +use Carbon\Carbon; -class LaporanLemburExport implements FromCollection, WithHeadings, WithMapping, ShouldAutoSize, WithStyles +class LaporanLemburExport implements FromCollection, WithHeadings, WithMapping, WithStyles, WithColumnWidths, WithTitle, WithEvents { protected $pegawai; protected $rekap; @@ -19,9 +27,14 @@ class LaporanLemburExport implements FromCollection, WithHeadings, WithMapping, public function __construct($pegawai, array $rekap, $bulan, $tahun) { $this->pegawai = $pegawai; - $this->rekap = $rekap; - $this->bulan = $bulan; - $this->tahun = $tahun; + $this->rekap = $rekap; + $this->bulan = $bulan; + $this->tahun = $tahun; + } + + public function title(): string + { + return 'Lembur ' . Carbon::create()->month($this->bulan)->translatedFormat('F Y'); } public function collection() @@ -31,21 +44,25 @@ public function collection() public function headings(): array { + $namaBulan = Carbon::create()->month($this->bulan)->translatedFormat('F'); + return [ ['LAPORAN REKAPITULASI LEMBUR PEGAWAI'], - ['Periode: ' . \Carbon\Carbon::create()->month($this->bulan)->translatedFormat('F') . ' ' . $this->tahun], + ['MPG HRIS Enterprise System'], + ['Periode: ' . $namaBulan . ' ' . $this->tahun], + ['Dicetak: ' . Carbon::now()->translatedFormat('d F Y, H:i') . ' WIB'], [], [ 'No', - 'NIK', + 'NIK / ID Karyawan', 'Nama Lengkap', 'Divisi / Dept', 'Jabatan', 'Jumlah Hari Lembur', 'Total Waktu (Menit)', - 'Durasi Jam', - 'Poin Lembur Diperoleh' - ] + 'Durasi (Jam)', + 'Poin Lembur Diperoleh', + ], ]; } @@ -55,10 +72,10 @@ public function map($row): array $no++; $dataRekap = $this->rekap[$row->id] ?? [ - 'total_menit' => 0, - 'format_jam' => '0j 0m', - 'jumlah_hari' => 0, - 'poin_diperoleh' => 0, + 'total_menit' => 0, + 'format_jam' => '0j 0m', + 'jumlah_hari' => 0, + 'poin_diperoleh'=> 0, ]; return [ @@ -67,20 +84,137 @@ public function map($row): array $row->nama_lengkap, $row->divisi->nama_divisi ?? '-', $row->jabatan->nama_jabatan ?? '-', - $dataRekap['jumlah_hari'] . ' Hari', - $dataRekap['total_menit'] . ' Menit', + $dataRekap['jumlah_hari'], + $dataRekap['total_menit'], $dataRekap['format_jam'], - $dataRekap['poin_diperoleh'] . ' Poin' + $dataRekap['poin_diperoleh'], + ]; + } + + public function columnWidths(): array + { + return [ + 'A' => 5, + 'B' => 22, + 'C' => 28, + 'D' => 22, + 'E' => 20, + 'F' => 18, + 'G' => 20, + 'H' => 14, + 'I' => 22, ]; } public function styles(Worksheet $sheet) { return [ + 1 => [ + 'font' => ['bold' => true, 'size' => 14, 'color' => ['rgb' => '1E293B']], + ], + 2 => [ + 'font' => ['italic' => true, 'size' => 10, 'color' => ['rgb' => '64748B']], + ], + 3 => [ + 'font' => ['size' => 10, 'color' => ['rgb' => '475569']], + ], + 4 => [ + 'font' => ['size' => 9, 'color' => ['rgb' => '94A3B8']], + ], + 6 => [ + 'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']], + 'fill' => [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => ['rgb' => '1E293B'], + ], + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_CENTER, + 'vertical' => Alignment::VERTICAL_CENTER, + 'wrapText' => true, + ], + 'borders' => [ + 'allBorders' => [ + 'borderStyle' => Border::BORDER_THIN, + 'color' => ['rgb' => '334155'], + ], + ], + ], + ]; + } - 1 => ['font' => ['bold' => true, 'size' => 14]], - 2 => ['font' => ['italic' => true]], - 4 => ['font' => ['bold' => true], 'fill' => ['fillType' => 'solid', 'color' => ['rgb' => 'E6F4EA']]], + public function registerEvents(): array + { + return [ + AfterSheet::class => function (AfterSheet $event) { + $sheet = $event->sheet->getDelegate(); + $lastRow = count($this->pegawai) + 6; + $dataStart = 7; + + $sheet->mergeCells('A1:I1'); + $sheet->mergeCells('A2:I2'); + $sheet->mergeCells('A3:I3'); + $sheet->mergeCells('A4:I4'); + + // Border + zebra striping + for ($r = $dataStart; $r <= $lastRow; $r++) { + $isEven = ($r - $dataStart) % 2 === 1; + + $sheet->getStyle("A{$r}:I{$r}")->applyFromArray([ + 'borders' => [ + 'allBorders' => [ + 'borderStyle' => Border::BORDER_THIN, + 'color' => ['rgb' => 'E2E8F0'], + ], + ], + 'fill' => $isEven ? [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => ['rgb' => 'F8FAFC'], + ] : [], + ]); + + $sheet->getStyle("A{$r}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle("F{$r}:I{$r}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle("C{$r}")->getFont()->setBold(true); + + // Highlight poin lembur > 0 (ungu muda) + $poinVal = $sheet->getCell("I{$r}")->getValue(); + if ($poinVal > 0) { + $sheet->getStyle("I{$r}")->applyFromArray([ + 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'F3E8FF']], + 'font' => ['color' => ['rgb' => '6B21A8'], 'bold' => true], + ]); + } + } + + $sheet->getRowDimension(6)->setRowHeight(30); + $sheet->freezePane('C7'); + + // Ringkasan total + $summaryRow = $lastRow + 2; + $totalHari = array_sum(array_column($this->rekap, 'jumlah_hari')); + $totalMenit = array_sum(array_column($this->rekap, 'total_menit')); + $totalPoin = array_sum(array_column($this->rekap, 'poin_diperoleh')); + $totalJam = intdiv($totalMenit, 60) . 'j ' . ($totalMenit % 60) . 'm'; + + $sheet->setCellValue("A{$summaryRow}", 'RINGKASAN'); + $sheet->setCellValue("B{$summaryRow}", 'Total Hari Lembur: ' . $totalHari . ' hari'); + $sheet->setCellValue("C{$summaryRow}", 'Total Waktu: ' . $totalMenit . ' menit'); + $sheet->setCellValue("D{$summaryRow}", 'Total Durasi: ' . $totalJam); + $sheet->setCellValue("E{$summaryRow}", 'Total Poin: ' . $totalPoin . ' poin'); + + $sheet->getStyle("A{$summaryRow}:E{$summaryRow}")->applyFromArray([ + 'font' => ['bold' => true, 'size' => 9, 'color' => ['rgb' => '1E293B']], + 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'F1F5F9']], + 'borders' => [ + 'allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'CBD5E1']], + ], + ]); + + $noteRow = $summaryRow + 2; + $sheet->setCellValue("A{$noteRow}", '* Laporan dibuat otomatis oleh sistem MPG HRIS. Data bersumber dari catatan lembur yang telah disetujui.'); + $sheet->getStyle("A{$noteRow}")->getFont()->setItalic(true)->setSize(8)->getColor()->setRGB('94A3B8'); + $sheet->mergeCells("A{$noteRow}:I{$noteRow}"); + }, ]; } } diff --git a/app/Exports/LaporanPresensiExport.php b/app/Exports/LaporanPresensiExport.php index 74cf2a2c..f065fcdc 100644 --- a/app/Exports/LaporanPresensiExport.php +++ b/app/Exports/LaporanPresensiExport.php @@ -7,9 +7,17 @@ use Maatwebsite\Excel\Concerns\WithMapping; use Maatwebsite\Excel\Concerns\ShouldAutoSize; use Maatwebsite\Excel\Concerns\WithStyles; +use Maatwebsite\Excel\Concerns\WithColumnWidths; +use Maatwebsite\Excel\Concerns\WithTitle; +use Maatwebsite\Excel\Concerns\WithEvents; +use Maatwebsite\Excel\Events\AfterSheet; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; +use PhpOffice\PhpSpreadsheet\Style\Alignment; +use PhpOffice\PhpSpreadsheet\Style\Border; +use PhpOffice\PhpSpreadsheet\Style\Fill; +use Carbon\Carbon; -class LaporanPresensiExport implements FromCollection, WithHeadings, WithMapping, ShouldAutoSize, WithStyles +class LaporanPresensiExport implements FromCollection, WithHeadings, WithMapping, WithStyles, WithColumnWidths, WithTitle, WithEvents { protected $rekap; protected $bulan; @@ -17,9 +25,14 @@ class LaporanPresensiExport implements FromCollection, WithHeadings, WithMapping public function __construct(array $rekap, $bulan, $tahun) { - $this->rekap = $rekap; - $this->bulan = $bulan; - $this->tahun = $tahun; + $this->rekap = $rekap; + $this->bulan = $bulan; + $this->tahun = $tahun; + } + + public function title(): string + { + return 'Presensi ' . Carbon::createFromDate($this->tahun, (int) $this->bulan, 1)->translatedFormat('F Y'); } public function collection() @@ -29,23 +42,27 @@ public function collection() public function headings(): array { + $namaBulan = Carbon::createFromDate($this->tahun, (int) $this->bulan, 1)->translatedFormat('F'); + return [ ['LAPORAN REKAPITULASI PRESENSI PEGAWAI'], - ['Periode: ' . \Carbon\Carbon::createFromDate($this->tahun, (int) $this->bulan, 1)->translatedFormat('F') . ' ' . $this->tahun], + ['MPG HRIS Enterprise System'], + ['Periode: ' . $namaBulan . ' ' . $this->tahun], + ['Dicetak: ' . Carbon::now()->translatedFormat('d F Y, H:i') . ' WIB'], [], [ 'No', - 'NIK', + 'NIK / ID Karyawan', 'Nama Lengkap', 'Jabatan', 'Divisi', - 'Hadir', - 'Izin / Cuti', - 'Sakit', - 'Alpha (Mangkir)', - 'Terlambat', - 'Poin Lembur Diperoleh' - ] + 'Hadir (Hari)', + 'Izin / Cuti (Hari)', + 'Sakit (Hari)', + 'Alpha / Mangkir (Hari)', + 'Terlambat (Kali)', + 'Poin Lembur', + ], ]; } @@ -60,22 +77,168 @@ public function map($row): array $row['user']->nama_lengkap, $row['user']->jabatan->nama_jabatan ?? '-', $row['user']->divisi->nama_divisi ?? '-', - $row['hadir'] . ' Hari', - $row['izin'] . ' Hari', - $row['sakit'] . ' Hari', - $row['alpha'] . ' Hari', - $row['terlambat'] . ' Kali', - $row['poin_lembur'] . ' Poin' + $row['hadir'], + $row['izin'], + $row['sakit'], + $row['alpha'], + $row['terlambat'], + $row['poin_lembur'], + ]; + } + + public function columnWidths(): array + { + return [ + 'A' => 5, + 'B' => 22, + 'C' => 28, + 'D' => 20, + 'E' => 20, + 'F' => 14, + 'G' => 18, + 'H' => 14, + 'I' => 22, + 'J' => 18, + 'K' => 14, ]; } public function styles(Worksheet $sheet) { - return [ + $lastRow = count($this->rekap) + 7; - 1 => ['font' => ['bold' => true, 'size' => 14]], - 2 => ['font' => ['italic' => true]], - 4 => ['font' => ['bold' => true], 'fill' => ['fillType' => 'solid', 'color' => ['rgb' => 'E2E8F0']]], + return [ + // Judul utama + 1 => [ + 'font' => ['bold' => true, 'size' => 14, 'color' => ['rgb' => '1E293B']], + ], + // Sub judul + 2 => [ + 'font' => ['italic' => true, 'size' => 10, 'color' => ['rgb' => '64748B']], + ], + // Periode + 3 => [ + 'font' => ['size' => 10, 'color' => ['rgb' => '475569']], + ], + // Tanggal cetak + 4 => [ + 'font' => ['size' => 9, 'color' => ['rgb' => '94A3B8']], + ], + // Header kolom + 6 => [ + 'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']], + 'fill' => [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => ['rgb' => '1E293B'], + ], + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_CENTER, + 'vertical' => Alignment::VERTICAL_CENTER, + 'wrapText' => true, + ], + 'borders' => [ + 'allBorders' => [ + 'borderStyle' => Border::BORDER_THIN, + 'color' => ['rgb' => '334155'], + ], + ], + ], + ]; + } + + public function registerEvents(): array + { + return [ + AfterSheet::class => function (AfterSheet $event) { + $sheet = $event->sheet->getDelegate(); + $lastRow = count($this->rekap) + 6; + $dataStart = 7; + + // Merge judul + $sheet->mergeCells('A1:K1'); + $sheet->mergeCells('A2:K2'); + $sheet->mergeCells('A3:K3'); + $sheet->mergeCells('A4:K4'); + + // Alignment judul + foreach (['A1', 'A2', 'A3', 'A4'] as $cell) { + $sheet->getStyle($cell)->getAlignment() + ->setHorizontal(Alignment::HORIZONTAL_LEFT); + } + + // Border + alignment data baris + for ($r = $dataStart; $r <= $lastRow; $r++) { + $isEven = ($r - $dataStart) % 2 === 1; + + $sheet->getStyle("A{$r}:K{$r}")->applyFromArray([ + 'borders' => [ + 'allBorders' => [ + 'borderStyle' => Border::BORDER_THIN, + 'color' => ['rgb' => 'E2E8F0'], + ], + ], + 'fill' => $isEven ? [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => ['rgb' => 'F8FAFC'], + ] : [], + ]); + + // Kolom A (No) — center + $sheet->getStyle("A{$r}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + + // Kolom F–K (angka) — center + $sheet->getStyle("F{$r}:K{$r}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + + // Bold nama karyawan + $sheet->getStyle("C{$r}")->getFont()->setBold(true); + + // Highlight alpha > 0 (merah muda) + $alphaVal = $sheet->getCell("I{$r}")->getValue(); + if ($alphaVal > 0) { + $sheet->getStyle("I{$r}")->applyFromArray([ + 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'FEE2E2']], + 'font' => ['color' => ['rgb' => '991B1B'], 'bold' => true], + ]); + } + } + + // Tinggi baris header + $sheet->getRowDimension(6)->setRowHeight(30); + + // Freeze panel di baris header + kolom nama + $sheet->freezePane('C7'); + + // Ringkasan di bawah tabel + $summaryRow = $lastRow + 2; + $totalHadir = array_sum(array_column($this->rekap, 'hadir')); + $totalIzin = array_sum(array_column($this->rekap, 'izin')); + $totalSakit = array_sum(array_column($this->rekap, 'sakit')); + $totalAlpha = array_sum(array_column($this->rekap, 'alpha')); + $totalTerlambat = array_sum(array_column($this->rekap, 'terlambat')); + $totalPoin = array_sum(array_column($this->rekap, 'poin_lembur')); + + $sheet->setCellValue("A{$summaryRow}", 'RINGKASAN'); + $sheet->setCellValue("B{$summaryRow}", 'Total Hadir: ' . $totalHadir . ' hari'); + $sheet->setCellValue("C{$summaryRow}", 'Total Izin: ' . $totalIzin . ' hari'); + $sheet->setCellValue("D{$summaryRow}", 'Total Sakit: ' . $totalSakit . ' hari'); + $sheet->setCellValue("E{$summaryRow}", 'Total Alpha: ' . $totalAlpha . ' hari'); + $sheet->setCellValue("F{$summaryRow}", 'Terlambat: ' . $totalTerlambat . 'x'); + $sheet->setCellValue("G{$summaryRow}", 'Poin Lembur: ' . $totalPoin); + + $sheet->getStyle("A{$summaryRow}:G{$summaryRow}")->applyFromArray([ + 'font' => ['bold' => true, 'size' => 9, 'color' => ['rgb' => '1E293B']], + 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'F1F5F9']], + 'borders' => [ + 'allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'CBD5E1']], + ], + ]); + + // Catatan kaki + $noteRow = $summaryRow + 2; + $sheet->setCellValue("A{$noteRow}", '* Laporan dibuat otomatis oleh sistem MPG HRIS. Data bersumber dari catatan presensi dan lembur bulan berjalan.'); + $sheet->getStyle("A{$noteRow}")->getFont()->setItalic(true)->setSize(8)->getColor()->setRGB('94A3B8'); + $sheet->mergeCells("A{$noteRow}:K{$noteRow}"); + }, ]; } } diff --git a/app/Http/Controllers/Api/KompensasiController.php b/app/Http/Controllers/Api/KompensasiController.php deleted file mode 100644 index 98411ce4..00000000 --- a/app/Http/Controllers/Api/KompensasiController.php +++ /dev/null @@ -1,21 +0,0 @@ -getMessage(), 500); - } - } -} diff --git a/app/Http/Controllers/Api/LemburController.php b/app/Http/Controllers/Api/LemburController.php index eb9c0760..52364f4e 100644 --- a/app/Http/Controllers/Api/LemburController.php +++ b/app/Http/Controllers/Api/LemburController.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Controller; use App\Helpers\ApiResponse; use App\Services\LemburService; +use App\Services\NotifikasiService; use App\Http\Requests\StoreLemburRequest; use App\Models\Lembur; use Illuminate\Http\Request; @@ -26,6 +27,13 @@ public function store(StoreLemburRequest $request) $this->lemburService->createLembur($user, $request->validated()); + app(NotifikasiService::class)->kirimKeRole( + 'hrd', + 'pengajuan_baru', + 'Pengajuan Lembur Baru', + $user->nama_lengkap . ' mengajukan lembur.' + ); + return ApiResponse::success(null, 'Pengajuan lembur berhasil dikirim.', 201); } catch (\Exception $e) { diff --git a/app/Http/Controllers/Api/NotifikasiApiController.php b/app/Http/Controllers/Api/NotifikasiApiController.php new file mode 100644 index 00000000..5ea07bf4 --- /dev/null +++ b/app/Http/Controllers/Api/NotifikasiApiController.php @@ -0,0 +1,59 @@ +orderBy('created_at', 'desc') + ->paginate(20); + + return ApiResponse::success($notifikasi); + } + + public function unreadCount() + { + $count = Notifikasi::forUser(Auth::id())->unread()->count(); + return ApiResponse::success(['count' => $count]); + } + + public function markAsRead($id) + { + $notif = Notifikasi::where('id_user', Auth::id())->findOrFail($id); + $notif->update(['is_read' => true]); + return ApiResponse::success(null, 'Notifikasi ditandai sudah dibaca.'); + } + + public function markAllAsRead() + { + Notifikasi::forUser(Auth::id())->unread()->update(['is_read' => true]); + return ApiResponse::success(null, 'Semua notifikasi ditandai sudah dibaca.'); + } + + public function saveDeviceToken(Request $request) + { + $request->validate([ + 'fcm_token' => 'required|string', + 'device_type' => 'nullable|in:android,ios', + ]); + + DeviceToken::updateOrCreate( + ['fcm_token' => $request->fcm_token], + [ + 'id_user' => Auth::id(), + 'device_type' => $request->device_type ?? 'android', + ] + ); + + return ApiResponse::success(null, 'Device token berhasil disimpan.'); + } +} diff --git a/app/Http/Controllers/Api/PresensiController.php b/app/Http/Controllers/Api/PresensiController.php index 37550242..bace4996 100644 --- a/app/Http/Controllers/Api/PresensiController.php +++ b/app/Http/Controllers/Api/PresensiController.php @@ -152,7 +152,9 @@ public function history(Request $request) $statusMasuk = '-'; if ($item->jam_masuk) { - if ($item->waktu_terlambat) { + if ($item->id_status == 5) { + $statusMasuk = 'Alpha (Batal)'; + } elseif ($item->waktu_terlambat) { $statusMasuk = 'Terlambat'; } elseif ($item->waktu_masuk_awal) { $statusMasuk = 'Datang Awal'; @@ -196,6 +198,8 @@ public function history(Request $request) } catch (\Exception $e) { $totalJam = '-'; } + } elseif ($item->jam_masuk && !$item->jam_pulang && $item->id_status == 5) { + $totalJam = '0j 0m (Batal)'; } return [ diff --git a/app/Http/Controllers/Api/SubmissionController.php b/app/Http/Controllers/Api/SubmissionController.php index 9f965f9a..4906c7c5 100644 --- a/app/Http/Controllers/Api/SubmissionController.php +++ b/app/Http/Controllers/Api/SubmissionController.php @@ -8,6 +8,7 @@ use App\Models\JenisIzin; use App\Models\SuratIzin; use App\Models\TandaTangan; +use App\Services\NotifikasiService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -29,6 +30,11 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request) $jenisIzin = JenisIzin::find($request->id_jenis_izin); if ($jenisIzin && $jenisIzin->nama_izin == 'Cuti') { + // Validasi sisa cuti + if ($user->sisa_cuti <= 0) { + return ApiResponse::error('Sisa cuti Anda sudah habis (0 hari). Pengajuan cuti tidak dapat dilanjutkan.', 400); + } + $tanggalMulai = Carbon::parse($request->tanggal_mulai); $minDate = Carbon::now()->addDays(7)->startOfDay(); @@ -37,6 +43,34 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request) } } + $tglMulai = $request->tanggal_mulai; + $tglSelesai = $request->tanggal_selesai; + $userId = $user->id; + + $overlappingIzin = PengajuanIzin::where('id_user', $userId) + ->whereIn('id_status', [1, 2]) + ->where(function ($q) use ($tglMulai, $tglSelesai) { + $q->whereBetween('tanggal_mulai', [$tglMulai, $tglSelesai]) + ->orWhereBetween('tanggal_selesai', [$tglMulai, $tglSelesai]) + ->orWhere(function ($q2) use ($tglMulai, $tglSelesai) { + $q2->where('tanggal_mulai', '<=', $tglMulai) + ->where('tanggal_selesai', '>=', $tglSelesai); + }); + })->exists(); + + if ($overlappingIzin) { + return ApiResponse::error('Anda sudah memiliki pengajuan Izin/Cuti (Pending/Disetujui) pada rentang tanggal tersebut!', 400); + } + + $existingPresensi = \App\Models\Presensi::where('id_user', $userId) + ->whereBetween('tanggal', [$tglMulai, $tglSelesai]) + ->whereNotNull('jam_masuk') + ->exists(); + + if ($existingPresensi) { + return ApiResponse::error('Anda sudah tercatat absen masuk (hadir) pada rentang tanggal tersebut!', 400); + } + DB::beginTransaction(); $path = null; @@ -74,7 +108,7 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request) $suratIzin = null; if ($jenisIzin && $jenisIzin->nama_izin == 'Cuti') { $suratIzin = SuratIzin::create([ - 'id_izin' => $submission->id_izin, + 'id_izin' => $submission->getKey(), 'id_user' => $user->id, 'id_ttd_pengaju' => $ttdAktif?->id_tanda_tangan, 'isi_surat' => $isiSurat, @@ -84,6 +118,14 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request) DB::commit(); + app(NotifikasiService::class)->kirimKeRole( + 'hrd', + 'pengajuan_baru', + 'Pengajuan Baru: ' . ($jenisIzin->nama_izin ?? 'Izin'), + $user->nama_lengkap . ' mengajukan ' . ($jenisIzin->nama_izin ?? 'izin') . '.', + ['id_izin' => $submission->getKey()] + ); + $responseData = $submission->toArray(); if ($suratIzin) { $responseData['surat_izin'] = [ @@ -157,6 +199,35 @@ public function update(\App\Http\Requests\UpdatePengajuanIzinRequest $request, $ return ApiResponse::error('Pengajuan tidak ditemukan atau sudah diproses.', 404); } + $tglMulai = $request->tanggal_mulai; + $tglSelesai = $request->tanggal_selesai; + $userId = Auth::id(); + + $overlappingIzin = PengajuanIzin::where('id_user', $userId) + ->where('id_izin', '!=', $id) + ->whereIn('id_status', [1, 2]) + ->where(function ($q) use ($tglMulai, $tglSelesai) { + $q->whereBetween('tanggal_mulai', [$tglMulai, $tglSelesai]) + ->orWhereBetween('tanggal_selesai', [$tglMulai, $tglSelesai]) + ->orWhere(function ($q2) use ($tglMulai, $tglSelesai) { + $q2->where('tanggal_mulai', '<=', $tglMulai) + ->where('tanggal_selesai', '>=', $tglSelesai); + }); + })->exists(); + + if ($overlappingIzin) { + return ApiResponse::error('Anda sudah memiliki pengajuan Izin/Cuti lain (Pending/Disetujui) pada rentang tanggal tersebut!', 400); + } + + $existingPresensi = \App\Models\Presensi::where('id_user', $userId) + ->whereBetween('tanggal', [$tglMulai, $tglSelesai]) + ->whereNotNull('jam_masuk') + ->exists(); + + if ($existingPresensi) { + return ApiResponse::error('Anda sudah tercatat absen masuk (hadir) pada rentang tanggal tersebut!', 400); + } + DB::beginTransaction(); $data = $request->validated(); diff --git a/app/Http/Controllers/CutiController.php b/app/Http/Controllers/CutiController.php index 3f22e5cf..7fa76cad 100644 --- a/app/Http/Controllers/CutiController.php +++ b/app/Http/Controllers/CutiController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\User; +use App\Services\NotifikasiService; use Illuminate\Http\Request; class CutiController extends Controller @@ -40,6 +41,13 @@ public function update(Request $request, $id) 'sisa_cuti' => $request->sisa_cuti ]); + app(NotifikasiService::class)->kirim( + $user->id, + 'update_cuti', + 'Sisa Cuti Diperbarui', + 'Sisa cuti Anda telah diperbarui menjadi ' . $request->sisa_cuti . ' hari.' + ); + return redirect()->back()->with('success', 'Sisa cuti ' . $user->nama_lengkap . ' berhasil diperbarui.'); } @@ -53,6 +61,12 @@ public function resetMassal(Request $request) 'sisa_cuti' => $request->jumlah_hari ]); + app(NotifikasiService::class)->kirimBroadcast( + 'reset_cuti', + 'Sisa Cuti Di-reset', + 'Sisa cuti Anda telah di-reset menjadi ' . $request->jumlah_hari . ' hari.' + ); + return redirect()->back()->with('success', 'Sisa cuti semua pegawai berhasil di-reset menjadi ' . $request->jumlah_hari . ' hari.'); } } diff --git a/app/Http/Controllers/FaceApprovalController.php b/app/Http/Controllers/FaceApprovalController.php index b906a029..c2503ffc 100644 --- a/app/Http/Controllers/FaceApprovalController.php +++ b/app/Http/Controllers/FaceApprovalController.php @@ -4,18 +4,39 @@ use App\Models\User; use App\Models\DataWajah; +use App\Services\NotifikasiService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; class FaceApprovalController extends Controller { - public function index() + public function index(Request $request) { - $users = User::whereHas('dataWajah', function($q) { - $q->where('is_verified', 0); - }) - ->with(['jabatan', 'divisi']) - ->get(); + $query = User::with(['jabatan', 'divisi', 'dataWajah']); + + $status = $request->get('status', ''); + + if ($status === 'pending') { + $query->whereHas('dataWajah', fn($q) => $q->where('is_verified', 0)); + } elseif ($status === 'approved') { + $query->whereHas('dataWajah', fn($q) => $q->where('is_verified', 1)); + } elseif ($status === 'rejected') { + $query->whereHas('dataWajah', fn($q) => $q->where('is_verified', 2)); + } elseif ($status === 'unregistered') { + $query->whereDoesntHave('dataWajah'); + } else { + $query->whereHas('dataWajah'); + } + + if ($request->search) { + $search = $request->search; + $query->where(function($q) use ($search) { + $q->where('nama_lengkap', 'like', "%{$search}%") + ->orWhere('nik', 'like', "%{$search}%"); + }); + } + + $users = $query->get(); $users->each(function ($user) { $storagePath = "face_datasets/{$user->id}"; @@ -36,7 +57,14 @@ public function index() $user->face_photos = $fotoList; }); - return view('face-approval.index', compact('users')); + $stats = [ + 'pending' => DataWajah::where('is_verified', 0)->count(), + 'approved' => DataWajah::where('is_verified', 1)->count(), + 'rejected' => DataWajah::where('is_verified', 2)->count(), + 'unregistered' => User::whereDoesntHave('dataWajah')->count(), + ]; + + return view('face-approval.index', compact('users', 'stats', 'status')); } public function approve($id) @@ -47,6 +75,13 @@ public function approve($id) $user->dataWajah->update(['is_verified' => 1]); } + app(NotifikasiService::class)->kirim( + $user->id, + 'face_disetujui', + 'Wajah Terverifikasi', + 'Data wajah Anda telah diverifikasi. Sekarang Anda bisa melakukan presensi.' + ); + return redirect()->back()->with('success', 'Wajah karyawan berhasil diverifikasi. Karyawan kini bisa melakukan presensi.'); } @@ -65,9 +100,41 @@ public function reject($id) Storage::disk('local')->deleteDirectory($storagePath); } + app(NotifikasiService::class)->kirim( + $user->id, + 'face_ditolak', + 'Registrasi Wajah Ditolak', + 'Data wajah Anda ditolak. Silakan lakukan registrasi ulang melalui aplikasi.' + ); + return redirect()->back()->with('success', 'Wajah ditolak. Karyawan diminta melakukan registrasi ulang.'); } + public function reset($id) + { + $user = User::findOrFail($id); + + if ($user->dataWajah) { + $user->dataWajah->delete(); + } + + $user->update(['is_face_registered' => 0]); + + $storagePath = "face_datasets/{$user->id}"; + if (Storage::disk('local')->exists($storagePath)) { + Storage::disk('local')->deleteDirectory($storagePath); + } + + app(NotifikasiService::class)->kirim( + $user->id, + 'face_reset', + 'Data Wajah Direset', + 'Data wajah Anda telah direset oleh HRD. Silakan lakukan registrasi ulang melalui aplikasi.' + ); + + return redirect()->back()->with('success', 'Data wajah berhasil direset. Karyawan harus melakukan registrasi ulang.'); + } + public function showPhoto($userId, $pose) { $extensions = ['jpg', 'jpeg', 'png']; diff --git a/app/Http/Controllers/JadwalController.php b/app/Http/Controllers/JadwalController.php index 87e20ad9..3bde9cb4 100644 --- a/app/Http/Controllers/JadwalController.php +++ b/app/Http/Controllers/JadwalController.php @@ -74,6 +74,12 @@ public function getEvents(Request $request) }); } + if ($request->filled('filter_nama') && $request->filter_nama != "") { + $query->whereHas('user', function ($q) use ($request) { + $q->where('nama_lengkap', 'like', '%' . $request->filter_nama . '%'); + }); + } + $jadwals = $query->get(); $poinUsed = PenggunaanPoin::with('jenisPengurangan') @@ -147,6 +153,7 @@ public function getEvents(Request $request) 'jabatan' => $jadwalItem->user?->jabatan?->nama_jabatan ?? '-', 'id_shift' => $jadwalItem->id_shift, 'id_user' => $jadwalItem->id_user, + 'nik' => $jadwalItem->user?->nik ?? '-', ] ]; } @@ -223,6 +230,11 @@ public function store(Request $request) 'user_ids.*' => 'exists:users,id', ]); + // Guard: pastikan ada data shift sebelum memproses + if (ShiftKerja::count() === 0) { + return redirect()->back()->with('error', 'Tidak ada data Shift yang tersedia. Tambahkan Shift terlebih dahulu di menu Data Master → Shift.'); + } + $user = Auth::user(); $isGlobalAdmin = $user->isGlobalAdmin(); diff --git a/app/Http/Controllers/LaporanController.php b/app/Http/Controllers/LaporanController.php index eb3b6549..c15fab6f 100644 --- a/app/Http/Controllers/LaporanController.php +++ b/app/Http/Controllers/LaporanController.php @@ -9,6 +9,7 @@ use Carbon\Carbon; use Maatwebsite\Excel\Facades\Excel; use App\Exports\LaporanPresensiExport; +use App\Exports\LaporanIzinExport; use Barryvdh\DomPDF\Facade\Pdf; class LaporanController extends Controller @@ -55,6 +56,41 @@ public function cuti(Request $request) return view('laporan.izin', compact('izinList', 'bulan', 'tahun', 'search', 'divisiId', 'divisiList')); } + public function exportIzinExcel(Request $request) + { + $bulan = $request->input('bulan', date('m')); + $tahun = $request->input('tahun', date('Y')); + $search = $request->input('search'); + $divisiId = $request->input('id_divisi'); + + $query = \App\Models\PengajuanIzin::with(['user.divisi', 'jenisIzin']) + ->whereYear('tanggal_mulai', $tahun) + ->whereMonth('tanggal_mulai', $bulan); + + if ($search) { + $query->whereHas('user', function ($q) use ($search) { + $q->where('nama_lengkap', 'like', "%{$search}%") + ->orWhere('nik', 'like', "%{$search}%"); + }); + } + + if ($divisiId) { + $query->whereHas('user', function ($q) use ($divisiId) { + $q->where('id_divisi', $divisiId); + }); + } + + $data = $query->orderBy('tanggal_mulai', 'desc')->get(); + + if ($data->isEmpty()) { + return redirect()->back()->with('error', 'Tidak ada data izin/cuti untuk diekspor pada periode tersebut.'); + } + + $filename = "Laporan_Izin_Cuti_{$bulan}_{$tahun}.xlsx"; + + return Excel::download(new LaporanIzinExport($data, $bulan, $tahun), $filename); + } + public function exportExcel(Request $request) { $bulan = $request->input('bulan', date('m')); @@ -63,6 +99,10 @@ public function exportExcel(Request $request) $rekap = $this->buildRekap($bulan, $tahun, $divisiId); + if (empty($rekap)) { + return redirect()->back()->with('error', 'Tidak ada data presensi untuk diekspor pada periode tersebut.'); + } + $filename = "Laporan_Presensi_{$bulan}_{$tahun}.xlsx"; return Excel::download(new LaporanPresensiExport($rekap, $bulan, $tahun), $filename); diff --git a/app/Http/Controllers/LaporanLemburController.php b/app/Http/Controllers/LaporanLemburController.php index ff0347a6..3a6c3a92 100644 --- a/app/Http/Controllers/LaporanLemburController.php +++ b/app/Http/Controllers/LaporanLemburController.php @@ -50,10 +50,10 @@ public function index(Request $request) $menit = $totalMenit % 60; $rekap[$p->id] = [ - 'total_menit' => $totalMenit, - 'format_jam' => "{$jam}j {$menit}m", - 'jumlah_hari' => $p->lemburs->count(), - 'poin_diperoleh' => $totalMenit, + 'total_menit' => $totalMenit, + 'format_jam' => "{$jam}j {$menit}m", + 'jumlah_hari' => $p->lemburs->count(), + 'poin_diperoleh' => $p->lemburs->sum('jumlah_poin'), ]; } @@ -81,6 +81,12 @@ public function exportExcel(Request $request) $pegawai = $query->orderBy('nama_lengkap', 'asc')->get(); + // Guard: cek apakah ada data lembur di periode tersebut + $adaDataLembur = $pegawai->contains(fn($p) => $p->lemburs->isNotEmpty()); + if (!$adaDataLembur) { + return redirect()->back()->with('error', 'Tidak ada data lembur yang disetujui untuk diekspor pada periode tersebut.'); + } + $rekap = []; foreach ($pegawai as $p) { $totalMenit = $p->lemburs->sum('durasi_menit'); @@ -88,10 +94,10 @@ public function exportExcel(Request $request) $menit = $totalMenit % 60; $rekap[$p->id] = [ - 'total_menit' => $totalMenit, - 'format_jam' => "{$jam}j {$menit}m", - 'jumlah_hari' => $p->lemburs->count(), - 'poin_diperoleh' => $totalMenit, + 'total_menit' => $totalMenit, + 'format_jam' => "{$jam}j {$menit}m", + 'jumlah_hari' => $p->lemburs->count(), + 'poin_diperoleh' => $p->lemburs->sum('jumlah_poin'), ]; } diff --git a/app/Http/Controllers/LemburController.php b/app/Http/Controllers/LemburController.php index 83208233..fa143360 100644 --- a/app/Http/Controllers/LemburController.php +++ b/app/Http/Controllers/LemburController.php @@ -6,6 +6,7 @@ use App\Models\User; use App\Models\JenisKompensasi; use App\Services\LemburService; +use App\Services\NotifikasiService; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -74,9 +75,25 @@ public function update(Request $request, Lembur $lembur) if ($lembur->id_kompensasi == 2) { $message .= ' Poin telah ditambahkan ke karyawan.'; } + + app(NotifikasiService::class)->kirim( + $lembur->id_user, + 'lembur_disetujui', + 'Lembur Disetujui', + 'Pengajuan lembur Anda pada tanggal ' . $lembur->tanggal_lembur . ' telah disetujui.', + ['id_lembur' => $lembur->id_lembur] + ); } else { $this->lemburService->reject($lembur, $request->alasan_penolakan); $message = 'Pengajuan lembur berhasil ditolak.'; + + app(NotifikasiService::class)->kirim( + $lembur->id_user, + 'lembur_ditolak', + 'Lembur Ditolak', + 'Pengajuan lembur Anda pada tanggal ' . $lembur->tanggal_lembur . ' ditolak. Alasan: ' . ($request->alasan_penolakan ?? '-'), + ['id_lembur' => $lembur->id_lembur] + ); } return redirect()->back()->with('success', $message); diff --git a/app/Http/Controllers/NotifikasiWebController.php b/app/Http/Controllers/NotifikasiWebController.php new file mode 100644 index 00000000..e8228483 --- /dev/null +++ b/app/Http/Controllers/NotifikasiWebController.php @@ -0,0 +1,59 @@ +orderByDesc('created_at') + ->paginate(15); + + $unreadCount = Notifikasi::forUser(Auth::id())->unread()->count(); + + return view('notifikasi.index', compact('notifikasi', 'unreadCount')); + } + + public function recent() + { + $items = Notifikasi::forUser(Auth::id()) + ->orderByDesc('created_at') + ->take(10) + ->get() + ->map(fn($n) => [ + 'id' => $n->id, + 'judul' => $n->judul, + 'pesan' => $n->pesan, + 'tipe' => $n->tipe, + 'is_read' => $n->is_read, + 'data' => $n->data, + 'waktu' => $n->created_at->diffForHumans(), + 'created_at' => $n->created_at->toIso8601String(), + ]); + + return response()->json(['data' => $items]); + } + + public function unreadCount() + { + $count = Notifikasi::forUser(Auth::id())->unread()->count(); + return response()->json(['count' => $count]); + } + + public function markAsRead($id) + { + Notifikasi::where('id_user', Auth::id())->findOrFail($id)->update(['is_read' => true]); + return response()->json(['success' => true]); + } + + public function markAllAsRead() + { + Notifikasi::forUser(Auth::id())->unread()->update(['is_read' => true]); + return response()->json(['success' => true]); + } +} diff --git a/app/Http/Controllers/PengajuanIzinController.php b/app/Http/Controllers/PengajuanIzinController.php index 9649dff6..01c564cc 100644 --- a/app/Http/Controllers/PengajuanIzinController.php +++ b/app/Http/Controllers/PengajuanIzinController.php @@ -8,6 +8,7 @@ use App\Models\User; use App\Models\SuratIzin; use App\Models\TandaTangan; +use App\Services\NotifikasiService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -67,10 +68,38 @@ public function store(Request $request) $diffParams = Carbon::parse($request->tanggal_mulai, 'Asia/Jakarta')->diffInDays(Carbon::now('Asia/Jakarta')); if (Carbon::parse($request->tanggal_mulai, 'Asia/Jakarta')->diffInDays(Carbon::now('Asia/Jakarta')) < 7) { - return back()->with('error', 'Pengajuan Cuti minimal H-7!'); + return back()->withInput()->with('error', 'Pengajuan Cuti minimal H-7!'); } } + $tglMulai = $request->tanggal_mulai; + $tglSelesai = $request->tanggal_selesai; + $userId = $request->id_user; + + $overlappingIzin = PengajuanIzin::where('id_user', $userId) + ->whereIn('id_status', [1, 2]) + ->where(function ($q) use ($tglMulai, $tglSelesai) { + $q->whereBetween('tanggal_mulai', [$tglMulai, $tglSelesai]) + ->orWhereBetween('tanggal_selesai', [$tglMulai, $tglSelesai]) + ->orWhere(function ($q2) use ($tglMulai, $tglSelesai) { + $q2->where('tanggal_mulai', '<=', $tglMulai) + ->where('tanggal_selesai', '>=', $tglSelesai); + }); + })->exists(); + + if ($overlappingIzin) { + return back()->withInput()->with('error', 'Pegawai sudah memiliki pengajuan Izin/Cuti (Pending/Disetujui) pada rentang tanggal tersebut!'); + } + + $existingPresensi = \App\Models\Presensi::where('id_user', $userId) + ->whereBetween('tanggal', [$tglMulai, $tglSelesai]) + ->whereNotNull('jam_masuk') + ->exists(); + + if ($existingPresensi) { + return back()->withInput()->with('error', 'Pegawai sudah tercatat melakukan absensi kehadiran pada rentang tanggal tersebut!'); + } + try { DB::beginTransaction(); @@ -182,6 +211,15 @@ public function approve($id) } DB::commit(); + + app(NotifikasiService::class)->kirim( + $izin->id_user, + 'izin_disetujui', + 'Pengajuan Izin Disetujui', + 'Pengajuan ' . $izin->jenisIzin->nama_izin . ' Anda telah disetujui.', + ['id_izin' => $izin->id_izin] + ); + return redirect()->back()->with('success', 'Izin disetujui & data presensi diperbarui.'); } catch (\Exception $e) { @@ -212,6 +250,14 @@ public function reject(Request $request, $id) 'id_status' => 3 ]); + app(NotifikasiService::class)->kirim( + $izin->id_user, + 'izin_ditolak', + 'Pengajuan Izin Ditolak', + 'Pengajuan ' . ($izin->jenisIzin->nama_izin ?? 'Izin') . ' Anda ditolak.', + ['id_izin' => $izin->id_izin] + ); + return redirect()->back()->with('success', 'Pengajuan izin berhasil ditolak.'); } } diff --git a/app/Http/Controllers/PenggunaanPoinController.php b/app/Http/Controllers/PenggunaanPoinController.php index 150fc9e2..a30cc792 100644 --- a/app/Http/Controllers/PenggunaanPoinController.php +++ b/app/Http/Controllers/PenggunaanPoinController.php @@ -4,6 +4,7 @@ use App\Models\PenggunaanPoin; use App\Services\PoinService; +use App\Services\NotifikasiService; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -39,7 +40,6 @@ public function update(Request $request, $id) if ($request->action == 'approve') { DB::transaction(function () use ($penggunaan) { $penggunaan->update(['id_status' => 2]); - $this->poinService->deductPoin( $penggunaan->id_user, $penggunaan->jumlah_poin, @@ -47,6 +47,14 @@ public function update(Request $request, $id) ); }); + app(NotifikasiService::class)->kirim( + $penggunaan->id_user, + 'poin_disetujui', + 'Penggunaan Poin Disetujui', + 'Pengajuan penggunaan poin Anda pada tanggal ' . $penggunaan->tanggal_penggunaan . ' telah disetujui.', + ['id_penggunaan' => $penggunaan->id_penggunaan] + ); + $message = 'Pengajuan berhasil disetujui dan poin telah dipotong.'; } else { $penggunaan->update([ @@ -54,6 +62,14 @@ public function update(Request $request, $id) 'alasan_penolakan' => $request->alasan_penolakan ]); + app(NotifikasiService::class)->kirim( + $penggunaan->id_user, + 'poin_ditolak', + 'Penggunaan Poin Ditolak', + 'Pengajuan penggunaan poin Anda pada tanggal ' . $penggunaan->tanggal_penggunaan . ' ditolak.', + ['id_penggunaan' => $penggunaan->id_penggunaan] + ); + $message = 'Pengajuan berhasil ditolak.'; } diff --git a/app/Http/Controllers/PengumumanController.php b/app/Http/Controllers/PengumumanController.php index 330ae7dc..d704683b 100644 --- a/app/Http/Controllers/PengumumanController.php +++ b/app/Http/Controllers/PengumumanController.php @@ -5,6 +5,7 @@ use App\Http\Requests\StorePengumumanRequest; use App\Http\Requests\UpdatePengumumanRequest; use App\Models\Pengumuman; +use App\Services\NotifikasiService; use Illuminate\Support\Facades\Auth; class PengumumanController extends Controller @@ -31,7 +32,14 @@ public function store(StorePengumumanRequest $request) $data = $request->validated(); $data['dibuat_oleh'] = Auth::id(); - Pengumuman::create($data); + $pengumuman = Pengumuman::create($data); + + app(NotifikasiService::class)->kirimBroadcast( + 'pengumuman_baru', + '📢 Pengumuman Baru', + $pengumuman->judul, + ['id_pengumuman' => $pengumuman->id] + ); return redirect()->route('pengumuman.index') ->with('success', 'Pengumuman berhasil ditambahkan.'); diff --git a/app/Http/Controllers/PermissionController.php b/app/Http/Controllers/PermissionController.php index bb9612e7..d45e37ec 100644 --- a/app/Http/Controllers/PermissionController.php +++ b/app/Http/Controllers/PermissionController.php @@ -32,7 +32,18 @@ public function store(Request $request) public function sync(Request $request, $id_role) { $role = Role::findOrFail($id_role); - $role->permissions()->sync($request->permissions); + + // Guard: role HRD tidak boleh kehilangan manage_permissions + if (strtolower($role->nama_role) === 'hrd') { + $managePermission = Permission::where('slug', 'manage_permissions')->first(); + $selectedIds = collect($request->permissions ?? []); + + if ($managePermission && !$selectedIds->contains($managePermission->id_permission)) { + return back()->with('error', 'Permission "manage_permissions" tidak boleh dicabut dari role HRD untuk mencegah lockout sistem.'); + } + } + + $role->permissions()->sync($request->permissions ?? []); return back()->with('success', 'Hak akses role berhasil diperbarui.'); } diff --git a/app/Http/Controllers/PresensiController.php b/app/Http/Controllers/PresensiController.php index 3aad89bd..4253c4c1 100644 --- a/app/Http/Controllers/PresensiController.php +++ b/app/Http/Controllers/PresensiController.php @@ -5,6 +5,7 @@ use App\Models\Presensi; use App\Models\JadwalKerja; use App\Models\Kantor; +use App\Services\NotifikasiService; use Illuminate\Http\Request; use App\Http\Requests\StorePresensiRequest; use App\Http\Requests\StoreManualPresensiRequest; @@ -155,6 +156,14 @@ public function approve($id) $presensi->update(['id_validasi' => 1]); + app(NotifikasiService::class)->kirim( + $presensi->id_user, + 'presensi_disetujui', + 'Presensi Disetujui ✅', + 'Presensi Anda pada tanggal ' . $presensi->tanggal . ' telah disetujui.', + ['id_presensi' => $presensi->id_presensi] + ); + return redirect()->back()->with('success', 'Presensi berhasil disetujui.'); } @@ -169,6 +178,14 @@ public function reject($id) $presensi->update(['id_validasi' => 3]); + app(NotifikasiService::class)->kirim( + $presensi->id_user, + 'presensi_ditolak', + 'Presensi Ditolak ❌', + 'Presensi Anda pada tanggal ' . $presensi->tanggal . ' ditolak. Silakan hubungi HRD.', + ['id_presensi' => $presensi->id_presensi] + ); + return redirect()->back()->with('success', 'Presensi berhasil ditolak.'); } diff --git a/app/Http/Controllers/RoleController.php b/app/Http/Controllers/RoleController.php index 158cc364..b02e6c51 100644 --- a/app/Http/Controllers/RoleController.php +++ b/app/Http/Controllers/RoleController.php @@ -10,9 +10,11 @@ class RoleController extends Controller { + const ROLE_KRITIS = ['manajer', 'manager', 'supervisor', 'hrd', 'super_admin', 'staff']; + public function index(Request $request) { - $query = Role::with(['permissions', 'users']); + $query = Role::with(['permissions'])->withCount('users'); if ($request->filled('search')) { $query->where('nama_role', 'like', '%' . $request->search . '%'); @@ -39,6 +41,12 @@ public function store(StoreRoleRequest $request) public function update(UpdateRoleRequest $request, $id) { $role = Role::findOrFail($id); + + if (in_array(strtolower($role->nama_role), self::ROLE_KRITIS)) { + return redirect()->route('role.index') + ->with('error', 'Role sistem tidak dapat diubah.'); + } + $role->update($request->validated()); if ($request->has('id_permissions')) { @@ -54,7 +62,11 @@ public function update(UpdateRoleRequest $request, $id) public function destroy($id) { $role = Role::findOrFail($id); - + + if (in_array(strtolower($role->nama_role), self::ROLE_KRITIS)) { + return redirect()->back()->with('error', 'Role sistem tidak dapat dihapus.'); + } + if ($role->users()->exists()) { return redirect()->back()->with('error', 'Role tidak bisa dihapus karena masih digunakan user.'); } diff --git a/app/Http/Controllers/SuratIzinController.php b/app/Http/Controllers/SuratIzinController.php index 74314346..3e89deb7 100644 --- a/app/Http/Controllers/SuratIzinController.php +++ b/app/Http/Controllers/SuratIzinController.php @@ -6,6 +6,7 @@ use App\Models\ApprovalSurat; use App\Models\TandaTangan; use App\Models\Presensi; +use App\Services\NotifikasiService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -61,6 +62,7 @@ public function show($id) } $canApprove = false; + $tahapApproval = null; if ($surat->status_surat === 'menunggu_manajer' && ($user->roles->contains('nama_role', 'manajer') || $isGlobalAdmin)) { $canApprove = true; @@ -128,7 +130,26 @@ public function approve(Request $request, $id) DB::commit(); - $statusLabel = $tahap === 1 ? 'Menunggu HRD' : 'Disetujui'; + if ($tahap === 1) { + $statusLabel = 'Menunggu HRD'; + app(NotifikasiService::class)->kirim( + $surat->id_user, + 'izin_proses', + 'Surat Izin Diproses', + 'Surat izin Anda telah disetujui Manajer dan sedang menunggu persetujuan HRD.', + ['id_surat' => $surat->id_surat] + ); + } else { + $statusLabel = 'Disetujui'; + app(NotifikasiService::class)->kirim( + $surat->id_user, + 'izin_disetujui', + 'Surat Izin Disetujui ✅', + 'Surat izin Anda telah disetujui sepenuhnya.', + ['id_surat' => $surat->id_surat] + ); + } + return redirect()->back()->with('success', "Surat berhasil disetujui. Status: {$statusLabel}"); } catch (\Exception $e) { @@ -184,6 +205,14 @@ public function reject(Request $request, $id) DB::commit(); + app(NotifikasiService::class)->kirim( + $surat->id_user, + 'izin_ditolak', + 'Surat Izin Ditolak ❌', + 'Surat izin Anda ditolak. Catatan: ' . ($request->catatan ?? '-'), + ['id_surat' => $surat->id_surat] + ); + return redirect()->back()->with('success', 'Surat izin berhasil ditolak.'); } catch (\Exception $e) { diff --git a/app/Http/Controllers/TukarShiftController.php b/app/Http/Controllers/TukarShiftController.php index e4517de7..a0b71430 100644 --- a/app/Http/Controllers/TukarShiftController.php +++ b/app/Http/Controllers/TukarShiftController.php @@ -6,6 +6,7 @@ use App\Models\JadwalKerja; use App\Models\RiwayatTukarShift; use App\Http\Requests\StoreTukarShiftRequest; +use App\Services\NotifikasiService; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Auth; @@ -47,7 +48,28 @@ public function store(StoreTukarShiftRequest $request) return redirect()->back()->withInput()->with('error', 'Data jadwal tidak sesuai dengan pegawai yang dipilih.'); } - // 2. Validasi bentrok jadwal kerja + // 2. Validasi tanggal tidak boleh di masa lalu + $hariIni = Carbon::today()->toDateString(); + if ($jadwal1->tanggal < $hariIni || $jadwal2->tanggal < $hariIni) { + return redirect()->back()->withInput()->with('error', 'Tidak bisa menukar shift untuk tanggal yang sudah lewat.'); + } + + // 3. Validasi presensi belum ada di tanggal tujuan + $presensi1 = \App\Models\Presensi::where('id_user', $request->id_user_1) + ->where('tanggal', $jadwal1->tanggal) + ->exists(); + if ($presensi1) { + return redirect()->back()->withInput()->with('error', 'Pegawai Pertama sudah memiliki data presensi pada tanggal (' . $jadwal1->tanggal . '). Tukar shift tidak dapat dilakukan.'); + } + + $presensi2 = \App\Models\Presensi::where('id_user', $request->id_user_2) + ->where('tanggal', $jadwal2->tanggal) + ->exists(); + if ($presensi2) { + return redirect()->back()->withInput()->with('error', 'Pegawai Kedua sudah memiliki data presensi pada tanggal (' . $jadwal2->tanggal . '). Tukar shift tidak dapat dilakukan.'); + } + + // 4. Validasi bentrok jadwal kerja $conflict1 = JadwalKerja::where('id_user', $request->id_user_1) ->where('tanggal', $jadwal2->tanggal) ->where('id_jadwal', '!=', $jadwal1->id_jadwal) @@ -64,7 +86,7 @@ public function store(StoreTukarShiftRequest $request) return redirect()->back()->withInput()->with('error', 'Pegawai Kedua sudah memiliki jadwal kerja lain pada tanggal tujuan (' . $jadwal1->tanggal . ').'); } - // 3. Validasi bentrok dengan Penggunaan Poin (Cuti / dll) + // 5. Validasi bentrok dengan Penggunaan Poin (Cuti / dll) $poin1 = \App\Models\PenggunaanPoin::where('id_user', $request->id_user_1) ->where('tanggal_penggunaan', $jadwal2->tanggal) ->where('id_status', 2) @@ -99,6 +121,23 @@ public function store(StoreTukarShiftRequest $request) DB::commit(); + $user1 = User::find($request->id_user_1, ['*']); + $user2 = User::find($request->id_user_2, ['*']); + + app(NotifikasiService::class)->kirim( + $request->id_user_1, + 'tukar_shift', + 'Jadwal Shift Ditukar', + 'Shift Anda telah ditukar dengan ' . ($user2->nama_lengkap ?? 'pegawai lain') . '.' + ); + + app(NotifikasiService::class)->kirim( + $request->id_user_2, + 'tukar_shift', + 'Jadwal Shift Ditukar', + 'Shift Anda telah ditukar dengan ' . ($user1->nama_lengkap ?? 'pegawai lain') . '.' + ); + return redirect()->route('tukar-shift.index') ->with('success', 'Berhasil menukar shift kerja untuk kedua pegawai tersebut.'); diff --git a/app/Http/Requests/StoreManualPresensiRequest.php b/app/Http/Requests/StoreManualPresensiRequest.php index d3c493ad..d9f9b643 100644 --- a/app/Http/Requests/StoreManualPresensiRequest.php +++ b/app/Http/Requests/StoreManualPresensiRequest.php @@ -32,7 +32,14 @@ public function rules(): array 'nullable', 'date_format:H:i' ], - 'jam_pulang' => 'nullable|date_format:H:i', + 'jam_pulang' => [ + 'nullable', + 'date_format:H:i', + Rule::when( + fn () => $this->jam_masuk && $this->jam_pulang, + ['after:jam_masuk'] + ), + ], 'alasan_telat' => 'nullable|string|max:255' ]; } diff --git a/app/Http/Requests/StorePegawaiRequest.php b/app/Http/Requests/StorePegawaiRequest.php index 980e8902..1766e22f 100644 --- a/app/Http/Requests/StorePegawaiRequest.php +++ b/app/Http/Requests/StorePegawaiRequest.php @@ -15,7 +15,8 @@ public function rules(): array { return [ 'nama_lengkap' => ['required', 'string', 'max:255'], - 'email' => ['required', 'email', 'unique:users,email'], + 'nik' => ['nullable', 'string', 'max:50', 'unique:users,nik'], + 'email' => ['required', 'email', 'unique:users,email'], 'no_telp' => ['nullable', 'string', 'max:20'], 'alamat' => ['nullable', 'string'], 'id_divisi' => ['required', 'exists:divisi,id_divisi'], diff --git a/app/Http/Requests/UpdatePegawaiRequest.php b/app/Http/Requests/UpdatePegawaiRequest.php index 68b5fc05..520e0a45 100644 --- a/app/Http/Requests/UpdatePegawaiRequest.php +++ b/app/Http/Requests/UpdatePegawaiRequest.php @@ -18,7 +18,8 @@ public function rules(): array return [ 'nama_lengkap' => ['required', 'string', 'max:255'], - 'email' => [ + 'nik' => ['nullable', 'string', 'max:50', Rule::unique('users', 'nik')->ignore($id)], + 'email' => [ 'required', 'email', 'unique:users,email,' . $this->route('pegawai') diff --git a/app/Models/DeviceToken.php b/app/Models/DeviceToken.php new file mode 100644 index 00000000..8e439ec0 --- /dev/null +++ b/app/Models/DeviceToken.php @@ -0,0 +1,21 @@ +belongsTo(User::class, 'id_user', 'id'); + } +} diff --git a/app/Models/Notifikasi.php b/app/Models/Notifikasi.php new file mode 100644 index 00000000..e45c0f82 --- /dev/null +++ b/app/Models/Notifikasi.php @@ -0,0 +1,39 @@ + 'array', + 'is_read' => 'boolean', + ]; + + public function user() + { + return $this->belongsTo(User::class, 'id_user', 'id'); + } + + public function scopeUnread($query) + { + return $query->where('is_read', false); + } + + public function scopeForUser($query, int $userId) + { + return $query->where('id_user', $userId); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index c5c6009c..3163acf5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -118,7 +118,7 @@ public function isGlobalAdmin(): bool public function isSuperAdmin(): bool { return $this->roles->contains(function ($role) { - return strtolower($role->nama_role) === 'hrd'; + return strtolower($role->nama_role) === 'super_admin'; }); } @@ -151,4 +151,9 @@ public function lemburs(): \Illuminate\Database\Eloquent\Relations\HasMany { return $this->hasMany(Lembur::class, 'id_user', 'id'); } + + public function notifikasi(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(Notifikasi::class, 'id_user', 'id'); + } } diff --git a/app/Services/LemburService.php b/app/Services/LemburService.php index d67c3353..183d5d0c 100644 --- a/app/Services/LemburService.php +++ b/app/Services/LemburService.php @@ -15,8 +15,14 @@ public function createLembur($user, array $data) { if (!isset($data['durasi_menit'])) { $start = Carbon::parse($data['jam_mulai']); - $end = Carbon::parse($data['jam_selesai']); - $diff = abs($start->diffInMinutes($end)); + $end = Carbon::parse($data['jam_selesai']); + + // Handle shift malam yang melewati tengah malam (jam selesai < jam mulai) + if ($end->lessThanOrEqualTo($start)) { + $end->addDay(); + } + + $diff = $start->diffInMinutes($end); } else { $diff = $data['durasi_menit']; } diff --git a/app/Services/NotifikasiService.php b/app/Services/NotifikasiService.php new file mode 100644 index 00000000..5dc0ba83 --- /dev/null +++ b/app/Services/NotifikasiService.php @@ -0,0 +1,134 @@ + $idUser, + 'tipe' => $tipe, + 'judul' => $judul, + 'pesan' => $pesan, + 'data' => $data, + 'is_read' => false, + ]); + + $this->sendPushNotification($idUser, $judul, $pesan, $data); + } catch (\Exception $e) { + Log::error('NotifikasiService::kirim gagal: ' . $e->getMessage()); + } + } + + /** + * Kirim notifikasi ke semua staff aktif (broadcast pengumuman). + */ + public function kirimBroadcast(string $tipe, string $judul, string $pesan, array $data = []): void + { + $users = User::where('status_aktif', 1) + ->whereDoesntHave('roles', function ($q) { + $q->whereIn('nama_role', ['super_admin', 'hrd']); + }) + ->pluck('id'); + + foreach ($users as $idUser) { + $this->kirim($idUser, $tipe, $judul, $pesan, $data); + } + } + + /** + * Kirim ke semua user dengan role tertentu (contoh: HRD, Manajer). + */ + public function kirimKeRole(string $namaRole, string $tipe, string $judul, string $pesan, array $data = []): void + { + $users = User::where('status_aktif', 1) + ->whereHas('roles', function ($q) use ($namaRole) { + $q->where('nama_role', $namaRole); + }) + ->pluck('id'); + + foreach ($users as $idUser) { + $this->kirim($idUser, $tipe, $judul, $pesan, $data); + } + } + + /** + * Kirim push notification ke device FCM user via FCM HTTP v1 API. + */ + private function sendPushNotification(int $idUser, string $judul, string $pesan, array $data = []): void + { + $tokens = DeviceToken::where('id_user', $idUser)->pluck('fcm_token'); + if ($tokens->isEmpty()) return; + + $accessToken = $this->getFcmAccessToken(); + if (!$accessToken) return; + + $projectId = env('FIREBASE_PROJECT_ID'); + + foreach ($tokens as $token) { + try { + \Illuminate\Support\Facades\Http::withToken($accessToken) + ->withoutVerifying() + ->post("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send", [ + 'message' => [ + 'token' => $token, + 'notification' => ['title' => $judul, 'body' => $pesan], + 'data' => array_map('strval', $data), + 'android' => [ + 'notification' => [ + 'channel_id' => 'hris_channel', + 'click_action' => 'FLUTTER_NOTIFICATION_CLICK', + ], + ], + ], + ]); + } catch (\Exception $e) { + Log::error("FCM gagal untuk token $token: " . $e->getMessage()); + } + } + } + + /** + * Ambil access token Google OAuth2 menggunakan Service Account JWT. + */ + private function getFcmAccessToken(): ?string + { + try { + $credPath = base_path(env('FIREBASE_CREDENTIALS')); + $credentials = json_decode(file_get_contents($credPath), true); + $now = time(); + + $header = rtrim(strtr(base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])), '+/', '-_'), '='); + $payload = rtrim(strtr(base64_encode(json_encode([ + 'iss' => $credentials['client_email'], + 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', + 'aud' => 'https://oauth2.googleapis.com/token', + 'iat' => $now, + 'exp' => $now + 3600, + ])), '+/', '-_'), '='); + + openssl_sign("$header.$payload", $sig, $credentials['private_key'], OPENSSL_ALGO_SHA256); + $jwt = "$header.$payload." . rtrim(strtr(base64_encode($sig), '+/', '-_'), '='); + + $resp = \Illuminate\Support\Facades\Http::asForm()->withoutVerifying()->post('https://oauth2.googleapis.com/token', [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt, + ]); + + return $resp->json('access_token'); + } catch (\Exception $e) { + Log::error('FCM Token error: ' . $e->getMessage()); + return null; + } + } +} diff --git a/app/Services/PresensiService.php b/app/Services/PresensiService.php index 45170f32..28e14da8 100644 --- a/app/Services/PresensiService.php +++ b/app/Services/PresensiService.php @@ -193,8 +193,23 @@ public function absenPulang($user, $request) $dynamicSchedule = $this->getDynamicSchedule($user->id, $hariIni); - $jamPulangEfektif = $dynamicSchedule['jam_pulang']; - $statusJadwal = $dynamicSchedule['status_jadwal']; + if (!$dynamicSchedule) { + $jadwalFallback = JadwalKerja::with('shift') + ->where('id_user', $user->id) + ->where('tanggal', $hariIni) + ->first(); + + if (!$jadwalFallback) { + throw new \Exception('Jadwal kerja tidak ditemukan.', 404); + } + + $jamPulangEfektif = $jadwalFallback->shift->jam_selesai; + $statusJadwal = 'Normal'; + } else { + $jamPulangEfektif = $dynamicSchedule['jam_pulang']; + $statusJadwal = $dynamicSchedule['status_jadwal']; + } + $jamPulangTarget = Carbon::parse($jamPulangEfektif, 'Asia/Jakarta'); $isPulangAwal = $jamSekarang->lessThan($jamPulangTarget); diff --git a/database/base_schema.sql b/database/base_schema.sql new file mode 100644 index 0000000000000000000000000000000000000000..87ba3fcc18d11cfdb5086769ca34335ca9319422 GIT binary patch literal 73006 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 + $table->unsignedBigInteger('id_user'); + $table->string('judul'); + $table->text('pesan'); + $table->string('tipe', 50); + $table->json('data')->nullable(); + $table->boolean('is_read')->default(false); + $table->timestamps(); + + $table->foreign('id_user')->references('id')->on('users')->onDelete('cascade'); + $table->index(['id_user', 'is_read']); + $table->index(['id_user', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifikasi'); + } +}; diff --git a/database/migrations_archive/2026_03_27_153516_create_device_tokens_table.php b/database/migrations_archive/2026_03_27_153516_create_device_tokens_table.php new file mode 100644 index 00000000..28f29104 --- /dev/null +++ b/database/migrations_archive/2026_03_27_153516_create_device_tokens_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('id_user'); + $table->string('fcm_token')->unique(); + $table->string('device_type', 20)->default('android'); + $table->timestamps(); + + $table->foreign('id_user')->references('id')->on('users')->onDelete('cascade'); + $table->index('id_user'); + }); + } + + public function down(): void + { + Schema::dropIfExists('device_tokens'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index f302e33b..d4c08fec 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -19,6 +19,7 @@ public function run(): void PresensiStatusSeeder::class, JenisIzinSeeder::class, StatusPengajuanSeeder::class, + JenisPenguranganSeeder::class, // 4. Data dummy (opsional, comment jika tidak diperlukan di production) // PresensiSeeder::class, diff --git a/database/seeders/JenisPenguranganSeeder.php b/database/seeders/JenisPenguranganSeeder.php index 55554431..c5049a2f 100644 --- a/database/seeders/JenisPenguranganSeeder.php +++ b/database/seeders/JenisPenguranganSeeder.php @@ -11,7 +11,10 @@ public function run(): void { DB::table('jenis_pengurangan')->insertOrIgnore([ ['id_pengurangan' => 1, 'nama_pengurangan' => 'Datang Terlambat'], - ['id_pengurangan' => 2, 'nama_pengurangan' => 'Pulang Cepat'], + ['id_pengurangan' => 2, 'nama_pengurangan' => 'Pulang Cepat (Biasa)'], + ['id_pengurangan' => 3, 'nama_pengurangan' => 'Tidak Hadir (Alpha)'], + ['id_pengurangan' => 4, 'nama_pengurangan' => 'Masuk Siang (Poin)'], + ['id_pengurangan' => 5, 'nama_pengurangan' => 'Pulang Cepat (Poin)'], ]); } } diff --git a/database/seeders/StatusSeeder.php b/database/seeders/StatusSeeder.php index 0f0bcac4..c4beb2b9 100644 --- a/database/seeders/StatusSeeder.php +++ b/database/seeders/StatusSeeder.php @@ -5,29 +5,30 @@ use Illuminate\Database\Seeder; use Illuminate\Support\Facades\DB; +/** + * StatusSeeder - versi aman menggunakan insertOrIgnore. + * Seeder ini merupakan alternatif dari PresensiStatusSeeder. + * Tidak dipanggil di DatabaseSeeder karena PresensiStatusSeeder sudah menangani ini. + * Dipertahankan sebagai referensi. + */ class StatusSeeder extends Seeder { public function run(): void { - - DB::table('status_presensi')->delete(); - - DB::table('status_presensi')->insert([ + DB::table('status_presensi')->insertOrIgnore([ ['id_status' => 1, 'nama_status' => 'Tepat Waktu', 'created_at' => now(), 'updated_at' => now()], - ['id_status' => 2, 'nama_status' => 'Terlambat', 'created_at' => now(), 'updated_at' => now()], - ['id_status' => 3, 'nama_status' => 'Izin', 'created_at' => now(), 'updated_at' => now()], - ['id_status' => 4, 'nama_status' => 'Sakit', 'created_at' => now(), 'updated_at' => now()], - ['id_status' => 5, 'nama_status' => 'Alpha', 'created_at' => now(), 'updated_at' => now()], + ['id_status' => 2, 'nama_status' => 'Terlambat', 'created_at' => now(), 'updated_at' => now()], + ['id_status' => 3, 'nama_status' => 'Izin', 'created_at' => now(), 'updated_at' => now()], + ['id_status' => 4, 'nama_status' => 'Sakit', 'created_at' => now(), 'updated_at' => now()], + ['id_status' => 5, 'nama_status' => 'Alpha', 'created_at' => now(), 'updated_at' => now()], ]); - DB::table('status_validasi_presensi')->delete(); - - DB::table('status_validasi_presensi')->insert([ - ['id_status' => 1, 'nama_status' => 'Valid', 'created_at' => now(), 'updated_at' => now()], + DB::table('status_validasi_presensi')->insertOrIgnore([ + ['id_status' => 1, 'nama_status' => 'Valid', 'created_at' => now(), 'updated_at' => now()], ['id_status' => 2, 'nama_status' => 'Pending', 'created_at' => now(), 'updated_at' => now()], ['id_status' => 3, 'nama_status' => 'Ditolak', 'created_at' => now(), 'updated_at' => now()], ]); - $this->command->info('Data Master Status berhasil dibuat!'); + $this->command->info('StatusSeeder: data status berhasil dicek/diisi.'); } } diff --git a/public/js/notifications.js b/public/js/notifications.js index b64bbb47..c8854ad6 100644 --- a/public/js/notifications.js +++ b/public/js/notifications.js @@ -72,8 +72,128 @@ flashSuccess.setAttribute('data-message', ''); } } + + // ==== Polling Badge Notifikasi ==== + function updateNotifBadge() { + const badge = document.getElementById('notif-badge'); + if (!badge) return; + + fetch('/notifikasi/unread-count', { + headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } + }) + .then(r => r.json()) + .then(data => { + const count = data.count ?? 0; + if (count > 0) { + badge.textContent = count > 99 ? '99+' : count; + badge.classList.remove('hidden'); + } else { + badge.classList.add('hidden'); + } + }) + .catch(() => {}); + } + + updateNotifBadge(); + setInterval(updateNotifBadge, 30000); })(); +// ==== Alpine.js Dropdown Notifikasi ==== +function notifDropdown() { + return { + open: false, + items: [], + unreadCount: 0, + loading: false, + + toggle() { + this.open = !this.open; + if (this.open) this.loadRecent(); + }, + + loadRecent() { + this.loading = true; + fetch('/notifikasi/recent', { + headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } + }) + .then(r => r.json()) + .then(data => { + this.items = data.data || []; + this.unreadCount = this.items.filter(i => !i.is_read).length; + this.loading = false; + }) + .catch(() => { this.loading = false; }); + }, + + readItem(item) { + if (!item.is_read) { + fetch('/notifikasi/' + item.id + '/read', { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json' + } + }).then(() => { + item.is_read = true; + this.unreadCount = Math.max(0, this.unreadCount - 1); + updateBadgeUI(this.unreadCount); + }); + } + }, + + markAllRead() { + fetch('/notifikasi/read-all', { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json' + } + }).then(() => { + this.items.forEach(i => i.is_read = true); + this.unreadCount = 0; + updateBadgeUI(0); + }); + }, + + getIconClass(tipe) { + var map = { + 'lembur': 'bg-amber-100 text-amber-600', + 'presensi': 'bg-green-100 text-green-600', + 'pengumuman': 'bg-blue-100 text-blue-600', + 'surat_izin': 'bg-purple-100 text-purple-600', + 'izin': 'bg-purple-100 text-purple-600', + 'poin': 'bg-rose-100 text-rose-600', + }; + return map[tipe] || 'bg-slate-100 text-slate-600'; + }, + + getIcon(tipe) { + var icons = { + 'lembur': '', + 'presensi': '', + 'pengumuman': '', + 'surat_izin': '', + 'izin': '', + 'poin': '', + }; + return icons[tipe] || ''; + } + }; +} + +function updateBadgeUI(count) { + var badge = document.getElementById('notif-badge'); + if (!badge) return; + if (count > 0) { + badge.textContent = count > 99 ? '99+' : count; + badge.classList.remove('hidden'); + } else { + badge.classList.add('hidden'); + } +} + function confirmDelete(id) { Swal.fire({ title: 'Apakah Anda yakin?', diff --git a/resources/views/components/input.blade.php b/resources/views/components/input.blade.php index 82c322b5..6149869f 100644 --- a/resources/views/components/input.blade.php +++ b/resources/views/components/input.blade.php @@ -1,4 +1,4 @@ -@props(['label' => null, 'name', 'type' => 'text', 'placeholder' => '', 'value' => '', 'disabled' => false]) +@props(['label' => null, 'name', 'type' => 'text', 'placeholder' => '', 'value' => '', 'disabled' => false]) @php $id = $attributes->get('id', $name); @@ -19,7 +19,7 @@ placeholder="{{ $placeholder }}" {{ $disabled ? 'disabled' : '' }} {!! $attributes->except(['id', 'name', 'type', 'value', 'placeholder', 'class'])->merge([ - 'class' => 'w-full px-4 py-2 border border-slate-200 bg-slate-50 rounded-xl focus:bg-white focus:ring-4 focus:ring-[#130F26]/10 focus:border-[#130F26] outline-none transition-all duration-300 placeholder-slate-400 disabled:bg-slate-100 disabled:text-slate-500 h-11' + 'class' => 'w-full block px-4 py-2 border border-slate-200 bg-slate-50 rounded-xl focus:bg-white focus:ring-4 focus:ring-[#130F26]/10 focus:border-[#130F26] outline-none transition-all duration-300 placeholder-slate-400 disabled:bg-slate-100 disabled:text-slate-500 h-11' ]) !!} > diff --git a/resources/views/components/select.blade.php b/resources/views/components/select.blade.php index 1706667e..f22fea03 100644 --- a/resources/views/components/select.blade.php +++ b/resources/views/components/select.blade.php @@ -1,10 +1,10 @@ -@props(['label' => null, 'name' => null, 'value' => '']) +@props(['label' => null, 'name' => null, 'value' => '']) @php $id = $attributes->get('id', $name); @endphp -
only('class')->merge(['class' => 'relative w-full']) }}> +
only('class')->merge(['class' => 'relative w-full mb-4']) }}> @if($label)
+ +
+ + +
-

Hati-hati! Tindakan ini akan menghapus semua jadwal shift pegawai pada rentang tanggal yang dipilih dan tidak dapat dikembalikan.

-
- - +
+
+
@@ -320,6 +458,7 @@ function initCalendar() { var filterKantor = document.getElementById('filter_kantor'); var filterDivisi = document.getElementById('filter_divisi'); + var filterNama = document.getElementById('filter_nama'); var loading = document.getElementById('loading'); if (window.jadwalCalendar) { @@ -336,57 +475,94 @@ function initCalendar() { center: 'title', right: 'dayGridMonth' }, - /* eventDidMount: function (info) { - $(info.el).tooltip({ - title: info.event.extendedProps.description, - placement: 'top', - trigger: 'hover', - container: 'body' - }); - }, */ + buttonText: { + today: 'Hari Ini', + month: 'Bulan', + list: 'Minggu' + }, + eventDidMount: function (info) { + if (info.event.extendedProps.description) { + let contentHtml = ` +
${info.event.extendedProps.nama_user}
+
${info.event.extendedProps.kantor} • ${info.event.extendedProps.jabatan}
+
+ ${info.event.extendedProps.description.replace(/\n/g, '
')} +
+ `; + + if (info.event.extendedProps.is_holiday) { + contentHtml = ` +
Libur Nasional
+
${info.event.extendedProps.keterangan}
+ `; + } + + tippy(info.el, { + content: contentHtml, + allowHTML: true, + animation: 'scale', + theme: 'light-border', + placement: 'top', + zIndex: 9999 + }); + } + }, eventContent: function (arg) { let contentEl = document.createElement('div'); + let classes = "bg-slate-50 text-slate-700 border-slate-200 hover:bg-slate-100"; + let bg = arg.event.backgroundColor; + + if (bg === '#10b981') classes = "bg-emerald-50 text-emerald-800 border-emerald-200 hover:bg-emerald-100"; + else if (bg === '#3b82f6') classes = "bg-blue-50 text-blue-800 border-blue-200 hover:bg-blue-100"; + else if (bg === '#f59e0b') classes = "bg-amber-50 text-amber-800 border-amber-200 hover:bg-amber-100"; + else if (bg === '#6b7280') classes = "bg-slate-100 text-slate-700 border-slate-200 hover:bg-slate-200"; + else if (bg === '#ef4444') classes = "bg-red-50 text-red-800 border-red-200 hover:bg-red-100"; + + let isPoin = arg.event.extendedProps.is_poin; + if (isPoin) classes = "bg-orange-50 text-orange-800 border-orange-200 hover:bg-orange-100"; + if (arg.event.extendedProps.is_holiday) { contentEl.innerHTML = ` -
- ${arg.event.title} -
- `; +
+ + ${arg.event.title} +
+ `; return { domNodes: [contentEl] }; } let jamMulai = arg.event.extendedProps.jam_mulai ? arg.event.extendedProps.jam_mulai.substring(0, 5) : '??:??'; let jamSelesai = arg.event.extendedProps.jam_selesai ? arg.event.extendedProps.jam_selesai.substring(0, 5) : '??:??'; - let isPoin = arg.event.extendedProps.is_poin; - let originalMulai = arg.event.extendedProps.original_jam_mulai ? arg.event.extendedProps.original_jam_mulai.substring(0, 5) : jamMulai; - let originalSelesai = arg.event.extendedProps.original_jam_selesai ? arg.event.extendedProps.original_jam_selesai.substring(0, 5) : jamSelesai; - - let bgColor = isPoin ? '#fff7ed' : 'transparent'; - let textColor = isPoin ? '#c2410c' : 'inherit'; - let borderLeft = isPoin ? '3px solid #f97316' : ''; - let titleStyle = isPoin ? 'font-weight:bold; color: #9a3412;' : 'font-weight:600;'; - let icon = isPoin ? '⚠️' : ''; - - let timeHtml = `${jamMulai} - ${jamSelesai}`; - - if (isPoin) { - timeHtml = ` -
${originalMulai} - ${originalSelesai}
-
${jamMulai} - ${jamSelesai} ${icon}
- `; + if (arg.view.type === 'listWeek') { + contentEl.innerHTML = ` +
+
+
+ ${arg.event.extendedProps.nama_user} + (${arg.event.extendedProps.nama_shift}) +
+
${jamMulai} - ${jamSelesai} ${isPoin?'⚠️':''}
+
${arg.event.extendedProps.kantor}
+
+ `; } else { - timeHtml = `
${jamMulai} - ${jamSelesai}
`; + // Month View - Premium Badge Design + contentEl.innerHTML = ` +
+ ${isPoin ? '
⚠️
' : ''} +
+ ${arg.event.extendedProps.nama_user.split(' ')[0]} +
+
+ + ${jamMulai} - ${jamSelesai} +
+
+ `; } - contentEl.innerHTML = ` -
-
${arg.event.title}
- ${timeHtml} -
- `; - return { domNodes: [contentEl] }; }, events: function (info, successCallback, failureCallback) { @@ -406,8 +582,9 @@ function initCalendar() { const params = new URLSearchParams({ start: info.startStr, end: info.endStr, - filter_kantor: filterKantor.value, - filter_divisi: filterDivisi.value + filter_kantor: filterKantor ? filterKantor.value : '', + filter_divisi: filterDivisi ? filterDivisi.value : '', + filter_nama: filterNama ? filterNama.value : '' }); fetch("{{ route('jadwal.events') }}?" + params.toString()) @@ -458,9 +635,9 @@ function initCalendar() { dayMaxEvents: 2, moreLinkClick: function (info) { const events = info.allSegs.map(seg => seg.event); - const date = info.date; - - openDetailHarian(date, events); + + // Alih-alih modal popover, isi tabel harian + populateDailyTable(info.date, events); return "function"; }, moreLinkText: 'lainnya', @@ -478,6 +655,16 @@ function initCalendar() { [filterKantor, filterDivisi].forEach(el => { if (el) el.addEventListener('change', () => window.jadwalCalendar.refetchEvents()); }); + + if (filterNama) { + let debounceTimer; + filterNama.addEventListener('keyup', () => { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + if (window.jadwalCalendar) window.jadwalCalendar.refetchEvents(); + }, 500); + }); + } } if (!window.__jadwalEventAttached) { diff --git a/resources/views/kantor/index.blade.php b/resources/views/kantor/index.blade.php index 0eea04a0..3054cb30 100644 --- a/resources/views/kantor/index.blade.php +++ b/resources/views/kantor/index.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.app') +@extends('layouts.app') @section('title', 'Data Kantor') @@ -115,17 +115,17 @@ class="p-2 bg-slate-100 text-slate-600 rounded-lg hover:bg-slate-200 transition"
-
- - +
+
+
- +
-
- - - +
+
+
+
@@ -143,17 +143,17 @@ class="px-4 py-2 text-sm bg-primary text-white rounded-xl hover:bg-primary/90">S
-
- - +
+
+
- +
-
- - - +
+
+
+
diff --git a/resources/views/laporan-lembur/index.blade.php b/resources/views/laporan-lembur/index.blade.php index f45a67cf..3b94d55f 100644 --- a/resources/views/laporan-lembur/index.blade.php +++ b/resources/views/laporan-lembur/index.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.app') +@extends('layouts.app') @section('title', 'Laporan Lembur') @@ -21,7 +21,7 @@
- + @for($i=1; $i<=12; $i++) @php $val = str_pad($i, 2, '0', STR_PAD_LEFT); @endphp
- + @for($i=date('Y'); $i>=2023; $i--) @endfor @@ -40,7 +40,7 @@
- + @foreach($divisiList as $div)
-
- +
+ Filter
diff --git a/resources/views/laporan/index.blade.php b/resources/views/laporan/index.blade.php index eed3eef1..483c680f 100644 --- a/resources/views/laporan/index.blade.php +++ b/resources/views/laporan/index.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.app') +@extends('layouts.app') @section('title', 'Laporan Bulanan') @@ -12,7 +12,7 @@
- + @for ($i = 1; $i <= 12; $i++)
- + @for ($y = date('Y'); $y >= date('Y') - 2; $y--) @endfor
- + @foreach($divisiList as $div)
-
- +
+ @@ -47,7 +47,7 @@ class="flex flex-wrap gap-4 items-end mb-6 border-b border-slate-100 pb-6"> Tampilkan + class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 hover:text-emerald-800 border border-emerald-200 rounded-lg text-sm font-semibold transition-all h-[44px]"> @@ -57,7 +57,7 @@ class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-50 text-emerald-700 h + class="inline-flex items-center gap-2 px-4 py-2 bg-red-50 text-red-700 hover:bg-red-100 hover:text-red-800 border border-red-200 rounded-lg text-sm font-semibold transition-all h-[44px]"> diff --git a/resources/views/laporan/izin.blade.php b/resources/views/laporan/izin.blade.php index b96daf94..5223c371 100644 --- a/resources/views/laporan/izin.blade.php +++ b/resources/views/laporan/izin.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.app') +@extends('layouts.app') @section('title', 'Laporan Izin & Cuti') @@ -8,7 +8,9 @@
- + Export Excel @@ -21,7 +23,7 @@
- + @for($i=1; $i<=12; $i++) @php $val = str_pad($i, 2, '0', STR_PAD_LEFT); @endphp
- + @for($i=date('Y'); $i>=2023; $i--) @endfor @@ -40,7 +42,7 @@
- + @foreach($divisiList as $div)
-
- +
+ Filter
@@ -102,8 +104,8 @@ class="!mb-0" oninput="if(this.value.length === 0) this.form.submit()" /> @php $color = 'yellow'; $label = 'Menunggu'; - if($izin->status == 'disetujui') { $color = 'green'; $label = 'Disetujui'; } - elseif($izin->status == 'ditolak') { $color = 'red'; $label = 'Ditolak'; } + if($izin->id_status == 2) { $color = 'green'; $label = 'Disetujui'; } + elseif($izin->id_status == 3) { $color = 'red'; $label = 'Ditolak'; } @endphp {{ $label }} diff --git a/resources/views/laporan/pdf.blade.php b/resources/views/laporan/pdf.blade.php index c9df04a9..1aac0815 100644 --- a/resources/views/laporan/pdf.blade.php +++ b/resources/views/laporan/pdf.blade.php @@ -1,65 +1,364 @@ - - + + - Laporan Presensi Pegawai + Laporan Rekapitulasi Presensi -
-

LAPORAN REKAPITULASI PRESENSI PEGAWAI

-

MPG HRIS ENTERPRISE SYSTEM

-

Periode: {{ \Carbon\Carbon::create()->month((int)$bulan)->translatedFormat('F') }} {{ $tahun }}

+ {{-- KOP SURAT --}} +
+
+
MPG HRIS Enterprise System
+
Human Resource Information System
+
+
+
Nomor Dokumen
+
LAP-{{ str_pad($bulan, 2, '0', STR_PAD_LEFT) }}/{{ $tahun }}/PRES
+
- + {{-- JUDUL --}} +
+

Laporan Rekapitulasi Presensi Pegawai

+ + Periode: {{ \Carbon\Carbon::create()->month((int)$bulan)->translatedFormat('F') }} {{ $tahun }} + +
+ + {{-- META INFO --}} +
+ + + + + + + + + + + + + + + + + + +
Tanggal Cetak:{{ \Carbon\Carbon::now()->translatedFormat('d F Y, H:i') }} WIBTotal Pegawai:{{ count($rekap) }} Orang
Dicetak Oleh:HRD DepartmentStatus Dokumen:Resmi / Official
+ + {{-- RINGKASAN STATISTIK --}} + @php + $totalHadir = collect($rekap)->sum('hadir'); + $totalIzin = collect($rekap)->sum('izin'); + $totalSakit = collect($rekap)->sum('sakit'); + $totalAlpha = collect($rekap)->sum('alpha'); + $totalTerlambat= collect($rekap)->sum('terlambat'); + $totalPoin = collect($rekap)->sum('poin_lembur'); + @endphp +
+
+
Total Hadir
+
{{ $totalHadir }}
+
+
+
Total Izin
+
{{ $totalIzin }}
+
+
+
Total Sakit
+
{{ $totalSakit }}
+
+
+
Total Alpha
+
{{ $totalAlpha }}
+
+
+
Terlambat
+
{{ $totalTerlambat }}
+
+
+
Poin Lembur
+
{{ $totalPoin }}
+
+
+ + {{-- TABEL DATA --}} + - - - - - - - - + + + + + + + + + + + @foreach($rekap as $index => $row) - + - - - - - - + + + + + + + + + @endforeach
NoNIKNama LengkapHadirIzin/CutiSakitAlphaTelatNoNIK / IDNama LengkapDivisiJabatanHadirIzinSakitAlphaTerlambatPoin
{{ $index + 1 }}{{ $index + 1 }} {{ $row['user']->nik ?? '-' }}{{ $row['user']->nama_lengkap }}{{ $row['hadir'] }} x{{ $row['izin'] }} x{{ $row['sakit'] }} x{{ $row['alpha'] }} x{{ $row['terlambat'] }} x{{ $row['user']->nama_lengkap }}{{ $row['user']->divisi->nama_divisi ?? '-' }}{{ $row['user']->jabatan->nama_jabatan ?? '-' }} + {{ $row['hadir'] }} + + @if($row['izin'] > 0) + {{ $row['izin'] }} + @else + + @endif + + @if($row['sakit'] > 0) + {{ $row['sakit'] }} + @else + + @endif + + @if($row['alpha'] > 0) + {{ $row['alpha'] }} + @else + + @endif + + @if($row['terlambat'] > 0) + {{ $row['terlambat'] }}x + @else + + @endif + + @if($row['poin_lembur'] > 0) + {{ $row['poin_lembur'] }} + @else + + @endif +
-