notifikasi +seeder +migration+penyesuaian

This commit is contained in:
jouel88 2026-03-28 15:37:41 +07:00
parent d5cb370890
commit 1f881a6c69
85 changed files with 2651 additions and 336 deletions

1
.gitignore vendored
View File

@ -18,6 +18,7 @@
/public/storage /public/storage
/storage/*.key /storage/*.key
/storage/pail /storage/pail
/storage/app/firebase/
/vendor /vendor
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml

View File

@ -28,20 +28,29 @@ class AutoPresensiJob extends Command
public function handle() 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) // Dapatkan jadwal H-1
->with('user') $jadwalH1 = JadwalKerja::where('tanggal', $hariIni)
->get() ->with(['shift', 'user'])
->pluck('id_user'); ->get();
$usersPresensi = Presensi::where('tanggal', $hariIni)->pluck('id_user'); // Dapatkan jadwal H-2
$jadwalH2 = JadwalKerja::where('tanggal', $hariLusa)
->with(['shift', 'user'])
->get();
$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; $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([ Presensi::create([
'id_user' => $userId, 'id_user' => $userId,
'tanggal' => $hariIni, '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); $dateLimit = Carbon::now()->subMonths(3);
$oldPresensi = Presensi::where('tanggal', '<', $dateLimit)->get(); $oldPresensi = Presensi::where('tanggal', '<', $dateLimit)->get();

View File

@ -0,0 +1,153 @@
<?php
namespace App\Exports;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
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\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use Carbon\Carbon;
class LaporanIzinExport implements FromCollection, WithHeadings, WithMapping, WithStyles, WithColumnWidths, WithTitle, WithEvents
{
protected $data;
protected $bulan;
protected $tahun;
public function __construct($data, $bulan, $tahun)
{
$this->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);
},
];
}
}

View File

@ -7,9 +7,17 @@
use Maatwebsite\Excel\Concerns\WithMapping; use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\ShouldAutoSize; use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithStyles; 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\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 $pegawai;
protected $rekap; protected $rekap;
@ -24,6 +32,11 @@ public function __construct($pegawai, array $rekap, $bulan, $tahun)
$this->tahun = $tahun; $this->tahun = $tahun;
} }
public function title(): string
{
return 'Lembur ' . Carbon::create()->month($this->bulan)->translatedFormat('F Y');
}
public function collection() public function collection()
{ {
return collect($this->pegawai); return collect($this->pegawai);
@ -31,21 +44,25 @@ public function collection()
public function headings(): array public function headings(): array
{ {
$namaBulan = Carbon::create()->month($this->bulan)->translatedFormat('F');
return [ return [
['LAPORAN REKAPITULASI LEMBUR PEGAWAI'], ['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', 'No',
'NIK', 'NIK / ID Karyawan',
'Nama Lengkap', 'Nama Lengkap',
'Divisi / Dept', 'Divisi / Dept',
'Jabatan', 'Jabatan',
'Jumlah Hari Lembur', 'Jumlah Hari Lembur',
'Total Waktu (Menit)', 'Total Waktu (Menit)',
'Durasi Jam', 'Durasi (Jam)',
'Poin Lembur Diperoleh' 'Poin Lembur Diperoleh',
] ],
]; ];
} }
@ -67,20 +84,137 @@ public function map($row): array
$row->nama_lengkap, $row->nama_lengkap,
$row->divisi->nama_divisi ?? '-', $row->divisi->nama_divisi ?? '-',
$row->jabatan->nama_jabatan ?? '-', $row->jabatan->nama_jabatan ?? '-',
$dataRekap['jumlah_hari'] . ' Hari', $dataRekap['jumlah_hari'],
$dataRekap['total_menit'] . ' Menit', $dataRekap['total_menit'],
$dataRekap['format_jam'], $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) public function styles(Worksheet $sheet)
{ {
return [ 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]], public function registerEvents(): array
2 => ['font' => ['italic' => true]], {
4 => ['font' => ['bold' => true], 'fill' => ['fillType' => 'solid', 'color' => ['rgb' => 'E6F4EA']]], 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}");
},
]; ];
} }
} }

View File

@ -7,9 +7,17 @@
use Maatwebsite\Excel\Concerns\WithMapping; use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\ShouldAutoSize; use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithStyles; 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\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 $rekap;
protected $bulan; protected $bulan;
@ -22,6 +30,11 @@ public function __construct(array $rekap, $bulan, $tahun)
$this->tahun = $tahun; $this->tahun = $tahun;
} }
public function title(): string
{
return 'Presensi ' . Carbon::createFromDate($this->tahun, (int) $this->bulan, 1)->translatedFormat('F Y');
}
public function collection() public function collection()
{ {
return collect($this->rekap); return collect($this->rekap);
@ -29,23 +42,27 @@ public function collection()
public function headings(): array public function headings(): array
{ {
$namaBulan = Carbon::createFromDate($this->tahun, (int) $this->bulan, 1)->translatedFormat('F');
return [ return [
['LAPORAN REKAPITULASI PRESENSI PEGAWAI'], ['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', 'No',
'NIK', 'NIK / ID Karyawan',
'Nama Lengkap', 'Nama Lengkap',
'Jabatan', 'Jabatan',
'Divisi', 'Divisi',
'Hadir', 'Hadir (Hari)',
'Izin / Cuti', 'Izin / Cuti (Hari)',
'Sakit', 'Sakit (Hari)',
'Alpha (Mangkir)', 'Alpha / Mangkir (Hari)',
'Terlambat', 'Terlambat (Kali)',
'Poin Lembur Diperoleh' 'Poin Lembur',
] ],
]; ];
} }
@ -60,22 +77,168 @@ public function map($row): array
$row['user']->nama_lengkap, $row['user']->nama_lengkap,
$row['user']->jabatan->nama_jabatan ?? '-', $row['user']->jabatan->nama_jabatan ?? '-',
$row['user']->divisi->nama_divisi ?? '-', $row['user']->divisi->nama_divisi ?? '-',
$row['hadir'] . ' Hari', $row['hadir'],
$row['izin'] . ' Hari', $row['izin'],
$row['sakit'] . ' Hari', $row['sakit'],
$row['alpha'] . ' Hari', $row['alpha'],
$row['terlambat'] . ' Kali', $row['terlambat'],
$row['poin_lembur'] . ' Poin' $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) public function styles(Worksheet $sheet)
{ {
return [ $lastRow = count($this->rekap) + 7;
1 => ['font' => ['bold' => true, 'size' => 14]], return [
2 => ['font' => ['italic' => true]], // Judul utama
4 => ['font' => ['bold' => true], 'fill' => ['fillType' => 'solid', 'color' => ['rgb' => 'E2E8F0']]], 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 FK (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}");
},
]; ];
} }
} }

View File

@ -1,21 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Helpers\ApiResponse;
use App\Models\JenisKompensasi;
class KompensasiController extends Controller
{
public function index()
{
try {
$kompensasi = JenisKompensasi::all();
return ApiResponse::success($kompensasi);
} catch (\Exception $e) {
return ApiResponse::error('Gagal memuat jenis kompensasi: ' . $e->getMessage(), 500);
}
}
}

View File

@ -5,6 +5,7 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Helpers\ApiResponse; use App\Helpers\ApiResponse;
use App\Services\LemburService; use App\Services\LemburService;
use App\Services\NotifikasiService;
use App\Http\Requests\StoreLemburRequest; use App\Http\Requests\StoreLemburRequest;
use App\Models\Lembur; use App\Models\Lembur;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -26,6 +27,13 @@ public function store(StoreLemburRequest $request)
$this->lemburService->createLembur($user, $request->validated()); $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); return ApiResponse::success(null, 'Pengajuan lembur berhasil dikirim.', 201);
} catch (\Exception $e) { } catch (\Exception $e) {

View File

@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Helpers\ApiResponse;
use App\Models\Notifikasi;
use App\Models\DeviceToken;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class NotifikasiApiController extends Controller
{
public function index(Request $request)
{
$notifikasi = Notifikasi::forUser(Auth::id())
->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.');
}
}

View File

@ -152,7 +152,9 @@ public function history(Request $request)
$statusMasuk = '-'; $statusMasuk = '-';
if ($item->jam_masuk) { if ($item->jam_masuk) {
if ($item->waktu_terlambat) { if ($item->id_status == 5) {
$statusMasuk = 'Alpha (Batal)';
} elseif ($item->waktu_terlambat) {
$statusMasuk = 'Terlambat'; $statusMasuk = 'Terlambat';
} elseif ($item->waktu_masuk_awal) { } elseif ($item->waktu_masuk_awal) {
$statusMasuk = 'Datang Awal'; $statusMasuk = 'Datang Awal';
@ -196,6 +198,8 @@ public function history(Request $request)
} catch (\Exception $e) { } catch (\Exception $e) {
$totalJam = '-'; $totalJam = '-';
} }
} elseif ($item->jam_masuk && !$item->jam_pulang && $item->id_status == 5) {
$totalJam = '0j 0m (Batal)';
} }
return [ return [

View File

@ -8,6 +8,7 @@
use App\Models\JenisIzin; use App\Models\JenisIzin;
use App\Models\SuratIzin; use App\Models\SuratIzin;
use App\Models\TandaTangan; use App\Models\TandaTangan;
use App\Services\NotifikasiService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -29,6 +30,11 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request)
$jenisIzin = JenisIzin::find($request->id_jenis_izin); $jenisIzin = JenisIzin::find($request->id_jenis_izin);
if ($jenisIzin && $jenisIzin->nama_izin == 'Cuti') { 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); $tanggalMulai = Carbon::parse($request->tanggal_mulai);
$minDate = Carbon::now()->addDays(7)->startOfDay(); $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(); DB::beginTransaction();
$path = null; $path = null;
@ -74,7 +108,7 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request)
$suratIzin = null; $suratIzin = null;
if ($jenisIzin && $jenisIzin->nama_izin == 'Cuti') { if ($jenisIzin && $jenisIzin->nama_izin == 'Cuti') {
$suratIzin = SuratIzin::create([ $suratIzin = SuratIzin::create([
'id_izin' => $submission->id_izin, 'id_izin' => $submission->getKey(),
'id_user' => $user->id, 'id_user' => $user->id,
'id_ttd_pengaju' => $ttdAktif?->id_tanda_tangan, 'id_ttd_pengaju' => $ttdAktif?->id_tanda_tangan,
'isi_surat' => $isiSurat, 'isi_surat' => $isiSurat,
@ -84,6 +118,14 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request)
DB::commit(); 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(); $responseData = $submission->toArray();
if ($suratIzin) { if ($suratIzin) {
$responseData['surat_izin'] = [ $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); 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(); DB::beginTransaction();
$data = $request->validated(); $data = $request->validated();

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\User; use App\Models\User;
use App\Services\NotifikasiService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class CutiController extends Controller class CutiController extends Controller
@ -40,6 +41,13 @@ public function update(Request $request, $id)
'sisa_cuti' => $request->sisa_cuti '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.'); 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 '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.'); return redirect()->back()->with('success', 'Sisa cuti semua pegawai berhasil di-reset menjadi ' . $request->jumlah_hari . ' hari.');
} }
} }

View File

@ -4,18 +4,39 @@
use App\Models\User; use App\Models\User;
use App\Models\DataWajah; use App\Models\DataWajah;
use App\Services\NotifikasiService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
class FaceApprovalController extends Controller class FaceApprovalController extends Controller
{ {
public function index() public function index(Request $request)
{ {
$users = User::whereHas('dataWajah', function($q) { $query = User::with(['jabatan', 'divisi', 'dataWajah']);
$q->where('is_verified', 0);
}) $status = $request->get('status', '');
->with(['jabatan', 'divisi'])
->get(); 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) { $users->each(function ($user) {
$storagePath = "face_datasets/{$user->id}"; $storagePath = "face_datasets/{$user->id}";
@ -36,7 +57,14 @@ public function index()
$user->face_photos = $fotoList; $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) public function approve($id)
@ -47,6 +75,13 @@ public function approve($id)
$user->dataWajah->update(['is_verified' => 1]); $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.'); 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); 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.'); 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) public function showPhoto($userId, $pose)
{ {
$extensions = ['jpg', 'jpeg', 'png']; $extensions = ['jpg', 'jpeg', 'png'];

View File

@ -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(); $jadwals = $query->get();
$poinUsed = PenggunaanPoin::with('jenisPengurangan') $poinUsed = PenggunaanPoin::with('jenisPengurangan')
@ -147,6 +153,7 @@ public function getEvents(Request $request)
'jabatan' => $jadwalItem->user?->jabatan?->nama_jabatan ?? '-', 'jabatan' => $jadwalItem->user?->jabatan?->nama_jabatan ?? '-',
'id_shift' => $jadwalItem->id_shift, 'id_shift' => $jadwalItem->id_shift,
'id_user' => $jadwalItem->id_user, 'id_user' => $jadwalItem->id_user,
'nik' => $jadwalItem->user?->nik ?? '-',
] ]
]; ];
} }
@ -223,6 +230,11 @@ public function store(Request $request)
'user_ids.*' => 'exists:users,id', '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(); $user = Auth::user();
$isGlobalAdmin = $user->isGlobalAdmin(); $isGlobalAdmin = $user->isGlobalAdmin();

View File

@ -9,6 +9,7 @@
use Carbon\Carbon; use Carbon\Carbon;
use Maatwebsite\Excel\Facades\Excel; use Maatwebsite\Excel\Facades\Excel;
use App\Exports\LaporanPresensiExport; use App\Exports\LaporanPresensiExport;
use App\Exports\LaporanIzinExport;
use Barryvdh\DomPDF\Facade\Pdf; use Barryvdh\DomPDF\Facade\Pdf;
class LaporanController extends Controller class LaporanController extends Controller
@ -55,6 +56,41 @@ public function cuti(Request $request)
return view('laporan.izin', compact('izinList', 'bulan', 'tahun', 'search', 'divisiId', 'divisiList')); 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) public function exportExcel(Request $request)
{ {
$bulan = $request->input('bulan', date('m')); $bulan = $request->input('bulan', date('m'));
@ -63,6 +99,10 @@ public function exportExcel(Request $request)
$rekap = $this->buildRekap($bulan, $tahun, $divisiId); $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"; $filename = "Laporan_Presensi_{$bulan}_{$tahun}.xlsx";
return Excel::download(new LaporanPresensiExport($rekap, $bulan, $tahun), $filename); return Excel::download(new LaporanPresensiExport($rekap, $bulan, $tahun), $filename);

View File

@ -53,7 +53,7 @@ public function index(Request $request)
'total_menit' => $totalMenit, 'total_menit' => $totalMenit,
'format_jam' => "{$jam}j {$menit}m", 'format_jam' => "{$jam}j {$menit}m",
'jumlah_hari' => $p->lemburs->count(), 'jumlah_hari' => $p->lemburs->count(),
'poin_diperoleh' => $totalMenit, 'poin_diperoleh' => $p->lemburs->sum('jumlah_poin'),
]; ];
} }
@ -81,6 +81,12 @@ public function exportExcel(Request $request)
$pegawai = $query->orderBy('nama_lengkap', 'asc')->get(); $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 = []; $rekap = [];
foreach ($pegawai as $p) { foreach ($pegawai as $p) {
$totalMenit = $p->lemburs->sum('durasi_menit'); $totalMenit = $p->lemburs->sum('durasi_menit');
@ -91,7 +97,7 @@ public function exportExcel(Request $request)
'total_menit' => $totalMenit, 'total_menit' => $totalMenit,
'format_jam' => "{$jam}j {$menit}m", 'format_jam' => "{$jam}j {$menit}m",
'jumlah_hari' => $p->lemburs->count(), 'jumlah_hari' => $p->lemburs->count(),
'poin_diperoleh' => $totalMenit, 'poin_diperoleh' => $p->lemburs->sum('jumlah_poin'),
]; ];
} }

View File

@ -6,6 +6,7 @@
use App\Models\User; use App\Models\User;
use App\Models\JenisKompensasi; use App\Models\JenisKompensasi;
use App\Services\LemburService; use App\Services\LemburService;
use App\Services\NotifikasiService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -74,9 +75,25 @@ public function update(Request $request, Lembur $lembur)
if ($lembur->id_kompensasi == 2) { if ($lembur->id_kompensasi == 2) {
$message .= ' Poin telah ditambahkan ke karyawan.'; $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 { } else {
$this->lemburService->reject($lembur, $request->alasan_penolakan); $this->lemburService->reject($lembur, $request->alasan_penolakan);
$message = 'Pengajuan lembur berhasil ditolak.'; $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); return redirect()->back()->with('success', $message);

View File

@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers;
use App\Models\Notifikasi;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class NotifikasiWebController extends Controller
{
public function index()
{
$notifikasi = Notifikasi::forUser(Auth::id())
->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]);
}
}

View File

@ -8,6 +8,7 @@
use App\Models\User; use App\Models\User;
use App\Models\SuratIzin; use App\Models\SuratIzin;
use App\Models\TandaTangan; use App\Models\TandaTangan;
use App\Services\NotifikasiService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; 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')); $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) { 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 { try {
DB::beginTransaction(); DB::beginTransaction();
@ -182,6 +211,15 @@ public function approve($id)
} }
DB::commit(); 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.'); return redirect()->back()->with('success', 'Izin disetujui & data presensi diperbarui.');
} catch (\Exception $e) { } catch (\Exception $e) {
@ -212,6 +250,14 @@ public function reject(Request $request, $id)
'id_status' => 3 '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.'); return redirect()->back()->with('success', 'Pengajuan izin berhasil ditolak.');
} }
} }

View File

@ -4,6 +4,7 @@
use App\Models\PenggunaanPoin; use App\Models\PenggunaanPoin;
use App\Services\PoinService; use App\Services\PoinService;
use App\Services\NotifikasiService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -39,7 +40,6 @@ public function update(Request $request, $id)
if ($request->action == 'approve') { if ($request->action == 'approve') {
DB::transaction(function () use ($penggunaan) { DB::transaction(function () use ($penggunaan) {
$penggunaan->update(['id_status' => 2]); $penggunaan->update(['id_status' => 2]);
$this->poinService->deductPoin( $this->poinService->deductPoin(
$penggunaan->id_user, $penggunaan->id_user,
$penggunaan->jumlah_poin, $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.'; $message = 'Pengajuan berhasil disetujui dan poin telah dipotong.';
} else { } else {
$penggunaan->update([ $penggunaan->update([
@ -54,6 +62,14 @@ public function update(Request $request, $id)
'alasan_penolakan' => $request->alasan_penolakan '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.'; $message = 'Pengajuan berhasil ditolak.';
} }

View File

@ -5,6 +5,7 @@
use App\Http\Requests\StorePengumumanRequest; use App\Http\Requests\StorePengumumanRequest;
use App\Http\Requests\UpdatePengumumanRequest; use App\Http\Requests\UpdatePengumumanRequest;
use App\Models\Pengumuman; use App\Models\Pengumuman;
use App\Services\NotifikasiService;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
class PengumumanController extends Controller class PengumumanController extends Controller
@ -31,7 +32,14 @@ public function store(StorePengumumanRequest $request)
$data = $request->validated(); $data = $request->validated();
$data['dibuat_oleh'] = Auth::id(); $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') return redirect()->route('pengumuman.index')
->with('success', 'Pengumuman berhasil ditambahkan.'); ->with('success', 'Pengumuman berhasil ditambahkan.');

View File

@ -32,7 +32,18 @@ public function store(Request $request)
public function sync(Request $request, $id_role) public function sync(Request $request, $id_role)
{ {
$role = Role::findOrFail($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.'); return back()->with('success', 'Hak akses role berhasil diperbarui.');
} }

View File

@ -5,6 +5,7 @@
use App\Models\Presensi; use App\Models\Presensi;
use App\Models\JadwalKerja; use App\Models\JadwalKerja;
use App\Models\Kantor; use App\Models\Kantor;
use App\Services\NotifikasiService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Requests\StorePresensiRequest; use App\Http\Requests\StorePresensiRequest;
use App\Http\Requests\StoreManualPresensiRequest; use App\Http\Requests\StoreManualPresensiRequest;
@ -155,6 +156,14 @@ public function approve($id)
$presensi->update(['id_validasi' => 1]); $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.'); return redirect()->back()->with('success', 'Presensi berhasil disetujui.');
} }
@ -169,6 +178,14 @@ public function reject($id)
$presensi->update(['id_validasi' => 3]); $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.'); return redirect()->back()->with('success', 'Presensi berhasil ditolak.');
} }

View File

@ -10,9 +10,11 @@
class RoleController extends Controller class RoleController extends Controller
{ {
const ROLE_KRITIS = ['manajer', 'manager', 'supervisor', 'hrd', 'super_admin', 'staff'];
public function index(Request $request) public function index(Request $request)
{ {
$query = Role::with(['permissions', 'users']); $query = Role::with(['permissions'])->withCount('users');
if ($request->filled('search')) { if ($request->filled('search')) {
$query->where('nama_role', 'like', '%' . $request->search . '%'); $query->where('nama_role', 'like', '%' . $request->search . '%');
@ -39,6 +41,12 @@ public function store(StoreRoleRequest $request)
public function update(UpdateRoleRequest $request, $id) public function update(UpdateRoleRequest $request, $id)
{ {
$role = Role::findOrFail($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()); $role->update($request->validated());
if ($request->has('id_permissions')) { if ($request->has('id_permissions')) {
@ -55,6 +63,10 @@ public function destroy($id)
{ {
$role = Role::findOrFail($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()) { if ($role->users()->exists()) {
return redirect()->back()->with('error', 'Role tidak bisa dihapus karena masih digunakan user.'); return redirect()->back()->with('error', 'Role tidak bisa dihapus karena masih digunakan user.');
} }

View File

@ -6,6 +6,7 @@
use App\Models\ApprovalSurat; use App\Models\ApprovalSurat;
use App\Models\TandaTangan; use App\Models\TandaTangan;
use App\Models\Presensi; use App\Models\Presensi;
use App\Services\NotifikasiService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -61,6 +62,7 @@ public function show($id)
} }
$canApprove = false; $canApprove = false;
$tahapApproval = null;
if ($surat->status_surat === 'menunggu_manajer' && ($user->roles->contains('nama_role', 'manajer') || $isGlobalAdmin)) { if ($surat->status_surat === 'menunggu_manajer' && ($user->roles->contains('nama_role', 'manajer') || $isGlobalAdmin)) {
$canApprove = true; $canApprove = true;
@ -128,7 +130,26 @@ public function approve(Request $request, $id)
DB::commit(); 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}"); return redirect()->back()->with('success', "Surat berhasil disetujui. Status: {$statusLabel}");
} catch (\Exception $e) { } catch (\Exception $e) {
@ -184,6 +205,14 @@ public function reject(Request $request, $id)
DB::commit(); 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.'); return redirect()->back()->with('success', 'Surat izin berhasil ditolak.');
} catch (\Exception $e) { } catch (\Exception $e) {

View File

@ -6,6 +6,7 @@
use App\Models\JadwalKerja; use App\Models\JadwalKerja;
use App\Models\RiwayatTukarShift; use App\Models\RiwayatTukarShift;
use App\Http\Requests\StoreTukarShiftRequest; use App\Http\Requests\StoreTukarShiftRequest;
use App\Services\NotifikasiService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth; 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.'); 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) $conflict1 = JadwalKerja::where('id_user', $request->id_user_1)
->where('tanggal', $jadwal2->tanggal) ->where('tanggal', $jadwal2->tanggal)
->where('id_jadwal', '!=', $jadwal1->id_jadwal) ->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 . ').'); 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) $poin1 = \App\Models\PenggunaanPoin::where('id_user', $request->id_user_1)
->where('tanggal_penggunaan', $jadwal2->tanggal) ->where('tanggal_penggunaan', $jadwal2->tanggal)
->where('id_status', 2) ->where('id_status', 2)
@ -99,6 +121,23 @@ public function store(StoreTukarShiftRequest $request)
DB::commit(); 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') return redirect()->route('tukar-shift.index')
->with('success', 'Berhasil menukar shift kerja untuk kedua pegawai tersebut.'); ->with('success', 'Berhasil menukar shift kerja untuk kedua pegawai tersebut.');

View File

@ -32,7 +32,14 @@ public function rules(): array
'nullable', 'nullable',
'date_format:H:i' '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' 'alasan_telat' => 'nullable|string|max:255'
]; ];
} }

View File

@ -15,6 +15,7 @@ public function rules(): array
{ {
return [ return [
'nama_lengkap' => ['required', 'string', 'max:255'], 'nama_lengkap' => ['required', 'string', 'max:255'],
'nik' => ['nullable', 'string', 'max:50', 'unique:users,nik'],
'email' => ['required', 'email', 'unique:users,email'], 'email' => ['required', 'email', 'unique:users,email'],
'no_telp' => ['nullable', 'string', 'max:20'], 'no_telp' => ['nullable', 'string', 'max:20'],
'alamat' => ['nullable', 'string'], 'alamat' => ['nullable', 'string'],

View File

@ -18,6 +18,7 @@ public function rules(): array
return [ return [
'nama_lengkap' => ['required', 'string', 'max:255'], 'nama_lengkap' => ['required', 'string', 'max:255'],
'nik' => ['nullable', 'string', 'max:50', Rule::unique('users', 'nik')->ignore($id)],
'email' => [ 'email' => [
'required', 'required',
'email', 'email',

View File

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class DeviceToken extends Model
{
protected $table = 'device_tokens';
protected $fillable = [
'id_user',
'fcm_token',
'device_type',
];
public function user()
{
return $this->belongsTo(User::class, 'id_user', 'id');
}
}

39
app/Models/Notifikasi.php Normal file
View File

@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Notifikasi extends Model
{
protected $table = 'notifikasi';
protected $fillable = [
'id_user',
'judul',
'pesan',
'tipe',
'data',
'is_read',
];
protected $casts = [
'data' => '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);
}
}

View File

@ -118,7 +118,7 @@ public function isGlobalAdmin(): bool
public function isSuperAdmin(): bool public function isSuperAdmin(): bool
{ {
return $this->roles->contains(function ($role) { 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'); return $this->hasMany(Lembur::class, 'id_user', 'id');
} }
public function notifikasi(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Notifikasi::class, 'id_user', 'id');
}
} }

View File

@ -16,7 +16,13 @@ public function createLembur($user, array $data)
if (!isset($data['durasi_menit'])) { if (!isset($data['durasi_menit'])) {
$start = Carbon::parse($data['jam_mulai']); $start = Carbon::parse($data['jam_mulai']);
$end = Carbon::parse($data['jam_selesai']); $end = Carbon::parse($data['jam_selesai']);
$diff = abs($start->diffInMinutes($end));
// Handle shift malam yang melewati tengah malam (jam selesai < jam mulai)
if ($end->lessThanOrEqualTo($start)) {
$end->addDay();
}
$diff = $start->diffInMinutes($end);
} else { } else {
$diff = $data['durasi_menit']; $diff = $data['durasi_menit'];
} }

View File

@ -0,0 +1,134 @@
<?php
namespace App\Services;
use App\Models\Notifikasi;
use App\Models\DeviceToken;
use App\Models\User;
use Illuminate\Support\Facades\Log;
class NotifikasiService
{
/**
* Kirim notifikasi ke satu user.
*/
public function kirim(int $idUser, string $tipe, string $judul, string $pesan, array $data = []): void
{
try {
Notifikasi::create([
'id_user' => $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;
}
}
}

View File

@ -193,8 +193,23 @@ public function absenPulang($user, $request)
$dynamicSchedule = $this->getDynamicSchedule($user->id, $hariIni); $dynamicSchedule = $this->getDynamicSchedule($user->id, $hariIni);
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']; $jamPulangEfektif = $dynamicSchedule['jam_pulang'];
$statusJadwal = $dynamicSchedule['status_jadwal']; $statusJadwal = $dynamicSchedule['status_jadwal'];
}
$jamPulangTarget = Carbon::parse($jamPulangEfektif, 'Asia/Jakarta'); $jamPulangTarget = Carbon::parse($jamPulangEfektif, 'Asia/Jakarta');
$isPulangAwal = $jamSekarang->lessThan($jamPulangTarget); $isPulangAwal = $jamSekarang->lessThan($jamPulangTarget);

