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:
Saufi
2026-07-11 02:40:00 +08:00
commit dfd22ff2af
95 changed files with 14681 additions and 0 deletions

View 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');
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View 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;
}
}

View 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.'"',
]);
}
}

View 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.');
}
}
}

View 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.',
};
}
}

View 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.');
}
}

View 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.');
}
}

View 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);
}
}

View 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
View 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 = [];
}

View 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 = [];
}

View 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));
}
}

View 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 ?? '-',
];
}
}

View 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');
}
}

View 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
View 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',
];
}
}

View 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.');
}
}

View 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
View 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>';
}
}