Roles Kantor

This commit is contained in:
jouel88 2026-03-19 22:30:01 +07:00
parent e5cb2e916c
commit bffdb4bef3
6 changed files with 285 additions and 46 deletions

70
QA_GUIDE.md Normal file
View File

@ -0,0 +1,70 @@
# 🛡️ QA & System Testing Guide - MPG HRIS
Dokumen ini disusun untuk membantu tim Quality Assurance (QA) memahami alur bisnis, aturan validasi, dan skenario pengujian untuk ekosistem MPG HRIS (Web & Mobile).
---
## 👥 1. Matriks Peran & Izin (RBAC)
Sistem ini menggunakan Role-Based Access Control yang ketat. Berikut adalah daftar akun uji coba default:
| Role | Email | Password | Cakupan Akses |
| :--- | :--- | :--- | :--- |
| **Super Admin** | `superadmin@mpg.co.id` | `password` | Seluruh data & seluruh kantor (Global). |
| **Admin** | `admin@mpg.co.id` | `admin123` | Manajemen data master & operasional (Global). |
| **Manajer** | (Sesuai User) | `Mpg123!` | Approval Izin & Koreksi Presensi (**Hanya Kantor Sendiri**). |
| **Supervisor** | (Sesuai User) | `Mpg123!` | Management Jadwal Pegawai (**Hanya Kantor Sendiri**). |
| **Pegawai** | `budi@mpg.co.id` | `Mpg123!` | Presensi & Pengajuan Izin via Mobile. |
---
## 🏢 2. Isolasi Data Per Kantor (Feature Highlight)
Fitur ini memastikan Manajer dan Supervisor **tidak bisa** melihat atau memproses data dari kantor lain.
**Skenario Test QA:**
1. Login sebagai Manajer Kantor A.
2. Pastikan di Dashboard/Laporan tidak muncul nama pegawai dari Kantor B.
3. Coba akses ID Izin/Presensi dari Kantor B via URL langsung (ID di URL).
4. **Expected Result**: Muncul error 403 (Forbidden) atau redirect kembali dengan pesan error.
---
## 📱 3. Skenario Pengujian Mobile (Pegawai)
### A. Presensi Real-time
1. **Validasi Radius**: Gunakan Mock Location. Coba absen di luar radius (misal > 100m dari titik kantor).
- **Expected**: Status presensi menjadi "Pending" (menunggu validasi admin) dan muncul peringatan "Di Luar Radius".
2. **Face Recognition**:
- Absen dengan wajah yang sudah terdaftar -> **Lolos**.
- Absen dengan wajah orang lain/foto -> **Gagal/Pending verifikasi**.
3. **Shift Kerja**: Absen sesuai jam shift. Jika lewat toleransi 15 menit, status otomatis "Terlambat".
### B. Pengajuan Izin/Cuti
1. **Aturan Cuti**: Coba ajukan cuti kurang dari 7 hari dari tanggal mulai (H-7).
- **Expected**: Muncul error "Pengajuan Cuti minimal H-7".
2. **Signature**: Pastikan tanda tangan tersimpan dengan jelas dan muncul di PDF/Web Panel.
---
## 💻 4. Skenario Pengujian Web (Manajer/Admin)
### A. Alur Persetujuan Izin (Workflow)
1. **Flow**: Pegawai (Mobile) -> Manajer (Approve) -> HRD/Admin (Approve).
2. **Dampak Data**: Setelah disetujui HRD (Final), cek tabel Presensi pegawai tersebut.
- **Expected**: Data presensi otomatis terisi sesuai jenis izin (Cuti/Sakit) pada rentang tanggal tersebut.
### B. Penjadwalan (Scheduling)
1. **Bulk Create**: Buat jadwal untuk 10 pegawai sekaligus untuk 1 bulan.
2. **Conflict Detect**: Buat jadwal pada tanggal di mana pegawai sudah mengajukan Cuti.
- **Expected**: Muncul peringatan konflik penggunaan poin/cuti.
---
## 🛠️ 5. Prasyarat Teknis QA
- **Web**: Laravel 12, MySQL 8, npm installed.
- **Mobile**: Flutter SDK, Dio, Google ML Kit (Face Detection).
- **Environment**: Pastikan `BASE_URL` di mobile `.env` sudah mengarah ke IP/Domain server Laravel.
---
*Dibuat oleh Tim Development MPG*