BIN
database/base_schema.sql Normal file

Binary file not shown.

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
return new class extends Migration
{
public function up(): void
{
$path = database_path('base_schema.sql');
if (File::exists($path)) {
$sql = File::get($path);
// Remove UTF-8 BOM if present
if (str_starts_with($sql, "\xEF\xBB\xBF")) {
$sql = substr($sql, 3);
}
// Also handle UTF-16LE just in case mysqldump was redirected directly in Powershell
// and we read raw bytes (UTF-16LE BOM is FF FE)
if (str_starts_with($sql, "\xFF\xFE")) {
$sql = mb_convert_encoding(substr($sql, 2), 'UTF-8', 'UTF-16LE');
}
DB::unprepared($sql);
}
}
public function down(): void
{
//
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('notifikasi', function (Blueprint $table) {
$table->id();
$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');
}
};

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('device_tokens', function (Blueprint $table) {
$table->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');
}
};

View File

@ -19,6 +19,7 @@ public function run(): void
PresensiStatusSeeder::class, PresensiStatusSeeder::class,
JenisIzinSeeder::class, JenisIzinSeeder::class,
StatusPengajuanSeeder::class, StatusPengajuanSeeder::class,
JenisPenguranganSeeder::class,
// 4. Data dummy (opsional, comment jika tidak diperlukan di production) // 4. Data dummy (opsional, comment jika tidak diperlukan di production)
// PresensiSeeder::class, // PresensiSeeder::class,

View File

@ -11,7 +11,10 @@ public function run(): void
{ {
DB::table('jenis_pengurangan')->insertOrIgnore([ DB::table('jenis_pengurangan')->insertOrIgnore([
['id_pengurangan' => 1, 'nama_pengurangan' => 'Datang Terlambat'], ['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)'],
]); ]);
} }
} }

View File

@ -5,14 +5,17 @@
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB; 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 class StatusSeeder extends Seeder
{ {
public function run(): void public function run(): void
{ {
DB::table('status_presensi')->insertOrIgnore([
DB::table('status_presensi')->delete();
DB::table('status_presensi')->insert([
['id_status' => 1, 'nama_status' => 'Tepat Waktu', 'created_at' => now(), 'updated_at' => now()], ['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' => 2, 'nama_status' => 'Terlambat', 'created_at' => now(), 'updated_at' => now()],
['id_status' => 3, 'nama_status' => 'Izin', 'created_at' => now(), 'updated_at' => now()], ['id_status' => 3, 'nama_status' => 'Izin', 'created_at' => now(), 'updated_at' => now()],
@ -20,14 +23,12 @@ public function run(): void
['id_status' => 5, 'nama_status' => 'Alpha', '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')->insertOrIgnore([
DB::table('status_validasi_presensi')->insert([
['id_status' => 1, 'nama_status' => 'Valid', 'created_at' => now(), 'updated_at' => now()], ['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' => 2, 'nama_status' => 'Pending', 'created_at' => now(), 'updated_at' => now()],
['id_status' => 3, 'nama_status' => 'Ditolak', '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.');
} }
} }

View File

@ -72,8 +72,128 @@
flashSuccess.setAttribute('data-message', ''); 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': '<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>',
'presensi': '<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>',
'pengumuman': '<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 1 1 0-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 0 1-1.44-4.282m3.102.069a18.03 18.03 0 0 1-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 0 1 8.835 2.535M10.34 6.66a23.847 23.847 0 0 0 8.835-2.535m0 0A23.74 23.74 0 0 0 18.795 3m.38 1.125a23.91 23.91 0 0 1 1.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 0 0 1.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 0 1 0 3.46" /></svg>',
'surat_izin': '<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></svg>',
'izin': '<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></svg>',
'poin': '<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" /></svg>',
};
return icons[tipe] || '<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" /></svg>';
}
};
}
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) { function confirmDelete(id) {
Swal.fire({ Swal.fire({
title: 'Apakah Anda yakin?', title: 'Apakah Anda yakin?',

View File

@ -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 @php
$id = $attributes->get('id', $name); $id = $attributes->get('id', $name);
@ -19,7 +19,7 @@
placeholder="{{ $placeholder }}" placeholder="{{ $placeholder }}"
{{ $disabled ? 'disabled' : '' }} {{ $disabled ? 'disabled' : '' }}
{!! $attributes->except(['id', 'name', 'type', 'value', 'placeholder', 'class'])->merge([ {!! $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'
]) !!} ]) !!}
> >

View File

@ -1,10 +1,10 @@
@props(['label' => null, 'name' => null, 'value' => '']) @props(['label' => null, 'name' => null, 'value' => ''])
@php @php
$id = $attributes->get('id', $name); $id = $attributes->get('id', $name);
@endphp @endphp
<div {{ $attributes->only('class')->merge(['class' => 'relative w-full']) }}> <div {{ $attributes->only('class')->merge(['class' => 'relative w-full mb-4']) }}>
@if($label) @if($label)
<label for="{{ $id }}" class="block text-sm font-semibold text-slate-700 mb-2"> <label for="{{ $id }}" class="block text-sm font-semibold text-slate-700 mb-2">
{{ $label }} {{ $label }}
@ -16,7 +16,7 @@
@if($name) name="{{ $name }}" @endif @if($name) name="{{ $name }}" @endif
id="{{ $id }}" id="{{ $id }}"
{{ $attributes->except(['class', 'id', 'name'])->merge(['class' => 'w-full pl-4 pr-12 bg-slate-50 focus:bg-white border border-slate-200 rounded-xl text-sm appearance-none outline-none focus:ring-4 focus:ring-[#130F26]/10 focus:border-[#130F26] transition-all duration-300 cursor-pointer truncate block h-11']) }} {{ $attributes->except(['class', 'id', 'name'])->merge(['class' => 'w-full pl-4 pr-12 bg-slate-50 focus:bg-white border border-slate-200 rounded-xl text-sm appearance-none outline-none focus:ring-4 focus:ring-[#130F26]/10 focus:border-[#130F26] transition-all duration-300 cursor-pointer text-slate-800 truncate block h-11']) }}
> >
{{ $slot }} {{ $slot }}
</select> </select>

View File

@ -1,4 +1,4 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Manajemen Sisa Cuti') @section('title', 'Manajemen Sisa Cuti')
@ -165,11 +165,21 @@ class="block w-full pl-10 pr-3 py-2 border border-slate-300 rounded-lg focus:rin
Aksi ini <strong>menimpa dan menghilangkan</strong> semua sisa cuti pegawai sebelumnya tanpa pandang bulu. Aksi ini tidak bisa dibatalkan! Aksi ini <strong>menimpa dan menghilangkan</strong> semua sisa cuti pegawai sebelumnya tanpa pandang bulu. Aksi ini tidak bisa dibatalkan!
</div> </div>
</div> </div>
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1">
Ketik <span class="font-mono text-rose-600 bg-rose-50 px-1 rounded">RESET</span> untuk mengkonfirmasi
</label>
<input type="text" id="konfirmasiReset" placeholder="Ketik RESET di sini..."
oninput="toggleResetBtn(this.value)"
class="block w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:ring-rose-500 focus:border-rose-500 transition-colors">
</div>
</div> </div>
</div> </div>
<div class="bg-slate-50 px-6 py-4 flex flex-row-reverse border-t border-slate-100 gap-2"> <div class="bg-slate-50 px-6 py-4 flex flex-row-reverse border-t border-slate-100 gap-2">
<button type="submit" class="w-full sm:w-auto inline-flex justify-center rounded-lg border border-transparent shadow-sm px-4 py-2 bg-slate-800 text-base font-medium text-white hover:bg-slate-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-900 sm:text-sm transition-colors" onclick="document.getElementById('submitBtnReset').innerHTML = 'Memproses...'"> <button type="submit" id="btnResetSubmit" disabled
class="w-full sm:w-auto inline-flex justify-center rounded-lg border border-transparent shadow-sm px-4 py-2 bg-rose-600 text-base font-medium text-white hover:bg-rose-700 disabled:opacity-40 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-600 sm:text-sm transition-colors">
<span id="submitBtnReset">Ya, Reset Sekarang</span> <span id="submitBtnReset">Ya, Reset Sekarang</span>
</button> </button>
<button type="button" class="w-full sm:w-auto inline-flex justify-center rounded-lg border border-slate-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary sm:text-sm" onclick="toggleModal('modalReset')"> <button type="button" class="w-full sm:w-auto inline-flex justify-center rounded-lg border border-slate-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary sm:text-sm" onclick="toggleModal('modalReset')">
@ -186,6 +196,19 @@ class="block w-full pl-10 pr-3 py-2 border border-slate-300 rounded-lg focus:rin
<script> <script>
function toggleModal(modalID) { function toggleModal(modalID) {
document.getElementById(modalID).classList.toggle("hidden"); document.getElementById(modalID).classList.toggle("hidden");
// Reset field konfirmasi reset saat modal ditutup
if (modalID === 'modalReset') {
const konfirmasi = document.getElementById('konfirmasiReset');
const btn = document.getElementById('btnResetSubmit');
if (konfirmasi) konfirmasi.value = '';
if (btn) btn.disabled = true;
}
}
function toggleResetBtn(value) {
const btn = document.getElementById('btnResetSubmit');
btn.disabled = value !== 'RESET';
} }
function openEditModal(id, nama, sisa) { function openEditModal(id, nama, sisa) {

View File

@ -1,22 +1,122 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Verifikasi Wajah') @section('title', 'Verifikasi Wajah')
@section('content') @section('content')
<div class="space-y-6"> <div class="space-y-6">
<x-page-header title="Verifikasi Wajah Karyawan" subtitle="Persetujuan pendaftaran Face ID karyawan baru" /> <x-page-header title="Verifikasi Wajah Karyawan" subtitle="Monitor dan kelola data Face ID seluruh karyawan." />
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<a href="{{ route('face.index', ['status' => 'pending']) }}"
class="bg-white rounded-xl border border-slate-200 p-4 hover:shadow-md transition-all {{ $status === 'pending' ? 'ring-2 ring-amber-400' : '' }}">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center">
<svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div>
<div class="text-2xl font-extrabold text-slate-800">{{ $stats['pending'] }}</div>
<div class="text-xs font-semibold text-slate-500">Menunggu</div>
</div>
</div>
</a>
<a href="{{ route('face.index', ['status' => 'approved']) }}"
class="bg-white rounded-xl border border-slate-200 p-4 hover:shadow-md transition-all {{ $status === 'approved' ? 'ring-2 ring-emerald-400' : '' }}">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-emerald-100 flex items-center justify-center">
<svg class="w-5 h-5 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div>
<div class="text-2xl font-extrabold text-slate-800">{{ $stats['approved'] }}</div>
<div class="text-xs font-semibold text-slate-500">Terverifikasi</div>
</div>
</div>
</a>
<a href="{{ route('face.index', ['status' => 'rejected']) }}"
class="bg-white rounded-xl border border-slate-200 p-4 hover:shadow-md transition-all {{ $status === 'rejected' ? 'ring-2 ring-red-400' : '' }}">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div>
<div class="text-2xl font-extrabold text-slate-800">{{ $stats['rejected'] }}</div>
<div class="text-xs font-semibold text-slate-500">Ditolak</div>
</div>
</div>
</a>
<a href="{{ route('face.index', ['status' => 'unregistered']) }}"
class="bg-white rounded-xl border border-slate-200 p-4 hover:shadow-md transition-all {{ $status === 'unregistered' ? 'ring-2 ring-slate-400' : '' }}">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center">
<svg class="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
</svg>
</div>
<div>
<div class="text-2xl font-extrabold text-slate-800">{{ $stats['unregistered'] }}</div>
<div class="text-xs font-semibold text-slate-500">Belum Daftar</div>
</div>
</div>
</a>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden"> <div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="p-4 border-b border-slate-100 bg-slate-50/50">
<form action="{{ route('face.index') }}" method="GET" class="flex flex-wrap gap-4 items-end">
<div class="w-full md:w-48">
<x-select label="Status" name="status" onchange="this.form.submit()" class="!mb-0">
<option value="">Semua (Terdaftar)</option>
<option value="pending" {{ $status === 'pending' ? 'selected' : '' }}>Menunggu Verifikasi</option>
<option value="approved" {{ $status === 'approved' ? 'selected' : '' }}>Terverifikasi</option>
<option value="rejected" {{ $status === 'rejected' ? 'selected' : '' }}>Ditolak</option>
<option value="unregistered" {{ $status === 'unregistered' ? 'selected' : '' }}>Belum Registrasi</option>
</x-select>
</div>
<div class="flex-1 min-w-[200px]">
<x-input type="text" name="search" label="Cari Pegawai" value="{{ request('search') }}" placeholder="Nama/NIK..."
class="!mb-0" />
</div>
<div>
<x-button type="submit" variant="secondary" class="h-[44px]">
Filter
</x-button>
</div>
@if($status || request('search'))
<div>
<a href="{{ route('face.index') }}" class="inline-flex items-center h-[44px] px-4 text-sm font-medium text-slate-500 hover:text-slate-800 transition">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
Reset Filter
</a>
</div>
@endif
</form>
</div>
<x-table> <x-table>
<x-slot:header> <x-slot:header>
<th class="px-6 py-5 text-xs font-bold text-slate-500 uppercase text-left">Karyawan</th> <th class="px-6 py-5 text-xs font-bold text-slate-500 uppercase text-left">Karyawan</th>
<th class="px-6 py-5 text-xs font-bold text-slate-500 uppercase text-left">Divisi / Jabatan</th> <th class="px-6 py-5 text-xs font-bold text-slate-500 uppercase text-left">Divisi / Jabatan</th>
<th class="px-6 py-5 text-xs font-bold text-slate-500 uppercase text-center">Preview Foto</th> <th class="px-6 py-5 text-xs font-bold text-slate-500 uppercase text-center">Preview Foto</th>
<th class="px-6 py-5 text-xs font-bold text-slate-500 uppercase text-center">Status Saat Ini</th> <th class="px-6 py-5 text-xs font-bold text-slate-500 uppercase text-center">Status</th>
<th class="px-6 py-5 text-xs font-bold text-slate-500 uppercase text-center">Aksi</th> <th class="px-6 py-5 text-xs font-bold text-slate-500 uppercase text-center">Aksi</th>
</x-slot:header> </x-slot:header>
@forelse ($users as $user) @forelse ($users as $user)
@php
$dw = $user->dataWajah;
$faceStatus = 'unregistered';
if ($dw) {
if ($dw->is_verified == 0) $faceStatus = 'pending';
elseif ($dw->is_verified == 1) $faceStatus = 'approved';
elseif ($dw->is_verified == 2) $faceStatus = 'rejected';
}
@endphp
<tr class="hover:bg-slate-50 transition-colors"> <tr class="hover:bg-slate-50 transition-colors">
<td class="px-6 py-4"> <td class="px-6 py-4">
<div class="flex items-center"> <div class="flex items-center">
@ -28,7 +128,7 @@
@endif @endif
<div> <div>
<div class="text-sm font-medium text-slate-900">{{ $user->nama_lengkap }}</div> <div class="text-sm font-medium text-slate-900">{{ $user->nama_lengkap }}</div>
<div class="text-xs text-slate-500">{{ $user->email }}</div> <div class="text-xs text-slate-500">{{ $user->nik ?? '-' }} {{ $user->email }}</div>
</div> </div>
</div> </div>
</td> </td>
@ -37,6 +137,7 @@
<div class="text-xs text-slate-500">{{ $user->jabatan->nama_jabatan ?? '-' }}</div> <div class="text-xs text-slate-500">{{ $user->jabatan->nama_jabatan ?? '-' }}</div>
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
@if($faceStatus !== 'unregistered')
<div class="flex items-center justify-center gap-2"> <div class="flex items-center justify-center gap-2">
@php @php
$poses = ['depan' => 'Depan', 'kanan' => 'Kanan', 'kiri' => 'Kiri', 'bawah' => 'Bawah']; $poses = ['depan' => 'Depan', 'kanan' => 'Kanan', 'kiri' => 'Kiri', 'bawah' => 'Bawah'];
@ -58,14 +159,35 @@ class="w-12 h-12 rounded-lg object-cover border-2 border-slate-200 group-hover:b
@endif @endif
@endforeach @endforeach
</div> </div>
@else
<div class="text-center text-xs text-slate-400 italic">Belum ada foto</div>
@endif
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-center"> <td class="px-6 py-4 whitespace-nowrap text-center">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"> @if($faceStatus === 'pending')
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold bg-amber-100 text-amber-800">
<span class="w-1.5 h-1.5 rounded-full bg-amber-500 mr-1.5 animate-pulse"></span>
Menunggu Verifikasi Menunggu Verifikasi
</span> </span>
@elseif($faceStatus === 'approved')
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold bg-emerald-100 text-emerald-800">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path></svg>
Terverifikasi
</span>
@elseif($faceStatus === 'rejected')
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold bg-red-100 text-red-800">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
Ditolak
</span>
@else
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold bg-slate-100 text-slate-500">
Belum Registrasi
</span>
@endif
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-center text-sm font-medium"> <td class="px-6 py-4 whitespace-nowrap text-center text-sm font-medium">
<div class="flex justify-center gap-2"> <div class="flex justify-center gap-2">
@if($faceStatus === 'pending')
<form id="approve-form-{{ $user->id }}" action="{{ route('face.approve', $user->id) }}" <form id="approve-form-{{ $user->id }}" action="{{ route('face.approve', $user->id) }}"
method="POST" class="hidden"> method="POST" class="hidden">
@csrf @csrf
@ -73,7 +195,7 @@ class="w-12 h-12 rounded-lg object-cover border-2 border-slate-200 group-hover:b
</form> </form>
<button <button
onclick="confirmAction(event, 'approve-form-{{ $user->id }}', 'Apakah Anda yakin wajah ini sesuai dengan karyawan tersebut?', '#16a34a', 'Ya, Terima!')" onclick="confirmAction(event, 'approve-form-{{ $user->id }}', 'Apakah Anda yakin wajah ini sesuai dengan karyawan tersebut?', '#16a34a', 'Ya, Terima!')"
class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-lg text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-colors shadow-sm" class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-lg text-white bg-green-600 hover:bg-green-700 transition-colors shadow-sm"
title="Terima Wajah"> title="Terima Wajah">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
@ -88,18 +210,36 @@ class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs fo
</form> </form>
<button <button
onclick="confirmAction(event, 'reject-form-{{ $user->id }}', 'Data wajah akan dihapus dan karyawan harus scan ulang. Lanjutkan?', '#dc2626', 'Ya, Tolak!')" onclick="confirmAction(event, 'reject-form-{{ $user->id }}', 'Data wajah akan dihapus dan karyawan harus scan ulang. Lanjutkan?', '#dc2626', 'Ya, Tolak!')"
class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-lg text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors shadow-sm" class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-lg text-white bg-red-600 hover:bg-red-700 transition-colors shadow-sm"
title="Tolak / Reset"> title="Tolak / Reset">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg> </svg>
Tolak Tolak
</button> </button>
@elseif($faceStatus === 'approved')
<form id="reset-form-{{ $user->id }}" action="{{ route('face.reset', $user->id) }}"
method="POST" class="hidden">
@csrf
@method('DELETE')
</form>
<button
onclick="confirmAction(event, 'reset-form-{{ $user->id }}', 'Data wajah akan dihapus dan karyawan harus melakukan registrasi ulang. Lanjutkan?', '#f59e0b', 'Ya, Reset!')"
class="inline-flex items-center px-3 py-1.5 border border-amber-200 text-xs font-medium rounded-lg text-amber-700 bg-amber-50 hover:bg-amber-100 transition-colors shadow-sm"
title="Reset Face ID">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Reset
</button>
@else
<span class="text-xs text-slate-400 italic"></span>
@endif
</div> </div>
</td> </td>
</tr> </tr>
@empty @empty
<x-empty-state colspan="5" message="Tidak ada permintaan verifikasi wajah baru" /> <x-empty-state colspan="5" message="Tidak ada data karyawan yang sesuai filter." />
@endforelse @endforelse
</x-table> </x-table>
</div> </div>

View File

@ -1,4 +1,4 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Manajemen Izin & Cuti') @section('title', 'Manajemen Izin & Cuti')
@ -121,20 +121,20 @@ class="px-3 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 border border-bl
<form action="{{ route('izin.store') }}" method="POST" enctype="multipart/form-data" class="space-y-4"> <form action="{{ route('izin.store') }}" method="POST" enctype="multipart/form-data" class="space-y-4">
@csrf @csrf
<div class="grid grid-cols-2 gap-4"> <div class="flex flex-col md:flex-row gap-4">
<x-select id="filter-kantor-izin" label="Filter Kantor"> <div class="flex-1"><x-select id="filter-kantor-izin" label="Filter Kantor">
<option value="">Semua Kantor</option> <option value="">Semua Kantor</option>
@foreach($kantor as $k) @foreach($kantor as $k)
<option value="{{ $k->id_kantor }}">{{ $k->nama_kantor }}</option> <option value="{{ $k->id_kantor }}">{{ $k->nama_kantor }}</option>
@endforeach @endforeach
</x-select> </x-select></div>
<x-select id="filter-divisi-izin" label="Filter Divisi"> <div class="flex-1"><x-select id="filter-divisi-izin" label="Filter Divisi">
<option value="">Semua Divisi</option> <option value="">Semua Divisi</option>
@foreach($divisi as $d) @foreach($divisi as $d)
<option value="{{ $d->id_divisi }}">{{ $d->nama_divisi }}</option> <option value="{{ $d->id_divisi }}">{{ $d->nama_divisi }}</option>
@endforeach @endforeach
</x-select> </x-select></div>
</div> </div>
<div> <div>
@ -156,9 +156,9 @@ class="px-3 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 border border-bl
</x-select> </x-select>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="flex flex-col md:flex-row gap-4">
<x-input type="date" label="Tanggal Mulai" name="tanggal_mulai" required /> <div class="flex-1"><x-input type="date" label="Tanggal Mulai" name="tanggal_mulai" required /></div>
<x-input type="date" label="Tanggal Selesai" name="tanggal_selesai" required /> <div class="flex-1"><x-input type="date" label="Tanggal Selesai" name="tanggal_selesai" required /></div>
</div> </div>
<div> <div>

View File

@ -1,16 +1,20 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Penjadwalan Shift') @section('title', 'Penjadwalan Shift')
@section('style') @section('style')
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.js'></script> <script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.js'></script>
<script src="https://unpkg.com/@popperjs/core@2"></script>
<script src="https://unpkg.com/tippy.js@6"></script>
<link rel="stylesheet" href="https://unpkg.com/tippy.js@6/animations/scale.css" />
<style> <style>
.fc-event { .fc-event {
cursor: pointer; cursor: pointer;
font-size: 11px; border: none !important;
padding: 2px 4px; outline: none !important;
border: none; box-shadow: none !important;
background: transparent;
} }
.fc-daygrid-event { .fc-daygrid-event {
@ -51,8 +55,134 @@
overflow-y: auto !important; overflow-y: auto !important;
} }
/* Tippy Light Border Theme Customization */
.tippy-box[data-theme~='light-border'] {
background-color: white;
color: #1e293b;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
font-size: 12px;
text-align: left;
}
/* Modern FullCalendar SaaS Overrides */
#calendar { #calendar {
min-height: 600px; min-height: 600px;
font-family: inherit;
}
.fc-theme-standard td, .fc-theme-standard th, .fc-theme-standard .fc-scrollgrid {
border-color: #f1f5f9;
}
.fc-theme-standard .fc-scrollgrid { border-radius: 12px; overflow: hidden; border: 1px solid #e2e8f0; }
.fc-col-header-cell {
background-color: #f8fafc;
padding: 12px 0 !important;
}
.fc-col-header-cell-cushion {
color: #64748b;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
text-decoration: none !important;
}
.fc-daygrid-day-top {
justify-content: center !important;
padding-top: 8px;
padding-bottom: 4px;
}
.fc-daygrid-day-number {
color: #475569;
font-weight: 600;
font-size: 0.875rem;
text-decoration: none !important;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
transition: all 0.2s;
}
.fc-daygrid-day-number:hover {
background-color: #f1f5f9;
}
.fc-day-today {
background-color: #ffffff !important;
}
.fc-day-today .fc-daygrid-day-number {
color: #ffffff !important;
background-color: #3b82f6 !important;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
}
.fc-daygrid-day-events {
padding: 0 6px !important;
}
.fc-daygrid-event-harness {
margin-bottom: 6px !important;
}
.fc-event {
border: none !important;
background: transparent !important;
box-shadow: none !important;
border-radius: 6px !important;
overflow: hidden;
}
.fc-daygrid-more-link {
color: #3b82f6 !important;
font-weight: 700;
font-size: 0.7rem;
padding: 5px 8px;
background-color: #eff6ff;
border-radius: 6px;
display: block;
text-align: center;
margin: 4px 6px 8px 6px;
transition: all 0.2s;
text-decoration: none !important;
}
.fc-daygrid-more-link:hover {
background-color: #dbeafe;
color: #2563eb !important;
}
.fc .fc-toolbar-title {
font-size: 1.25rem !important;
font-weight: 800;
color: #0f172a;
}
.fc .fc-button-primary {
background-color: #ffffff !important;
color: #475569 !important;
border: 1px solid #e2e8f0 !important;
border-radius: 8px !important;
font-weight: 600;
text-transform: capitalize;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
padding: 6px 16px !important;
transition: all 0.2s;
}
.fc .fc-button-primary:hover {
background-color: #f8fafc !important;
color: #0f172a !important;
border-color: #cbd5e1 !important;
}
.fc .fc-button-primary:not(:disabled).fc-button-active,
.fc .fc-button-primary:not(:disabled):active {
background-color: #f1f5f9 !important;
color: #0f172a !important;
border-color: #cbd5e1 !important;
box-shadow: inset 0 2px 4px 0 rgb(0 0 0 / 0.05) !important;
}
.fc-toolbar.fc-header-toolbar {
margin-bottom: 24px !important;
} }
</style> </style>
@endsection @endsection
@ -61,6 +191,14 @@
<div class="space-y-6"> <div class="space-y-6">
<x-page-header title="Penjadwalan Shift" subtitle="Monitor dan atur jadwal kerja pegawai." class="lg:items-end"> <x-page-header title="Penjadwalan Shift" subtitle="Monitor dan atur jadwal kerja pegawai." class="lg:items-end">
<div class="relative max-w-xs w-full lg:w-48">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<svg class="w-4 h-4 text-slate-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
</svg>
</div>
<input type="text" id="filter_nama" class="bg-gray-50 border border-slate-300 text-slate-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full pl-10 p-2" placeholder="Cari Pegawai...">
</div>
<x-filter-select id="filter_kantor"> <x-filter-select id="filter_kantor">
<option value="">Semua Kantor</option> <option value="">Semua Kantor</option>
@foreach($kantor as $k) @foreach($kantor as $k)
@ -253,9 +391,9 @@ class="text-red-500 text-sm hover:underline font-medium">Hapus Jadwal</button>
<p>Hati-hati! Tindakan ini akan menghapus semua jadwal shift pegawai pada rentang tanggal yang dipilih dan tidak dapat dikembalikan.</p> <p>Hati-hati! Tindakan ini akan menghapus semua jadwal shift pegawai pada rentang tanggal yang dipilih dan tidak dapat dikembalikan.</p>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="flex flex-col md:flex-row gap-4">
<x-date-input label="Tanggal Mulai" name="tanggal_mulai" required class="mb-0" x-model="tglMulai" x-on:change="if(tglMulai) { $refs.endInput.min = tglMulai; if(tglSelesai && tglSelesai < tglMulai) tglSelesai = tglMulai; }" x-ref="startInput" /> <div class="flex-1"><x-date-input label="Tanggal Mulai" name="tanggal_mulai" required class="mb-0" x-model="tglMulai" x-on:change="if(tglMulai) { $refs.endInput.min = tglMulai; if(tglSelesai && tglSelesai < tglMulai) tglSelesai = tglMulai; }" x-ref="startInput" /></div>
<x-date-input label="Tanggal Selesai" name="tanggal_selesai" required class="mb-0" x-model="tglSelesai" x-on:change="if(tglSelesai) { $refs.startInput.max = tglSelesai; if(tglMulai && tglMulai > tglSelesai) tglMulai = tglSelesai; }" x-ref="endInput" /> <div class="flex-1"><x-date-input label="Tanggal Selesai" name="tanggal_selesai" required class="mb-0" x-model="tglSelesai" x-on:change="if(tglSelesai) { $refs.startInput.max = tglSelesai; if(tglMulai && tglMulai > tglSelesai) tglMulai = tglSelesai; }" x-ref="endInput" /></div>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
@ -320,6 +458,7 @@ function initCalendar() {
var filterKantor = document.getElementById('filter_kantor'); var filterKantor = document.getElementById('filter_kantor');
var filterDivisi = document.getElementById('filter_divisi'); var filterDivisi = document.getElementById('filter_divisi');
var filterNama = document.getElementById('filter_nama');
var loading = document.getElementById('loading'); var loading = document.getElementById('loading');
if (window.jadwalCalendar) { if (window.jadwalCalendar) {
@ -336,21 +475,58 @@ function initCalendar() {
center: 'title', center: 'title',
right: 'dayGridMonth' right: 'dayGridMonth'
}, },
/* eventDidMount: function (info) { buttonText: {
$(info.el).tooltip({ today: 'Hari Ini',
title: info.event.extendedProps.description, month: 'Bulan',
list: 'Minggu'
},
eventDidMount: function (info) {
if (info.event.extendedProps.description) {
let contentHtml = `
<div class="font-bold text-slate-800 mb-1 border-b border-slate-100 pb-1">${info.event.extendedProps.nama_user}</div>
<div class="text-xs text-slate-500 mb-2">${info.event.extendedProps.kantor} &bull; ${info.event.extendedProps.jabatan}</div>
<div class="text-[11px] leading-relaxed">
${info.event.extendedProps.description.replace(/\n/g, '<br>')}
</div>
`;
if (info.event.extendedProps.is_holiday) {
contentHtml = `
<div class="font-bold text-red-600 border-b border-red-100 pb-1 mb-1">Libur Nasional</div>
<div class="text-xs text-red-500">${info.event.extendedProps.keterangan}</div>
`;
}
tippy(info.el, {
content: contentHtml,
allowHTML: true,
animation: 'scale',
theme: 'light-border',
placement: 'top', placement: 'top',
trigger: 'hover', zIndex: 9999
container: 'body'
}); });
}, */ }
},
eventContent: function (arg) { eventContent: function (arg) {
let contentEl = document.createElement('div'); 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) { if (arg.event.extendedProps.is_holiday) {
contentEl.innerHTML = ` contentEl.innerHTML = `
<div style="background:#fef2f2; color:#dc2626; padding: 3px 6px; border-radius: 4px; border-left: 3px solid #ef4444; font-weight:700; font-size: 11px;"> <div class="bg-red-50 text-red-700 px-2 py-1.5 rounded-lg border border-red-200 font-bold text-[10px] flex items-center gap-1.5 w-full truncate shadow-sm">
${arg.event.title} <span class="w-2 h-2 rounded-full bg-red-500 flex-shrink-0"></span>
<span class="truncate">${arg.event.title}</span>
</div> </div>
`; `;
return { domNodes: [contentEl] }; return { domNodes: [contentEl] };
@ -359,33 +535,33 @@ function initCalendar() {
let jamMulai = arg.event.extendedProps.jam_mulai ? arg.event.extendedProps.jam_mulai.substring(0, 5) : '??:??'; 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 jamSelesai = arg.event.extendedProps.jam_selesai ? arg.event.extendedProps.jam_selesai.substring(0, 5) : '??:??';
let isPoin = arg.event.extendedProps.is_poin; if (arg.view.type === 'listWeek') {
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 = `
<div style="text-decoration: line-through; color: #9ca3af; font-size: 0.8em;">${originalMulai} - ${originalSelesai}</div>
<div style="font-weight: bold; color: #c2410c;">${jamMulai} - ${jamSelesai} ${icon}</div>
`;
} else {
timeHtml = `<div style="font-size: 0.85em;">${jamMulai} - ${jamSelesai}</div>`;
}
contentEl.innerHTML = ` contentEl.innerHTML = `
<div style="background:${bgColor}; color:${textColor}; border-left:${borderLeft}; padding: 2px 4px; border-radius: 4px;"> <div class="flex items-center gap-3 w-full py-2 px-1">
<div class="fc-event-title" style="${titleStyle}">${arg.event.title}</div> <div class="w-3 h-3 rounded-full shadow-sm" style="background-color: ${bg};"></div>
${timeHtml} <div class="font-bold text-slate-800 text-sm whitespace-normal flex-1">
${arg.event.extendedProps.nama_user}
<span class="font-normal text-xs text-slate-500 ml-2">(${arg.event.extendedProps.nama_shift})</span>
</div>
<div class="text-sm font-mono text-slate-600 font-semibold whitespace-nowrap">${jamMulai} - ${jamSelesai} ${isPoin?'⚠️':''}</div>
<div class="text-xs font-semibold px-3 py-1 rounded-full bg-slate-100 whitespace-nowrap">${arg.event.extendedProps.kantor}</div>
</div> </div>
`; `;
} else {
// Month View - Premium Badge Design
contentEl.innerHTML = `
<div class="${classes} px-2 py-1.5 rounded-lg border text-left transition-all w-full flex flex-col gap-0.5 relative overflow-hidden group">
${isPoin ? '<div class="absolute right-1 top-1 text-[10px]">⚠️</div>' : ''}
<div class="font-bold text-[11.5px] truncate leading-tight pr-3 w-full group-hover:opacity-80 transition-opacity">
${arg.event.extendedProps.nama_user.split(' ')[0]}
</div>
<div class="text-[10px] font-mono font-medium opacity-80 tracking-tight flex items-center gap-1">
<svg class="w-2.5 h-2.5 opacity-70" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
${jamMulai} - ${jamSelesai}
</div>
</div>
`;
}
return { domNodes: [contentEl] }; return { domNodes: [contentEl] };
}, },
@ -406,8 +582,9 @@ function initCalendar() {
const params = new URLSearchParams({ const params = new URLSearchParams({
start: info.startStr, start: info.startStr,
end: info.endStr, end: info.endStr,
filter_kantor: filterKantor.value, filter_kantor: filterKantor ? filterKantor.value : '',
filter_divisi: filterDivisi.value filter_divisi: filterDivisi ? filterDivisi.value : '',
filter_nama: filterNama ? filterNama.value : ''
}); });
fetch("{{ route('jadwal.events') }}?" + params.toString()) fetch("{{ route('jadwal.events') }}?" + params.toString())
@ -458,9 +635,9 @@ function initCalendar() {
dayMaxEvents: 2, dayMaxEvents: 2,
moreLinkClick: function (info) { moreLinkClick: function (info) {
const events = info.allSegs.map(seg => seg.event); 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"; return "function";
}, },
moreLinkText: 'lainnya', moreLinkText: 'lainnya',
@ -478,6 +655,16 @@ function initCalendar() {
[filterKantor, filterDivisi].forEach(el => { [filterKantor, filterDivisi].forEach(el => {
if (el) el.addEventListener('change', () => window.jadwalCalendar.refetchEvents()); 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) { if (!window.__jadwalEventAttached) {

View File

@ -1,4 +1,4 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Data Kantor') @section('title', 'Data Kantor')
@ -115,17 +115,17 @@ class="p-2 bg-slate-100 text-slate-600 rounded-lg hover:bg-slate-200 transition"
<label class="block text-sm font-semibold text-slate-700 mb-2">Tentukan Lokasi</label> <label class="block text-sm font-semibold text-slate-700 mb-2">Tentukan Lokasi</label>
<div id="map-create"></div> <div id="map-create"></div>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="flex flex-col md:flex-row gap-4">
<x-input label="Nama Kantor" name="nama_kantor" placeholder="Cth: Cabang Malang" required /> <div class="flex-1"><x-input label="Nama Kantor" name="nama_kantor" placeholder="Cth: Cabang Malang" required /></div>
<x-select label="Tipe" name="tipe" required> <div class="flex-1"><x-select label="Tipe" name="tipe" required>
<option value="Cabang">Cabang</option> <option value="Cabang">Cabang</option>
<option value="Pusat">Pusat</option> <option value="Pusat">Pusat</option>
</x-select> </x-select></div>
</div> </div>
<div class="grid grid-cols-3 gap-4"> <div class="flex flex-col md:flex-row gap-4">
<x-input label="Lat" name="latitude" id="create-lat" readonly required /> <div class="flex-1"><x-input label="Lat" name="latitude" id="create-lat" readonly required /></div>
<x-input label="Long" name="longitude" id="create-long" readonly required /> <div class="flex-1"><x-input label="Long" name="longitude" id="create-long" readonly required /></div>
<x-input type="number" label="Radius (m)" name="radius" id="create-radius" value="50" required /> <div class="w-full md:w-32"><x-input type="number" label="Radius (m)" name="radius" id="create-radius" value="50" required /></div>
</div> </div>
<x-textarea label="Alamat" name="alamat" rows="2" /> <x-textarea label="Alamat" name="alamat" rows="2" />
<div class="flex justify-end gap-3 pt-4 border-t border-slate-100 mt-2"> <div class="flex justify-end gap-3 pt-4 border-t border-slate-100 mt-2">
@ -143,17 +143,17 @@ class="px-4 py-2 text-sm bg-primary text-white rounded-xl hover:bg-primary/90">S
<div class="mb-4"> <div class="mb-4">
<div id="map-edit"></div> <div id="map-edit"></div>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="flex flex-col md:flex-row gap-4">
<x-input label="Nama" name="nama_kantor" id="edit-nama" required /> <div class="flex-1"><x-input label="Nama" name="nama_kantor" id="edit-nama" required /></div>
<x-select label="Tipe" name="tipe" id="edit-tipe" required> <div class="flex-1"><x-select label="Tipe" name="tipe" id="edit-tipe" required>
<option value="Cabang">Cabang</option> <option value="Cabang">Cabang</option>
<option value="Pusat">Pusat</option> <option value="Pusat">Pusat</option>
</x-select> </x-select></div>
</div> </div>
<div class="grid grid-cols-3 gap-4"> <div class="flex flex-col md:flex-row gap-4">
<x-input label="Lat" name="latitude" id="edit-lat" readonly required /> <div class="flex-1"><x-input label="Lat" name="latitude" id="edit-lat" readonly required /></div>
<x-input label="Long" name="longitude" id="edit-long" readonly required /> <div class="flex-1"><x-input label="Long" name="longitude" id="edit-long" readonly required /></div>
<x-input type="number" label="Radius" name="radius" id="edit-radius" required /> <div class="w-full md:w-32"><x-input type="number" label="Radius (m)" name="radius" id="edit-radius" required /></div>
</div> </div>
<x-textarea label="Alamat" name="alamat" id="edit-alamat" rows="2" /> <x-textarea label="Alamat" name="alamat" id="edit-alamat" rows="2" />
<div class="flex justify-end gap-3 pt-4 border-t border-slate-100 mt-2"> <div class="flex justify-end gap-3 pt-4 border-t border-slate-100 mt-2">

View File

@ -1,4 +1,4 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Laporan Lembur') @section('title', 'Laporan Lembur')
@ -21,7 +21,7 @@
<div class="p-4 border-b border-slate-100 bg-slate-50/50"> <div class="p-4 border-b border-slate-100 bg-slate-50/50">
<form action="{{ route('laporan-lembur.index') }}" method="GET" class="flex flex-wrap gap-4 items-end"> <form action="{{ route('laporan-lembur.index') }}" method="GET" class="flex flex-wrap gap-4 items-end">
<div class="w-full md:w-40"> <div class="w-full md:w-40">
<x-select label="Bulan" name="bulan" onchange="this.form.submit()"> <x-select label="Bulan" name="bulan" onchange="this.form.submit()" class="!mb-0">
@for($i=1; $i<=12; $i++) @for($i=1; $i<=12; $i++)
@php $val = str_pad($i, 2, '0', STR_PAD_LEFT); @endphp @php $val = str_pad($i, 2, '0', STR_PAD_LEFT); @endphp
<option value="{{ $val }}" {{ $bulan == $val ? 'selected' : '' }}> <option value="{{ $val }}" {{ $bulan == $val ? 'selected' : '' }}>
@ -32,7 +32,7 @@
</div> </div>
<div class="w-full md:w-32"> <div class="w-full md:w-32">
<x-select label="Tahun" name="tahun" onchange="this.form.submit()"> <x-select label="Tahun" name="tahun" onchange="this.form.submit()" class="!mb-0">
@for($i=date('Y'); $i>=2023; $i--) @for($i=date('Y'); $i>=2023; $i--)
<option value="{{ $i }}" {{ $tahun == $i ? 'selected' : '' }}>{{ $i }}</option> <option value="{{ $i }}" {{ $tahun == $i ? 'selected' : '' }}>{{ $i }}</option>
@endfor @endfor
@ -40,7 +40,7 @@
</div> </div>
<div class="w-full md:w-48"> <div class="w-full md:w-48">
<x-select label="Divisi" name="id_divisi" onchange="this.form.submit()"> <x-select label="Divisi" name="id_divisi" onchange="this.form.submit()" class="!mb-0">
<option value="">Semua Divisi</option> <option value="">Semua Divisi</option>
@foreach($divisiList as $div) @foreach($divisiList as $div)
<option value="{{ $div->id_divisi }}" {{ ($divisiId ?? '') == $div->id_divisi ? 'selected' : '' }}> <option value="{{ $div->id_divisi }}" {{ ($divisiId ?? '') == $div->id_divisi ? 'selected' : '' }}>
@ -55,8 +55,8 @@
class="!mb-0" oninput="if(this.value.length === 0) this.form.submit()" /> class="!mb-0" oninput="if(this.value.length === 0) this.form.submit()" />
</div> </div>
<div class="pb-1"> <div>
<x-button type="submit" variant="secondary" class="h-[42px]"> <x-button type="submit" variant="secondary" class="h-[44px]">
Filter Filter
</x-button> </x-button>
</div> </div>

View File

@ -1,4 +1,4 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Laporan Bulanan') @section('title', 'Laporan Bulanan')
@ -12,7 +12,7 @@
<form method="GET" action="{{ route('laporan.index') }}" <form method="GET" action="{{ route('laporan.index') }}"
class="flex flex-wrap gap-4 items-end mb-6 border-b border-slate-100 pb-6"> class="flex flex-wrap gap-4 items-end mb-6 border-b border-slate-100 pb-6">
<div class="w-full md:w-40"> <div class="w-full md:w-40">
<x-select label="Bulan" name="bulan"> <x-select label="Bulan" name="bulan" class="!mb-0">
@for ($i = 1; $i <= 12; $i++) @for ($i = 1; $i <= 12; $i++)
<option value="{{ $i }}" {{ $bulan == $i ? 'selected' : '' }}> <option value="{{ $i }}" {{ $bulan == $i ? 'selected' : '' }}>
{{ DateTime::createFromFormat('!m', $i)->format('F') }} {{ DateTime::createFromFormat('!m', $i)->format('F') }}
@ -21,14 +21,14 @@ class="flex flex-wrap gap-4 items-end mb-6 border-b border-slate-100 pb-6">
</x-select> </x-select>
</div> </div>
<div class="w-full md:w-32"> <div class="w-full md:w-32">
<x-select label="Tahun" name="tahun"> <x-select label="Tahun" name="tahun" class="!mb-0">
@for ($y = date('Y'); $y >= date('Y') - 2; $y--) @for ($y = date('Y'); $y >= date('Y') - 2; $y--)
<option value="{{ $y }}" {{ $tahun == $y ? 'selected' : '' }}>{{ $y }}</option> <option value="{{ $y }}" {{ $tahun == $y ? 'selected' : '' }}>{{ $y }}</option>
@endfor @endfor
</x-select> </x-select>
</div> </div>
<div class="w-full md:w-48"> <div class="w-full md:w-48">
<x-select label="Divisi" name="id_divisi"> <x-select label="Divisi" name="id_divisi" class="!mb-0">
<option value="">Semua Divisi</option> <option value="">Semua Divisi</option>
@foreach($divisiList as $div) @foreach($divisiList as $div)
<option value="{{ $div->id_divisi }}" {{ $divisiId == $div->id_divisi ? 'selected' : '' }}> <option value="{{ $div->id_divisi }}" {{ $divisiId == $div->id_divisi ? 'selected' : '' }}>
@ -37,8 +37,8 @@ class="flex flex-wrap gap-4 items-end mb-6 border-b border-slate-100 pb-6">
@endforeach @endforeach
</x-select> </x-select>
</div> </div>
<div class="w-full md:w-auto pb-0.5 flex gap-2"> <div class="w-full md:w-auto flex gap-2">
<x-button type="submit" variant="primary" class="h-[42px]"> <x-button type="submit" variant="primary" class="h-[44px]">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"> d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z">
@ -47,7 +47,7 @@ class="flex flex-wrap gap-4 items-end mb-6 border-b border-slate-100 pb-6">
Tampilkan Tampilkan
</x-button> </x-button>
<a href="{{ route('laporan.export', ['bulan' => $bulan, 'tahun' => $tahun, 'id_divisi' => $divisiId]) }}" data-turbo="false" <a href="{{ route('laporan.export', ['bulan' => $bulan, 'tahun' => $tahun, 'id_divisi' => $divisiId]) }}" data-turbo="false"
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-[42px]"> 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]">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path> d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
@ -57,7 +57,7 @@ class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-50 text-emerald-700 h
<a href="{{ route('laporan.exportPdf', ['bulan' => $bulan, 'tahun' => $tahun, 'id_divisi' => $divisiId]) }}" data-turbo="false" <a href="{{ route('laporan.exportPdf', ['bulan' => $bulan, 'tahun' => $tahun, 'id_divisi' => $divisiId]) }}" data-turbo="false"
target="_blank" target="_blank"
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-[42px]"> 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]">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path> d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>

View File

@ -1,4 +1,4 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Laporan Izin & Cuti') @section('title', 'Laporan Izin & Cuti')
@ -8,7 +8,9 @@
<x-page-header title="Laporan Izin & Cuti" subtitle="Histori pengajuan izin dan cuti pegawai secara bulanan."> <x-page-header title="Laporan Izin & Cuti" subtitle="Histori pengajuan izin dan cuti pegawai secara bulanan.">
<x-slot:actions> <x-slot:actions>
<div class="flex gap-2"> <div class="flex gap-2">
<x-button type="link" href="#" class="!bg-emerald-50 !text-emerald-700 hover:!bg-emerald-100 !border-emerald-600 !ring-0 flex items-center gap-2 h-[42px]"> <x-button type="link"
href="{{ route('laporan.izin.export', ['bulan' => $bulan, 'tahun' => $tahun, 'id_divisi' => $divisiId, 'search' => $search]) }}"
class="!bg-emerald-50 !text-emerald-700 hover:!bg-emerald-100 !border-emerald-600 !ring-0 flex items-center gap-2 h-[42px]">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
Export Excel Export Excel
</x-button> </x-button>
@ -21,7 +23,7 @@
<div class="p-4 border-b border-slate-100 bg-slate-50/50"> <div class="p-4 border-b border-slate-100 bg-slate-50/50">
<form action="{{ route('laporan.izin') }}" method="GET" class="flex flex-wrap gap-4 items-end"> <form action="{{ route('laporan.izin') }}" method="GET" class="flex flex-wrap gap-4 items-end">
<div class="w-full md:w-40"> <div class="w-full md:w-40">
<x-select label="Bulan" name="bulan" onchange="this.form.submit()"> <x-select label="Bulan" name="bulan" onchange="this.form.submit()" class="!mb-0">
@for($i=1; $i<=12; $i++) @for($i=1; $i<=12; $i++)
@php $val = str_pad($i, 2, '0', STR_PAD_LEFT); @endphp @php $val = str_pad($i, 2, '0', STR_PAD_LEFT); @endphp
<option value="{{ $val }}" {{ $bulan == $val ? 'selected' : '' }}> <option value="{{ $val }}" {{ $bulan == $val ? 'selected' : '' }}>
@ -32,7 +34,7 @@
</div> </div>
<div class="w-full md:w-32"> <div class="w-full md:w-32">
<x-select label="Tahun" name="tahun" onchange="this.form.submit()"> <x-select label="Tahun" name="tahun" onchange="this.form.submit()" class="!mb-0">
@for($i=date('Y'); $i>=2023; $i--) @for($i=date('Y'); $i>=2023; $i--)
<option value="{{ $i }}" {{ $tahun == $i ? 'selected' : '' }}>{{ $i }}</option> <option value="{{ $i }}" {{ $tahun == $i ? 'selected' : '' }}>{{ $i }}</option>
@endfor @endfor
@ -40,7 +42,7 @@
</div> </div>
<div class="w-full md:w-48"> <div class="w-full md:w-48">
<x-select label="Divisi" name="id_divisi" onchange="this.form.submit()"> <x-select label="Divisi" name="id_divisi" onchange="this.form.submit()" class="!mb-0">
<option value="">Semua Divisi</option> <option value="">Semua Divisi</option>
@foreach($divisiList as $div) @foreach($divisiList as $div)
<option value="{{ $div->id_divisi }}" {{ ($divisiId ?? '') == $div->id_divisi ? 'selected' : '' }}> <option value="{{ $div->id_divisi }}" {{ ($divisiId ?? '') == $div->id_divisi ? 'selected' : '' }}>
@ -55,8 +57,8 @@
class="!mb-0" oninput="if(this.value.length === 0) this.form.submit()" /> class="!mb-0" oninput="if(this.value.length === 0) this.form.submit()" />
</div> </div>
<div class="pb-1"> <div>
<x-button type="submit" variant="secondary" class="h-[42px]"> <x-button type="submit" variant="secondary" class="h-[44px]">
Filter Filter
</x-button> </x-button>
</div> </div>
@ -102,8 +104,8 @@ class="!mb-0" oninput="if(this.value.length === 0) this.form.submit()" />
<td class="px-6 py-4 text-center whitespace-nowrap"> <td class="px-6 py-4 text-center whitespace-nowrap">
@php @php
$color = 'yellow'; $label = 'Menunggu'; $color = 'yellow'; $label = 'Menunggu';
if($izin->status == 'disetujui') { $color = 'green'; $label = 'Disetujui'; } if($izin->id_status == 2) { $color = 'green'; $label = 'Disetujui'; }
elseif($izin->status == 'ditolak') { $color = 'red'; $label = 'Ditolak'; } elseif($izin->id_status == 3) { $color = 'red'; $label = 'Ditolak'; }
@endphp @endphp
<x-badge color="{{ $color }}"> <x-badge color="{{ $color }}">
{{ $label }} {{ $label }}

View File

@ -1,65 +1,364 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="id">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Laporan Presensi Pegawai</title> <title>Laporan Rekapitulasi Presensi</title>
<style> <style>
body { font-family: sans-serif; font-size: 11px; } * {
.header { text-align: center; margin-bottom: 20px; border-bottom: 2px solid #333; padding-bottom: 10px; } box-sizing: border-box;
.header h2 { margin: 0; padding: 0; font-size: 16px; } margin: 0;
.header p { margin: 5px 0 0; color: #555; } padding: 0;
table { width: 100%; border-collapse: collapse; margin-top: 10px; } font-family: DejaVu Sans, sans-serif;
th, td { border: 1px solid #ddd; padding: 6px; text-align: left; } }
th { background-color: #f4f4f4; font-weight: bold; } body {
.text-center { text-align: center; } font-family: DejaVu Sans, sans-serif;
.footer { margin-top: 30px; text-align: right; } font-size: 10px;
.signature { margin-top: 50px; } color: #1e293b;
background: #fff;
}
/* ── KOP SURAT ── */
.kop {
display: table;
width: 100%;
border-bottom: 3px solid #1e293b;
padding-bottom: 10px;
margin-bottom: 16px;
}
.kop-left { display: table-cell; width: 70%; vertical-align: middle; }
.kop-right { display: table-cell; width: 30%; vertical-align: middle; text-align: right; }
.company-name {
font-size: 16px;
font-weight: bold;
color: #1e293b;
letter-spacing: 0.5px;
}
.company-sub {
font-size: 10px;
color: #64748b;
margin-top: 2px;
}
.doc-label {
font-size: 9px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 1px;
}
.doc-no {
font-size: 11px;
font-weight: bold;
color: #1e293b;
}
/* ── JUDUL DOKUMEN ── */
.doc-title {
text-align: center;
margin-bottom: 14px;
}
.doc-title h1 {
font-size: 13px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
color: #1e293b;
}
.doc-title .periode-badge {
display: inline-block;
margin-top: 5px;
padding: 3px 12px;
background: #f1f5f9;
border: 1px solid #cbd5e1;
border-radius: 4px;
font-size: 10px;
color: #475569;
}
/* ── INFO DOKUMEN ── */
.meta-table {
width: 100%;
margin-bottom: 14px;
border-collapse: collapse;
font-size: 9.5px;
}
.meta-table td { padding: 2px 4px; color: #475569; }
.meta-table .label { width: 28%; color: #94a3b8; }
.meta-table .sep { width: 2%; }
.meta-table .value { font-weight: 600; color: #1e293b; }
/* ── TABEL DATA ── */
table.data {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
font-size: 9.5px;
}
table.data thead tr {
background-color: #1e293b;
color: #fff;
}
table.data thead th {
padding: 7px 6px;
text-align: center;
font-weight: bold;
border: 1px solid #334155;
white-space: nowrap;
}
table.data thead th.left { text-align: left; }
table.data tbody tr:nth-child(even) { background: #f8fafc; }
table.data tbody tr:nth-child(odd) { background: #ffffff; }
table.data tbody td {
padding: 6px;
border: 1px solid #e2e8f0;
vertical-align: middle;
}
table.data tbody td.center { text-align: center; }
table.data tbody td.name { font-weight: 600; }
table.data tbody td.sub { font-size: 9px; color: #64748b; }
/* Status badge */
.badge {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 9px;
font-weight: 600;
}
.badge-green { background: #dcfce7; color: #166534; }
.badge-blue { background: #dbeafe; color: #1e40af; }
.badge-orange { background: #ffedd5; color: #9a3412; }
.badge-red { background: #fee2e2; color: #991b1b; }
.badge-purple { background: #f3e8ff; color: #6b21a8; }
.badge-gray { background: #f1f5f9; color: #475569; }
/* ── RINGKASAN ── */
.summary-box {
width: 100%;
display: table;
margin-bottom: 20px;
border: 1px solid #e2e8f0;
border-radius: 6px;
overflow: hidden;
}
.summary-item {
display: table-cell;
width: 16.66%;
padding: 8px 10px;
text-align: center;
border-right: 1px solid #e2e8f0;
}
.summary-item:last-child { border-right: none; }
.summary-label {
font-size: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #94a3b8;
margin-bottom: 3px;
}
.summary-value {
font-size: 14px;
font-weight: bold;
color: #1e293b;
}
.summary-value.green { color: #16a34a; }
.summary-value.blue { color: #2563eb; }
.summary-value.orange { color: #ea580c; }
.summary-value.red { color: #dc2626; }
.summary-value.purple { color: #9333ea; }
/* ── FOOTER / TANDA TANGAN ── */
.footer-area {
margin-top: 30px;
display: table;
width: 100%;
}
.footer-left { display: table-cell; width: 50%; vertical-align: top; font-size: 9px; color: #64748b; }
.footer-right { display: table-cell; width: 50%; text-align: right; vertical-align: top; }
.ttd-box {
display: inline-block;
text-align: center;
font-size: 9.5px;
}
.ttd-box .city-date { color: #475569; margin-bottom: 48px; }
.ttd-box .ttd-name { font-weight: bold; border-top: 1px solid #1e293b; padding-top: 4px; }
.ttd-box .ttd-title { color: #64748b; }
.page-number { text-align: center; font-size: 8px; color: #94a3b8; margin-top: 12px; }
</style> </style>
</head> </head>
<body> <body>
<div class="header"> {{-- KOP SURAT --}}
<h2>LAPORAN REKAPITULASI PRESENSI PEGAWAI</h2> <div class="kop">
<h2>MPG HRIS ENTERPRISE SYSTEM</h2> <div class="kop-left">
<p>Periode: {{ \Carbon\Carbon::create()->month((int)$bulan)->translatedFormat('F') }} {{ $tahun }}</p> <div class="company-name">MPG HRIS Enterprise System</div>
<div class="company-sub">Human Resource Information System</div>
</div>
<div class="kop-right">
<div class="doc-label">Nomor Dokumen</div>
<div class="doc-no">LAP-{{ str_pad($bulan, 2, '0', STR_PAD_LEFT) }}/{{ $tahun }}/PRES</div>
</div>
</div> </div>
<table> {{-- JUDUL --}}
<div class="doc-title">
<h1>Laporan Rekapitulasi Presensi Pegawai</h1>
<span class="periode-badge">
Periode: {{ \Carbon\Carbon::create()->month((int)$bulan)->translatedFormat('F') }} {{ $tahun }}
</span>
</div>
{{-- META INFO --}}
<table class="meta-table">
<tr>
<td class="label">Tanggal Cetak</td>
<td class="sep">:</td>
<td class="value">{{ \Carbon\Carbon::now()->translatedFormat('d F Y, H:i') }} WIB</td>
<td width="30%"></td>
<td class="label">Total Pegawai</td>
<td class="sep">:</td>
<td class="value">{{ count($rekap) }} Orang</td>
</tr>
<tr>
<td class="label">Dicetak Oleh</td>
<td class="sep">:</td>
<td class="value">HRD Department</td>
<td></td>
<td class="label">Status Dokumen</td>
<td class="sep">:</td>
<td class="value">Resmi / Official</td>
</tr>
</table>
{{-- 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
<div class="summary-box">
<div class="summary-item">
<div class="summary-label">Total Hadir</div>
<div class="summary-value green">{{ $totalHadir }}</div>
</div>
<div class="summary-item">
<div class="summary-label">Total Izin</div>
<div class="summary-value blue">{{ $totalIzin }}</div>
</div>
<div class="summary-item">
<div class="summary-label">Total Sakit</div>
<div class="summary-value orange">{{ $totalSakit }}</div>
</div>
<div class="summary-item">
<div class="summary-label">Total Alpha</div>
<div class="summary-value red">{{ $totalAlpha }}</div>
</div>
<div class="summary-item">
<div class="summary-label">Terlambat</div>
<div class="summary-value orange">{{ $totalTerlambat }}</div>
</div>
<div class="summary-item">
<div class="summary-label">Poin Lembur</div>
<div class="summary-value purple">{{ $totalPoin }}</div>
</div>
</div>
{{-- TABEL DATA --}}
<table class="data">
<thead> <thead>
<tr> <tr>
<th class="text-center" width="5%">No</th> <th style="width:4%">No</th>
<th width="12%">NIK</th> <th class="left" style="width:11%">NIK / ID</th>
<th width="25%">Nama Lengkap</th> <th class="left" style="width:20%">Nama Lengkap</th>
<th width="15%">Hadir</th> <th class="left" style="width:13%">Divisi</th>
<th width="12%">Izin/Cuti</th> <th class="left" style="width:12%">Jabatan</th>
<th width="12%">Sakit</th> <th style="width:7%">Hadir</th>
<th width="10%">Alpha</th> <th style="width:7%">Izin</th>
<th width="9%">Telat</th> <th style="width:7%">Sakit</th>
<th style="width:6%">Alpha</th>
<th style="width:7%">Terlambat</th>
<th style="width:6%">Poin</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach($rekap as $index => $row) @foreach($rekap as $index => $row)
<tr> <tr>
<td class="text-center">{{ $index + 1 }}</td> <td class="center">{{ $index + 1 }}</td>
<td>{{ $row['user']->nik ?? '-' }}</td> <td>{{ $row['user']->nik ?? '-' }}</td>
<td>{{ $row['user']->nama_lengkap }}</td> <td class="name">{{ $row['user']->nama_lengkap }}</td>
<td class="text-center">{{ $row['hadir'] }} x</td> <td>{{ $row['user']->divisi->nama_divisi ?? '-' }}</td>
<td class="text-center">{{ $row['izin'] }} x</td> <td>{{ $row['user']->jabatan->nama_jabatan ?? '-' }}</td>
<td class="text-center">{{ $row['sakit'] }} x</td> <td class="center">
<td class="text-center">{{ $row['alpha'] }} x</td> <span class="badge badge-green">{{ $row['hadir'] }}</span>
<td class="text-center">{{ $row['terlambat'] }} x</td> </td>
<td class="center">
@if($row['izin'] > 0)
<span class="badge badge-blue">{{ $row['izin'] }}</span>
@else
<span style="color:#cbd5e1"></span>
@endif
</td>
<td class="center">
@if($row['sakit'] > 0)
<span class="badge badge-orange">{{ $row['sakit'] }}</span>
@else
<span style="color:#cbd5e1"></span>
@endif
</td>
<td class="center">
@if($row['alpha'] > 0)
<span class="badge badge-red">{{ $row['alpha'] }}</span>
@else
<span style="color:#cbd5e1"></span>
@endif
</td>
<td class="center">
@if($row['terlambat'] > 0)
<span class="badge badge-orange">{{ $row['terlambat'] }}x</span>
@else
<span style="color:#cbd5e1"></span>
@endif
</td>
<td class="center">
@if($row['poin_lembur'] > 0)
<span class="badge badge-purple">{{ $row['poin_lembur'] }}</span>
@else
<span style="color:#cbd5e1"></span>
@endif
</td>
</tr> </tr>
@endforeach @endforeach
</tbody> </tbody>
</table> </table>
<div class="footer"> {{-- FOOTER --}}
<p>Dicetak pada: {{ \Carbon\Carbon::now()->format('d/m/Y H:i') }}</p> <div class="footer-area">
<div class="signature"> <div class="footer-left">
<p>( _______________________ )</p> <p>* Laporan ini dibuat secara otomatis oleh sistem MPG HRIS.</p>
<p><strong>HR Department</strong></p> <p>* Data bersumber dari catatan presensi dan lembur bulan berjalan.</p>
</div>
<div class="footer-right">
<div class="ttd-box">
<div class="city-date">Jakarta, {{ \Carbon\Carbon::now()->translatedFormat('d F Y') }}</div>
<div class="ttd-name">HR Department</div>
<div class="ttd-title">Human Resources</div>
</div>
</div> </div>
</div> </div>
<div class="page-number"></div>
<script type="text/php">
if (isset($pdf)) {
$w = $pdf->get_width();
$h = $pdf->get_height();
$font = $fontMetrics->get_font("DejaVu Sans, sans-serif", "normal");
$pdf->page_text($w / 2 - 40, $h - 20, "— Halaman {PAGE_NUM} dari {PAGE_COUNT} —", $font, 7, [0.58, 0.64, 0.71]);
}
</script>
</body> </body>
</html> </html>

View File

@ -96,6 +96,7 @@
'/cuti': 'shimmer-presensi', '/cuti': 'shimmer-presensi',
'/surat-izin': 'shimmer-presensi', '/surat-izin': 'shimmer-presensi',
'/tukar-shift': 'shimmer-presensi', '/tukar-shift': 'shimmer-presensi',
'/notifikasi': 'shimmer-presensi',
}; };
function getShimmerId(url) { function getShimmerId(url) {
@ -251,7 +252,57 @@ class="text-xl md:text-2xl font-bold text-slate-800 tracking-tight whitespace-no
</h2> </h2>
</div> </div>
<div class="flex items-center gap-6"> <div class="flex items-center gap-4 md:gap-6">
{{-- Bell Notifikasi --}}
<div class="relative" x-data="notifDropdown()" @click.away="open = false">
<button @click="toggle()" class="relative p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-xl transition-all duration-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
</svg>
<span id="notif-badge" class="hidden absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center text-[10px] font-bold text-white bg-red-500 rounded-full ring-2 ring-white">0</span>
</button>
{{-- Dropdown Panel --}}
<div x-show="open" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-y-2 scale-95" x-transition:enter-end="opacity-100 translate-y-0 scale-100" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 translate-y-0 scale-100" x-transition:leave-end="opacity-0 translate-y-2 scale-95"
class="absolute right-0 mt-2 w-96 bg-white rounded-2xl shadow-2xl border border-slate-200/60 overflow-hidden z-50" style="display: none;">
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<div>
<h3 class="text-sm font-bold text-slate-800">Notifikasi</h3>
<p class="text-xs text-slate-400 mt-0.5" x-text="unreadCount > 0 ? unreadCount + ' belum dibaca' : 'Semua sudah dibaca'"></p>
</div>
<button x-show="unreadCount > 0" @click="markAllRead()" class="text-xs font-semibold text-primary hover:underline">Tandai semua dibaca</button>
</div>
<div class="max-h-80 overflow-y-auto" id="notif-dropdown-list">
<template x-if="items.length === 0">
<div class="px-5 py-10 text-center">
<svg class="w-12 h-12 mx-auto text-slate-200 mb-3" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" /></svg>
<p class="text-sm text-slate-400">Belum ada notifikasi</p>
</div>
</template>
<template x-for="item in items" :key="item.id">
<button @click="readItem(item)" class="w-full text-left px-5 py-3.5 hover:bg-slate-50 transition-colors border-b border-slate-50 last:border-0 flex gap-3" :class="{ 'bg-blue-50/40': !item.is_read }">
<div class="flex-shrink-0 mt-0.5">
<span class="w-9 h-9 rounded-lg flex items-center justify-center text-sm" :class="getIconClass(item.tipe)" x-html="getIcon(item.tipe)"></span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-slate-800 truncate" x-text="item.judul"></p>
<p class="text-xs text-slate-500 mt-0.5 line-clamp-2" x-text="item.pesan"></p>
<p class="text-[11px] text-slate-400 mt-1" x-text="item.waktu"></p>
</div>
<span x-show="!item.is_read" class="flex-shrink-0 mt-2 w-2 h-2 rounded-full bg-blue-500"></span>
</button>
</template>
</div>
<a href="{{ route('notifikasi.index') }}" class="block px-5 py-3 text-center text-xs font-semibold text-primary hover:bg-slate-50 border-t border-slate-100 transition-colors" data-turbo="false">
Lihat Semua Notifikasi
</a>
</div>
</div>
<div class="text-right hidden md:block"> <div class="text-right hidden md:block">
<p class="text-sm font-bold text-slate-800">{{ Auth::user()->nama_lengkap ?? 'Guest' }}</p> <p class="text-sm font-bold text-slate-800">{{ Auth::user()->nama_lengkap ?? 'Guest' }}</p>
<p class="text-xs text-slate-500 font-medium">{{ Auth::user()->email ?? 'user@example.com' <p class="text-xs text-slate-500 font-medium">{{ Auth::user()->email ?? 'user@example.com'

View File

@ -316,8 +316,8 @@ class="block py-2.5 px-4 text-sm rounded-lg transition-all {{ request()->routeIs
</nav> </nav>
{{-- ── Footer: Pengaturan Akun & Logout ── --}} {{-- Footer: Pengaturan Akun & Logout --}}
<div class="px-8 py-6 border-t border-slate-100 mt-auto bg-slate-50/50"> <div class="px-8 py-4 mt-auto bg-slate-50/50">
<a href="{{ route('profile.edit') }}" <a href="{{ route('profile.edit') }}"
class="flex items-center px-4 py-3 rounded-xl transition-all duration-200 group {{ request()->routeIs('profile.edit') ? 'bg-[#130F26] text-white shadow-lg shadow-[#130F26]/30' : 'text-slate-500 hover:bg-slate-50 hover:text-slate-900' }}"> class="flex items-center px-4 py-3 rounded-xl transition-all duration-200 group {{ request()->routeIs('profile.edit') ? 'bg-[#130F26] text-white shadow-lg shadow-[#130F26]/30' : 'text-slate-500 hover:bg-slate-50 hover:text-slate-900' }}">
<svg class="w-5 h-5 mr-3 {{ request()->routeIs('profile.edit') ? 'text-white' : 'text-slate-400 group-hover:text-slate-600' }} group-hover:scale-110 transition-transform duration-300" <svg class="w-5 h-5 mr-3 {{ request()->routeIs('profile.edit') ? 'text-white' : 'text-slate-400 group-hover:text-slate-600' }} group-hover:scale-110 transition-transform duration-300"

View File

@ -0,0 +1,108 @@
@extends('layouts.app')
@section('title', 'Notifikasi')
@section('content')
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-slate-800">Notifikasi</h1>
<p class="text-sm text-slate-500 mt-1">Daftar semua notifikasi Anda</p>
</div>
@if($unreadCount > 0)
<form id="mark-all-read-form" onsubmit="return false;">
<x-button type="button" variant="secondary" onclick="markAllReadPage()">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>
Tandai Semua Dibaca ({{ $unreadCount }})
</x-button>
</form>
@endif
</div>
<div class="bg-white rounded-2xl border border-slate-200/60 shadow-sm overflow-hidden">
@forelse($notifikasi as $item)
<div class="flex items-start gap-4 px-5 py-4 border-b border-slate-100 last:border-0 hover:bg-slate-50/50 transition-colors cursor-pointer notif-item {{ !$item->is_read ? 'bg-blue-50/30' : '' }}"
data-id="{{ $item->id }}"
data-read="{{ $item->is_read ? '1' : '0' }}"
onclick="handleNotifClick(this)">
<div class="flex-shrink-0 mt-0.5">
@php
$iconConfig = match($item->tipe) {
'lembur' => ['bg' => 'bg-amber-100', 'text' => 'text-amber-600', 'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />'],
'presensi' => ['bg' => 'bg-green-100', 'text' => 'text-green-600', 'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />'],
'pengumuman' => ['bg' => 'bg-blue-100', 'text' => 'text-blue-600', 'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 1 1 0-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 0 1-1.44-4.282m3.102.069a18.03 18.03 0 0 1-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 0 1 8.835 2.535M10.34 6.66a23.847 23.847 0 0 0 8.835-2.535m0 0A23.74 23.74 0 0 0 18.795 3m.38 1.125a23.91 23.91 0 0 1 1.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 0 0 1.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 0 1 0 3.46" />'],
'surat_izin', 'izin' => ['bg' => 'bg-purple-100', 'text' => 'text-purple-600', 'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />'],
'poin' => ['bg' => 'bg-rose-100', 'text' => 'text-rose-600', 'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />'],
default => ['bg' => 'bg-slate-100', 'text' => 'text-slate-600', 'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />'],
};
@endphp
<span class="w-10 h-10 rounded-xl flex items-center justify-center {{ $iconConfig['bg'] }} {{ $iconConfig['text'] }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">{!! $iconConfig['icon'] !!}</svg>
</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-semibold text-slate-800 {{ !$item->is_read ? 'font-bold' : '' }}">{{ $item->judul }}</p>
<p class="text-sm text-slate-500 mt-0.5">{{ $item->pesan }}</p>
</div>
@if(!$item->is_read)
<span class="flex-shrink-0 mt-1.5 w-2.5 h-2.5 rounded-full bg-blue-500"></span>
@endif
</div>
<p class="text-xs text-slate-400 mt-1.5">{{ $item->created_at->diffForHumans() }}</p>
</div>
</div>
@empty
<div class="px-5 py-16 text-center">
<svg class="w-16 h-16 mx-auto text-slate-200 mb-4" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" /></svg>
<p class="text-slate-400 font-medium">Belum ada notifikasi</p>
<p class="text-sm text-slate-300 mt-1">Notifikasi akan muncul saat ada aktivitas terkait Anda</p>
</div>
@endforelse
</div>
@if($notifikasi->hasPages())
<div class="mt-6">
{{ $notifikasi->links() }}
</div>
@endif
@endsection
@section('script')
<script>
function handleNotifClick(el) {
var id = el.getAttribute('data-id');
var isRead = el.getAttribute('data-read') === '1';
if (!isRead) {
fetch('/notifikasi/' + id + '/read', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
}
}).then(function() {
el.classList.remove('bg-blue-50/30');
el.setAttribute('data-read', '1');
var dot = el.querySelector('.bg-blue-500');
if (dot) dot.remove();
var title = el.querySelector('.font-bold');
if (title) title.classList.remove('font-bold');
});
}
}
function markAllReadPage() {
fetch('/notifikasi/read-all', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
}
}).then(function() {
window.location.reload();
});
}
</script>
@endsection

View File

@ -1,4 +1,4 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Manajemen Role') @section('title', 'Manajemen Role')
@ -36,16 +36,28 @@ class="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:
</x-slot> </x-slot>
@forelse($roles as $index => $role) @forelse($roles as $index => $role)
@php
$roleKritis = in_array(strtolower($role->nama_role), ['manajer', 'manager', 'supervisor', 'hrd', 'super_admin', 'staff']);
@endphp
<tr class="hover:bg-slate-50 transition border-b border-slate-50 last:border-b-0"> <tr class="hover:bg-slate-50 transition border-b border-slate-50 last:border-b-0">
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-600">{{ $roles->firstItem() + $index }}</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-slate-600">{{ $roles->firstItem() + $index }}</td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center gap-2">
<x-badge color="blue">{{ $role->nama_role }}</x-badge> <x-badge color="blue">{{ $role->nama_role }}</x-badge>
@if($roleKritis)
<span class="text-xs text-slate-400 bg-slate-100 px-2 py-0.5 rounded-full">Sistem</span>
@endif
</div>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-center text-sm text-slate-600">{{ $role->users_count }} <td class="px-6 py-4 whitespace-nowrap text-center text-sm text-slate-600">{{ $role->users_count }}
Orang</td> Orang</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
@if($roleKritis)
<span class="px-3 py-1.5 text-xs text-slate-400 bg-slate-50 border border-slate-200 rounded-lg cursor-not-allowed" title="Role sistem tidak dapat diubah">
Terkunci
</span>
@else
<x-button-edit onclick="openEditModal(this)" <x-button-edit onclick="openEditModal(this)"
data-id="{{ $role->id_role }}" data-id="{{ $role->id_role }}"
data-nama="{{ $role->nama_role }}" data-nama="{{ $role->nama_role }}"
@ -56,6 +68,7 @@ class="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:
method="POST" class="hidden"> method="POST" class="hidden">
@csrf @method('DELETE') @csrf @method('DELETE')
</form> </form>
@endif
</div> </div>
</td> </td>
</tr> </tr>

View File

@ -1,4 +1,4 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Master Shift Kerja') @section('title', 'Master Shift Kerja')
@ -82,12 +82,10 @@ class="text-sm font-mono bg-red-50 text-red-700 px-2 py-1 rounded">{{ \Carbon\Ca
<x-modal name="create-shift" title="Tambah Shift Baru"> <x-modal name="create-shift" title="Tambah Shift Baru">
<form action="{{ route('shift.store') }}" method="POST"> <form action="{{ route('shift.store') }}" method="POST">
@csrf @csrf
<div class="space-y-4"> <div class="flex flex-col md:flex-row gap-4">
<x-input label="Nama Shift" name="nama_shift" placeholder="Contoh: Shift Pagi" required /> <div class="flex-1"><x-input label="Nama Shift" name="nama_shift" placeholder="Cth: Shift Pagi" required /></div>
<div class="grid grid-cols-2 gap-4"> <div class="flex-1"><x-input type="time" label="Jam Masuk" name="jam_mulai" required /></div>
<x-input type="time" label="Jam Masuk" name="jam_mulai" required /> <div class="flex-1"><x-input type="time" label="Jam Pulang" name="jam_selesai" required /></div>
<x-input type="time" label="Jam Pulang" name="jam_selesai" required />
</div>
</div> </div>
<div class="flex justify-end gap-3 pt-4 border-t border-slate-100 mt-4"> <div class="flex justify-end gap-3 pt-4 border-t border-slate-100 mt-4">
<button type="button" x-data @click="$dispatch('close-modal', 'create-shift')" <button type="button" x-data @click="$dispatch('close-modal', 'create-shift')"
@ -101,12 +99,10 @@ class="px-4 py-2 text-sm bg-primary text-white rounded-xl hover:bg-primary/90">S
<x-modal name="edit-shift" title="Edit Shift"> <x-modal name="edit-shift" title="Edit Shift">
<form id="editForm" method="POST"> <form id="editForm" method="POST">
@csrf @method('PUT') @csrf @method('PUT')
<div class="space-y-4"> <div class="flex flex-col md:flex-row gap-4">
<x-input label="Nama Shift" name="nama_shift" id="edit_nama_shift" required /> <div class="flex-1"><x-input label="Nama Shift" name="nama_shift" id="edit_nama_shift" required /></div>
<div class="grid grid-cols-2 gap-4"> <div class="flex-1"><x-input type="time" label="Jam Masuk" name="jam_mulai" id="edit_jam_mulai" required /></div>
<x-input type="time" label="Jam Masuk" name="jam_mulai" id="edit_jam_mulai" required /> <div class="flex-1"><x-input type="time" label="Jam Pulang" name="jam_selesai" id="edit_jam_selesai" required /></div>
<x-input type="time" label="Jam Pulang" name="jam_selesai" id="edit_jam_selesai" required />
</div>
</div> </div>
<div class="flex justify-end gap-3 pt-4 border-t border-slate-100 mt-4"> <div class="flex justify-end gap-3 pt-4 border-t border-slate-100 mt-4">
<button type="button" x-data @click="$dispatch('close-modal', 'edit-shift')" <button type="button" x-data @click="$dispatch('close-modal', 'edit-shift')"

View File

@ -10,7 +10,7 @@
use App\Http\Controllers\Api\ProfileController; use App\Http\Controllers\Api\ProfileController;
use App\Http\Controllers\Api\FaceEnrollmentController; use App\Http\Controllers\Api\FaceEnrollmentController;
use App\Http\Controllers\Api\PoinController; use App\Http\Controllers\Api\PoinController;
use App\Http\Controllers\Api\KompensasiController;
use App\Http\Controllers\Api\SignatureApiController; use App\Http\Controllers\Api\SignatureApiController;
use App\Http\Controllers\Api\SuratIzinApiController; use App\Http\Controllers\Api\SuratIzinApiController;
@ -22,7 +22,6 @@
Route::get('/dashboard', [\App\Http\Controllers\Api\DashboardController::class, 'index']); Route::get('/dashboard', [\App\Http\Controllers\Api\DashboardController::class, 'index']);
Route::post('/profile/update', [ProfileController::class, 'update']); Route::post('/profile/update', [ProfileController::class, 'update']);
Route::post('/profile/password', [ProfileController::class, 'password']); Route::post('/profile/password', [ProfileController::class, 'password']);
Route::post('/profile/update2', [AuthController::class, 'updateProfile']);
Route::post('/face/enroll', [FaceEnrollmentController::class, 'enrollFace']); Route::post('/face/enroll', [FaceEnrollmentController::class, 'enrollFace']);
Route::get('/face/status', [FaceEnrollmentController::class, 'getFaceStatus']); Route::get('/face/status', [FaceEnrollmentController::class, 'getFaceStatus']);
@ -46,16 +45,10 @@
Route::put('/submission/{id}', [SubmissionController::class, 'update']); Route::put('/submission/{id}', [SubmissionController::class, 'update']);
Route::get('/submission/history', [SubmissionController::class, 'history']); Route::get('/submission/history', [SubmissionController::class, 'history']);
Route::get('/jenis-izin', [SubmissionController::class, 'types']);
Route::post('/pengajuan-izin', [SubmissionController::class, 'store']);
Route::put('/pengajuan-izin/{id}', [SubmissionController::class, 'update']);
Route::post('/lembur', [LemburController::class, 'store']); Route::post('/lembur', [LemburController::class, 'store']);
Route::put('/lembur/{id}', [LemburController::class, 'update']); Route::put('/lembur/{id}', [LemburController::class, 'update']);
Route::get('/lembur/history', [LemburController::class, 'history']); Route::get('/lembur/history', [LemburController::class, 'history']);
Route::get('/kompensasi', [KompensasiController::class, 'index']);
Route::get('/pengumuman', [\App\Http\Controllers\Api\PengumumanApiController::class, 'index']); Route::get('/pengumuman', [\App\Http\Controllers\Api\PengumumanApiController::class, 'index']);
Route::get('/user', [AuthController::class, 'user']); Route::get('/user', [AuthController::class, 'user']);
@ -68,4 +61,13 @@
Route::post('/surat-izin', [SuratIzinApiController::class, 'store']); Route::post('/surat-izin', [SuratIzinApiController::class, 'store']);
Route::get('/surat-izin/{id}', [SuratIzinApiController::class, 'show']); Route::get('/surat-izin/{id}', [SuratIzinApiController::class, 'show']);
// Notifikasi
Route::prefix('notifikasi')->group(function () {
Route::get('/', [\App\Http\Controllers\Api\NotifikasiApiController::class, 'index']);
Route::get('/unread-count', [\App\Http\Controllers\Api\NotifikasiApiController::class, 'unreadCount']);
Route::post('/{id}/read', [\App\Http\Controllers\Api\NotifikasiApiController::class, 'markAsRead']);
Route::post('/read-all', [\App\Http\Controllers\Api\NotifikasiApiController::class, 'markAllAsRead']);
});
Route::post('/device-token', [\App\Http\Controllers\Api\NotifikasiApiController::class, 'saveDeviceToken']);
}); });

View File

@ -17,3 +17,7 @@
Artisan::command('inspire', function () { Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote')->hourly(); })->purpose('Display an inspiring quote')->hourly();
use Illuminate\Support\Facades\Schedule;
Schedule::command('presensi:auto-alpha')->dailyAt('01:00')->timezone('Asia/Jakarta');

View File

@ -47,6 +47,7 @@
Route::get('/laporan', [LaporanController::class, 'index'])->name('laporan.index'); Route::get('/laporan', [LaporanController::class, 'index'])->name('laporan.index');
Route::get('/laporan/izin', [LaporanController::class, 'cuti'])->name('laporan.izin'); Route::get('/laporan/izin', [LaporanController::class, 'cuti'])->name('laporan.izin');
Route::get('/laporan/izin/export', [LaporanController::class, 'exportIzinExcel'])->name('laporan.izin.export');
Route::get('/laporan/export', [LaporanController::class, 'exportExcel'])->name('laporan.export'); Route::get('/laporan/export', [LaporanController::class, 'exportExcel'])->name('laporan.export');
Route::get('/laporan/export-pdf', [LaporanController::class, 'exportPdf'])->name('laporan.exportPdf'); Route::get('/laporan/export-pdf', [LaporanController::class, 'exportPdf'])->name('laporan.exportPdf');
@ -72,6 +73,13 @@
Route::get('/tanda-tangan', [SignatureController::class, 'show'])->name('signature.show'); Route::get('/tanda-tangan', [SignatureController::class, 'show'])->name('signature.show');
Route::post('/tanda-tangan', [SignatureController::class, 'store'])->name('signature.store'); Route::post('/tanda-tangan', [SignatureController::class, 'store'])->name('signature.store');
// Notifikasi Web (semua role)
Route::get('/notifikasi', [\App\Http\Controllers\NotifikasiWebController::class, 'index'])->name('notifikasi.index');
Route::get('/notifikasi/unread-count', [\App\Http\Controllers\NotifikasiWebController::class, 'unreadCount'])->name('notifikasi.unread-count');
Route::get('/notifikasi/recent', [\App\Http\Controllers\NotifikasiWebController::class, 'recent'])->name('notifikasi.recent');
Route::post('/notifikasi/read-all', [\App\Http\Controllers\NotifikasiWebController::class, 'markAllAsRead'])->name('notifikasi.read-all');
Route::post('/notifikasi/{id}/read', [\App\Http\Controllers\NotifikasiWebController::class, 'markAsRead'])->name('notifikasi.read');
}); });
Route::middleware(['auth', 'role:hrd'])->group(function () { Route::middleware(['auth', 'role:hrd'])->group(function () {
@ -107,6 +115,9 @@
Route::get('/face-approval/photo/{userId}/{pose}', [FaceApprovalController::class, 'showPhoto'])->name('face.photo'); Route::get('/face-approval/photo/{userId}/{pose}', [FaceApprovalController::class, 'showPhoto'])->name('face.photo');
Route::put('/face-approval/{id}/approve', [FaceApprovalController::class, 'approve'])->name('face.approve'); Route::put('/face-approval/{id}/approve', [FaceApprovalController::class, 'approve'])->name('face.approve');
Route::delete('/face-approval/{id}/reject', [FaceApprovalController::class, 'reject'])->name('face.reject'); Route::delete('/face-approval/{id}/reject', [FaceApprovalController::class, 'reject'])->name('face.reject');
Route::delete('/face-approval/{id}/reset', [FaceApprovalController::class, 'reset'])->name('face.reset');
Route::resource('role', RoleController::class)->except(['create', 'edit']); Route::resource('role', RoleController::class)->except(['create', 'edit']);
}); });