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);
|
||||
}
|
||||
}
|
||||
40
app/Models/KehadiranKtm.php
Normal file
40
app/Models/KehadiranKtm.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Sppm\PetugasAssignment;
|
||||
use App\Models\Sppm\PetugasPermohonan;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class KehadiranKtm extends Model
|
||||
{
|
||||
protected $table = 'kehadiran_ktm';
|
||||
|
||||
protected $fillable = [
|
||||
'assignment_id',
|
||||
'permohonan_id',
|
||||
'dun_id',
|
||||
'hadir_at',
|
||||
'dicatat_oleh_user_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'hadir_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function assignment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PetugasAssignment::class, 'assignment_id');
|
||||
}
|
||||
|
||||
public function permohonan(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PetugasPermohonan::class, 'permohonan_id');
|
||||
}
|
||||
|
||||
public function dicatatOleh(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'dicatat_oleh_user_id');
|
||||
}
|
||||
}
|
||||
12
app/Models/Sppm/Dun.php
Normal file
12
app/Models/Sppm/Dun.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sppm;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Dun extends Model
|
||||
{
|
||||
protected $connection = 'sppm';
|
||||
protected $table = 'dun';
|
||||
protected $guarded = [];
|
||||
}
|
||||
12
app/Models/Sppm/JawatanTetapan.php
Normal file
12
app/Models/Sppm/JawatanTetapan.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sppm;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class JawatanTetapan extends Model
|
||||
{
|
||||
protected $connection = 'sppm';
|
||||
protected $table = 'jawatan_tetapan';
|
||||
protected $guarded = [];
|
||||
}
|
||||
49
app/Models/Sppm/PetugasAssignment.php
Normal file
49
app/Models/Sppm/PetugasAssignment.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sppm;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PetugasAssignment extends Model
|
||||
{
|
||||
protected $connection = 'sppm';
|
||||
protected $table = 'petugas_assignments';
|
||||
protected $guarded = [];
|
||||
|
||||
public function permohonan(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PetugasPermohonan::class, 'permohonan_id');
|
||||
}
|
||||
|
||||
public function pusatMengundi(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PusatMengundi::class, 'pusat_mengundi_id');
|
||||
}
|
||||
|
||||
public function saluran(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Saluran::class, 'saluran_id');
|
||||
}
|
||||
|
||||
public function jawatan(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(JawatanTetapan::class, 'jawatan_tetapan_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Penempatan KTM (Ketua Tempat Mengundi) sahaja.
|
||||
*/
|
||||
public function scopeKtm($query)
|
||||
{
|
||||
return $query->whereHas('jawatan', fn ($q) => $q->where('kod', 'KTM'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Penempatan dalam DUN tertentu (melalui pusat mengundi penempatan).
|
||||
*/
|
||||
public function scopeUntukDun($query, int $dunId)
|
||||
{
|
||||
return $query->whereHas('pusatMengundi', fn ($q) => $q->where('dun_id', $dunId));
|
||||
}
|
||||
}
|
||||
76
app/Models/Sppm/PetugasPermohonan.php
Normal file
76
app/Models/Sppm/PetugasPermohonan.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sppm;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class PetugasPermohonan extends Model
|
||||
{
|
||||
protected $connection = 'sppm';
|
||||
protected $table = 'petugas_permohonan';
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* Petugas berstatus 'assigned' untuk sesuatu DUN. Padanan DUN melalui
|
||||
* dun_id permohonan atau dun pusat mengundi penempatan.
|
||||
*/
|
||||
public function scopeAssignedUntukDun($query, int $dunId)
|
||||
{
|
||||
return $query->where('status', 'assigned')
|
||||
->where(function ($q) use ($dunId) {
|
||||
$q->where('dun_id', $dunId)
|
||||
->orWhereHas('assignments.pusatMengundi', fn ($qq) => $qq->where('dun_id', $dunId));
|
||||
});
|
||||
}
|
||||
|
||||
public function dun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Dun::class, 'dun_id');
|
||||
}
|
||||
|
||||
public function assignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(PetugasAssignment::class, 'permohonan_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Penempatan utama petugas (urutan terendah).
|
||||
*/
|
||||
public function assignmentUtama(): ?PetugasAssignment
|
||||
{
|
||||
if ($this->relationLoaded('assignments')) {
|
||||
return $this->assignments->sortBy('urutan')->first();
|
||||
}
|
||||
|
||||
return $this->assignments()
|
||||
->with(['pusatMengundi', 'saluran', 'jawatan'])
|
||||
->orderBy('urutan')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maklumat penempatan untuk paparan: pusat mengundi, saluran, jawatan.
|
||||
* Diambil daripada petugas_assignments; fallback kepada medan permohonan.
|
||||
*/
|
||||
public function butiranPenempatan(): array
|
||||
{
|
||||
$assignment = $this->assignmentUtama();
|
||||
|
||||
$pusatMengundi = $assignment?->pusatMengundi
|
||||
?? ($this->pusat_mengundi_id ? PusatMengundi::find($this->pusat_mengundi_id) : null);
|
||||
|
||||
$saluran = $assignment?->saluran
|
||||
?? ($this->assigned_saluran_id ? Saluran::find($this->assigned_saluran_id) : null);
|
||||
|
||||
$jawatan = $assignment?->jawatan
|
||||
?? ($this->jawatan_id ? JawatanTetapan::find($this->jawatan_id) : null);
|
||||
|
||||
return [
|
||||
'pusat_mengundi' => $pusatMengundi?->nama ?? '-',
|
||||
'saluran' => $saluran ? 'Saluran ' . $saluran->nombor_saluran : '-',
|
||||
'jawatan' => $jawatan?->nama ?? '-',
|
||||
];
|
||||
}
|
||||
}
|
||||
18
app/Models/Sppm/PusatMengundi.php
Normal file
18
app/Models/Sppm/PusatMengundi.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sppm;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PusatMengundi extends Model
|
||||
{
|
||||
protected $connection = 'sppm';
|
||||
protected $table = 'pusat_mengundi';
|
||||
protected $guarded = [];
|
||||
|
||||
public function dun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Dun::class, 'dun_id');
|
||||
}
|
||||
}
|
||||
12
app/Models/Sppm/Saluran.php
Normal file
12
app/Models/Sppm/Saluran.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Sppm;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Saluran extends Model
|
||||
{
|
||||
protected $connection = 'sppm';
|
||||
protected $table = 'saluran';
|
||||
protected $guarded = [];
|
||||
}
|
||||
58
app/Models/User.php
Normal file
58
app/Models/User.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use App\Models\Sppm\Dun;
|
||||
use App\Notifications\ResetKataLaluanNotification;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password', 'role', 'dun_id'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
public function isSuperadmin(): bool
|
||||
{
|
||||
return $this->role === 'superadmin';
|
||||
}
|
||||
|
||||
public function isAdminDun(): bool
|
||||
{
|
||||
return $this->role === 'admin';
|
||||
}
|
||||
|
||||
public function dun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Dun::class, 'dun_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Guna e-mel dalam Bahasa Melayu untuk pautan set semula kata laluan.
|
||||
*/
|
||||
public function sendPasswordResetNotification(#[\SensitiveParameter] $token): void
|
||||
{
|
||||
$this->notify(new ResetKataLaluanNotification($token));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
38
app/Notifications/ResetKataLaluanNotification.php
Normal file
38
app/Notifications/ResetKataLaluanNotification.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ResetKataLaluanNotification extends Notification
|
||||
{
|
||||
public function __construct(private readonly string $token)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public function via(mixed $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(mixed $notifiable): MailMessage
|
||||
{
|
||||
$url = url(route('password.reset', [
|
||||
'token' => $this->token,
|
||||
'email' => $notifiable->getEmailForPasswordReset(),
|
||||
], false));
|
||||
|
||||
$minit = config('auth.passwords.users.expire');
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('Set Semula Kata Laluan - Kehadiran KTM SPR')
|
||||
->line('Kami menerima permintaan untuk set semula kata laluan akaun anda.')
|
||||
->action('Set Semula Kata Laluan', $url)
|
||||
->line("Pautan ini akan tamat tempoh dalam {$minit} minit.")
|
||||
->line('Jika anda tidak membuat permintaan ini, sila abaikan e-mel ini.');
|
||||
}
|
||||
}
|
||||
24
app/Providers/AppServiceProvider.php
Normal file
24
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
191
app/Support/SimpleXlsx.php
Normal file
191
app/Support/SimpleXlsx.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* Penjana .xlsx ringan tanpa kebergantungan luar. Setiap nilai sel ditulis
|
||||
* sebagai "inline string" supaya nilai seperti No. KP kekal sebagai teks
|
||||
* (mengekalkan sengkang / sifar di hadapan). Menyokong berbilang worksheet.
|
||||
*/
|
||||
class SimpleXlsx
|
||||
{
|
||||
/** @var array<int,array{name:string,rows:array<int,array<int,scalar|null>>}> */
|
||||
private array $sheets = [];
|
||||
|
||||
/**
|
||||
* Tambah satu worksheet. Baris pertama dianggap pengepala (huruf tebal).
|
||||
*
|
||||
* @param array<int,array<int,scalar|null>> $rows
|
||||
*/
|
||||
public function addSheet(string $name, array $rows): static
|
||||
{
|
||||
$this->sheets[] = ['name' => $this->namaSah($name), 'rows' => $rows];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hasilkan kandungan binari fail .xlsx.
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'xlsx');
|
||||
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($tmp, ZipArchive::OVERWRITE);
|
||||
|
||||
$zip->addFromString('[Content_Types].xml', $this->contentTypes());
|
||||
$zip->addFromString('_rels/.rels', $this->rootRels());
|
||||
$zip->addFromString('xl/workbook.xml', $this->workbook());
|
||||
$zip->addFromString('xl/_rels/workbook.xml.rels', $this->workbookRels());
|
||||
$zip->addFromString('xl/styles.xml', $this->styles());
|
||||
|
||||
foreach ($this->sheets as $i => $sheet) {
|
||||
$zip->addFromString('xl/worksheets/sheet'.($i + 1).'.xml', $this->sheetXml($sheet['rows']));
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
$data = file_get_contents($tmp);
|
||||
@unlink($tmp);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function namaSah(string $name): string
|
||||
{
|
||||
$name = preg_replace('/[\\\\\/\*\?\[\]:]/', ' ', $name);
|
||||
|
||||
return mb_substr(trim($name), 0, 31) ?: 'Sheet';
|
||||
}
|
||||
|
||||
/**
|
||||
* Tukar indeks lajur (0-asas) kepada huruf A, B, … AA.
|
||||
*/
|
||||
private function col(int $index): string
|
||||
{
|
||||
$letters = '';
|
||||
$index++;
|
||||
|
||||
while ($index > 0) {
|
||||
$mod = ($index - 1) % 26;
|
||||
$letters = chr(65 + $mod).$letters;
|
||||
$index = intdiv($index - 1, 26);
|
||||
}
|
||||
|
||||
return $letters;
|
||||
}
|
||||
|
||||
private function cell(string $ref, string $value, int $style): string
|
||||
{
|
||||
$v = htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
$s = $style > 0 ? ' s="'.$style.'"' : '';
|
||||
|
||||
return '<c r="'.$ref.'"'.$s.' t="inlineStr"><is><t xml:space="preserve">'.$v.'</t></is></c>';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<int,scalar|null>> $rows
|
||||
*/
|
||||
private function sheetXml(array $rows): string
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>';
|
||||
|
||||
foreach ($rows as $r => $cells) {
|
||||
$rowNum = $r + 1;
|
||||
$style = $r === 0 ? 1 : 0;
|
||||
$xml .= '<row r="'.$rowNum.'">';
|
||||
|
||||
foreach (array_values($cells) as $c => $val) {
|
||||
$xml .= $this->cell($this->col($c).$rowNum, (string) $val, $style);
|
||||
}
|
||||
|
||||
$xml .= '</row>';
|
||||
}
|
||||
|
||||
return $xml.'</sheetData></worksheet>';
|
||||
}
|
||||
|
||||
private function contentTypes(): string
|
||||
{
|
||||
$overrides = '';
|
||||
|
||||
foreach ($this->sheets as $i => $s) {
|
||||
$overrides .= '<Override PartName="/xl/worksheets/sheet'.($i + 1).'.xml" '
|
||||
.'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
|
||||
}
|
||||
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
.'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
.'<Default Extension="xml" ContentType="application/xml"/>'
|
||||
.'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
||||
.'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
|
||||
.$overrides
|
||||
.'</Types>';
|
||||
}
|
||||
|
||||
private function rootRels(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
.'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
||||
.'</Relationships>';
|
||||
}
|
||||
|
||||
private function workbook(): string
|
||||
{
|
||||
$sheetsXml = '';
|
||||
|
||||
foreach ($this->sheets as $i => $s) {
|
||||
$name = htmlspecialchars($s['name'], ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
$sheetsXml .= '<sheet name="'.$name.'" sheetId="'.($i + 1).'" r:id="rId'.($i + 1).'"/>';
|
||||
}
|
||||
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
||||
.'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||||
.'<sheets>'.$sheetsXml.'</sheets></workbook>';
|
||||
}
|
||||
|
||||
private function workbookRels(): string
|
||||
{
|
||||
$rels = '';
|
||||
$count = count($this->sheets);
|
||||
|
||||
foreach ($this->sheets as $i => $s) {
|
||||
$rels .= '<Relationship Id="rId'.($i + 1).'" '
|
||||
.'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
|
||||
.'Target="worksheets/sheet'.($i + 1).'.xml"/>';
|
||||
}
|
||||
|
||||
$rels .= '<Relationship Id="rId'.($count + 1).'" '
|
||||
.'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" '
|
||||
.'Target="styles.xml"/>';
|
||||
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
.$rels.'</Relationships>';
|
||||
}
|
||||
|
||||
private function styles(): string
|
||||
{
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
.'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||
.'<fonts count="2">'
|
||||
.'<font><sz val="11"/><name val="Calibri"/></font>'
|
||||
.'<font><b/><sz val="11"/><name val="Calibri"/></font>'
|
||||
.'</fonts>'
|
||||
.'<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>'
|
||||
.'<borders count="1"><border/></borders>'
|
||||
.'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
|
||||
.'<cellXfs count="2">'
|
||||
.'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
|
||||
.'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
|
||||
.'</cellXfs>'
|
||||
.'</styleSheet>';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user