View File

@ -61,6 +61,11 @@ ## 📜 Aturan Pengembangan (Project Rules)
- `<x-button>` & `<x-back-button>`: Tombol aksi.
- `<x-modal>`: Untuk CRUD Data Master sederhana.
## 🛡️ Quality Assurance (QA) & Testing
Untuk tim QA, silakan merujuk ke dokumen panduan pengujian komprehensif di:
👉 **[QA_GUIDE.md](file:///c:/xampp/htdocs/mpg-hris/QA_GUIDE.md)**
## 🔑 Akun Default (Seeder)
- **Super Admin**: `superadmin@mpg.co.id` / `password`

View File

@ -17,14 +17,21 @@ class JadwalController extends Controller
{
public function index()
{
$kantor = Kantor::all();
$user = Auth::user();
$isGlobalAdmin = $user->isGlobalAdmin();
$kantor = $isGlobalAdmin ? Kantor::all() : Kantor::where('id_kantor', $user->id_kantor)->get();
$divisi = Divisi::all();
$shifts = ShiftKerja::all()->map(function ($s) {
$s->color = $this->getShiftColor($s->nama_shift);
return $s;
});
$pegawai = User::where('status_aktif', 1)->orderBy('nama_lengkap', 'asc')->get();
$pegawaiQuery = User::where('status_aktif', 1);
if (!$isGlobalAdmin) {
$pegawaiQuery->where('id_kantor', $user->id_kantor);
}
$pegawai = $pegawaiQuery->orderBy('nama_lengkap', 'asc')->get();
return view('jadwal.index', compact('kantor', 'divisi', 'shifts', 'pegawai'));
}
@ -34,16 +41,32 @@ public function getEvents(Request $request)
$start = Carbon::parse($request->start)->format('Y-m-d');
$end = Carbon::parse($request->end)->format('Y-m-d');
$user = Auth::user();
$isGlobalAdmin = $user->isGlobalAdmin();
$query = JadwalKerja::with(['user.kantor', 'user.jabatan', 'shift'])
->whereDate('tanggal', '>=', $start)
->whereDate('tanggal', '<=', $end);
if ($request->filled('filter_kantor') && $request->filter_kantor != "") {
$query->whereHas('user', function ($q) use ($request) {
$q->where('id_kantor', $request->filter_kantor);
if (!$isGlobalAdmin) {
$query->whereHas('user', function ($q) use ($user) {
$q->where('id_kantor', $user->id_kantor);
});
}
if ($request->filled('filter_kantor') && $request->filter_kantor != "") {
// Jika bukan admin, pastikan filter_kantor sesuai dengan kantornya
if (!$isGlobalAdmin && $request->filter_kantor != $user->id_kantor) {
$query->whereHas('user', function ($q) use ($user) {
$q->where('id_kantor', $user->id_kantor);
});
} else {
$query->whereHas('user', function ($q) use ($request) {
$q->where('id_kantor', $request->filter_kantor);
});
}
}
if ($request->filled('filter_divisi') && $request->filter_divisi != "") {
$query->whereHas('user', function ($q) use ($request) {
$q->where('id_divisi', $request->filter_divisi);
@ -129,12 +152,12 @@ public function getEvents(Request $request)
$hariLibursQuery = \App\Models\HariLibur::whereBetween('tanggal', [$start, $end]);
if ($request->filled('filter_kantor') && $request->filter_kantor != "") {
$hariLibursQuery->where(function($q) use ($request) {
$hariLibursQuery->where(function ($q) use ($request) {
$q->whereNull('id_kantor')->orWhere('id_kantor', $request->filter_kantor);
});
}
$hariLiburs = $hariLibursQuery->get();
foreach ($hariLiburs as $hl) {
$events[] = [
'id' => 'libur-' . $hl->id,
@ -173,14 +196,18 @@ private function getShiftColor($namaShift)
public function create()
{
$user = Auth::user();
$isGlobalAdmin = $user->isGlobalAdmin();
$shifts = ShiftKerja::all();
$kantor = Kantor::all();
$kantor = $isGlobalAdmin ? Kantor::all() : Kantor::where('id_kantor', $user->id_kantor)->get();
$divisi = Divisi::all();
$pegawai = User::where('status_aktif', 1)
->with(['kantor', 'jabatan'])
->orderBy('nama_lengkap', 'asc')
->get();
$pegawaiQuery = User::where('status_aktif', 1)->with(['kantor', 'jabatan']);
if (!$isGlobalAdmin) {
$pegawaiQuery->where('id_kantor', $user->id_kantor);
}
$pegawai = $pegawaiQuery->orderBy('nama_lengkap', 'asc')->get();
return view('jadwal.create', compact('shifts', 'pegawai', 'kantor', 'divisi'));
}
@ -195,6 +222,20 @@ public function store(Request $request)
'user_ids.*' => 'exists:users,id',
]);
$user = Auth::user();
$isGlobalAdmin = $user->isGlobalAdmin();
// Validasi Kantor (Security Check)
if (!$isGlobalAdmin) {
$invalidUsers = User::whereIn('id', $request->user_ids)
->where('id_kantor', '!=', $user->id_kantor)
->exists();
if ($invalidUsers) {
return redirect()->back()->with('error', 'Anda tidak diizinkan membuat jadwal untuk karyawan di luar kantor Anda.');
}
}
$startDate = Carbon::parse($request->tanggal_mulai);
$endDate = Carbon::parse($request->tanggal_selesai);
$period = \Carbon\CarbonPeriod::create($startDate, $endDate);
@ -216,10 +257,10 @@ public function store(Request $request)
foreach ($userIds as $userId) {
$user = $usersData->get($userId);
$isLibur = $libursDate->contains(function($libur) use ($user) {
return is_null($libur->id_kantor) || ($user && $libur->id_kantor == $user->id_kantor);
$isLibur = $libursDate->contains(function ($libur) use ($user) {
return is_null($libur->id_kantor) || ($user && $libur->id_kantor == $user->id_kantor);
});
if ($isLibur) {
continue;
}
@ -249,7 +290,6 @@ public function store(Request $request)
}
return redirect()->route('jadwal.index')->with('success', $message);
} catch (\Exception $e) {
return redirect()->back()->with('error', 'Terjadi kesalahan: ' . $e->getMessage());
}
@ -262,6 +302,13 @@ public function update(Request $request, $id)
]);
$jadwal = JadwalKerja::findOrFail($id);
$user = Auth::user();
// Security Check
if (!$user->isGlobalAdmin() && $jadwal->user->id_kantor != $user->id_kantor) {
return response()->json(['success' => false, 'message' => 'Anda tidak diizinkan mengubah jadwal karyawan di luar kantor Anda.'], 403);
}
$warningMsg = null;
try {
@ -284,7 +331,6 @@ public function update(Request $request, $id)
'success' => true,
'message' => $warningMsg ?? 'Jadwal berhasil diperbarui'
]);
} catch (\Exception $e) {
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
}
@ -293,6 +339,13 @@ public function update(Request $request, $id)
public function destroy($id)
{
$jadwal = JadwalKerja::findOrFail($id);
$user = Auth::user();
// Security Check
if (!$user->isGlobalAdmin() && $jadwal->user->id_kantor != $user->id_kantor) {
return response()->json(['success' => false, 'message' => 'Anda tidak diizinkan menghapus jadwal karyawan di luar kantor Anda.'], 403);
}
$jadwal->delete();
return response()->json(['success' => true, 'message' => 'Jadwal berhasil dihapus']);
@ -307,6 +360,19 @@ public function bulkDestroy(Request $request)
'user_ids.*' => 'exists:users,id',
]);
$user = Auth::user();
$isGlobalAdmin = $user->isGlobalAdmin();
if (!$isGlobalAdmin) {
$invalidUsers = User::whereIn('id', $request->user_ids)
->where('id_kantor', '!=', $user->id_kantor)
->exists();
if ($invalidUsers) {
return redirect()->back()->with('error', 'Anda tidak diizinkan menghapus jadwal karyawan di luar kantor Anda.');
}
}
try {
DB::transaction(function () use ($request) {
JadwalKerja::whereIn('id_user', $request->user_ids)

View File

@ -19,9 +19,17 @@ class PengajuanIzinController extends Controller
public function index(Request $request)
{
$statusId = $request->get('status');
$user = Auth::user();
$isGlobalAdmin = $user->isGlobalAdmin();
$query = PengajuanIzin::with(['user', 'jenisIzin', 'statusPengajuan', 'suratIzin']);
if (!$isGlobalAdmin) {
$query->whereHas('user', function ($q) use ($user) {
$q->where('id_kantor', $user->id_kantor);
});
}
if ($statusId) {
$query->where('id_status', $statusId);
}
@ -121,7 +129,13 @@ public function approve($id)
try {
DB::beginTransaction();
$izin = PengajuanIzin::with('jenisIzin')->findOrFail($id);
$izin = PengajuanIzin::with(['jenisIzin', 'user'])->findOrFail($id);
$user = Auth::user();
// Security Check
if (!$user->isGlobalAdmin() && $izin->user->id_kantor != $user->id_kantor) {
return redirect()->back()->with('error', 'Anda tidak diizinkan menyetujui pengajuan dari kantor lain.');
}
if ($izin->id_status !== 1) {
return redirect()->back()->with('error', 'Pengajuan ini sudah diproses sebelumnya.');
@ -178,7 +192,13 @@ public function approve($id)
public function reject(Request $request, $id)
{
$izin = PengajuanIzin::findOrFail($id);
$izin = PengajuanIzin::with('user')->findOrFail($id);
$user = Auth::user();
// Security Check
if (!$user->isGlobalAdmin() && $izin->user->id_kantor != $user->id_kantor) {
return redirect()->back()->with('error', 'Anda tidak diizinkan menolak pengajuan dari kantor lain.');
}
if ($izin->id_status !== 1) {
return redirect()->back()->with('error', 'Pengajuan ini sudah diproses sebelumnya.');

View File

@ -17,11 +17,20 @@ class PresensiController extends Controller
{
public function index(Request $request)
{
$user = Auth::user();
$isGlobalAdmin = $user->isGlobalAdmin();
$tanggal = $request->get('tanggal', Carbon::today()->format('Y-m-d'));
$divisiId = $request->get('divisi_id');
$pendingDates = Presensi::where('id_validasi', 2)
->selectRaw('DATE(tanggal) as tgl, COUNT(*) as jumlah')
$pendingQuery = Presensi::where('id_validasi', 2);
if (!$isGlobalAdmin) {
$pendingQuery->whereHas('user', function ($q) use ($user) {
$q->where('id_kantor', $user->id_kantor);
});
}
$pendingDates = $pendingQuery->selectRaw('DATE(tanggal) as tgl, COUNT(*) as jumlah')
->groupBy('tgl')
->orderBy('tgl', 'desc')
->get();
@ -29,6 +38,12 @@ public function index(Request $request)
$query = Presensi::with(['user.jabatan', 'user.kantor'])
->whereDate('tanggal', $tanggal);
if (!$isGlobalAdmin) {
$query->whereHas('user', function ($q) use ($user) {
$q->where('id_kantor', $user->id_kantor);
});
}
if ($divisiId) {
$query->whereHas('user', function ($q) use ($divisiId) {
$q->where('id_divisi', $divisiId);
@ -109,7 +124,7 @@ public function index(Request $request)
$item->jarak_masuk = null;
if ($item->user && $item->user->kantor && $item->lat_masuk && $item->lon_masuk) {
$item->jarak_masuk = app(self::class)->calculateDistance(
$item->jarak_masuk = $this->calculateDistance(
$item->lat_masuk,
$item->lon_masuk,
$item->user->kantor->latitude,
@ -131,7 +146,13 @@ public function index(Request $request)
public function approve($id)
{
$presensi = Presensi::findOrFail($id);
$presensi = Presensi::with('user')->findOrFail($id);
$user = Auth::user();
if (!$user->isGlobalAdmin() && $presensi->user->id_kantor != $user->id_kantor) {
return redirect()->back()->with('error', 'Anda tidak diizinkan menyetujui presensi dari kantor lain.');
}
$presensi->update(['id_validasi' => 1]);
return redirect()->back()->with('success', 'Presensi berhasil disetujui.');
@ -139,7 +160,13 @@ public function approve($id)
public function reject($id)
{
$presensi = Presensi::findOrFail($id);
$presensi = Presensi::with('user')->findOrFail($id);
$user = Auth::user();
if (!$user->isGlobalAdmin() && $presensi->user->id_kantor != $user->id_kantor) {
return redirect()->back()->with('error', 'Anda tidak diizinkan menolak presensi dari kantor lain.');
}
$presensi->update(['id_validasi' => 3]);
return redirect()->back()->with('success', 'Presensi berhasil ditolak.');
@ -152,6 +179,19 @@ public function bulkAction(Request $request)
'action' => 'required|in:approve,reject'
]);
$user = Auth::user();
if (!$user->isGlobalAdmin()) {
$invalidCount = Presensi::whereIn('id_presensi', $request->presensi_ids)
->whereHas('user', function ($q) use ($user) {
$q->where('id_kantor', '!=', $user->id_kantor);
})
->count();
if ($invalidCount > 0) {
return redirect()->back()->with('error', 'Terdapat data dari kantor lain dalam pilihan Anda.');
}
}
$statusValidasi = $request->action == 'approve' ? 1 : 3;
Presensi::whereIn('id_presensi', $request->presensi_ids)
@ -164,9 +204,17 @@ public function bulkAction(Request $request)
public function create()
{
$pegawai = \App\Models\User::with('divisi')
->orderBy('nama_lengkap', 'asc')
->get();
$user = Auth::user();
$isGlobalAdmin = $user->isGlobalAdmin();
$pegawaiQuery = \App\Models\User::with('divisi')
->orderBy('nama_lengkap', 'asc');
if (!$isGlobalAdmin) {
$pegawaiQuery->where('id_kantor', $user->id_kantor);
}
$pegawai = $pegawaiQuery->get();
$statuses = \Illuminate\Support\Facades\DB::table('status_presensi')->get();
@ -213,6 +261,12 @@ public function storeManual(StoreManualPresensiRequest $request)
public function edit($id)
{
$presensi = Presensi::with('user.divisi')->findOrFail($id);
$user = Auth::user();
if (!$user->isGlobalAdmin() && $presensi->user->id_kantor != $user->id_kantor) {
abort(403, 'Anda tidak diizinkan mengedit presensi dari kantor lain.');
}
$statuses = \Illuminate\Support\Facades\DB::table('status_presensi')->get();
return view('presensi.edit', compact('presensi', 'statuses'));
@ -220,6 +274,13 @@ public function edit($id)
public function updateManual(Request $request, $id)
{
$presensi = Presensi::with('user')->findOrFail($id);
$user = Auth::user();
if (!$user->isGlobalAdmin() && $presensi->user->id_kantor != $user->id_kantor) {
return redirect()->back()->with('error', 'Anda tidak diizinkan mengoreksi presensi dari kantor lain.');
}
$request->validate([
'id_status' => 'required|exists:status_presensi,id_status',
'jam_masuk' => [

View File

@ -15,8 +15,17 @@ class SuratIzinController extends Controller
{
public function index(Request $request)
{
$user = Auth::user();
$isGlobalAdmin = $user->isGlobalAdmin();
$query = SuratIzin::with(['user', 'pengajuanIzin.jenisIzin', 'approvals.approver']);
if (!$isGlobalAdmin) {
$query->whereHas('user', function ($q) use ($user) {
$q->where('id_kantor', $user->id_kantor);
});
}
if ($request->filled('status')) {
$query->where('status_surat', $request->status);
}
@ -44,17 +53,19 @@ public function show($id)
])->findOrFail($id);
$user = Auth::user();
$isGlobalAdmin = $user->isGlobalAdmin();
// Security Check: Office Isolation
if (!$isGlobalAdmin && $surat->user->id_kantor != $user->id_kantor) {
abort(403, 'Anda tidak diizinkan mengakses data dari kantor lain.');
}
$canApprove = false;
$tahapApproval = null;
$isSuperAdmin = $user->roles->contains(function ($role) {
return in_array(strtolower($role->nama_role), ['admin', 'super admin', 'super_admin']);
});
if ($surat->status_surat === 'menunggu_manajer' && ($user->roles->contains('nama_role', 'manajer') || $isSuperAdmin)) {
if ($surat->status_surat === 'menunggu_manajer' && ($user->roles->contains('nama_role', 'manajer') || $isGlobalAdmin)) {
$canApprove = true;
$tahapApproval = 1;
} elseif ($surat->status_surat === 'menunggu_hrd' && ($user->roles->contains('nama_role', 'hrd') || $isSuperAdmin)) {
} elseif ($surat->status_surat === 'menunggu_hrd' && ($user->roles->contains('nama_role', 'hrd') || $isGlobalAdmin)) {
$canApprove = true;
$tahapApproval = 2;
}
@ -67,21 +78,24 @@ public function show($id)
public function approve(Request $request, $id)
{
try {
$surat = SuratIzin::findOrFail($id);
$surat = SuratIzin::with('user')->findOrFail($id);
$user = Auth::user();
$isGlobalAdmin = $user->isGlobalAdmin();
// Security Check
if (!$isGlobalAdmin && $surat->user->id_kantor != $user->id_kantor) {
return redirect()->back()->with('error', 'Anda tidak diizinkan menyetujui surat dari karyawan kantor lain.');
}
if (in_array($surat->status_surat, ['disetujui', 'ditolak'])) {
return redirect()->back()->with('error', 'Surat ini sudah diproses sebelumnya.');
}
$tahap = null;
$isSuperAdmin = $user->roles->contains(function ($role) {
return in_array(strtolower($role->nama_role), ['admin', 'super admin', 'super_admin']);
});
if ($surat->status_surat === 'menunggu_manajer' && ($user->roles->contains('nama_role', 'manajer') || $isSuperAdmin)) {
if ($surat->status_surat === 'menunggu_manajer' && ($user->roles->contains('nama_role', 'manajer') || $isGlobalAdmin)) {
$tahap = 1;
} elseif ($surat->status_surat === 'menunggu_hrd' && ($user->roles->contains('nama_role', 'hrd') || $isSuperAdmin)) {
} elseif ($surat->status_surat === 'menunggu_hrd' && ($user->roles->contains('nama_role', 'hrd') || $isGlobalAdmin)) {
$tahap = 2;
}
@ -126,21 +140,24 @@ public function approve(Request $request, $id)
public function reject(Request $request, $id)
{
try {
$surat = SuratIzin::findOrFail($id);
$surat = SuratIzin::with('user')->findOrFail($id);
$user = Auth::user();
$isGlobalAdmin = $user->isGlobalAdmin();
// Security Check
if (!$isGlobalAdmin && $surat->user->id_kantor != $user->id_kantor) {
return redirect()->back()->with('error', 'Anda tidak diizinkan menolak surat dari karyawan kantor lain.');
}
if (in_array($surat->status_surat, ['disetujui', 'ditolak'])) {
return redirect()->back()->with('error', 'Surat ini sudah diproses sebelumnya.');
}
$tahap = null;
$isSuperAdmin = $user->roles->contains(function ($role) {
return in_array(strtolower($role->nama_role), ['admin', 'super admin', 'super_admin']);
});
if ($surat->status_surat === 'menunggu_manajer' && ($user->roles->contains('nama_role', 'manajer') || $isSuperAdmin)) {
if ($surat->status_surat === 'menunggu_manajer' && ($user->roles->contains('nama_role', 'manajer') || $isGlobalAdmin)) {
$tahap = 1;
} elseif ($surat->status_surat === 'menunggu_hrd' && ($user->roles->contains('nama_role', 'hrd') || $isSuperAdmin)) {
} elseif ($surat->status_surat === 'menunggu_hrd' && ($user->roles->contains('nama_role', 'hrd') || $isGlobalAdmin)) {
$tahap = 2;
}