notifikasi +seeder +migration+penyesuaian
This commit is contained in:
parent
d5cb370890
commit
1f881a6c69
|
|
@ -18,6 +18,7 @@
|
|||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/storage/app/firebase/
|
||||
/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
|
|
|
|||
|
|
@ -28,20 +28,29 @@ class AutoPresensiJob extends Command
|
|||
|
||||
public function handle()
|
||||
{
|
||||
$hariIni = Carbon::today()->format('Y-m-d');
|
||||
// Target H-1 (Kemarin) untuk mencegah the Midnight Bug
|
||||
$hariIni = Carbon::yesterday('Asia/Jakarta')->format('Y-m-d');
|
||||
// Target H-2 untuk sweeping pekerja shift malam
|
||||
$hariLusa = Carbon::now('Asia/Jakarta')->subDays(2)->format('Y-m-d');
|
||||
|
||||
$usersWithJadwal = JadwalKerja::where('tanggal', $hariIni)
|
||||
->with('user')
|
||||
->get()
|
||||
->pluck('id_user');
|
||||
// Dapatkan jadwal H-1
|
||||
$jadwalH1 = JadwalKerja::where('tanggal', $hariIni)
|
||||
->with(['shift', 'user'])
|
||||
->get();
|
||||
|
||||
$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;
|
||||
|
||||
foreach ($usersAlpha as $userId) {
|
||||
// 1. Pegawai H-1 yang sama sekali tidak absen masuk
|
||||
foreach ($usersAlphaH1 as $userId) {
|
||||
Presensi::create([
|
||||
'id_user' => $userId,
|
||||
'tanggal' => $hariIni,
|
||||
|
|
@ -51,8 +60,67 @@ public function handle()
|
|||
]);
|
||||
}
|
||||
|
||||
$this->info('Auto Alpha processed for ' . $usersAlpha->count() . ' users.');
|
||||
$this->info('Auto Alpha processed for ' . $usersAlphaH1->count() . ' users (no clock in H-1).');
|
||||
|
||||
// 2. Sweeping pegawai yang lupa absen pulang pada H-1
|
||||
$incompleteH1 = Presensi::where('tanggal', $hariIni)
|
||||
->whereNotNull('jam_masuk')
|
||||
->whereNull('jam_pulang')
|
||||
->get();
|
||||
|
||||
$countIncomplete = 0;
|
||||
|
||||
foreach ($incompleteH1 as $presensi) {
|
||||
$userJadwal = $jadwalH1->where('id_user', $presensi->id_user)->first();
|
||||
$isNightShift = false;
|
||||
|
||||
if ($userJadwal && $userJadwal->shift) {
|
||||
if ($userJadwal->shift->jam_selesai < $userJadwal->shift->jam_mulai || stripos($userJadwal->shift->nama_shift, 'Malam') !== false) {
|
||||
$isNightShift = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Eksekusi jika bukan shift malam
|
||||
if (!$isNightShift) {
|
||||
$presensi->update([
|
||||
'id_status' => $idAlpha,
|
||||
'id_validasi' => 1,
|
||||
'alasan_telat' => 'Auto Alpha: Lupa Absen Pulang (Shift Normal)'
|
||||
]);
|
||||
$countIncomplete++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sweeping pekerja Shift Malam yang lupa absen pulang pada H-2
|
||||
$incompleteH2 = Presensi::where('tanggal', $hariLusa)
|
||||
->whereNotNull('jam_masuk')
|
||||
->whereNull('jam_pulang')
|
||||
->get();
|
||||
|
||||
foreach ($incompleteH2 as $presensi) {
|
||||
$userJadwal = $jadwalH2->where('id_user', $presensi->id_user)->first();
|
||||
$isNightShift = false;
|
||||
|
||||
if ($userJadwal && $userJadwal->shift) {
|
||||
if ($userJadwal->shift->jam_selesai < $userJadwal->shift->jam_mulai || stripos($userJadwal->shift->nama_shift, 'Malam') !== false) {
|
||||
$isNightShift = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Eksekusi HANYA jika ia benar shift malam
|
||||
if ($isNightShift) {
|
||||
$presensi->update([
|
||||
'id_status' => $idAlpha,
|
||||
'id_validasi' => 1,
|
||||
'alasan_telat' => 'Auto Alpha: Lupa Absen Pulang (Shift Malam)'
|
||||
]);
|
||||
$countIncomplete++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Auto Alpha updated for $countIncomplete users (missing clock out with Night Shift Protection).");
|
||||
|
||||
// 4. Cleanup old photos
|
||||
$dateLimit = Carbon::now()->subMonths(3);
|
||||
|
||||
$oldPresensi = Presensi::where('tanggal', '<', $dateLimit)->get();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -7,9 +7,17 @@
|
|||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use Maatwebsite\Excel\Concerns\WithColumnWidths;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||
use Maatwebsite\Excel\Events\AfterSheet;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class LaporanLemburExport implements FromCollection, WithHeadings, WithMapping, ShouldAutoSize, WithStyles
|
||||
class LaporanLemburExport implements FromCollection, WithHeadings, WithMapping, WithStyles, WithColumnWidths, WithTitle, WithEvents
|
||||
{
|
||||
protected $pegawai;
|
||||
protected $rekap;
|
||||
|
|
@ -19,9 +27,14 @@ class LaporanLemburExport implements FromCollection, WithHeadings, WithMapping,
|
|||
public function __construct($pegawai, array $rekap, $bulan, $tahun)
|
||||
{
|
||||
$this->pegawai = $pegawai;
|
||||
$this->rekap = $rekap;
|
||||
$this->bulan = $bulan;
|
||||
$this->tahun = $tahun;
|
||||
$this->rekap = $rekap;
|
||||
$this->bulan = $bulan;
|
||||
$this->tahun = $tahun;
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return 'Lembur ' . Carbon::create()->month($this->bulan)->translatedFormat('F Y');
|
||||
}
|
||||
|
||||
public function collection()
|
||||
|
|
@ -31,21 +44,25 @@ public function collection()
|
|||
|
||||
public function headings(): array
|
||||
{
|
||||
$namaBulan = Carbon::create()->month($this->bulan)->translatedFormat('F');
|
||||
|
||||
return [
|
||||
['LAPORAN REKAPITULASI LEMBUR PEGAWAI'],
|
||||
['Periode: ' . \Carbon\Carbon::create()->month($this->bulan)->translatedFormat('F') . ' ' . $this->tahun],
|
||||
['MPG HRIS Enterprise System'],
|
||||
['Periode: ' . $namaBulan . ' ' . $this->tahun],
|
||||
['Dicetak: ' . Carbon::now()->translatedFormat('d F Y, H:i') . ' WIB'],
|
||||
[],
|
||||
[
|
||||
'No',
|
||||
'NIK',
|
||||
'NIK / ID Karyawan',
|
||||
'Nama Lengkap',
|
||||
'Divisi / Dept',
|
||||
'Jabatan',
|
||||
'Jumlah Hari Lembur',
|
||||
'Total Waktu (Menit)',
|
||||
'Durasi Jam',
|
||||
'Poin Lembur Diperoleh'
|
||||
]
|
||||
'Durasi (Jam)',
|
||||
'Poin Lembur Diperoleh',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -55,10 +72,10 @@ public function map($row): array
|
|||
$no++;
|
||||
|
||||
$dataRekap = $this->rekap[$row->id] ?? [
|
||||
'total_menit' => 0,
|
||||
'format_jam' => '0j 0m',
|
||||
'jumlah_hari' => 0,
|
||||
'poin_diperoleh' => 0,
|
||||
'total_menit' => 0,
|
||||
'format_jam' => '0j 0m',
|
||||
'jumlah_hari' => 0,
|
||||
'poin_diperoleh'=> 0,
|
||||
];
|
||||
|
||||
return [
|
||||
|
|
@ -67,20 +84,137 @@ public function map($row): array
|
|||
$row->nama_lengkap,
|
||||
$row->divisi->nama_divisi ?? '-',
|
||||
$row->jabatan->nama_jabatan ?? '-',
|
||||
$dataRekap['jumlah_hari'] . ' Hari',
|
||||
$dataRekap['total_menit'] . ' Menit',
|
||||
$dataRekap['jumlah_hari'],
|
||||
$dataRekap['total_menit'],
|
||||
$dataRekap['format_jam'],
|
||||
$dataRekap['poin_diperoleh'] . ' Poin'
|
||||
$dataRekap['poin_diperoleh'],
|
||||
];
|
||||
}
|
||||
|
||||
public function columnWidths(): array
|
||||
{
|
||||
return [
|
||||
'A' => 5,
|
||||
'B' => 22,
|
||||
'C' => 28,
|
||||
'D' => 22,
|
||||
'E' => 20,
|
||||
'F' => 18,
|
||||
'G' => 20,
|
||||
'H' => 14,
|
||||
'I' => 22,
|
||||
];
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet)
|
||||
{
|
||||
return [
|
||||
1 => [
|
||||
'font' => ['bold' => true, 'size' => 14, 'color' => ['rgb' => '1E293B']],
|
||||
],
|
||||
2 => [
|
||||
'font' => ['italic' => true, 'size' => 10, 'color' => ['rgb' => '64748B']],
|
||||
],
|
||||
3 => [
|
||||
'font' => ['size' => 10, 'color' => ['rgb' => '475569']],
|
||||
],
|
||||
4 => [
|
||||
'font' => ['size' => 9, 'color' => ['rgb' => '94A3B8']],
|
||||
],
|
||||
6 => [
|
||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => [
|
||||
'fillType' => Fill::FILL_SOLID,
|
||||
'startColor' => ['rgb' => '1E293B'],
|
||||
],
|
||||
'alignment' => [
|
||||
'horizontal' => Alignment::HORIZONTAL_CENTER,
|
||||
'vertical' => Alignment::VERTICAL_CENTER,
|
||||
'wrapText' => true,
|
||||
],
|
||||
'borders' => [
|
||||
'allBorders' => [
|
||||
'borderStyle' => Border::BORDER_THIN,
|
||||
'color' => ['rgb' => '334155'],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
1 => ['font' => ['bold' => true, 'size' => 14]],
|
||||
2 => ['font' => ['italic' => true]],
|
||||
4 => ['font' => ['bold' => true], 'fill' => ['fillType' => 'solid', 'color' => ['rgb' => 'E6F4EA']]],
|
||||
public function registerEvents(): array
|
||||
{
|
||||
return [
|
||||
AfterSheet::class => function (AfterSheet $event) {
|
||||
$sheet = $event->sheet->getDelegate();
|
||||
$lastRow = count($this->pegawai) + 6;
|
||||
$dataStart = 7;
|
||||
|
||||
$sheet->mergeCells('A1:I1');
|
||||
$sheet->mergeCells('A2:I2');
|
||||
$sheet->mergeCells('A3:I3');
|
||||
$sheet->mergeCells('A4:I4');
|
||||
|
||||
// Border + zebra striping
|
||||
for ($r = $dataStart; $r <= $lastRow; $r++) {
|
||||
$isEven = ($r - $dataStart) % 2 === 1;
|
||||
|
||||
$sheet->getStyle("A{$r}:I{$r}")->applyFromArray([
|
||||
'borders' => [
|
||||
'allBorders' => [
|
||||
'borderStyle' => Border::BORDER_THIN,
|
||||
'color' => ['rgb' => 'E2E8F0'],
|
||||
],
|
||||
],
|
||||
'fill' => $isEven ? [
|
||||
'fillType' => Fill::FILL_SOLID,
|
||||
'startColor' => ['rgb' => 'F8FAFC'],
|
||||
] : [],
|
||||
]);
|
||||
|
||||
$sheet->getStyle("A{$r}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle("F{$r}:I{$r}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle("C{$r}")->getFont()->setBold(true);
|
||||
|
||||
// Highlight poin lembur > 0 (ungu muda)
|
||||
$poinVal = $sheet->getCell("I{$r}")->getValue();
|
||||
if ($poinVal > 0) {
|
||||
$sheet->getStyle("I{$r}")->applyFromArray([
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'F3E8FF']],
|
||||
'font' => ['color' => ['rgb' => '6B21A8'], 'bold' => true],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$sheet->getRowDimension(6)->setRowHeight(30);
|
||||
$sheet->freezePane('C7');
|
||||
|
||||
// Ringkasan total
|
||||
$summaryRow = $lastRow + 2;
|
||||
$totalHari = array_sum(array_column($this->rekap, 'jumlah_hari'));
|
||||
$totalMenit = array_sum(array_column($this->rekap, 'total_menit'));
|
||||
$totalPoin = array_sum(array_column($this->rekap, 'poin_diperoleh'));
|
||||
$totalJam = intdiv($totalMenit, 60) . 'j ' . ($totalMenit % 60) . 'm';
|
||||
|
||||
$sheet->setCellValue("A{$summaryRow}", 'RINGKASAN');
|
||||
$sheet->setCellValue("B{$summaryRow}", 'Total Hari Lembur: ' . $totalHari . ' hari');
|
||||
$sheet->setCellValue("C{$summaryRow}", 'Total Waktu: ' . $totalMenit . ' menit');
|
||||
$sheet->setCellValue("D{$summaryRow}", 'Total Durasi: ' . $totalJam);
|
||||
$sheet->setCellValue("E{$summaryRow}", 'Total Poin: ' . $totalPoin . ' poin');
|
||||
|
||||
$sheet->getStyle("A{$summaryRow}:E{$summaryRow}")->applyFromArray([
|
||||
'font' => ['bold' => true, 'size' => 9, 'color' => ['rgb' => '1E293B']],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'F1F5F9']],
|
||||
'borders' => [
|
||||
'allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'CBD5E1']],
|
||||
],
|
||||
]);
|
||||
|
||||
$noteRow = $summaryRow + 2;
|
||||
$sheet->setCellValue("A{$noteRow}", '* Laporan dibuat otomatis oleh sistem MPG HRIS. Data bersumber dari catatan lembur yang telah disetujui.');
|
||||
$sheet->getStyle("A{$noteRow}")->getFont()->setItalic(true)->setSize(8)->getColor()->setRGB('94A3B8');
|
||||
$sheet->mergeCells("A{$noteRow}:I{$noteRow}");
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,17 @@
|
|||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use Maatwebsite\Excel\Concerns\WithColumnWidths;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||
use Maatwebsite\Excel\Events\AfterSheet;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class LaporanPresensiExport implements FromCollection, WithHeadings, WithMapping, ShouldAutoSize, WithStyles
|
||||
class LaporanPresensiExport implements FromCollection, WithHeadings, WithMapping, WithStyles, WithColumnWidths, WithTitle, WithEvents
|
||||
{
|
||||
protected $rekap;
|
||||
protected $bulan;
|
||||
|
|
@ -17,9 +25,14 @@ class LaporanPresensiExport implements FromCollection, WithHeadings, WithMapping
|
|||
|
||||
public function __construct(array $rekap, $bulan, $tahun)
|
||||
{
|
||||
$this->rekap = $rekap;
|
||||
$this->bulan = $bulan;
|
||||
$this->tahun = $tahun;
|
||||
$this->rekap = $rekap;
|
||||
$this->bulan = $bulan;
|
||||
$this->tahun = $tahun;
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return 'Presensi ' . Carbon::createFromDate($this->tahun, (int) $this->bulan, 1)->translatedFormat('F Y');
|
||||
}
|
||||
|
||||
public function collection()
|
||||
|
|
@ -29,23 +42,27 @@ public function collection()
|
|||
|
||||
public function headings(): array
|
||||
{
|
||||
$namaBulan = Carbon::createFromDate($this->tahun, (int) $this->bulan, 1)->translatedFormat('F');
|
||||
|
||||
return [
|
||||
['LAPORAN REKAPITULASI PRESENSI PEGAWAI'],
|
||||
['Periode: ' . \Carbon\Carbon::createFromDate($this->tahun, (int) $this->bulan, 1)->translatedFormat('F') . ' ' . $this->tahun],
|
||||
['MPG HRIS Enterprise System'],
|
||||
['Periode: ' . $namaBulan . ' ' . $this->tahun],
|
||||
['Dicetak: ' . Carbon::now()->translatedFormat('d F Y, H:i') . ' WIB'],
|
||||
[],
|
||||
[
|
||||
'No',
|
||||
'NIK',
|
||||
'NIK / ID Karyawan',
|
||||
'Nama Lengkap',
|
||||
'Jabatan',
|
||||
'Divisi',
|
||||
'Hadir',
|
||||
'Izin / Cuti',
|
||||
'Sakit',
|
||||
'Alpha (Mangkir)',
|
||||
'Terlambat',
|
||||
'Poin Lembur Diperoleh'
|
||||
]
|
||||
'Hadir (Hari)',
|
||||
'Izin / Cuti (Hari)',
|
||||
'Sakit (Hari)',
|
||||
'Alpha / Mangkir (Hari)',
|
||||
'Terlambat (Kali)',
|
||||
'Poin Lembur',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -60,22 +77,168 @@ public function map($row): array
|
|||
$row['user']->nama_lengkap,
|
||||
$row['user']->jabatan->nama_jabatan ?? '-',
|
||||
$row['user']->divisi->nama_divisi ?? '-',
|
||||
$row['hadir'] . ' Hari',
|
||||
$row['izin'] . ' Hari',
|
||||
$row['sakit'] . ' Hari',
|
||||
$row['alpha'] . ' Hari',
|
||||
$row['terlambat'] . ' Kali',
|
||||
$row['poin_lembur'] . ' Poin'
|
||||
$row['hadir'],
|
||||
$row['izin'],
|
||||
$row['sakit'],
|
||||
$row['alpha'],
|
||||
$row['terlambat'],
|
||||
$row['poin_lembur'],
|
||||
];
|
||||
}
|
||||
|
||||
public function columnWidths(): array
|
||||
{
|
||||
return [
|
||||
'A' => 5,
|
||||
'B' => 22,
|
||||
'C' => 28,
|
||||
'D' => 20,
|
||||
'E' => 20,
|
||||
'F' => 14,
|
||||
'G' => 18,
|
||||
'H' => 14,
|
||||
'I' => 22,
|
||||
'J' => 18,
|
||||
'K' => 14,
|
||||
];
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet)
|
||||
{
|
||||
return [
|
||||
$lastRow = count($this->rekap) + 7;
|
||||
|
||||
1 => ['font' => ['bold' => true, 'size' => 14]],
|
||||
2 => ['font' => ['italic' => true]],
|
||||
4 => ['font' => ['bold' => true], 'fill' => ['fillType' => 'solid', 'color' => ['rgb' => 'E2E8F0']]],
|
||||
return [
|
||||
// Judul utama
|
||||
1 => [
|
||||
'font' => ['bold' => true, 'size' => 14, 'color' => ['rgb' => '1E293B']],
|
||||
],
|
||||
// Sub judul
|
||||
2 => [
|
||||
'font' => ['italic' => true, 'size' => 10, 'color' => ['rgb' => '64748B']],
|
||||
],
|
||||
// Periode
|
||||
3 => [
|
||||
'font' => ['size' => 10, 'color' => ['rgb' => '475569']],
|
||||
],
|
||||
// Tanggal cetak
|
||||
4 => [
|
||||
'font' => ['size' => 9, 'color' => ['rgb' => '94A3B8']],
|
||||
],
|
||||
// Header kolom
|
||||
6 => [
|
||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => [
|
||||
'fillType' => Fill::FILL_SOLID,
|
||||
'startColor' => ['rgb' => '1E293B'],
|
||||
],
|
||||
'alignment' => [
|
||||
'horizontal' => Alignment::HORIZONTAL_CENTER,
|
||||
'vertical' => Alignment::VERTICAL_CENTER,
|
||||
'wrapText' => true,
|
||||
],
|
||||
'borders' => [
|
||||
'allBorders' => [
|
||||
'borderStyle' => Border::BORDER_THIN,
|
||||
'color' => ['rgb' => '334155'],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function registerEvents(): array
|
||||
{
|
||||
return [
|
||||
AfterSheet::class => function (AfterSheet $event) {
|
||||
$sheet = $event->sheet->getDelegate();
|
||||
$lastRow = count($this->rekap) + 6;
|
||||
$dataStart = 7;
|
||||
|
||||
// Merge judul
|
||||
$sheet->mergeCells('A1:K1');
|
||||
$sheet->mergeCells('A2:K2');
|
||||
$sheet->mergeCells('A3:K3');
|
||||
$sheet->mergeCells('A4:K4');
|
||||
|
||||
// Alignment judul
|
||||
foreach (['A1', 'A2', 'A3', 'A4'] as $cell) {
|
||||
$sheet->getStyle($cell)->getAlignment()
|
||||
->setHorizontal(Alignment::HORIZONTAL_LEFT);
|
||||
}
|
||||
|
||||
// Border + alignment data baris
|
||||
for ($r = $dataStart; $r <= $lastRow; $r++) {
|
||||
$isEven = ($r - $dataStart) % 2 === 1;
|
||||
|
||||
$sheet->getStyle("A{$r}:K{$r}")->applyFromArray([
|
||||
'borders' => [
|
||||
'allBorders' => [
|
||||
'borderStyle' => Border::BORDER_THIN,
|
||||
'color' => ['rgb' => 'E2E8F0'],
|
||||
],
|
||||
],
|
||||
'fill' => $isEven ? [
|
||||
'fillType' => Fill::FILL_SOLID,
|
||||
'startColor' => ['rgb' => 'F8FAFC'],
|
||||
] : [],
|
||||
]);
|
||||
|
||||
// Kolom A (No) — center
|
||||
$sheet->getStyle("A{$r}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
|
||||
// Kolom F–K (angka) — center
|
||||
$sheet->getStyle("F{$r}:K{$r}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
|
||||
// Bold nama karyawan
|
||||
$sheet->getStyle("C{$r}")->getFont()->setBold(true);
|
||||
|
||||
// Highlight alpha > 0 (merah muda)
|
||||
$alphaVal = $sheet->getCell("I{$r}")->getValue();
|
||||
if ($alphaVal > 0) {
|
||||
$sheet->getStyle("I{$r}")->applyFromArray([
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'FEE2E2']],
|
||||
'font' => ['color' => ['rgb' => '991B1B'], 'bold' => true],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Tinggi baris header
|
||||
$sheet->getRowDimension(6)->setRowHeight(30);
|
||||
|
||||
// Freeze panel di baris header + kolom nama
|
||||
$sheet->freezePane('C7');
|
||||
|
||||
// Ringkasan di bawah tabel
|
||||
$summaryRow = $lastRow + 2;
|
||||
$totalHadir = array_sum(array_column($this->rekap, 'hadir'));
|
||||
$totalIzin = array_sum(array_column($this->rekap, 'izin'));
|
||||
$totalSakit = array_sum(array_column($this->rekap, 'sakit'));
|
||||
$totalAlpha = array_sum(array_column($this->rekap, 'alpha'));
|
||||
$totalTerlambat = array_sum(array_column($this->rekap, 'terlambat'));
|
||||
$totalPoin = array_sum(array_column($this->rekap, 'poin_lembur'));
|
||||
|
||||
$sheet->setCellValue("A{$summaryRow}", 'RINGKASAN');
|
||||
$sheet->setCellValue("B{$summaryRow}", 'Total Hadir: ' . $totalHadir . ' hari');
|
||||
$sheet->setCellValue("C{$summaryRow}", 'Total Izin: ' . $totalIzin . ' hari');
|
||||
$sheet->setCellValue("D{$summaryRow}", 'Total Sakit: ' . $totalSakit . ' hari');
|
||||
$sheet->setCellValue("E{$summaryRow}", 'Total Alpha: ' . $totalAlpha . ' hari');
|
||||
$sheet->setCellValue("F{$summaryRow}", 'Terlambat: ' . $totalTerlambat . 'x');
|
||||
$sheet->setCellValue("G{$summaryRow}", 'Poin Lembur: ' . $totalPoin);
|
||||
|
||||
$sheet->getStyle("A{$summaryRow}:G{$summaryRow}")->applyFromArray([
|
||||
'font' => ['bold' => true, 'size' => 9, 'color' => ['rgb' => '1E293B']],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'F1F5F9']],
|
||||
'borders' => [
|
||||
'allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'CBD5E1']],
|
||||
],
|
||||
]);
|
||||
|
||||
// Catatan kaki
|
||||
$noteRow = $summaryRow + 2;
|
||||
$sheet->setCellValue("A{$noteRow}", '* Laporan dibuat otomatis oleh sistem MPG HRIS. Data bersumber dari catatan presensi dan lembur bulan berjalan.');
|
||||
$sheet->getStyle("A{$noteRow}")->getFont()->setItalic(true)->setSize(8)->getColor()->setRGB('94A3B8');
|
||||
$sheet->mergeCells("A{$noteRow}:K{$noteRow}");
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Services\LemburService;
|
||||
use App\Services\NotifikasiService;
|
||||
use App\Http\Requests\StoreLemburRequest;
|
||||
use App\Models\Lembur;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -26,6 +27,13 @@ public function store(StoreLemburRequest $request)
|
|||
|
||||
$this->lemburService->createLembur($user, $request->validated());
|
||||
|
||||
app(NotifikasiService::class)->kirimKeRole(
|
||||
'hrd',
|
||||
'pengajuan_baru',
|
||||
'Pengajuan Lembur Baru',
|
||||
$user->nama_lengkap . ' mengajukan lembur.'
|
||||
);
|
||||
|
||||
return ApiResponse::success(null, 'Pengajuan lembur berhasil dikirim.', 201);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -152,7 +152,9 @@ public function history(Request $request)
|
|||
|
||||
$statusMasuk = '-';
|
||||
if ($item->jam_masuk) {
|
||||
if ($item->waktu_terlambat) {
|
||||
if ($item->id_status == 5) {
|
||||
$statusMasuk = 'Alpha (Batal)';
|
||||
} elseif ($item->waktu_terlambat) {
|
||||
$statusMasuk = 'Terlambat';
|
||||
} elseif ($item->waktu_masuk_awal) {
|
||||
$statusMasuk = 'Datang Awal';
|
||||
|
|
@ -196,6 +198,8 @@ public function history(Request $request)
|
|||
} catch (\Exception $e) {
|
||||
$totalJam = '-';
|
||||
}
|
||||
} elseif ($item->jam_masuk && !$item->jam_pulang && $item->id_status == 5) {
|
||||
$totalJam = '0j 0m (Batal)';
|
||||
}
|
||||
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Models\JenisIzin;
|
||||
use App\Models\SuratIzin;
|
||||
use App\Models\TandaTangan;
|
||||
use App\Services\NotifikasiService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -29,6 +30,11 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request)
|
|||
|
||||
$jenisIzin = JenisIzin::find($request->id_jenis_izin);
|
||||
if ($jenisIzin && $jenisIzin->nama_izin == 'Cuti') {
|
||||
// Validasi sisa cuti
|
||||
if ($user->sisa_cuti <= 0) {
|
||||
return ApiResponse::error('Sisa cuti Anda sudah habis (0 hari). Pengajuan cuti tidak dapat dilanjutkan.', 400);
|
||||
}
|
||||
|
||||
$tanggalMulai = Carbon::parse($request->tanggal_mulai);
|
||||
$minDate = Carbon::now()->addDays(7)->startOfDay();
|
||||
|
||||
|
|
@ -37,6 +43,34 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request)
|
|||
}
|
||||
}
|
||||
|
||||
$tglMulai = $request->tanggal_mulai;
|
||||
$tglSelesai = $request->tanggal_selesai;
|
||||
$userId = $user->id;
|
||||
|
||||
$overlappingIzin = PengajuanIzin::where('id_user', $userId)
|
||||
->whereIn('id_status', [1, 2])
|
||||
->where(function ($q) use ($tglMulai, $tglSelesai) {
|
||||
$q->whereBetween('tanggal_mulai', [$tglMulai, $tglSelesai])
|
||||
->orWhereBetween('tanggal_selesai', [$tglMulai, $tglSelesai])
|
||||
->orWhere(function ($q2) use ($tglMulai, $tglSelesai) {
|
||||
$q2->where('tanggal_mulai', '<=', $tglMulai)
|
||||
->where('tanggal_selesai', '>=', $tglSelesai);
|
||||
});
|
||||
})->exists();
|
||||
|
||||
if ($overlappingIzin) {
|
||||
return ApiResponse::error('Anda sudah memiliki pengajuan Izin/Cuti (Pending/Disetujui) pada rentang tanggal tersebut!', 400);
|
||||
}
|
||||
|
||||
$existingPresensi = \App\Models\Presensi::where('id_user', $userId)
|
||||
->whereBetween('tanggal', [$tglMulai, $tglSelesai])
|
||||
->whereNotNull('jam_masuk')
|
||||
->exists();
|
||||
|
||||
if ($existingPresensi) {
|
||||
return ApiResponse::error('Anda sudah tercatat absen masuk (hadir) pada rentang tanggal tersebut!', 400);
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
$path = null;
|
||||
|
|
@ -74,7 +108,7 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request)
|
|||
$suratIzin = null;
|
||||
if ($jenisIzin && $jenisIzin->nama_izin == 'Cuti') {
|
||||
$suratIzin = SuratIzin::create([
|
||||
'id_izin' => $submission->id_izin,
|
||||
'id_izin' => $submission->getKey(),
|
||||
'id_user' => $user->id,
|
||||
'id_ttd_pengaju' => $ttdAktif?->id_tanda_tangan,
|
||||
'isi_surat' => $isiSurat,
|
||||
|
|
@ -84,6 +118,14 @@ public function store(\App\Http\Requests\StorePengajuanIzinRequest $request)
|
|||
|
||||
DB::commit();
|
||||
|
||||
app(NotifikasiService::class)->kirimKeRole(
|
||||
'hrd',
|
||||
'pengajuan_baru',
|
||||
'Pengajuan Baru: ' . ($jenisIzin->nama_izin ?? 'Izin'),
|
||||
$user->nama_lengkap . ' mengajukan ' . ($jenisIzin->nama_izin ?? 'izin') . '.',
|
||||
['id_izin' => $submission->getKey()]
|
||||
);
|
||||
|
||||
$responseData = $submission->toArray();
|
||||
if ($suratIzin) {
|
||||
$responseData['surat_izin'] = [
|
||||
|
|
@ -157,6 +199,35 @@ public function update(\App\Http\Requests\UpdatePengajuanIzinRequest $request, $
|
|||
return ApiResponse::error('Pengajuan tidak ditemukan atau sudah diproses.', 404);
|
||||
}
|
||||
|
||||
$tglMulai = $request->tanggal_mulai;
|
||||
$tglSelesai = $request->tanggal_selesai;
|
||||
$userId = Auth::id();
|
||||
|
||||
$overlappingIzin = PengajuanIzin::where('id_user', $userId)
|
||||
->where('id_izin', '!=', $id)
|
||||
->whereIn('id_status', [1, 2])
|
||||
->where(function ($q) use ($tglMulai, $tglSelesai) {
|
||||
$q->whereBetween('tanggal_mulai', [$tglMulai, $tglSelesai])
|
||||
->orWhereBetween('tanggal_selesai', [$tglMulai, $tglSelesai])
|
||||
->orWhere(function ($q2) use ($tglMulai, $tglSelesai) {
|
||||
$q2->where('tanggal_mulai', '<=', $tglMulai)
|
||||
->where('tanggal_selesai', '>=', $tglSelesai);
|
||||
});
|
||||
})->exists();
|
||||
|
||||
if ($overlappingIzin) {
|
||||
return ApiResponse::error('Anda sudah memiliki pengajuan Izin/Cuti lain (Pending/Disetujui) pada rentang tanggal tersebut!', 400);
|
||||
}
|
||||
|
||||
$existingPresensi = \App\Models\Presensi::where('id_user', $userId)
|
||||
->whereBetween('tanggal', [$tglMulai, $tglSelesai])
|
||||
->whereNotNull('jam_masuk')
|
||||
->exists();
|
||||
|
||||
if ($existingPresensi) {
|
||||
return ApiResponse::error('Anda sudah tercatat absen masuk (hadir) pada rentang tanggal tersebut!', 400);
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
$data = $request->validated();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\NotifikasiService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CutiController extends Controller
|
||||
|
|
@ -40,6 +41,13 @@ public function update(Request $request, $id)
|
|||
'sisa_cuti' => $request->sisa_cuti
|
||||
]);
|
||||
|
||||
app(NotifikasiService::class)->kirim(
|
||||
$user->id,
|
||||
'update_cuti',
|
||||
'Sisa Cuti Diperbarui',
|
||||
'Sisa cuti Anda telah diperbarui menjadi ' . $request->sisa_cuti . ' hari.'
|
||||
);
|
||||
|
||||
return redirect()->back()->with('success', 'Sisa cuti ' . $user->nama_lengkap . ' berhasil diperbarui.');
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +61,12 @@ public function resetMassal(Request $request)
|
|||
'sisa_cuti' => $request->jumlah_hari
|
||||
]);
|
||||
|
||||
app(NotifikasiService::class)->kirimBroadcast(
|
||||
'reset_cuti',
|
||||
'Sisa Cuti Di-reset',
|
||||
'Sisa cuti Anda telah di-reset menjadi ' . $request->jumlah_hari . ' hari.'
|
||||
);
|
||||
|
||||
return redirect()->back()->with('success', 'Sisa cuti semua pegawai berhasil di-reset menjadi ' . $request->jumlah_hari . ' hari.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,18 +4,39 @@
|
|||
|
||||
use App\Models\User;
|
||||
use App\Models\DataWajah;
|
||||
use App\Services\NotifikasiService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class FaceApprovalController extends Controller
|
||||
{
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
$users = User::whereHas('dataWajah', function($q) {
|
||||
$q->where('is_verified', 0);
|
||||
})
|
||||
->with(['jabatan', 'divisi'])
|
||||
->get();
|
||||
$query = User::with(['jabatan', 'divisi', 'dataWajah']);
|
||||
|
||||
$status = $request->get('status', '');
|
||||
|
||||
if ($status === 'pending') {
|
||||
$query->whereHas('dataWajah', fn($q) => $q->where('is_verified', 0));
|
||||
} elseif ($status === 'approved') {
|
||||
$query->whereHas('dataWajah', fn($q) => $q->where('is_verified', 1));
|
||||
} elseif ($status === 'rejected') {
|
||||
$query->whereHas('dataWajah', fn($q) => $q->where('is_verified', 2));
|
||||
} elseif ($status === 'unregistered') {
|
||||
$query->whereDoesntHave('dataWajah');
|
||||
} else {
|
||||
$query->whereHas('dataWajah');
|
||||
}
|
||||
|
||||
if ($request->search) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('nama_lengkap', 'like', "%{$search}%")
|
||||
->orWhere('nik', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$users = $query->get();
|
||||
|
||||
$users->each(function ($user) {
|
||||
$storagePath = "face_datasets/{$user->id}";
|
||||
|
|
@ -36,7 +57,14 @@ public function index()
|
|||
$user->face_photos = $fotoList;
|
||||
});
|
||||
|
||||
return view('face-approval.index', compact('users'));
|
||||
$stats = [
|
||||
'pending' => DataWajah::where('is_verified', 0)->count(),
|
||||
'approved' => DataWajah::where('is_verified', 1)->count(),
|
||||
'rejected' => DataWajah::where('is_verified', 2)->count(),
|
||||
'unregistered' => User::whereDoesntHave('dataWajah')->count(),
|
||||
];
|
||||
|
||||
return view('face-approval.index', compact('users', 'stats', 'status'));
|
||||
}
|
||||
|
||||
public function approve($id)
|
||||
|
|
@ -47,6 +75,13 @@ public function approve($id)
|
|||
$user->dataWajah->update(['is_verified' => 1]);
|
||||
}
|
||||
|
||||
app(NotifikasiService::class)->kirim(
|
||||
$user->id,
|
||||
'face_disetujui',
|
||||
'Wajah Terverifikasi',
|
||||
'Data wajah Anda telah diverifikasi. Sekarang Anda bisa melakukan presensi.'
|
||||
);
|
||||
|
||||
return redirect()->back()->with('success', 'Wajah karyawan berhasil diverifikasi. Karyawan kini bisa melakukan presensi.');
|
||||
}
|
||||
|
||||
|
|
@ -65,9 +100,41 @@ public function reject($id)
|
|||
Storage::disk('local')->deleteDirectory($storagePath);
|
||||
}
|
||||
|
||||
app(NotifikasiService::class)->kirim(
|
||||
$user->id,
|
||||
'face_ditolak',
|
||||
'Registrasi Wajah Ditolak',
|
||||
'Data wajah Anda ditolak. Silakan lakukan registrasi ulang melalui aplikasi.'
|
||||
);
|
||||
|
||||
return redirect()->back()->with('success', 'Wajah ditolak. Karyawan diminta melakukan registrasi ulang.');
|
||||
}
|
||||
|
||||
public function reset($id)
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
if ($user->dataWajah) {
|
||||
$user->dataWajah->delete();
|
||||
}
|
||||
|
||||
$user->update(['is_face_registered' => 0]);
|
||||
|
||||
$storagePath = "face_datasets/{$user->id}";
|
||||
if (Storage::disk('local')->exists($storagePath)) {
|
||||
Storage::disk('local')->deleteDirectory($storagePath);
|
||||
}
|
||||
|
||||
app(NotifikasiService::class)->kirim(
|
||||
$user->id,
|
||||
'face_reset',
|
||||
'Data Wajah Direset',
|
||||
'Data wajah Anda telah direset oleh HRD. Silakan lakukan registrasi ulang melalui aplikasi.'
|
||||
);
|
||||
|
||||
return redirect()->back()->with('success', 'Data wajah berhasil direset. Karyawan harus melakukan registrasi ulang.');
|
||||
}
|
||||
|
||||
public function showPhoto($userId, $pose)
|
||||
{
|
||||
$extensions = ['jpg', 'jpeg', 'png'];
|
||||
|
|
|
|||
|
|
@ -74,6 +74,12 @@ public function getEvents(Request $request)
|
|||
});
|
||||
}
|
||||
|
||||
if ($request->filled('filter_nama') && $request->filter_nama != "") {
|
||||
$query->whereHas('user', function ($q) use ($request) {
|
||||
$q->where('nama_lengkap', 'like', '%' . $request->filter_nama . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$jadwals = $query->get();
|
||||
|
||||
$poinUsed = PenggunaanPoin::with('jenisPengurangan')
|
||||
|
|
@ -147,6 +153,7 @@ public function getEvents(Request $request)
|
|||
'jabatan' => $jadwalItem->user?->jabatan?->nama_jabatan ?? '-',
|
||||
'id_shift' => $jadwalItem->id_shift,
|
||||
'id_user' => $jadwalItem->id_user,
|
||||
'nik' => $jadwalItem->user?->nik ?? '-',
|
||||
]
|
||||
];
|
||||
}
|
||||
|
|
@ -223,6 +230,11 @@ public function store(Request $request)
|
|||
'user_ids.*' => 'exists:users,id',
|
||||
]);
|
||||
|
||||
// Guard: pastikan ada data shift sebelum memproses
|
||||
if (ShiftKerja::count() === 0) {
|
||||
return redirect()->back()->with('error', 'Tidak ada data Shift yang tersedia. Tambahkan Shift terlebih dahulu di menu Data Master → Shift.');
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$isGlobalAdmin = $user->isGlobalAdmin();
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
use Carbon\Carbon;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use App\Exports\LaporanPresensiExport;
|
||||
use App\Exports\LaporanIzinExport;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
|
||||
class LaporanController extends Controller
|
||||
|
|
@ -55,6 +56,41 @@ public function cuti(Request $request)
|
|||
return view('laporan.izin', compact('izinList', 'bulan', 'tahun', 'search', 'divisiId', 'divisiList'));
|
||||
}
|
||||
|
||||
public function exportIzinExcel(Request $request)
|
||||
{
|
||||
$bulan = $request->input('bulan', date('m'));
|
||||
$tahun = $request->input('tahun', date('Y'));
|
||||
$search = $request->input('search');
|
||||
$divisiId = $request->input('id_divisi');
|
||||
|
||||
$query = \App\Models\PengajuanIzin::with(['user.divisi', 'jenisIzin'])
|
||||
->whereYear('tanggal_mulai', $tahun)
|
||||
->whereMonth('tanggal_mulai', $bulan);
|
||||
|
||||
if ($search) {
|
||||
$query->whereHas('user', function ($q) use ($search) {
|
||||
$q->where('nama_lengkap', 'like', "%{$search}%")
|
||||
->orWhere('nik', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($divisiId) {
|
||||
$query->whereHas('user', function ($q) use ($divisiId) {
|
||||
$q->where('id_divisi', $divisiId);
|
||||
});
|
||||
}
|
||||
|
||||
$data = $query->orderBy('tanggal_mulai', 'desc')->get();
|
||||
|
||||
if ($data->isEmpty()) {
|
||||
return redirect()->back()->with('error', 'Tidak ada data izin/cuti untuk diekspor pada periode tersebut.');
|
||||
}
|
||||
|
||||
$filename = "Laporan_Izin_Cuti_{$bulan}_{$tahun}.xlsx";
|
||||
|
||||
return Excel::download(new LaporanIzinExport($data, $bulan, $tahun), $filename);
|
||||
}
|
||||
|
||||
public function exportExcel(Request $request)
|
||||
{
|
||||
$bulan = $request->input('bulan', date('m'));
|
||||
|
|
@ -63,6 +99,10 @@ public function exportExcel(Request $request)
|
|||
|
||||
$rekap = $this->buildRekap($bulan, $tahun, $divisiId);
|
||||
|
||||
if (empty($rekap)) {
|
||||
return redirect()->back()->with('error', 'Tidak ada data presensi untuk diekspor pada periode tersebut.');
|
||||
}
|
||||
|
||||
$filename = "Laporan_Presensi_{$bulan}_{$tahun}.xlsx";
|
||||
|
||||
return Excel::download(new LaporanPresensiExport($rekap, $bulan, $tahun), $filename);
|
||||
|
|
|
|||
|
|
@ -50,10 +50,10 @@ public function index(Request $request)
|
|||
$menit = $totalMenit % 60;
|
||||
|
||||
$rekap[$p->id] = [
|
||||
'total_menit' => $totalMenit,
|
||||
'format_jam' => "{$jam}j {$menit}m",
|
||||
'jumlah_hari' => $p->lemburs->count(),
|
||||
'poin_diperoleh' => $totalMenit,
|
||||
'total_menit' => $totalMenit,
|
||||
'format_jam' => "{$jam}j {$menit}m",
|
||||
'jumlah_hari' => $p->lemburs->count(),
|
||||
'poin_diperoleh' => $p->lemburs->sum('jumlah_poin'),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -81,6 +81,12 @@ public function exportExcel(Request $request)
|
|||
|
||||
$pegawai = $query->orderBy('nama_lengkap', 'asc')->get();
|
||||
|
||||
// Guard: cek apakah ada data lembur di periode tersebut
|
||||
$adaDataLembur = $pegawai->contains(fn($p) => $p->lemburs->isNotEmpty());
|
||||
if (!$adaDataLembur) {
|
||||
return redirect()->back()->with('error', 'Tidak ada data lembur yang disetujui untuk diekspor pada periode tersebut.');
|
||||
}
|
||||
|
||||
$rekap = [];
|
||||
foreach ($pegawai as $p) {
|
||||
$totalMenit = $p->lemburs->sum('durasi_menit');
|
||||
|
|
@ -88,10 +94,10 @@ public function exportExcel(Request $request)
|
|||
$menit = $totalMenit % 60;
|
||||
|
||||
$rekap[$p->id] = [
|
||||
'total_menit' => $totalMenit,
|
||||
'format_jam' => "{$jam}j {$menit}m",
|
||||
'jumlah_hari' => $p->lemburs->count(),
|
||||
'poin_diperoleh' => $totalMenit,
|
||||
'total_menit' => $totalMenit,
|
||||
'format_jam' => "{$jam}j {$menit}m",
|
||||
'jumlah_hari' => $p->lemburs->count(),
|
||||
'poin_diperoleh' => $p->lemburs->sum('jumlah_poin'),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
use App\Models\User;
|
||||
use App\Models\JenisKompensasi;
|
||||
use App\Services\LemburService;
|
||||
use App\Services\NotifikasiService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
|
|
@ -74,9 +75,25 @@ public function update(Request $request, Lembur $lembur)
|
|||
if ($lembur->id_kompensasi == 2) {
|
||||
$message .= ' Poin telah ditambahkan ke karyawan.';
|
||||
}
|
||||
|
||||
app(NotifikasiService::class)->kirim(
|
||||
$lembur->id_user,
|
||||
'lembur_disetujui',
|
||||
'Lembur Disetujui',
|
||||
'Pengajuan lembur Anda pada tanggal ' . $lembur->tanggal_lembur . ' telah disetujui.',
|
||||
['id_lembur' => $lembur->id_lembur]
|
||||
);
|
||||
} else {
|
||||
$this->lemburService->reject($lembur, $request->alasan_penolakan);
|
||||
$message = 'Pengajuan lembur berhasil ditolak.';
|
||||
|
||||
app(NotifikasiService::class)->kirim(
|
||||
$lembur->id_user,
|
||||
'lembur_ditolak',
|
||||
'Lembur Ditolak',
|
||||
'Pengajuan lembur Anda pada tanggal ' . $lembur->tanggal_lembur . ' ditolak. Alasan: ' . ($request->alasan_penolakan ?? '-'),
|
||||
['id_lembur' => $lembur->id_lembur]
|
||||
);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', $message);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Models\User;
|
||||
use App\Models\SuratIzin;
|
||||
use App\Models\TandaTangan;
|
||||
use App\Services\NotifikasiService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -67,10 +68,38 @@ public function store(Request $request)
|
|||
$diffParams = Carbon::parse($request->tanggal_mulai, 'Asia/Jakarta')->diffInDays(Carbon::now('Asia/Jakarta'));
|
||||
|
||||
if (Carbon::parse($request->tanggal_mulai, 'Asia/Jakarta')->diffInDays(Carbon::now('Asia/Jakarta')) < 7) {
|
||||
return back()->with('error', 'Pengajuan Cuti minimal H-7!');
|
||||
return back()->withInput()->with('error', 'Pengajuan Cuti minimal H-7!');
|
||||
}
|
||||
}
|
||||
|
||||
$tglMulai = $request->tanggal_mulai;
|
||||
$tglSelesai = $request->tanggal_selesai;
|
||||
$userId = $request->id_user;
|
||||
|
||||
$overlappingIzin = PengajuanIzin::where('id_user', $userId)
|
||||
->whereIn('id_status', [1, 2])
|
||||
->where(function ($q) use ($tglMulai, $tglSelesai) {
|
||||
$q->whereBetween('tanggal_mulai', [$tglMulai, $tglSelesai])
|
||||
->orWhereBetween('tanggal_selesai', [$tglMulai, $tglSelesai])
|
||||
->orWhere(function ($q2) use ($tglMulai, $tglSelesai) {
|
||||
$q2->where('tanggal_mulai', '<=', $tglMulai)
|
||||
->where('tanggal_selesai', '>=', $tglSelesai);
|
||||
});
|
||||
})->exists();
|
||||
|
||||
if ($overlappingIzin) {
|
||||
return back()->withInput()->with('error', 'Pegawai sudah memiliki pengajuan Izin/Cuti (Pending/Disetujui) pada rentang tanggal tersebut!');
|
||||
}
|
||||
|
||||
$existingPresensi = \App\Models\Presensi::where('id_user', $userId)
|
||||
->whereBetween('tanggal', [$tglMulai, $tglSelesai])
|
||||
->whereNotNull('jam_masuk')
|
||||
->exists();
|
||||
|
||||
if ($existingPresensi) {
|
||||
return back()->withInput()->with('error', 'Pegawai sudah tercatat melakukan absensi kehadiran pada rentang tanggal tersebut!');
|
||||
}
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
|
|
@ -182,6 +211,15 @@ public function approve($id)
|
|||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
app(NotifikasiService::class)->kirim(
|
||||
$izin->id_user,
|
||||
'izin_disetujui',
|
||||
'Pengajuan Izin Disetujui',
|
||||
'Pengajuan ' . $izin->jenisIzin->nama_izin . ' Anda telah disetujui.',
|
||||
['id_izin' => $izin->id_izin]
|
||||
);
|
||||
|
||||
return redirect()->back()->with('success', 'Izin disetujui & data presensi diperbarui.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
|
|
@ -212,6 +250,14 @@ public function reject(Request $request, $id)
|
|||
'id_status' => 3
|
||||
]);
|
||||
|
||||
app(NotifikasiService::class)->kirim(
|
||||
$izin->id_user,
|
||||
'izin_ditolak',
|
||||
'Pengajuan Izin Ditolak',
|
||||
'Pengajuan ' . ($izin->jenisIzin->nama_izin ?? 'Izin') . ' Anda ditolak.',
|
||||
['id_izin' => $izin->id_izin]
|
||||
);
|
||||
|
||||
return redirect()->back()->with('success', 'Pengajuan izin berhasil ditolak.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Models\PenggunaanPoin;
|
||||
use App\Services\PoinService;
|
||||
use App\Services\NotifikasiService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
|
|
@ -39,7 +40,6 @@ public function update(Request $request, $id)
|
|||
if ($request->action == 'approve') {
|
||||
DB::transaction(function () use ($penggunaan) {
|
||||
$penggunaan->update(['id_status' => 2]);
|
||||
|
||||
$this->poinService->deductPoin(
|
||||
$penggunaan->id_user,
|
||||
$penggunaan->jumlah_poin,
|
||||
|
|
@ -47,6 +47,14 @@ public function update(Request $request, $id)
|
|||
);
|
||||
});
|
||||
|
||||
app(NotifikasiService::class)->kirim(
|
||||
$penggunaan->id_user,
|
||||
'poin_disetujui',
|
||||
'Penggunaan Poin Disetujui',
|
||||
'Pengajuan penggunaan poin Anda pada tanggal ' . $penggunaan->tanggal_penggunaan . ' telah disetujui.',
|
||||
['id_penggunaan' => $penggunaan->id_penggunaan]
|
||||
);
|
||||
|
||||
$message = 'Pengajuan berhasil disetujui dan poin telah dipotong.';
|
||||
} else {
|
||||
$penggunaan->update([
|
||||
|
|
@ -54,6 +62,14 @@ public function update(Request $request, $id)
|
|||
'alasan_penolakan' => $request->alasan_penolakan
|
||||
]);
|
||||
|
||||
app(NotifikasiService::class)->kirim(
|
||||
$penggunaan->id_user,
|
||||
'poin_ditolak',
|
||||
'Penggunaan Poin Ditolak',
|
||||
'Pengajuan penggunaan poin Anda pada tanggal ' . $penggunaan->tanggal_penggunaan . ' ditolak.',
|
||||
['id_penggunaan' => $penggunaan->id_penggunaan]
|
||||
);
|
||||
|
||||
$message = 'Pengajuan berhasil ditolak.';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Http\Requests\StorePengumumanRequest;
|
||||
use App\Http\Requests\UpdatePengumumanRequest;
|
||||
use App\Models\Pengumuman;
|
||||
use App\Services\NotifikasiService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class PengumumanController extends Controller
|
||||
|
|
@ -31,7 +32,14 @@ public function store(StorePengumumanRequest $request)
|
|||
$data = $request->validated();
|
||||
$data['dibuat_oleh'] = Auth::id();
|
||||
|
||||
Pengumuman::create($data);
|
||||
$pengumuman = Pengumuman::create($data);
|
||||
|
||||
app(NotifikasiService::class)->kirimBroadcast(
|
||||
'pengumuman_baru',
|
||||
'📢 Pengumuman Baru',
|
||||
$pengumuman->judul,
|
||||
['id_pengumuman' => $pengumuman->id]
|
||||
);
|
||||
|
||||
return redirect()->route('pengumuman.index')
|
||||
->with('success', 'Pengumuman berhasil ditambahkan.');
|
||||
|
|
|
|||
|
|
@ -32,7 +32,18 @@ public function store(Request $request)
|
|||
public function sync(Request $request, $id_role)
|
||||
{
|
||||
$role = Role::findOrFail($id_role);
|
||||
$role->permissions()->sync($request->permissions);
|
||||
|
||||
// Guard: role HRD tidak boleh kehilangan manage_permissions
|
||||
if (strtolower($role->nama_role) === 'hrd') {
|
||||
$managePermission = Permission::where('slug', 'manage_permissions')->first();
|
||||
$selectedIds = collect($request->permissions ?? []);
|
||||
|
||||
if ($managePermission && !$selectedIds->contains($managePermission->id_permission)) {
|
||||
return back()->with('error', 'Permission "manage_permissions" tidak boleh dicabut dari role HRD untuk mencegah lockout sistem.');
|
||||
}
|
||||
}
|
||||
|
||||
$role->permissions()->sync($request->permissions ?? []);
|
||||
|
||||
return back()->with('success', 'Hak akses role berhasil diperbarui.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Models\Presensi;
|
||||
use App\Models\JadwalKerja;
|
||||
use App\Models\Kantor;
|
||||
use App\Services\NotifikasiService;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Requests\StorePresensiRequest;
|
||||
use App\Http\Requests\StoreManualPresensiRequest;
|
||||
|
|
@ -155,6 +156,14 @@ public function approve($id)
|
|||
|
||||
$presensi->update(['id_validasi' => 1]);
|
||||
|
||||
app(NotifikasiService::class)->kirim(
|
||||
$presensi->id_user,
|
||||
'presensi_disetujui',
|
||||
'Presensi Disetujui ✅',
|
||||
'Presensi Anda pada tanggal ' . $presensi->tanggal . ' telah disetujui.',
|
||||
['id_presensi' => $presensi->id_presensi]
|
||||
);
|
||||
|
||||
return redirect()->back()->with('success', 'Presensi berhasil disetujui.');
|
||||
}
|
||||
|
||||
|
|
@ -169,6 +178,14 @@ public function reject($id)
|
|||
|
||||
$presensi->update(['id_validasi' => 3]);
|
||||
|
||||
app(NotifikasiService::class)->kirim(
|
||||
$presensi->id_user,
|
||||
'presensi_ditolak',
|
||||
'Presensi Ditolak ❌',
|
||||
'Presensi Anda pada tanggal ' . $presensi->tanggal . ' ditolak. Silakan hubungi HRD.',
|
||||
['id_presensi' => $presensi->id_presensi]
|
||||
);
|
||||
|
||||
return redirect()->back()->with('success', 'Presensi berhasil ditolak.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@
|
|||
|
||||
class RoleController extends Controller
|
||||
{
|
||||
const ROLE_KRITIS = ['manajer', 'manager', 'supervisor', 'hrd', 'super_admin', 'staff'];
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Role::with(['permissions', 'users']);
|
||||
$query = Role::with(['permissions'])->withCount('users');
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where('nama_role', 'like', '%' . $request->search . '%');
|
||||
|
|
@ -39,6 +41,12 @@ public function store(StoreRoleRequest $request)
|
|||
public function update(UpdateRoleRequest $request, $id)
|
||||
{
|
||||
$role = Role::findOrFail($id);
|
||||
|
||||
if (in_array(strtolower($role->nama_role), self::ROLE_KRITIS)) {
|
||||
return redirect()->route('role.index')
|
||||
->with('error', 'Role sistem tidak dapat diubah.');
|
||||
}
|
||||
|
||||
$role->update($request->validated());
|
||||
|
||||
if ($request->has('id_permissions')) {
|
||||
|
|
@ -55,6 +63,10 @@ public function destroy($id)
|
|||
{
|
||||
$role = Role::findOrFail($id);
|
||||
|
||||
if (in_array(strtolower($role->nama_role), self::ROLE_KRITIS)) {
|
||||
return redirect()->back()->with('error', 'Role sistem tidak dapat dihapus.');
|
||||
}
|
||||
|
||||
if ($role->users()->exists()) {
|
||||
return redirect()->back()->with('error', 'Role tidak bisa dihapus karena masih digunakan user.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
use App\Models\ApprovalSurat;
|
||||
use App\Models\TandaTangan;
|
||||
use App\Models\Presensi;
|
||||
use App\Services\NotifikasiService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -61,6 +62,7 @@ public function show($id)
|
|||
}
|
||||
|
||||
$canApprove = false;
|
||||
$tahapApproval = null;
|
||||
|
||||
if ($surat->status_surat === 'menunggu_manajer' && ($user->roles->contains('nama_role', 'manajer') || $isGlobalAdmin)) {
|
||||
$canApprove = true;
|
||||
|
|
@ -128,7 +130,26 @@ public function approve(Request $request, $id)
|
|||
|
||||
DB::commit();
|
||||
|
||||
$statusLabel = $tahap === 1 ? 'Menunggu HRD' : 'Disetujui';
|
||||
if ($tahap === 1) {
|
||||
$statusLabel = 'Menunggu HRD';
|
||||
app(NotifikasiService::class)->kirim(
|
||||
$surat->id_user,
|
||||
'izin_proses',
|
||||
'Surat Izin Diproses',
|
||||
'Surat izin Anda telah disetujui Manajer dan sedang menunggu persetujuan HRD.',
|
||||
['id_surat' => $surat->id_surat]
|
||||
);
|
||||
} else {
|
||||
$statusLabel = 'Disetujui';
|
||||
app(NotifikasiService::class)->kirim(
|
||||
$surat->id_user,
|
||||
'izin_disetujui',
|
||||
'Surat Izin Disetujui ✅',
|
||||
'Surat izin Anda telah disetujui sepenuhnya.',
|
||||
['id_surat' => $surat->id_surat]
|
||||
);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', "Surat berhasil disetujui. Status: {$statusLabel}");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
|
|
@ -184,6 +205,14 @@ public function reject(Request $request, $id)
|
|||
|
||||
DB::commit();
|
||||
|
||||
app(NotifikasiService::class)->kirim(
|
||||
$surat->id_user,
|
||||
'izin_ditolak',
|
||||
'Surat Izin Ditolak ❌',
|
||||
'Surat izin Anda ditolak. Catatan: ' . ($request->catatan ?? '-'),
|
||||
['id_surat' => $surat->id_surat]
|
||||
);
|
||||
|
||||
return redirect()->back()->with('success', 'Surat izin berhasil ditolak.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
use App\Models\JadwalKerja;
|
||||
use App\Models\RiwayatTukarShift;
|
||||
use App\Http\Requests\StoreTukarShiftRequest;
|
||||
use App\Services\NotifikasiService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
|
@ -47,7 +48,28 @@ public function store(StoreTukarShiftRequest $request)
|
|||
return redirect()->back()->withInput()->with('error', 'Data jadwal tidak sesuai dengan pegawai yang dipilih.');
|
||||
}
|
||||
|
||||
// 2. Validasi bentrok jadwal kerja
|
||||
// 2. Validasi tanggal tidak boleh di masa lalu
|
||||
$hariIni = Carbon::today()->toDateString();
|
||||
if ($jadwal1->tanggal < $hariIni || $jadwal2->tanggal < $hariIni) {
|
||||
return redirect()->back()->withInput()->with('error', 'Tidak bisa menukar shift untuk tanggal yang sudah lewat.');
|
||||
}
|
||||
|
||||
// 3. Validasi presensi belum ada di tanggal tujuan
|
||||
$presensi1 = \App\Models\Presensi::where('id_user', $request->id_user_1)
|
||||
->where('tanggal', $jadwal1->tanggal)
|
||||
->exists();
|
||||
if ($presensi1) {
|
||||
return redirect()->back()->withInput()->with('error', 'Pegawai Pertama sudah memiliki data presensi pada tanggal (' . $jadwal1->tanggal . '). Tukar shift tidak dapat dilakukan.');
|
||||
}
|
||||
|
||||
$presensi2 = \App\Models\Presensi::where('id_user', $request->id_user_2)
|
||||
->where('tanggal', $jadwal2->tanggal)
|
||||
->exists();
|
||||
if ($presensi2) {
|
||||
return redirect()->back()->withInput()->with('error', 'Pegawai Kedua sudah memiliki data presensi pada tanggal (' . $jadwal2->tanggal . '). Tukar shift tidak dapat dilakukan.');
|
||||
}
|
||||
|
||||
// 4. Validasi bentrok jadwal kerja
|
||||
$conflict1 = JadwalKerja::where('id_user', $request->id_user_1)
|
||||
->where('tanggal', $jadwal2->tanggal)
|
||||
->where('id_jadwal', '!=', $jadwal1->id_jadwal)
|
||||
|
|
@ -64,7 +86,7 @@ public function store(StoreTukarShiftRequest $request)
|
|||
return redirect()->back()->withInput()->with('error', 'Pegawai Kedua sudah memiliki jadwal kerja lain pada tanggal tujuan (' . $jadwal1->tanggal . ').');
|
||||
}
|
||||
|
||||
// 3. Validasi bentrok dengan Penggunaan Poin (Cuti / dll)
|
||||
// 5. Validasi bentrok dengan Penggunaan Poin (Cuti / dll)
|
||||
$poin1 = \App\Models\PenggunaanPoin::where('id_user', $request->id_user_1)
|
||||
->where('tanggal_penggunaan', $jadwal2->tanggal)
|
||||
->where('id_status', 2)
|
||||
|
|
@ -99,6 +121,23 @@ public function store(StoreTukarShiftRequest $request)
|
|||
|
||||
DB::commit();
|
||||
|
||||
$user1 = User::find($request->id_user_1, ['*']);
|
||||
$user2 = User::find($request->id_user_2, ['*']);
|
||||
|
||||
app(NotifikasiService::class)->kirim(
|
||||
$request->id_user_1,
|
||||
'tukar_shift',
|
||||
'Jadwal Shift Ditukar',
|
||||
'Shift Anda telah ditukar dengan ' . ($user2->nama_lengkap ?? 'pegawai lain') . '.'
|
||||
);
|
||||
|
||||
app(NotifikasiService::class)->kirim(
|
||||
$request->id_user_2,
|
||||
'tukar_shift',
|
||||
'Jadwal Shift Ditukar',
|
||||
'Shift Anda telah ditukar dengan ' . ($user1->nama_lengkap ?? 'pegawai lain') . '.'
|
||||
);
|
||||
|
||||
return redirect()->route('tukar-shift.index')
|
||||
->with('success', 'Berhasil menukar shift kerja untuk kedua pegawai tersebut.');
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,14 @@ public function rules(): array
|
|||
'nullable',
|
||||
'date_format:H:i'
|
||||
],
|
||||
'jam_pulang' => 'nullable|date_format:H:i',
|
||||
'jam_pulang' => [
|
||||
'nullable',
|
||||
'date_format:H:i',
|
||||
Rule::when(
|
||||
fn () => $this->jam_masuk && $this->jam_pulang,
|
||||
['after:jam_masuk']
|
||||
),
|
||||
],
|
||||
'alasan_telat' => 'nullable|string|max:255'
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ public function rules(): array
|
|||
{
|
||||
return [
|
||||
'nama_lengkap' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'unique:users,email'],
|
||||
'nik' => ['nullable', 'string', 'max:50', 'unique:users,nik'],
|
||||
'email' => ['required', 'email', 'unique:users,email'],
|
||||
'no_telp' => ['nullable', 'string', 'max:20'],
|
||||
'alamat' => ['nullable', 'string'],
|
||||
'id_divisi' => ['required', 'exists:divisi,id_divisi'],
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ public function rules(): array
|
|||
|
||||
return [
|
||||
'nama_lengkap' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'nik' => ['nullable', 'string', 'max:50', Rule::unique('users', 'nik')->ignore($id)],
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
'unique:users,email,' . $this->route('pegawai')
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -118,7 +118,7 @@ public function isGlobalAdmin(): bool
|
|||
public function isSuperAdmin(): bool
|
||||
{
|
||||
return $this->roles->contains(function ($role) {
|
||||
return strtolower($role->nama_role) === 'hrd';
|
||||
return strtolower($role->nama_role) === 'super_admin';
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -151,4 +151,9 @@ public function lemburs(): \Illuminate\Database\Eloquent\Relations\HasMany
|
|||
{
|
||||
return $this->hasMany(Lembur::class, 'id_user', 'id');
|
||||
}
|
||||
|
||||
public function notifikasi(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
{
|
||||
return $this->hasMany(Notifikasi::class, 'id_user', 'id');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,14 @@ public function createLembur($user, array $data)
|
|||
{
|
||||
if (!isset($data['durasi_menit'])) {
|
||||
$start = Carbon::parse($data['jam_mulai']);
|
||||
$end = Carbon::parse($data['jam_selesai']);
|
||||
$diff = abs($start->diffInMinutes($end));
|
||||
$end = Carbon::parse($data['jam_selesai']);
|
||||
|
||||
// Handle shift malam yang melewati tengah malam (jam selesai < jam mulai)
|
||||
if ($end->lessThanOrEqualTo($start)) {
|
||||
$end->addDay();
|
||||
}
|
||||
|
||||
$diff = $start->diffInMinutes($end);
|
||||
} else {
|
||||
$diff = $data['durasi_menit'];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -193,8 +193,23 @@ public function absenPulang($user, $request)
|
|||
|
||||
$dynamicSchedule = $this->getDynamicSchedule($user->id, $hariIni);
|
||||
|
||||
$jamPulangEfektif = $dynamicSchedule['jam_pulang'];
|
||||
$statusJadwal = $dynamicSchedule['status_jadwal'];
|
||||
if (!$dynamicSchedule) {
|
||||
$jadwalFallback = JadwalKerja::with('shift')
|
||||
->where('id_user', $user->id)
|
||||
->where('tanggal', $hariIni)
|
||||
->first();
|
||||
|
||||
if (!$jadwalFallback) {
|
||||
throw new \Exception('Jadwal kerja tidak ditemukan.', 404);
|
||||
}
|
||||
|
||||
$jamPulangEfektif = $jadwalFallback->shift->jam_selesai;
|
||||
$statusJadwal = 'Normal';
|
||||
} else {
|
||||
$jamPulangEfektif = $dynamicSchedule['jam_pulang'];
|
||||
$statusJadwal = $dynamicSchedule['status_jadwal'];
|
||||
}
|
||||
|
||||
|
||||
$jamPulangTarget = Carbon::parse($jamPulangEfektif, 'Asia/Jakarta');
|
||||
$isPulangAwal = $jamSekarang->lessThan($jamPulangTarget);
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -19,6 +19,7 @@ public function run(): void
|
|||
PresensiStatusSeeder::class,
|
||||
JenisIzinSeeder::class,
|
||||
StatusPengajuanSeeder::class,
|
||||
JenisPenguranganSeeder::class,
|
||||
|
||||
// 4. Data dummy (opsional, comment jika tidak diperlukan di production)
|
||||
// PresensiSeeder::class,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ public function run(): void
|
|||
{
|
||||
DB::table('jenis_pengurangan')->insertOrIgnore([
|
||||
['id_pengurangan' => 1, 'nama_pengurangan' => 'Datang Terlambat'],
|
||||
['id_pengurangan' => 2, 'nama_pengurangan' => 'Pulang Cepat'],
|
||||
['id_pengurangan' => 2, 'nama_pengurangan' => 'Pulang Cepat (Biasa)'],
|
||||
['id_pengurangan' => 3, 'nama_pengurangan' => 'Tidak Hadir (Alpha)'],
|
||||
['id_pengurangan' => 4, 'nama_pengurangan' => 'Masuk Siang (Poin)'],
|
||||
['id_pengurangan' => 5, 'nama_pengurangan' => 'Pulang Cepat (Poin)'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,29 +5,30 @@
|
|||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* StatusSeeder - versi aman menggunakan insertOrIgnore.
|
||||
* Seeder ini merupakan alternatif dari PresensiStatusSeeder.
|
||||
* Tidak dipanggil di DatabaseSeeder karena PresensiStatusSeeder sudah menangani ini.
|
||||
* Dipertahankan sebagai referensi.
|
||||
*/
|
||||
class StatusSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
|
||||
DB::table('status_presensi')->delete();
|
||||
|
||||
DB::table('status_presensi')->insert([
|
||||
DB::table('status_presensi')->insertOrIgnore([
|
||||
['id_status' => 1, 'nama_status' => 'Tepat Waktu', 'created_at' => now(), 'updated_at' => now()],
|
||||
['id_status' => 2, 'nama_status' => 'Terlambat', 'created_at' => now(), 'updated_at' => now()],
|
||||
['id_status' => 3, 'nama_status' => 'Izin', 'created_at' => now(), 'updated_at' => now()],
|
||||
['id_status' => 4, 'nama_status' => 'Sakit', 'created_at' => now(), 'updated_at' => now()],
|
||||
['id_status' => 5, 'nama_status' => 'Alpha', 'created_at' => now(), 'updated_at' => now()],
|
||||
['id_status' => 2, 'nama_status' => 'Terlambat', 'created_at' => now(), 'updated_at' => now()],
|
||||
['id_status' => 3, 'nama_status' => 'Izin', 'created_at' => now(), 'updated_at' => now()],
|
||||
['id_status' => 4, 'nama_status' => 'Sakit', 'created_at' => now(), 'updated_at' => now()],
|
||||
['id_status' => 5, 'nama_status' => 'Alpha', 'created_at' => now(), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
DB::table('status_validasi_presensi')->delete();
|
||||
|
||||
DB::table('status_validasi_presensi')->insert([
|
||||
['id_status' => 1, 'nama_status' => 'Valid', 'created_at' => now(), 'updated_at' => now()],
|
||||
DB::table('status_validasi_presensi')->insertOrIgnore([
|
||||
['id_status' => 1, 'nama_status' => 'Valid', 'created_at' => now(), 'updated_at' => now()],
|
||||
['id_status' => 2, 'nama_status' => 'Pending', 'created_at' => now(), 'updated_at' => now()],
|
||||
['id_status' => 3, 'nama_status' => 'Ditolak', 'created_at' => now(), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
$this->command->info('Data Master Status berhasil dibuat!');
|
||||
$this->command->info('StatusSeeder: data status berhasil dicek/diisi.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,8 +72,128 @@
|
|||
flashSuccess.setAttribute('data-message', '');
|
||||
}
|
||||
}
|
||||
|
||||
// ==== Polling Badge Notifikasi ====
|
||||
function updateNotifBadge() {
|
||||
const badge = document.getElementById('notif-badge');
|
||||
if (!badge) return;
|
||||
|
||||
fetch('/notifikasi/unread-count', {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const count = data.count ?? 0;
|
||||
if (count > 0) {
|
||||
badge.textContent = count > 99 ? '99+' : count;
|
||||
badge.classList.remove('hidden');
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
updateNotifBadge();
|
||||
setInterval(updateNotifBadge, 30000);
|
||||
})();
|
||||
|
||||
// ==== Alpine.js Dropdown Notifikasi ====
|
||||
function notifDropdown() {
|
||||
return {
|
||||
open: false,
|
||||
items: [],
|
||||
unreadCount: 0,
|
||||
loading: false,
|
||||
|
||||
toggle() {
|
||||
this.open = !this.open;
|
||||
if (this.open) this.loadRecent();
|
||||
},
|
||||
|
||||
loadRecent() {
|
||||
this.loading = true;
|
||||
fetch('/notifikasi/recent', {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
this.items = data.data || [];
|
||||
this.unreadCount = this.items.filter(i => !i.is_read).length;
|
||||
this.loading = false;
|
||||
})
|
||||
.catch(() => { this.loading = false; });
|
||||
},
|
||||
|
||||
readItem(item) {
|
||||
if (!item.is_read) {
|
||||
fetch('/notifikasi/' + item.id + '/read', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}).then(() => {
|
||||
item.is_read = true;
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1);
|
||||
updateBadgeUI(this.unreadCount);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
markAllRead() {
|
||||
fetch('/notifikasi/read-all', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}).then(() => {
|
||||
this.items.forEach(i => i.is_read = true);
|
||||
this.unreadCount = 0;
|
||||
updateBadgeUI(0);
|
||||
});
|
||||
},
|
||||
|
||||
getIconClass(tipe) {
|
||||
var map = {
|
||||
'lembur': 'bg-amber-100 text-amber-600',
|
||||
'presensi': 'bg-green-100 text-green-600',
|
||||
'pengumuman': 'bg-blue-100 text-blue-600',
|
||||
'surat_izin': 'bg-purple-100 text-purple-600',
|
||||
'izin': 'bg-purple-100 text-purple-600',
|
||||
'poin': 'bg-rose-100 text-rose-600',
|
||||
};
|
||||
return map[tipe] || 'bg-slate-100 text-slate-600';
|
||||
},
|
||||
|
||||
getIcon(tipe) {
|
||||
var icons = {
|
||||
'lembur': '<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) {
|
||||
Swal.fire({
|
||||
title: 'Apakah Anda yakin?',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@props(['label' => null, 'name', 'type' => 'text', 'placeholder' => '', 'value' => '', 'disabled' => false])
|
||||
@props(['label' => null, 'name', 'type' => 'text', 'placeholder' => '', 'value' => '', 'disabled' => false])
|
||||
|
||||
@php
|
||||
$id = $attributes->get('id', $name);
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
placeholder="{{ $placeholder }}"
|
||||
{{ $disabled ? 'disabled' : '' }}
|
||||
{!! $attributes->except(['id', 'name', 'type', 'value', 'placeholder', 'class'])->merge([
|
||||
'class' => 'w-full px-4 py-2 border border-slate-200 bg-slate-50 rounded-xl focus:bg-white focus:ring-4 focus:ring-[#130F26]/10 focus:border-[#130F26] outline-none transition-all duration-300 placeholder-slate-400 disabled:bg-slate-100 disabled:text-slate-500 h-11'
|
||||
'class' => 'w-full block px-4 py-2 border border-slate-200 bg-slate-50 rounded-xl focus:bg-white focus:ring-4 focus:ring-[#130F26]/10 focus:border-[#130F26] outline-none transition-all duration-300 placeholder-slate-400 disabled:bg-slate-100 disabled:text-slate-500 h-11'
|
||||
]) !!}
|
||||
>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
@props(['label' => null, 'name' => null, 'value' => ''])
|
||||
@props(['label' => null, 'name' => null, 'value' => ''])
|
||||
|
||||
@php
|
||||
$id = $attributes->get('id', $name);
|
||||
@endphp
|
||||
|
||||
<div {{ $attributes->only('class')->merge(['class' => 'relative w-full']) }}>
|
||||
<div {{ $attributes->only('class')->merge(['class' => 'relative w-full mb-4']) }}>
|
||||
@if($label)
|
||||
<label for="{{ $id }}" class="block text-sm font-semibold text-slate-700 mb-2">
|
||||
{{ $label }}
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
@if($name) name="{{ $name }}" @endif
|
||||
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 }}
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@extends('layouts.app')
|
||||
@extends('layouts.app')
|
||||
|
||||
@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!
|
||||
</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 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>
|
||||
</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')">
|
||||
|
|
@ -186,6 +196,19 @@ class="block w-full pl-10 pr-3 py-2 border border-slate-300 rounded-lg focus:rin
|
|||
<script>
|
||||
function toggleModal(modalID) {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,122 @@
|
|||
@extends('layouts.app')
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Verifikasi Wajah')
|
||||
|
||||
@section('content')
|
||||
<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="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-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">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">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>
|
||||
</x-slot:header>
|
||||
|
||||
@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">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
|
|
@ -28,7 +128,7 @@
|
|||
@endif
|
||||
<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>
|
||||
</td>
|
||||
|
|
@ -37,6 +137,7 @@
|
|||
<div class="text-xs text-slate-500">{{ $user->jabatan->nama_jabatan ?? '-' }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
@if($faceStatus !== 'unregistered')
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
@php
|
||||
$poses = ['depan' => 'Depan', 'kanan' => 'Kanan', 'kiri' => 'Kiri', 'bawah' => 'Bawah'];
|
||||
|
|
@ -58,48 +159,87 @@ class="w-12 h-12 rounded-lg object-cover border-2 border-slate-200 group-hover:b
|
|||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center text-xs text-slate-400 italic">Belum ada foto</div>
|
||||
@endif
|
||||
</td>
|
||||
<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">
|
||||
Menunggu Verifikasi
|
||||
</span>
|
||||
@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
|
||||
</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 class="px-6 py-4 whitespace-nowrap text-center text-sm font-medium">
|
||||
<div class="flex justify-center gap-2">
|
||||
<form id="approve-form-{{ $user->id }}" action="{{ route('face.approve', $user->id) }}"
|
||||
method="POST" class="hidden">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
</form>
|
||||
<button
|
||||
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"
|
||||
title="Terima Wajah">
|
||||
<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>
|
||||
</svg>
|
||||
Terima
|
||||
</button>
|
||||
@if($faceStatus === 'pending')
|
||||
<form id="approve-form-{{ $user->id }}" action="{{ route('face.approve', $user->id) }}"
|
||||
method="POST" class="hidden">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
</form>
|
||||
<button
|
||||
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 transition-colors shadow-sm"
|
||||
title="Terima Wajah">
|
||||
<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>
|
||||
</svg>
|
||||
Terima
|
||||
</button>
|
||||
|
||||
<form id="reject-form-{{ $user->id }}" action="{{ route('face.reject', $user->id) }}"
|
||||
method="POST" class="hidden">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
</form>
|
||||
<button
|
||||
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"
|
||||
title="Tolak / Reset">
|
||||
<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>
|
||||
</svg>
|
||||
Tolak
|
||||
</button>
|
||||
<form id="reject-form-{{ $user->id }}" action="{{ route('face.reject', $user->id) }}"
|
||||
method="POST" class="hidden">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
</form>
|
||||
<button
|
||||
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 transition-colors shadow-sm"
|
||||
title="Tolak / Reset">
|
||||
<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>
|
||||
</svg>
|
||||
Tolak
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
@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
|
||||
</x-table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@extends('layouts.app')
|
||||
@extends('layouts.app')
|
||||
|
||||
@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">
|
||||
@csrf
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<x-select id="filter-kantor-izin" label="Filter Kantor">
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex-1"><x-select id="filter-kantor-izin" label="Filter Kantor">
|
||||
<option value="">Semua Kantor</option>
|
||||
@foreach($kantor as $k)
|
||||
<option value="{{ $k->id_kantor }}">{{ $k->nama_kantor }}</option>
|
||||
@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>
|
||||
@foreach($divisi as $d)
|
||||
<option value="{{ $d->id_divisi }}">{{ $d->nama_divisi }}</option>
|
||||
@endforeach
|
||||
</x-select>
|
||||
</x-select></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>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<x-input type="date" label="Tanggal Mulai" name="tanggal_mulai" required />
|
||||
<x-input type="date" label="Tanggal Selesai" name="tanggal_selesai" required />
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex-1"><x-input type="date" label="Tanggal Mulai" name="tanggal_mulai" required /></div>
|
||||
<div class="flex-1"><x-input type="date" label="Tanggal Selesai" name="tanggal_selesai" required /></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
@extends('layouts.app')
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Penjadwalan Shift')
|
||||
|
||||
@section('style')
|
||||
|
||||
<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>
|
||||
.fc-event {
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
padding: 2px 4px;
|
||||
border: none;
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.fc-daygrid-event {
|
||||
|
|
@ -51,8 +55,134 @@
|
|||
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 {
|
||||
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>
|
||||
@endsection
|
||||
|
|
@ -61,6 +191,14 @@
|
|||
<div class="space-y-6">
|
||||
|
||||
<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">
|
||||
<option value="">Semua Kantor</option>
|
||||
@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>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 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" />
|
||||
<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 flex-col md:flex-row gap-4">
|
||||
<div class="flex-1"><x-date-input label="Tanggal Mulai" name="tanggal_mulai" required class="mb-0" x-model="tglMulai" x-on:change="if(tglMulai) { $refs.endInput.min = tglMulai; if(tglSelesai && tglSelesai < tglMulai) tglSelesai = tglMulai; }" x-ref="startInput" /></div>
|
||||
<div class="flex-1"><x-date-input label="Tanggal Selesai" name="tanggal_selesai" required class="mb-0" x-model="tglSelesai" x-on:change="if(tglSelesai) { $refs.startInput.max = tglSelesai; if(tglMulai && tglMulai > tglSelesai) tglMulai = tglSelesai; }" x-ref="endInput" /></div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
|
|
@ -320,6 +458,7 @@ function initCalendar() {
|
|||
|
||||
var filterKantor = document.getElementById('filter_kantor');
|
||||
var filterDivisi = document.getElementById('filter_divisi');
|
||||
var filterNama = document.getElementById('filter_nama');
|
||||
var loading = document.getElementById('loading');
|
||||
|
||||
if (window.jadwalCalendar) {
|
||||
|
|
@ -336,57 +475,94 @@ function initCalendar() {
|
|||
center: 'title',
|
||||
right: 'dayGridMonth'
|
||||
},
|
||||
/* eventDidMount: function (info) {
|
||||
$(info.el).tooltip({
|
||||
title: info.event.extendedProps.description,
|
||||
placement: 'top',
|
||||
trigger: 'hover',
|
||||
container: 'body'
|
||||
});
|
||||
}, */
|
||||
buttonText: {
|
||||
today: 'Hari Ini',
|
||||
month: 'Bulan',
|
||||
list: 'Minggu'
|
||||
},
|
||||
eventDidMount: function (info) {
|
||||
if (info.event.extendedProps.description) {
|
||||
let contentHtml = `
|
||||
<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} • ${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',
|
||||
zIndex: 9999
|
||||
});
|
||||
}
|
||||
},
|
||||
eventContent: function (arg) {
|
||||
let contentEl = document.createElement('div');
|
||||
|
||||
let classes = "bg-slate-50 text-slate-700 border-slate-200 hover:bg-slate-100";
|
||||
let bg = arg.event.backgroundColor;
|
||||
|
||||
if (bg === '#10b981') classes = "bg-emerald-50 text-emerald-800 border-emerald-200 hover:bg-emerald-100";
|
||||
else if (bg === '#3b82f6') classes = "bg-blue-50 text-blue-800 border-blue-200 hover:bg-blue-100";
|
||||
else if (bg === '#f59e0b') classes = "bg-amber-50 text-amber-800 border-amber-200 hover:bg-amber-100";
|
||||
else if (bg === '#6b7280') classes = "bg-slate-100 text-slate-700 border-slate-200 hover:bg-slate-200";
|
||||
else if (bg === '#ef4444') classes = "bg-red-50 text-red-800 border-red-200 hover:bg-red-100";
|
||||
|
||||
let isPoin = arg.event.extendedProps.is_poin;
|
||||
if (isPoin) classes = "bg-orange-50 text-orange-800 border-orange-200 hover:bg-orange-100";
|
||||
|
||||
if (arg.event.extendedProps.is_holiday) {
|
||||
contentEl.innerHTML = `
|
||||
<div style="background:#fef2f2; color:#dc2626; padding: 3px 6px; border-radius: 4px; border-left: 3px solid #ef4444; font-weight:700; font-size: 11px;">
|
||||
${arg.event.title}
|
||||
</div>
|
||||
`;
|
||||
<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">
|
||||
<span class="w-2 h-2 rounded-full bg-red-500 flex-shrink-0"></span>
|
||||
<span class="truncate">${arg.event.title}</span>
|
||||
</div>
|
||||
`;
|
||||
return { domNodes: [contentEl] };
|
||||
}
|
||||
|
||||
let jamMulai = arg.event.extendedProps.jam_mulai ? arg.event.extendedProps.jam_mulai.substring(0, 5) : '??:??';
|
||||
let jamSelesai = arg.event.extendedProps.jam_selesai ? arg.event.extendedProps.jam_selesai.substring(0, 5) : '??:??';
|
||||
|
||||
let isPoin = arg.event.extendedProps.is_poin;
|
||||
let originalMulai = arg.event.extendedProps.original_jam_mulai ? arg.event.extendedProps.original_jam_mulai.substring(0, 5) : jamMulai;
|
||||
let originalSelesai = arg.event.extendedProps.original_jam_selesai ? arg.event.extendedProps.original_jam_selesai.substring(0, 5) : jamSelesai;
|
||||
|
||||
let bgColor = isPoin ? '#fff7ed' : 'transparent';
|
||||
let textColor = isPoin ? '#c2410c' : 'inherit';
|
||||
let borderLeft = isPoin ? '3px solid #f97316' : '';
|
||||
let titleStyle = isPoin ? 'font-weight:bold; color: #9a3412;' : 'font-weight:600;';
|
||||
let icon = isPoin ? '⚠️' : '';
|
||||
|
||||
let timeHtml = `${jamMulai} - ${jamSelesai}`;
|
||||
|
||||
if (isPoin) {
|
||||
timeHtml = `
|
||||
<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>
|
||||
`;
|
||||
if (arg.view.type === 'listWeek') {
|
||||
contentEl.innerHTML = `
|
||||
<div class="flex items-center gap-3 w-full py-2 px-1">
|
||||
<div class="w-3 h-3 rounded-full shadow-sm" style="background-color: ${bg};"></div>
|
||||
<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>
|
||||
`;
|
||||
} else {
|
||||
timeHtml = `<div style="font-size: 0.85em;">${jamMulai} - ${jamSelesai}</div>`;
|
||||
// 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>
|
||||
`;
|
||||
}
|
||||
|
||||
contentEl.innerHTML = `
|
||||
<div style="background:${bgColor}; color:${textColor}; border-left:${borderLeft}; padding: 2px 4px; border-radius: 4px;">
|
||||
<div class="fc-event-title" style="${titleStyle}">${arg.event.title}</div>
|
||||
${timeHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return { domNodes: [contentEl] };
|
||||
},
|
||||
events: function (info, successCallback, failureCallback) {
|
||||
|
|
@ -406,8 +582,9 @@ function initCalendar() {
|
|||
const params = new URLSearchParams({
|
||||
start: info.startStr,
|
||||
end: info.endStr,
|
||||
filter_kantor: filterKantor.value,
|
||||
filter_divisi: filterDivisi.value
|
||||
filter_kantor: filterKantor ? filterKantor.value : '',
|
||||
filter_divisi: filterDivisi ? filterDivisi.value : '',
|
||||
filter_nama: filterNama ? filterNama.value : ''
|
||||
});
|
||||
|
||||
fetch("{{ route('jadwal.events') }}?" + params.toString())
|
||||
|
|
@ -458,9 +635,9 @@ function initCalendar() {
|
|||
dayMaxEvents: 2,
|
||||
moreLinkClick: function (info) {
|
||||
const events = info.allSegs.map(seg => seg.event);
|
||||
const date = info.date;
|
||||
|
||||
openDetailHarian(date, events);
|
||||
// Alih-alih modal popover, isi tabel harian
|
||||
populateDailyTable(info.date, events);
|
||||
return "function";
|
||||
},
|
||||
moreLinkText: 'lainnya',
|
||||
|
|
@ -478,6 +655,16 @@ function initCalendar() {
|
|||
[filterKantor, filterDivisi].forEach(el => {
|
||||
if (el) el.addEventListener('change', () => window.jadwalCalendar.refetchEvents());
|
||||
});
|
||||
|
||||
if (filterNama) {
|
||||
let debounceTimer;
|
||||
filterNama.addEventListener('keyup', () => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
if (window.jadwalCalendar) window.jadwalCalendar.refetchEvents();
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!window.__jadwalEventAttached) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@extends('layouts.app')
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Data Kantor')
|
||||
|
||||
|
|
@ -115,17 +115,17 @@ class="p-2 bg-slate-100 text-slate-600 rounded-lg hover:bg-slate-200 transition"
|
|||
<label class="block text-sm font-semibold text-slate-700 mb-2">Tentukan Lokasi</label>
|
||||
<div id="map-create"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<x-input label="Nama Kantor" name="nama_kantor" placeholder="Cth: Cabang Malang" required />
|
||||
<x-select label="Tipe" name="tipe" required>
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex-1"><x-input label="Nama Kantor" name="nama_kantor" placeholder="Cth: Cabang Malang" required /></div>
|
||||
<div class="flex-1"><x-select label="Tipe" name="tipe" required>
|
||||
<option value="Cabang">Cabang</option>
|
||||
<option value="Pusat">Pusat</option>
|
||||
</x-select>
|
||||
</x-select></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<x-input label="Lat" name="latitude" id="create-lat" readonly required />
|
||||
<x-input label="Long" name="longitude" id="create-long" readonly required />
|
||||
<x-input type="number" label="Radius (m)" name="radius" id="create-radius" value="50" required />
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex-1"><x-input label="Lat" name="latitude" id="create-lat" readonly required /></div>
|
||||
<div class="flex-1"><x-input label="Long" name="longitude" id="create-long" readonly required /></div>
|
||||
<div class="w-full md:w-32"><x-input type="number" label="Radius (m)" name="radius" id="create-radius" value="50" required /></div>
|
||||
</div>
|
||||
<x-textarea label="Alamat" name="alamat" rows="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 id="map-edit"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<x-input label="Nama" name="nama_kantor" id="edit-nama" required />
|
||||
<x-select label="Tipe" name="tipe" id="edit-tipe" required>
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex-1"><x-input label="Nama" name="nama_kantor" id="edit-nama" required /></div>
|
||||
<div class="flex-1"><x-select label="Tipe" name="tipe" id="edit-tipe" required>
|
||||
<option value="Cabang">Cabang</option>
|
||||
<option value="Pusat">Pusat</option>
|
||||
</x-select>
|
||||
</x-select></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<x-input label="Lat" name="latitude" id="edit-lat" readonly required />
|
||||
<x-input label="Long" name="longitude" id="edit-long" readonly required />
|
||||
<x-input type="number" label="Radius" name="radius" id="edit-radius" required />
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex-1"><x-input label="Lat" name="latitude" id="edit-lat" readonly required /></div>
|
||||
<div class="flex-1"><x-input label="Long" name="longitude" id="edit-long" readonly required /></div>
|
||||
<div class="w-full md:w-32"><x-input type="number" label="Radius (m)" name="radius" id="edit-radius" required /></div>
|
||||
</div>
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@extends('layouts.app')
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Laporan Lembur')
|
||||
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
<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">
|
||||
<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++)
|
||||
@php $val = str_pad($i, 2, '0', STR_PAD_LEFT); @endphp
|
||||
<option value="{{ $val }}" {{ $bulan == $val ? 'selected' : '' }}>
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
</div>
|
||||
|
||||
<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--)
|
||||
<option value="{{ $i }}" {{ $tahun == $i ? 'selected' : '' }}>{{ $i }}</option>
|
||||
@endfor
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
</div>
|
||||
|
||||
<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>
|
||||
@foreach($divisiList as $div)
|
||||
<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()" />
|
||||
</div>
|
||||
|
||||
<div class="pb-1">
|
||||
<x-button type="submit" variant="secondary" class="h-[42px]">
|
||||
<div>
|
||||
<x-button type="submit" variant="secondary" class="h-[44px]">
|
||||
Filter
|
||||
</x-button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@extends('layouts.app')
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Laporan Bulanan')
|
||||
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
<form method="GET" action="{{ route('laporan.index') }}"
|
||||
class="flex flex-wrap gap-4 items-end mb-6 border-b border-slate-100 pb-6">
|
||||
<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++)
|
||||
<option value="{{ $i }}" {{ $bulan == $i ? 'selected' : '' }}>
|
||||
{{ 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>
|
||||
</div>
|
||||
<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--)
|
||||
<option value="{{ $y }}" {{ $tahun == $y ? 'selected' : '' }}>{{ $y }}</option>
|
||||
@endfor
|
||||
</x-select>
|
||||
</div>
|
||||
<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>
|
||||
@foreach($divisiList as $div)
|
||||
<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
|
||||
</x-select>
|
||||
</div>
|
||||
<div class="w-full md:w-auto pb-0.5 flex gap-2">
|
||||
<x-button type="submit" variant="primary" class="h-[42px]">
|
||||
<div class="w-full md:w-auto flex gap-2">
|
||||
<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">
|
||||
<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">
|
||||
|
|
@ -47,7 +47,7 @@ class="flex flex-wrap gap-4 items-end mb-6 border-b border-slate-100 pb-6">
|
|||
Tampilkan
|
||||
</x-button>
|
||||
<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">
|
||||
<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>
|
||||
|
|
@ -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"
|
||||
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">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@extends('layouts.app')
|
||||
@extends('layouts.app')
|
||||
|
||||
@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-slot:actions>
|
||||
<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>
|
||||
Export Excel
|
||||
</x-button>
|
||||
|
|
@ -21,7 +23,7 @@
|
|||
<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">
|
||||
<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++)
|
||||
@php $val = str_pad($i, 2, '0', STR_PAD_LEFT); @endphp
|
||||
<option value="{{ $val }}" {{ $bulan == $val ? 'selected' : '' }}>
|
||||
|
|
@ -32,7 +34,7 @@
|
|||
</div>
|
||||
|
||||
<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--)
|
||||
<option value="{{ $i }}" {{ $tahun == $i ? 'selected' : '' }}>{{ $i }}</option>
|
||||
@endfor
|
||||
|
|
@ -40,7 +42,7 @@
|
|||
</div>
|
||||
|
||||
<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>
|
||||
@foreach($divisiList as $div)
|
||||
<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()" />
|
||||
</div>
|
||||
|
||||
<div class="pb-1">
|
||||
<x-button type="submit" variant="secondary" class="h-[42px]">
|
||||
<div>
|
||||
<x-button type="submit" variant="secondary" class="h-[44px]">
|
||||
Filter
|
||||
</x-button>
|
||||
</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">
|
||||
@php
|
||||
$color = 'yellow'; $label = 'Menunggu';
|
||||
if($izin->status == 'disetujui') { $color = 'green'; $label = 'Disetujui'; }
|
||||
elseif($izin->status == 'ditolak') { $color = 'red'; $label = 'Ditolak'; }
|
||||
if($izin->id_status == 2) { $color = 'green'; $label = 'Disetujui'; }
|
||||
elseif($izin->id_status == 3) { $color = 'red'; $label = 'Ditolak'; }
|
||||
@endphp
|
||||
<x-badge color="{{ $color }}">
|
||||
{{ $label }}
|
||||
|
|
|
|||
|
|
@ -1,65 +1,364 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Laporan Presensi Pegawai</title>
|
||||
<title>Laporan Rekapitulasi Presensi</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; font-size: 11px; }
|
||||
.header { text-align: center; margin-bottom: 20px; border-bottom: 2px solid #333; padding-bottom: 10px; }
|
||||
.header h2 { margin: 0; padding: 0; font-size: 16px; }
|
||||
.header p { margin: 5px 0 0; color: #555; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
||||
th, td { border: 1px solid #ddd; padding: 6px; text-align: left; }
|
||||
th { background-color: #f4f4f4; font-weight: bold; }
|
||||
.text-center { text-align: center; }
|
||||
.footer { margin-top: 30px; text-align: right; }
|
||||
.signature { margin-top: 50px; }
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: DejaVu Sans, sans-serif;
|
||||
}
|
||||
body {
|
||||
font-family: DejaVu Sans, sans-serif;
|
||||
font-size: 10px;
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h2>LAPORAN REKAPITULASI PRESENSI PEGAWAI</h2>
|
||||
<h2>MPG HRIS ENTERPRISE SYSTEM</h2>
|
||||
<p>Periode: {{ \Carbon\Carbon::create()->month((int)$bulan)->translatedFormat('F') }} {{ $tahun }}</p>
|
||||
{{-- KOP SURAT --}}
|
||||
<div class="kop">
|
||||
<div class="kop-left">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<tr>
|
||||
<th class="text-center" width="5%">No</th>
|
||||
<th width="12%">NIK</th>
|
||||
<th width="25%">Nama Lengkap</th>
|
||||
<th width="15%">Hadir</th>
|
||||
<th width="12%">Izin/Cuti</th>
|
||||
<th width="12%">Sakit</th>
|
||||
<th width="10%">Alpha</th>
|
||||
<th width="9%">Telat</th>
|
||||
<th style="width:4%">No</th>
|
||||
<th class="left" style="width:11%">NIK / ID</th>
|
||||
<th class="left" style="width:20%">Nama Lengkap</th>
|
||||
<th class="left" style="width:13%">Divisi</th>
|
||||
<th class="left" style="width:12%">Jabatan</th>
|
||||
<th style="width:7%">Hadir</th>
|
||||
<th style="width:7%">Izin</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>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($rekap as $index => $row)
|
||||
<tr>
|
||||
<td class="text-center">{{ $index + 1 }}</td>
|
||||
<td class="center">{{ $index + 1 }}</td>
|
||||
<td>{{ $row['user']->nik ?? '-' }}</td>
|
||||
<td>{{ $row['user']->nama_lengkap }}</td>
|
||||
<td class="text-center">{{ $row['hadir'] }} x</td>
|
||||
<td class="text-center">{{ $row['izin'] }} x</td>
|
||||
<td class="text-center">{{ $row['sakit'] }} x</td>
|
||||
<td class="text-center">{{ $row['alpha'] }} x</td>
|
||||
<td class="text-center">{{ $row['terlambat'] }} x</td>
|
||||
<td class="name">{{ $row['user']->nama_lengkap }}</td>
|
||||
<td>{{ $row['user']->divisi->nama_divisi ?? '-' }}</td>
|
||||
<td>{{ $row['user']->jabatan->nama_jabatan ?? '-' }}</td>
|
||||
<td class="center">
|
||||
<span class="badge badge-green">{{ $row['hadir'] }}</span>
|
||||
</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>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="footer">
|
||||
<p>Dicetak pada: {{ \Carbon\Carbon::now()->format('d/m/Y H:i') }}</p>
|
||||
<div class="signature">
|
||||
<p>( _______________________ )</p>
|
||||
<p><strong>HR Department</strong></p>
|
||||
{{-- FOOTER --}}
|
||||
<div class="footer-area">
|
||||
<div class="footer-left">
|
||||
<p>* Laporan ini dibuat secara otomatis oleh sistem MPG HRIS.</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 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>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@
|
|||
'/cuti': 'shimmer-presensi',
|
||||
'/surat-izin': 'shimmer-presensi',
|
||||
'/tukar-shift': 'shimmer-presensi',
|
||||
'/notifikasi': 'shimmer-presensi',
|
||||
};
|
||||
|
||||
function getShimmerId(url) {
|
||||
|
|
@ -251,7 +252,57 @@ class="text-xl md:text-2xl font-bold text-slate-800 tracking-tight whitespace-no
|
|||
</h2>
|
||||
</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">
|
||||
<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'
|
||||
|
|
|
|||
|
|
@ -316,8 +316,8 @@ class="block py-2.5 px-4 text-sm rounded-lg transition-all {{ request()->routeIs
|
|||
|
||||
</nav>
|
||||
|
||||
{{-- ── Footer: Pengaturan Akun & Logout ── --}}
|
||||
<div class="px-8 py-6 border-t border-slate-100 mt-auto bg-slate-50/50">
|
||||
{{-- Footer: Pengaturan Akun & Logout --}}
|
||||
<div class="px-8 py-4 mt-auto bg-slate-50/50">
|
||||
<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' }}">
|
||||
<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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
@extends('layouts.app')
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Manajemen Role')
|
||||
|
||||
|
|
@ -36,26 +36,39 @@ class="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:
|
|||
</x-slot>
|
||||
|
||||
@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">
|
||||
<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">
|
||||
<x-badge color="blue">{{ $role->nama_role }}</x-badge>
|
||||
<div class="flex items-center gap-2">
|
||||
<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 class="px-6 py-4 whitespace-nowrap text-center text-sm text-slate-600">{{ $role->users_count }}
|
||||
Orang</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<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)"
|
||||
data-id="{{ $role->id_role }}"
|
||||
data-nama="{{ $role->nama_role }}"
|
||||
data-permissions="{{ json_encode($role->permissions->pluck('id_permission')) }}" />
|
||||
|
||||
<x-button-edit onclick="openEditModal(this)"
|
||||
data-id="{{ $role->id_role }}"
|
||||
data-nama="{{ $role->nama_role }}"
|
||||
data-permissions="{{ json_encode($role->permissions->pluck('id_permission')) }}" />
|
||||
|
||||
<x-delete-button :id="$role->id_role" />
|
||||
<form id="delete-form-{{ $role->id_role }}" action="{{ route('role.destroy', $role->id_role) }}"
|
||||
method="POST" class="hidden">
|
||||
@csrf @method('DELETE')
|
||||
</form>
|
||||
<x-delete-button :id="$role->id_role" />
|
||||
<form id="delete-form-{{ $role->id_role }}" action="{{ route('role.destroy', $role->id_role) }}"
|
||||
method="POST" class="hidden">
|
||||
@csrf @method('DELETE')
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@extends('layouts.app')
|
||||
@extends('layouts.app')
|
||||
|
||||
@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">
|
||||
<form action="{{ route('shift.store') }}" method="POST">
|
||||
@csrf
|
||||
<div class="space-y-4">
|
||||
<x-input label="Nama Shift" name="nama_shift" placeholder="Contoh: Shift Pagi" required />
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<x-input type="time" label="Jam Masuk" name="jam_mulai" required />
|
||||
<x-input type="time" label="Jam Pulang" name="jam_selesai" required />
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex-1"><x-input label="Nama Shift" name="nama_shift" placeholder="Cth: Shift Pagi" required /></div>
|
||||
<div class="flex-1"><x-input type="time" label="Jam Masuk" name="jam_mulai" required /></div>
|
||||
<div class="flex-1"><x-input type="time" label="Jam Pulang" name="jam_selesai" required /></div>
|
||||
</div>
|
||||
<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')"
|
||||
|
|
@ -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">
|
||||
<form id="editForm" method="POST">
|
||||
@csrf @method('PUT')
|
||||
<div class="space-y-4">
|
||||
<x-input label="Nama Shift" name="nama_shift" id="edit_nama_shift" required />
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<x-input type="time" label="Jam Masuk" name="jam_mulai" id="edit_jam_mulai" required />
|
||||
<x-input type="time" label="Jam Pulang" name="jam_selesai" id="edit_jam_selesai" required />
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex-1"><x-input label="Nama Shift" name="nama_shift" id="edit_nama_shift" required /></div>
|
||||
<div class="flex-1"><x-input type="time" label="Jam Masuk" name="jam_mulai" id="edit_jam_mulai" required /></div>
|
||||
<div class="flex-1"><x-input type="time" label="Jam Pulang" name="jam_selesai" id="edit_jam_selesai" required /></div>
|
||||
</div>
|
||||
<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')"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
use App\Http\Controllers\Api\ProfileController;
|
||||
use App\Http\Controllers\Api\FaceEnrollmentController;
|
||||
use App\Http\Controllers\Api\PoinController;
|
||||
use App\Http\Controllers\Api\KompensasiController;
|
||||
|
||||
use App\Http\Controllers\Api\SignatureApiController;
|
||||
use App\Http\Controllers\Api\SuratIzinApiController;
|
||||
|
||||
|
|
@ -22,7 +22,6 @@
|
|||
Route::get('/dashboard', [\App\Http\Controllers\Api\DashboardController::class, 'index']);
|
||||
Route::post('/profile/update', [ProfileController::class, 'update']);
|
||||
Route::post('/profile/password', [ProfileController::class, 'password']);
|
||||
Route::post('/profile/update2', [AuthController::class, 'updateProfile']);
|
||||
|
||||
Route::post('/face/enroll', [FaceEnrollmentController::class, 'enrollFace']);
|
||||
Route::get('/face/status', [FaceEnrollmentController::class, 'getFaceStatus']);
|
||||
|
|
@ -46,16 +45,10 @@
|
|||
Route::put('/submission/{id}', [SubmissionController::class, 'update']);
|
||||
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::put('/lembur/{id}', [LemburController::class, 'update']);
|
||||
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('/user', [AuthController::class, 'user']);
|
||||
|
|
@ -68,4 +61,13 @@
|
|||
Route::post('/surat-izin', [SuratIzinApiController::class, 'store']);
|
||||
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']);
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,3 +17,7 @@
|
|||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote')->hourly();
|
||||
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::command('presensi:auto-alpha')->dailyAt('01:00')->timezone('Asia/Jakarta');
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@
|
|||
|
||||
Route::get('/laporan', [LaporanController::class, 'index'])->name('laporan.index');
|
||||
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-pdf', [LaporanController::class, 'exportPdf'])->name('laporan.exportPdf');
|
||||
|
||||
|
|
@ -72,6 +73,13 @@
|
|||
|
||||
Route::get('/tanda-tangan', [SignatureController::class, 'show'])->name('signature.show');
|
||||
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 () {
|
||||
|
|
@ -107,6 +115,9 @@
|
|||
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::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']);
|
||||
|
||||
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue