Aplikasi kehadiran KTM ambil peti undi (SPR 2026)
Sistem untuk admin DUN merekod waktu KTM (Ketua Tempat Mengundi) hadir mengambil peti undi. Data KTM dibaca daripada DB sppm (spr2026) melalui connection kedua — hanya penempatan berjawatan KTM yang dipaparkan. - Dashboard: senarai KTM ikut pusat mengundi (asc) & saluran (asc), status BELUM HADIR (merah) / HADIR (hijau) + tarikh & masa - Carian nama/no. KP, tapisan pusat mengundi & status - Tanda hadir / batal dengan kawalan DUN (admin terhad ke DUN sendiri) - Eksport Excel (worksheet Hadir & Belum Hadir) guna SimpleXlsx - Peranan: superadmin (urus pengguna, semua DUN) & admin DUN - Docker: nginx + php-fpm + queue + scheduler + webhook deploy, port 127.0.0.1:8009, TZ Asia/Kuala_Lumpur di semua container - Zon waktu aplikasi: Asia/Kuala_Lumpur (APP_TIMEZONE) Berasaskan scaffold spr2026_taklimat. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
45
app/Http/Controllers/AuthController.php
Normal file
45
app/Http/Controllers/AuthController.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function showLogin()
|
||||
{
|
||||
if (Auth::check()) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required'],
|
||||
]);
|
||||
|
||||
if (Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
|
||||
return back()->withErrors([
|
||||
'email' => 'Emel atau kata laluan tidak sah.',
|
||||
])->onlyInput('email');
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
90
app/Http/Controllers/DashboardController.php
Normal file
90
app/Http/Controllers/DashboardController.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\KehadiranKtm;
|
||||
use App\Models\Sppm\Dun;
|
||||
use App\Models\Sppm\PetugasAssignment;
|
||||
use App\Models\Sppm\PusatMengundi;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
/**
|
||||
* Papar semua KTM yang telah diassign dalam DUN, disusun ikut
|
||||
* pusat mengundi (asc) dan saluran (asc). Boleh ditapis dengan
|
||||
* carian nama/no. KP dan pusat mengundi.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$dunId = $this->dunTerpilih($request);
|
||||
|
||||
$senaraiDun = $user->isSuperadmin()
|
||||
? Dun::where('is_active', true)->orderBy('code')->get()
|
||||
: collect();
|
||||
|
||||
$assignments = PetugasAssignment::ktm()
|
||||
->when($dunId, fn ($q) => $q->untukDun($dunId))
|
||||
->when($request->filled('q'), function ($q) use ($request) {
|
||||
$cari = trim($request->input('q'));
|
||||
$q->whereHas('permohonan', function ($qq) use ($cari) {
|
||||
$qq->where('nama_ic', 'like', "%{$cari}%")
|
||||
->orWhere('no_kp', 'like', "%{$cari}%");
|
||||
});
|
||||
})
|
||||
->when($request->filled('pusat_id'), fn ($q) => $q->where('pusat_mengundi_id', $request->integer('pusat_id')))
|
||||
->with(['permohonan', 'pusatMengundi.dun', 'saluran', 'jawatan'])
|
||||
->get()
|
||||
->sortBy([
|
||||
fn ($a, $b) => strcmp($a->pusatMengundi?->nama ?? '', $b->pusatMengundi?->nama ?? ''),
|
||||
fn ($a, $b) => ($a->saluran?->nombor_saluran ?? 0) <=> ($b->saluran?->nombor_saluran ?? 0),
|
||||
])
|
||||
->values();
|
||||
|
||||
$kehadiran = KehadiranKtm::whereIn('assignment_id', $assignments->pluck('id'))
|
||||
->get()
|
||||
->keyBy('assignment_id');
|
||||
|
||||
// Tapis ikut status selepas kehadiran diketahui
|
||||
if (in_array($request->input('status'), ['hadir', 'belum'], true)) {
|
||||
$mahuHadir = $request->input('status') === 'hadir';
|
||||
$assignments = $assignments
|
||||
->filter(fn ($a) => $kehadiran->has($a->id) === $mahuHadir)
|
||||
->values();
|
||||
}
|
||||
|
||||
$senaraiPusat = PusatMengundi::when($dunId, fn ($q) => $q->where('dun_id', $dunId))
|
||||
->orderBy('nama')
|
||||
->get(['id', 'nama']);
|
||||
|
||||
$jumlahSemua = $assignments->count();
|
||||
$jumlahHadir = $assignments->filter(fn ($a) => $kehadiran->has($a->id))->count();
|
||||
|
||||
return view('dashboard', [
|
||||
'assignments' => $assignments,
|
||||
'kehadiran' => $kehadiran,
|
||||
'senaraiPusat' => $senaraiPusat,
|
||||
'senaraiDun' => $senaraiDun,
|
||||
'dunId' => $dunId,
|
||||
'jumlahSemua' => $jumlahSemua,
|
||||
'jumlahHadir' => $jumlahHadir,
|
||||
'jumlahBelum' => $jumlahSemua - $jumlahHadir,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* DUN yang dipaparkan: admin DUN terikat pada DUN sendiri; superadmin
|
||||
* boleh pilih (null = semua DUN).
|
||||
*/
|
||||
private function dunTerpilih(Request $request): ?int
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user->isSuperadmin()) {
|
||||
return (int) $user->dun_id;
|
||||
}
|
||||
|
||||
return $request->filled('dun_id') ? $request->integer('dun_id') : null;
|
||||
}
|
||||
}
|
||||
93
app/Http/Controllers/EksportKehadiranController.php
Normal file
93
app/Http/Controllers/EksportKehadiranController.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\KehadiranKtm;
|
||||
use App\Models\Sppm\PetugasAssignment;
|
||||
use App\Support\SimpleXlsx;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EksportKehadiranController extends Controller
|
||||
{
|
||||
private const PENGEPALA = [
|
||||
'Bil', 'DUN', 'Pusat Mengundi', 'Saluran', 'Nama', 'No. KP',
|
||||
'No. Telefon', 'Jawatan', 'Status', 'Tarikh Hadir', 'Masa Hadir', 'Dicatat Oleh',
|
||||
];
|
||||
|
||||
/**
|
||||
* Eksport senarai kehadiran KTM ke Excel: worksheet "Hadir" dan
|
||||
* "Belum Hadir". Admin DUN dihadkan kepada DUN sendiri; superadmin
|
||||
* boleh eksport semua atau DUN terpilih (?dun_id=).
|
||||
*/
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$dunId = $user->isSuperadmin()
|
||||
? ($request->filled('dun_id') ? $request->integer('dun_id') : null)
|
||||
: (int) $user->dun_id;
|
||||
|
||||
$assignments = PetugasAssignment::ktm()
|
||||
->when($dunId, fn ($q) => $q->untukDun($dunId))
|
||||
->with(['permohonan', 'pusatMengundi.dun', 'saluran', 'jawatan'])
|
||||
->get()
|
||||
->sortBy([
|
||||
fn ($a, $b) => strcmp($a->pusatMengundi?->dun?->code ?? '', $b->pusatMengundi?->dun?->code ?? ''),
|
||||
fn ($a, $b) => strcmp($a->pusatMengundi?->nama ?? '', $b->pusatMengundi?->nama ?? ''),
|
||||
fn ($a, $b) => ($a->saluran?->nombor_saluran ?? 0) <=> ($b->saluran?->nombor_saluran ?? 0),
|
||||
])
|
||||
->values();
|
||||
|
||||
$kehadiran = KehadiranKtm::whereIn('assignment_id', $assignments->pluck('id'))
|
||||
->with('dicatatOleh')
|
||||
->get()
|
||||
->keyBy('assignment_id');
|
||||
|
||||
$hadir = [self::PENGEPALA];
|
||||
$belum = [self::PENGEPALA];
|
||||
|
||||
foreach ($assignments as $a) {
|
||||
$rekod = $kehadiran->get($a->id);
|
||||
$dun = $a->pusatMengundi?->dun;
|
||||
// dun.nama kebiasaannya sudah bermula dengan kod (cth. "N49 Kota Iskandar")
|
||||
$dunLabel = str_starts_with($dun->nama ?? '', $dun->code ?? "\0")
|
||||
? ($dun->nama ?? '')
|
||||
: trim(($dun->code ?? '').' '.($dun->nama ?? ''));
|
||||
|
||||
$baris = [
|
||||
0, // Bil diisi selepas pengasingan
|
||||
$dunLabel,
|
||||
$a->pusatMengundi?->nama ?? '',
|
||||
$a->saluran ? 'Saluran '.$a->saluran->nombor_saluran : '',
|
||||
$a->permohonan?->nama_ic ?? '',
|
||||
$a->permohonan?->no_kp ?? '',
|
||||
$a->permohonan?->no_telefon ?? '',
|
||||
$a->jawatan?->nama ?? 'Ketua Tempat Mengundi',
|
||||
$rekod ? 'HADIR' : 'BELUM HADIR',
|
||||
$rekod?->hadir_at?->format('d/m/Y') ?? '',
|
||||
$rekod?->hadir_at?->format('h:i A') ?? '',
|
||||
$rekod?->dicatatOleh?->name ?? '',
|
||||
];
|
||||
|
||||
if ($rekod) {
|
||||
$baris[0] = count($hadir);
|
||||
$hadir[] = $baris;
|
||||
} else {
|
||||
$baris[0] = count($belum);
|
||||
$belum[] = $baris;
|
||||
}
|
||||
}
|
||||
|
||||
$xlsx = (new SimpleXlsx)
|
||||
->addSheet('Hadir', $hadir)
|
||||
->addSheet('Belum Hadir', $belum);
|
||||
|
||||
$namaFail = 'Kehadiran-KTM-'.now()->format('Ymd-His').'.xlsx';
|
||||
|
||||
return response($xlsx->toString(), 200, [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition' => 'attachment; filename="'.$namaFail.'"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
66
app/Http/Controllers/KehadiranController.php
Normal file
66
app/Http/Controllers/KehadiranController.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\KehadiranKtm;
|
||||
use App\Models\Sppm\PetugasAssignment;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class KehadiranController extends Controller
|
||||
{
|
||||
/**
|
||||
* Tanda KTM sebagai hadir mengambil peti undi. Rekod tarikh dan masa.
|
||||
*/
|
||||
public function hadir(Request $request, int $assignmentId)
|
||||
{
|
||||
$assignment = PetugasAssignment::ktm()
|
||||
->with(['permohonan', 'pusatMengundi'])
|
||||
->findOrFail($assignmentId);
|
||||
|
||||
$this->pastikanDunDibenarkan($request, $assignment);
|
||||
|
||||
KehadiranKtm::firstOrCreate(
|
||||
['assignment_id' => $assignment->id],
|
||||
[
|
||||
'permohonan_id' => $assignment->permohonan_id,
|
||||
'dun_id' => $assignment->pusatMengundi->dun_id,
|
||||
'hadir_at' => now(),
|
||||
'dicatat_oleh_user_id' => $request->user()->id,
|
||||
],
|
||||
);
|
||||
|
||||
return back()->with('status', ($assignment->permohonan?->nama_ic ?? 'KTM').' ditanda HADIR.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Batalkan rekod kehadiran (jika tersilap tanda).
|
||||
*/
|
||||
public function batal(Request $request, int $assignmentId)
|
||||
{
|
||||
$assignment = PetugasAssignment::ktm()
|
||||
->with(['permohonan', 'pusatMengundi'])
|
||||
->findOrFail($assignmentId);
|
||||
|
||||
$this->pastikanDunDibenarkan($request, $assignment);
|
||||
|
||||
KehadiranKtm::where('assignment_id', $assignment->id)->delete();
|
||||
|
||||
return back()->with('status', 'Rekod kehadiran '.($assignment->permohonan?->nama_ic ?? 'KTM').' dibatalkan.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin DUN hanya boleh urus KTM dalam DUN sendiri.
|
||||
*/
|
||||
private function pastikanDunDibenarkan(Request $request, PetugasAssignment $assignment): void
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user->isSuperadmin()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) $assignment->pusatMengundi?->dun_id !== (int) $user->dun_id) {
|
||||
abort(403, 'KTM ini bukan dalam DUN anda.');
|
||||
}
|
||||
}
|
||||
}
|
||||
67
app/Http/Controllers/PasswordResetController.php
Normal file
67
app/Http/Controllers/PasswordResetController.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
|
||||
class PasswordResetController extends Controller
|
||||
{
|
||||
public function showLinkRequestForm()
|
||||
{
|
||||
return view('auth.lupa-katalaluan');
|
||||
}
|
||||
|
||||
public function sendResetLink(Request $request)
|
||||
{
|
||||
$request->validate(['email' => ['required', 'email']]);
|
||||
|
||||
$status = Password::sendResetLink($request->only('email'));
|
||||
|
||||
return $status === Password::RESET_LINK_SENT
|
||||
? back()->with('status', 'Pautan set semula kata laluan telah dihantar ke e-mel anda.')
|
||||
: back()->withErrors(['email' => $this->mesejRalat($status)]);
|
||||
}
|
||||
|
||||
public function showResetForm(Request $request, string $token)
|
||||
{
|
||||
return view('auth.set-kata-laluan', [
|
||||
'token' => $token,
|
||||
'email' => $request->query('email', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function reset(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'token' => ['required', 'string'],
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
], [
|
||||
'password.min' => 'Kata laluan perlu sekurang-kurangnya 8 aksara.',
|
||||
'password.confirmed' => 'Pengesahan kata laluan tidak sepadan.',
|
||||
]);
|
||||
|
||||
$status = Password::reset(
|
||||
$data,
|
||||
function ($user, $password) {
|
||||
$user->forceFill(['password' => Hash::make($password)])->save();
|
||||
}
|
||||
);
|
||||
|
||||
return $status === Password::PASSWORD_RESET
|
||||
? redirect()->route('login')->with('status', 'Kata laluan berjaya ditetapkan semula. Sila log masuk.')
|
||||
: back()->withErrors(['email' => $this->mesejRalat($status)])->onlyInput('email');
|
||||
}
|
||||
|
||||
private function mesejRalat(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
Password::INVALID_USER => 'Akaun dengan e-mel ini tidak ditemui.',
|
||||
Password::RESET_THROTTLED => 'Sila tunggu sebentar sebelum cuba lagi.',
|
||||
Password::INVALID_TOKEN => 'Pautan set semula tidak sah atau telah tamat tempoh.',
|
||||
default => 'Gagal memproses permintaan. Sila cuba lagi.',
|
||||
};
|
||||
}
|
||||
}
|
||||
68
app/Http/Controllers/PenggunaController.php
Normal file
68
app/Http/Controllers/PenggunaController.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Sppm\Dun;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class PenggunaController extends Controller
|
||||
{
|
||||
/**
|
||||
* Peranan yang boleh didaftarkan oleh superadmin.
|
||||
*/
|
||||
private const PERANAN = ['superadmin', 'admin'];
|
||||
|
||||
public function index()
|
||||
{
|
||||
$pengguna = User::query()
|
||||
->with('dun')
|
||||
->orderBy('role')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('pengguna.index', compact('pengguna'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$senaraiDun = Dun::where('is_active', true)->orderBy('code')->get();
|
||||
|
||||
return view('pengguna.create', ['senaraiDun' => $senaraiDun, 'peranan' => self::PERANAN]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'role' => ['required', 'string', Rule::in(self::PERANAN)],
|
||||
'dun_id' => ['nullable', 'integer'],
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
], [
|
||||
'email.unique' => 'E-mel ini sudah didaftarkan.',
|
||||
'password.min' => 'Kata laluan perlu sekurang-kurangnya 8 aksara.',
|
||||
'password.confirmed' => 'Pengesahan kata laluan tidak sepadan.',
|
||||
]);
|
||||
|
||||
if ($data['role'] === 'superadmin') {
|
||||
$data['dun_id'] = null;
|
||||
} elseif (empty($data['dun_id'])) {
|
||||
return back()->withErrors(['dun_id' => 'Sila pilih DUN untuk admin DUN.'])->withInput();
|
||||
} elseif (! Dun::where('id', $data['dun_id'])->where('is_active', true)->exists()) {
|
||||
return back()->withErrors(['dun_id' => 'DUN tidak sah.'])->withInput();
|
||||
}
|
||||
|
||||
User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'role' => $data['role'],
|
||||
'dun_id' => $data['dun_id'] ?? null,
|
||||
'password' => Hash::make($data['password']),
|
||||
]);
|
||||
|
||||
return redirect()->route('pengguna.index')->with('status', 'Pengguna baharu berjaya didaftarkan.');
|
||||
}
|
||||
}
|
||||
37
app/Http/Controllers/ProfilController.php
Normal file
37
app/Http/Controllers/ProfilController.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class ProfilController extends Controller
|
||||
{
|
||||
public function edit(Request $request)
|
||||
{
|
||||
return view('profil.edit', ['pengguna' => $request->user()]);
|
||||
}
|
||||
|
||||
public function updatePassword(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'kata_laluan_semasa' => ['required', 'string'],
|
||||
'kata_laluan_baharu' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
], [
|
||||
'kata_laluan_semasa.required' => 'Sila masukkan kata laluan semasa.',
|
||||
'kata_laluan_baharu.required' => 'Sila masukkan kata laluan baharu.',
|
||||
'kata_laluan_baharu.min' => 'Kata laluan baharu perlu sekurang-kurangnya 8 aksara.',
|
||||
'kata_laluan_baharu.confirmed' => 'Pengesahan kata laluan baharu tidak sepadan.',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! Hash::check($data['kata_laluan_semasa'], $user->password)) {
|
||||
return back()->withErrors(['kata_laluan_semasa' => 'Kata laluan semasa tidak tepat.']);
|
||||
}
|
||||
|
||||
$user->update(['password' => Hash::make($data['kata_laluan_baharu'])]);
|
||||
|
||||
return back()->with('status', 'Kata laluan berjaya dikemaskini.');
|
||||
}
|
||||
}
|
||||
19
app/Http/Middleware/EnsureRole.php
Normal file
19
app/Http/Middleware/EnsureRole.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureRole
|
||||
{
|
||||
public function handle(Request $request, Closure $next, string ...$roles): Response
|
||||
{
|
||||
if (! $request->user() || ! in_array($request->user()->role, $roles, true)) {
|
||||
abort(403, 'Anda tidak mempunyai kebenaran untuk akses halaman ini.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user