This commit is contained in:
Saufi
2026-06-03 08:51:22 +08:00
commit a14d43fe34
347 changed files with 38197 additions and 0 deletions

19
.claude/settings.json Normal file
View File

@@ -0,0 +1,19 @@
{
"permissions": {
"allow": [
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\User\\\\Aplikasi\\\\prn2026\" -Force)",
"Bash(Select-Object Name, Mode)",
"Bash(Format-Table -AutoSize)",
"Read(//c/Users/User/.claude/projects/c--Users-User-Aplikasi-prn2026/memory/**)",
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\User\\\\Aplikasi\\\\prn2026\" -Recurse -Directory)",
"Bash(Select-Object -First 20)",
"Bash(del \"c:\\\\Users\\\\User\\\\Aplikasi\\\\prn2026\\\\app\\\\Http\\\\Requests\\\\Admin\\\\Setup\\\\UpdateElectionSettingsRequest.php\")",
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\User\\\\Aplikasi\\\\prn2026\\\\app\\\\Http\\\\Controllers\" -Recurse -Filter \"*Application*\")",
"Bash(Select-Object -ExpandProperty FullName)",
"PowerShell(cd c:\\\\Users\\\\User\\\\Aplikasi\\\\prn2026; git add . 2>&1 | Select-Object -First 5; Write-Host \"Exit: $LASTEXITCODE\")"
],
"additionalDirectories": [
"c:\\Users\\User\\.claude\\projects\\c--Users-User-Aplikasi-prn2026\\memory"
]
}
}

18
.editorconfig Normal file
View File

@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4

70
.env.example Normal file
View File

@@ -0,0 +1,70 @@
APP_NAME="Sistem Pengurusan Pusat Mengundi"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_TIMEZONE=Asia/Kuala_Lumpur
APP_LOCALE=ms
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=ms_MY
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=prn2026
DB_USERNAME=root
DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="noreply@example.gov.my"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
SEED_ADMIN_NAME="Admin Sistem"
SEED_ADMIN_EMAIL=admin@prn.local
SEED_ADMIN_PASSWORD=password

11
.gitattributes vendored Normal file
View File

@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.codex
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
database/*.sqlite
_laravel_scaffold/
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
ignore-scripts=true
audit=true

58
README.md Normal file
View File

@@ -0,0 +1,58 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
## Agentic Development
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
```bash
composer require laravel/boost --dev
php artisan boost:install
```
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Console\Commands;
use App\Models\ExportLog;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class ExportPurgeCommand extends Command
{
protected $signature = 'exports:purge {--days=30 : Minimum age in days before an export file is purged}';
protected $description = 'Purge export files older than the configured retention period and mark export_logs records as purged.';
public function handle(): int
{
$days = (int) $this->option('days');
if ($days < 1) {
$this->error('--days must be at least 1.');
return self::FAILURE;
}
$cutoff = now()->subDays($days);
$logs = ExportLog::query()
->whereNull('purged_at')
->where('created_at', '<', $cutoff)
->get();
if ($logs->isEmpty()) {
$this->info("No export files eligible for purge (older than {$days} days).");
return self::SUCCESS;
}
$purged = 0;
$missing = 0;
foreach ($logs as $log) {
$disk = $log->disk ?? 'local';
$path = $log->path;
if ($path && Storage::disk($disk)->exists($path)) {
Storage::disk($disk)->delete($path);
$purged++;
} else {
$missing++;
}
$log->forceFill(['purged_at' => now()])->save();
activity('exports')
->performedOn($log)
->withProperties([
'report_type' => $log->report_type,
'path' => $path,
'retention_days' => $days,
])
->log('export_file_purged');
}
$this->info("Purged {$purged} export file(s). {$missing} record(s) had no file on disk.");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Exports;
use App\Models\Application;
use App\Models\Attendance;
use App\Models\Position;
use App\Models\PusatMengundi;
use App\Models\SaluranMengundi;
use App\Models\StaffAssignment;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use OpenSpout\Common\Entity\Row;
use OpenSpout\Writer\XLSX\Writer;
class AttendanceDetailExport
{
/**
* @param Collection<int, StaffAssignment> $assignments
*/
public function write(Collection $assignments, string $absolutePath): void
{
$writer = new Writer;
$writer->openToFile($absolutePath);
$writer->addRow(Row::fromValues([
'Pusat Mengundi',
'Saluran',
'Nama',
'Jawatan',
'Status Kehadiran',
'Masa Check-in',
'Catatan',
]));
foreach ($assignments as $assignment) {
$application = $assignment->application;
$user = $assignment->user;
$pusat = $assignment->pusatMengundi;
$position = $assignment->position;
$saluran = $assignment->saluranMengundi;
$attendance = $assignment->attendance;
$checkInTime = $attendance instanceof Attendance && filled($attendance->check_in_time)
? Carbon::parse($attendance->check_in_time)->format('d/m/Y H:i')
: '';
$writer->addRow(Row::fromValues([
$pusat instanceof PusatMengundi ? $pusat->code.' - '.$pusat->name : '',
$saluran instanceof SaluranMengundi ? $saluran->number : '',
($application instanceof Application ? $application->name : null) ?: ($user instanceof User ? $user->name : ''),
$position instanceof Position ? $position->code : '',
$attendance instanceof Attendance ? $attendance->status : 'not_recorded',
$checkInTime,
$attendance instanceof Attendance ? ($attendance->note ?? '') : '',
]));
}
$writer->close();
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Exports;
use App\Models\Application;
use App\Models\BankVerification;
use App\Models\Position;
use App\Models\PusatMengundi;
use Illuminate\Support\Collection;
use OpenSpout\Common\Entity\Row;
use OpenSpout\Writer\XLSX\Writer;
class FinanceVerificationExport
{
/**
* @param Collection<int, BankVerification> $verifications
*/
public function write(Collection $verifications, string $absolutePath): void
{
$writer = new Writer;
$writer->openToFile($absolutePath);
$writer->addRow(Row::fromValues([
'Nama',
'No IC',
'Telefon',
'Email',
'Pusat Mengundi',
'Jawatan',
'Bank',
'No Akaun',
'Status Bank',
'Penyata Bank',
'Nota Kewangan',
]));
foreach ($verifications as $verification) {
$application = $verification->application;
if (! $application instanceof Application) {
continue;
}
$position = $application->approvedPosition ?: $application->requestedPosition;
$positionCode = $position instanceof Position ? $position->code : null;
$pusat = $application->pusatMengundi;
$pusatLabel = $pusat instanceof PusatMengundi
? trim($pusat->code.' - '.$pusat->name)
: '';
$hasBankStatement = $application->documents->contains('document_type', 'bank_statement') === true;
$writer->addRow(Row::fromValues([
$application->name,
$application->ic_number,
$application->phone_number,
$application->email,
$pusatLabel,
$positionCode,
$application->bank_name,
$application->bank_account_number,
$verification->status,
$hasBankStatement ? 'Ada' : 'Tiada',
$verification->finance_note,
]));
}
$writer->close();
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Concerns;
use Illuminate\Database\Eloquent\Builder;
trait SortsQuery
{
/**
* @param array<int, string> $allowed
*/
protected function sortedQuery(Builder $query, array $allowed, string $default, string $defaultDir = 'asc'): Builder
{
$sort = request()->input('sort', $default);
$dir = request()->input('direction', $defaultDir);
if (! in_array($sort, $allowed, true)) {
$sort = $default;
}
if (! in_array($dir, ['asc', 'desc'], true)) {
$dir = $defaultDir;
}
return $query->orderBy($sort, $dir);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Concerns\SortsQuery;
use App\Http\Controllers\Controller;
use App\Models\PusatMengundi;
use App\Models\StaffAssignment;
use App\Services\Attendance\AttendanceExportService;
use App\Services\Attendance\AttendanceSummaryService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AttendanceController extends Controller
{
use SortsQuery;
public function index(AttendanceSummaryService $summaryService): View
{
return view('admin.attendance.index', [
'pusats' => $this->sortedQuery(
PusatMengundi::query()->with(['election.settings']),
['code', 'name'],
'name'
)->paginate(15)->withQueryString(),
'summaryService' => $summaryService,
]);
}
public function show(PusatMengundi $pusatMengundi, AttendanceSummaryService $summaryService): View
{
$assignments = StaffAssignment::query()
->with(['application', 'user', 'position', 'saluranMengundi', 'attendance.recordedBy'])
->where('pusat_mengundi_id', $pusatMengundi->id)
->where('status', 'active')
->orderBy('position_id')
->orderBy('saluran_mengundi_id')
->get();
return view('admin.attendance.show', [
'pusat' => $pusatMengundi->load('election.settings'),
'assignments' => $assignments,
'summary' => $summaryService->totalsForPusat($pusatMengundi),
'roleSummaries' => $summaryService->roleTotalsForPusat($pusatMengundi),
]);
}
public function export(PusatMengundi $pusatMengundi, Request $request, AttendanceExportService $exportService): StreamedResponse
{
$exportLog = $exportService->exportPusat($request->user(), $pusatMengundi);
return Storage::disk($exportLog->disk ?? 'local')
->download($exportLog->path, $exportLog->file_name);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Admin\Management;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\JkmRepresentative;
use App\Models\KkmRepresentative;
use App\Models\PoliceEscort;
use App\Models\StaffAssignment;
use App\Models\WheelchairAllocation;
use App\Services\Admin\Management\WheelchairManagementService;
use Illuminate\View\View;
class AdminManagementController extends Controller
{
public function index(WheelchairManagementService $wheelchairService): View
{
$allocations = WheelchairAllocation::query()->with('transactions')->get();
return view('admin.management.index', [
'counts' => [
'applications' => Application::query()->count(),
'assigned' => Application::query()->where('status', 'assigned')->count(),
'staffAssignments' => StaffAssignment::query()->where('status', 'active')->count(),
'representatives' => PoliceEscort::query()->count() + KkmRepresentative::query()->count() + JkmRepresentative::query()->count(),
'missingDocuments' => Application::query()
->whereDoesntHave('documents', fn ($query) => $query->where('document_type', 'ic_document'))
->orWhereDoesntHave('documents', fn ($query) => $query->where('document_type', 'bank_statement'))
->count(),
'incompleteBank' => Application::query()
->where(fn ($query) => $query->whereNull('bank_name')->orWhereNull('bank_account_number'))
->count(),
'wheelchairAllocated' => $allocations->sum('allocated_quantity'),
'wheelchairOutstanding' => $allocations->sum(fn (WheelchairAllocation $allocation): int => $wheelchairService->outstandingQuantity($allocation)),
],
]);
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Http\Controllers\Admin\Management;
use App\Http\Concerns\SortsQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Management\AssignApplicationRequest;
use App\Http\Requests\Admin\Management\StoreManualApplicationRequest;
use App\Http\Requests\Admin\Management\UpdateManagedApplicationRequest;
use App\Models\Application;
use App\Models\Election;
use App\Models\Position;
use App\Models\PusatMengundi;
use App\Models\SaluranMengundi;
use App\Models\StaffAssignment;
use App\Services\Admin\Management\AdminApplicationManagementService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ManagedApplicationController extends Controller
{
use SortsQuery;
public function index(Request $request): View
{
$query = Application::query()
->with(['election', 'pusatMengundi', 'requestedPosition', 'approvedPosition', 'selectedKtmAssignment.user'])
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->when($request->filled('pusat_mengundi_id'), fn ($q) => $q->where('pusat_mengundi_id', $request->integer('pusat_mengundi_id')))
->when($request->filled('position_id'), function ($q) use ($request): void {
$q->where(function ($nested) use ($request): void {
$nested->where('requested_position_id', $request->integer('position_id'))
->orWhere('approved_position_id', $request->integer('position_id'));
});
})
->when($request->filled('q'), function ($q) use ($request): void {
$term = '%'.$request->string('q')->toString().'%';
$q->where(fn ($nested) => $nested
->where('name', 'like', $term)
->orWhere('ic_number', 'like', $term)
->orWhere('email', 'like', $term));
});
$applications = $this->sortedQuery($query, ['name', 'status', 'created_at'], 'created_at', 'desc')
->paginate(15)
->withQueryString();
return view('admin.management.applications.index', [
'applications' => $applications,
'positions' => Position::query()->orderBy('sort_order')->orderBy('name')->get(),
'pusats' => PusatMengundi::query()->orderBy('name')->get(),
'filters' => $request->only(['status', 'pusat_mengundi_id', 'position_id', 'q']),
]);
}
public function create(): View
{
return view('admin.management.applications.create', $this->formData([
'application' => new Application,
]));
}
public function store(StoreManualApplicationRequest $request, AdminApplicationManagementService $service): RedirectResponse
{
$application = $service->create($request->user(), $request->validated());
return redirect()->route('admin.management.applications.edit', $application->public_uuid)
->with('status', 'Permohonan manual telah disimpan.');
}
public function edit(Application $application): View
{
return view('admin.management.applications.edit', $this->formData([
'application' => $application->load(['election.settings', 'pusatMengundi', 'requestedPosition', 'approvedPosition', 'documents', 'staffAssignments.position', 'staffAssignments.saluranMengundi', 'statusHistories']),
]));
}
public function update(UpdateManagedApplicationRequest $request, Application $application, AdminApplicationManagementService $service): RedirectResponse
{
$service->update($application, $request->user(), $request->validated());
return redirect()->route('admin.management.applications.edit', $application->public_uuid)
->with('status', 'Permohonan telah dikemas kini.');
}
public function assign(AssignApplicationRequest $request, Application $application, AdminApplicationManagementService $service): RedirectResponse
{
$service->assign($application, $request->user(), $request->validated());
return redirect()->route('admin.management.applications.edit', $application->public_uuid)
->with('status', 'Tugasan staf telah dikemas kini.');
}
/**
* @param array<string, mixed> $extra
* @return array<string, mixed>
*/
private function formData(array $extra = []): array
{
$ktmPosition = Position::query()->where('code', 'KTM')->first();
return [
...$extra,
'elections' => Election::query()->with('settings')->orderByDesc('id')->get(),
'pusats' => PusatMengundi::query()->with('election')->orderBy('name')->get(),
'salurans' => SaluranMengundi::query()->with('pusatMengundi')->orderBy('number')->get(),
'positions' => Position::query()->where('is_assignable', true)->orderBy('sort_order')->orderBy('name')->get(),
'ktmAssignments' => $ktmPosition
? StaffAssignment::query()
->with(['user', 'pusatMengundi', 'saluranMengundi'])
->where('position_id', $ktmPosition->id)
->where('status', 'active')
->whereNotNull('saluran_mengundi_id')
->orderByDesc('id')
->get()
: collect(),
];
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Http\Controllers\Admin\Management;
use App\Http\Controllers\Controller;
use App\Models\Position;
use App\Models\PusatMengundi;
use App\Models\SaluranMengundi;
use App\Models\StaffAssignment;
use App\Services\Admin\Management\PennempatanService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class PennempatanController extends Controller
{
public function show(PusatMengundi $pusatMengundi, PennempatanService $service): View
{
$pusatMengundi->load(['dun', 'election', 'saluranMengundis']);
$ppmPosition = Position::query()->where('code', 'PPM')->first();
$ppmAssignment = $ppmPosition
? StaffAssignment::query()
->with('application')
->where('pusat_mengundi_id', $pusatMengundi->id)
->where('position_id', $ppmPosition->id)
->where('status', 'active')
->whereNull('saluran_mengundi_id')
->first()
: null;
return view('admin.management.penempatan.show', [
'pusat' => $pusatMengundi,
'ppm' => $ppmAssignment,
'sections' => $service->detailForPusat($pusatMengundi),
]);
}
public function lepas(Request $request, PusatMengundi $pusatMengundi, StaffAssignment $assignment): RedirectResponse
{
abort_unless($assignment->pusat_mengundi_id === $pusatMengundi->id, 404);
$assignment->update([
'status' => 'inactive',
'assigned_by_user_id' => $request->user()->id,
]);
activity('admin_management')
->performedOn($assignment)
->causedBy($request->user())
->log('assignment_removed_by_admin');
return redirect()->route('admin.management.penempatan.show', $pusatMengundi)
->with('status', 'Petugas telah dilepaskan dari tugasan.');
}
public function pindahForm(PusatMengundi $pusatMengundi, StaffAssignment $assignment): View
{
abort_unless($assignment->pusat_mengundi_id === $pusatMengundi->id, 404);
return view('admin.management.penempatan.pindah', [
'pusat' => $pusatMengundi,
'assignment' => $assignment->load(['application', 'position', 'saluranMengundi']),
'pusats' => PusatMengundi::query()->with('election')->orderBy('code')->get(),
'salurans' => SaluranMengundi::query()
->where('pusat_mengundi_id', $pusatMengundi->id)
->orderBy('number')
->get(),
]);
}
public function pindah(Request $request, PusatMengundi $pusatMengundi, StaffAssignment $assignment): RedirectResponse
{
abort_unless($assignment->pusat_mengundi_id === $pusatMengundi->id, 404);
$data = $request->validate([
'pusat_mengundi_id' => ['required', 'exists:pusat_mengundis,id'],
'saluran_mengundi_id' => ['nullable', 'exists:saluran_mengundis,id'],
]);
$newPusat = PusatMengundi::query()->findOrFail($data['pusat_mengundi_id']);
$assignment->update([
'pusat_mengundi_id' => $newPusat->id,
'saluran_mengundi_id' => $data['saluran_mengundi_id'] ?? null,
'assigned_by_user_id' => $request->user()->id,
'assigned_at' => now(),
]);
// Update linked application's pusat too
if ($assignment->application) {
$assignment->application->forceFill(['pusat_mengundi_id' => $newPusat->id])->save();
}
activity('admin_management')
->performedOn($assignment)
->causedBy($request->user())
->withProperties([
'from_pusat_id' => $pusatMengundi->id,
'to_pusat_id' => $newPusat->id,
])
->log('assignment_transferred_by_admin');
return redirect()->route('admin.management.penempatan.show', $newPusat)
->with('status', 'Petugas telah dipindahkan ke '.$newPusat->name.'.');
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Admin\Management;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Management\DeleteRepresentativeRequest;
use App\Http\Requests\Admin\Management\StoreRepresentativeRequest;
use App\Models\Election;
use App\Models\JkmRepresentative;
use App\Models\KkmRepresentative;
use App\Models\PoliceEscort;
use App\Models\PusatMengundi;
use App\Models\SaluranMengundi;
use App\Services\Admin\Management\AdminRepresentativeService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class RepresentativeController extends Controller
{
public function index(): View
{
return view('admin.management.representatives.index', [
'policeEscorts' => PoliceEscort::query()->with(['election', 'pusatMengundi', 'saluranMengundi'])->orderBy('name')->paginate(10, ['*'], 'police_page'),
'kkmRepresentatives' => KkmRepresentative::query()->with(['election', 'pusatMengundi'])->orderBy('name')->paginate(10, ['*'], 'kkm_page'),
'jkmRepresentatives' => JkmRepresentative::query()->with(['election', 'pusatMengundi'])->orderBy('name')->paginate(10, ['*'], 'jkm_page'),
'elections' => Election::query()->orderByDesc('id')->get(),
'pusats' => PusatMengundi::query()->orderBy('name')->get(),
'salurans' => SaluranMengundi::query()->with('pusatMengundi')->orderBy('number')->get(),
]);
}
public function store(StoreRepresentativeRequest $request, AdminRepresentativeService $service): RedirectResponse
{
$service->store($request->user(), $request->validated());
return redirect()->route('admin.management.representatives.index')
->with('status', 'Wakil agensi telah disimpan.');
}
public function destroy(DeleteRepresentativeRequest $request, string $type, int $id, AdminRepresentativeService $service): RedirectResponse
{
$service->delete($this->resolveRepresentative($type, $id), $request->user(), $request->validated('note'));
return redirect()->route('admin.management.representatives.index')
->with('status', 'Wakil agensi telah dipadam.');
}
private function resolveRepresentative(string $type, int $id): Model
{
return match ($type) {
'police' => PoliceEscort::query()->findOrFail($id),
'kkm' => KkmRepresentative::query()->findOrFail($id),
'jkm' => JkmRepresentative::query()->findOrFail($id),
default => abort(404),
};
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Admin\Management;
use App\Http\Concerns\SortsQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Management\UpdateStaffAssignmentRequest;
use App\Models\Position;
use App\Models\PusatMengundi;
use App\Models\SaluranMengundi;
use App\Models\StaffAssignment;
use App\Services\Admin\Management\AdminAssignmentManagementService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class StaffAssignmentController extends Controller
{
use SortsQuery;
public function index(Request $request): View
{
$query = StaffAssignment::query()
->with(['application', 'user', 'position', 'pusatMengundi', 'saluranMengundi', 'reportsTo.user'])
->when($request->filled('pusat_mengundi_id'), fn ($q) => $q->where('pusat_mengundi_id', $request->integer('pusat_mengundi_id')))
->when($request->filled('position_id'), fn ($q) => $q->where('position_id', $request->integer('position_id')))
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')));
return view('admin.management.assignments.index', [
'assignments' => $this->sortedQuery($query, ['status', 'assigned_at'], 'id', 'desc')
->paginate(15)
->withQueryString(),
'positions' => Position::query()->orderBy('sort_order')->orderBy('name')->get(),
'pusats' => PusatMengundi::query()->orderBy('name')->get(),
'filters' => $request->only(['pusat_mengundi_id', 'position_id', 'status']),
]);
}
public function edit(StaffAssignment $assignment): View
{
$ktmPosition = Position::query()->where('code', 'KTM')->first();
return view('admin.management.assignments.edit', [
'assignment' => $assignment->load(['application', 'user', 'election.settings', 'position', 'pusatMengundi', 'saluranMengundi', 'reportsTo.user', 'histories']),
'positions' => Position::query()->where('is_assignable', true)->orderBy('sort_order')->orderBy('name')->get(),
'pusats' => PusatMengundi::query()->with('election')->orderBy('name')->get(),
'salurans' => SaluranMengundi::query()->with('pusatMengundi')->orderBy('number')->get(),
'ktmAssignments' => $ktmPosition
? StaffAssignment::query()
->with(['user', 'pusatMengundi', 'saluranMengundi'])
->where('position_id', $ktmPosition->id)
->where('status', 'active')
->where('id', '!=', $assignment->id)
->whereNotNull('saluran_mengundi_id')
->orderByDesc('id')
->get()
: collect(),
]);
}
public function update(UpdateStaffAssignmentRequest $request, StaffAssignment $assignment, AdminAssignmentManagementService $service): RedirectResponse
{
$service->update($assignment, $request->user(), $request->validated());
return redirect()->route('admin.management.assignments.edit', $assignment)
->with('status', 'Tugasan staf telah dikemas kini.');
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Admin\Management;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Management\StoreReturnTransactionRequest;
use App\Http\Requests\Admin\Management\StoreTakenTransactionRequest;
use App\Http\Requests\Admin\Management\UpsertWheelchairAllocationRequest;
use App\Models\Election;
use App\Models\PusatMengundi;
use App\Models\WheelchairAllocation;
use App\Services\Admin\Management\WheelchairManagementService;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class WheelchairController extends Controller
{
public function index(WheelchairManagementService $service): View
{
$allocations = WheelchairAllocation::query()
->with(['election', 'pusatMengundi', 'transactions'])
->latest()
->paginate(15);
return view('admin.management.wheelchairs.index', [
'allocations' => $allocations,
'elections' => Election::query()->orderByDesc('id')->get(),
'pusats' => PusatMengundi::query()->orderBy('name')->get(),
'service' => $service,
]);
}
public function store(UpsertWheelchairAllocationRequest $request, WheelchairManagementService $service): RedirectResponse
{
$allocation = $service->upsertAllocation($request->user(), $request->validated());
return redirect()->route('admin.management.wheelchairs.show', $allocation)
->with('status', 'Peruntukan kerusi roda telah disimpan.');
}
public function show(WheelchairAllocation $allocation, WheelchairManagementService $service): View
{
return view('admin.management.wheelchairs.show', [
'allocation' => $allocation->load(['election', 'pusatMengundi', 'transactions.recordedBy']),
'elections' => Election::query()->orderByDesc('id')->get(),
'pusats' => PusatMengundi::query()->orderBy('name')->get(),
'service' => $service,
]);
}
public function update(UpsertWheelchairAllocationRequest $request, WheelchairAllocation $allocation, WheelchairManagementService $service): RedirectResponse
{
$data = [
...$request->validated(),
'election_id' => $allocation->election_id,
'pusat_mengundi_id' => $allocation->pusat_mengundi_id,
];
$service->upsertAllocation($request->user(), $data);
return redirect()->route('admin.management.wheelchairs.show', $allocation)
->with('status', 'Peruntukan kerusi roda telah dikemas kini.');
}
public function recordTaken(StoreTakenTransactionRequest $request, WheelchairAllocation $allocation, WheelchairManagementService $service): RedirectResponse
{
$service->recordTransaction($allocation, $request->user(), [
'transaction_type' => 'taken',
...$request->validated(),
]);
return redirect()->route('admin.management.wheelchairs.show', $allocation)
->with('status', 'Rekod ambil kerusi roda telah disimpan.');
}
public function recordReturn(StoreReturnTransactionRequest $request, WheelchairAllocation $allocation, WheelchairManagementService $service): RedirectResponse
{
$service->recordTransaction($allocation, $request->user(), [
'transaction_type' => 'returned',
...$request->validated(),
]);
return redirect()->route('admin.management.wheelchairs.show', $allocation)
->with('status', 'Rekod pulang kerusi roda telah disimpan.');
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Admin\Setup;
use App\Http\Controllers\Controller;
use App\Models\Dun;
use App\Models\Election;
use App\Models\Position;
use App\Models\PositionQuota;
use App\Models\PusatMengundi;
use App\Models\SaluranMengundi;
use App\Models\StaffAssignment;
use Illuminate\View\View;
class AdminSetupController extends Controller
{
public function index(): View
{
return view('admin.setup.index', [
'counts' => [
'elections' => Election::query()->count(),
'duns' => Dun::query()->count(),
'pusat' => PusatMengundi::query()->count(),
'saluran' => SaluranMengundi::query()->count(),
'positions' => Position::query()->count(),
'quotas' => PositionQuota::query()->count(),
'ppmAssignments' => StaffAssignment::query()
->whereHas('position', fn ($query) => $query->where('code', 'PPM'))
->where('status', 'active')
->count(),
],
]);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Admin\Setup;
use App\Http\Concerns\SortsQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Setup\UpsertBahagianPilihanrayaRequest;
use App\Models\BahagianPilihanraya;
use App\Models\Election;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class BahagianPilihanrayaController extends Controller
{
use SortsQuery;
public function index(): View
{
return view('admin.setup.bahagian.index', [
'bahagians' => $this->sortedQuery(
BahagianPilihanraya::query()->with('election'),
['code', 'name'],
'code'
)->paginate(15)->withQueryString(),
]);
}
public function create(): View
{
return view('admin.setup.bahagian.create', [
'bahagian' => new BahagianPilihanraya,
'elections' => Election::query()->orderBy('name')->get(),
]);
}
public function store(UpsertBahagianPilihanrayaRequest $request): RedirectResponse
{
BahagianPilihanraya::query()->create($request->validated());
return redirect()->route('admin.setup.bahagian.index')
->with('status', 'Bahagian Pilihanraya telah disimpan.');
}
public function edit(BahagianPilihanraya $bahagianPilihanraya): View
{
return view('admin.setup.bahagian.edit', [
'bahagian' => $bahagianPilihanraya,
'elections' => Election::query()->orderBy('name')->get(),
]);
}
public function update(UpsertBahagianPilihanrayaRequest $request, BahagianPilihanraya $bahagianPilihanraya): RedirectResponse
{
$bahagianPilihanraya->update($request->validated());
return redirect()->route('admin.setup.bahagian.index')
->with('status', 'Bahagian Pilihanraya telah dikemas kini.');
}
public function destroy(BahagianPilihanraya $bahagianPilihanraya): RedirectResponse
{
$bahagianPilihanraya->forceDelete();
return redirect()->route('admin.setup.bahagian.index')
->with('status', 'Bahagian Pilihanraya telah dipadam.');
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\Admin\Setup;
use App\Http\Concerns\SortsQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Setup\UpsertDaerahMengundiRequest;
use App\Models\BahagianPilihanraya;
use App\Models\DaerahMengundi;
use App\Models\Election;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class DaerahMengundiController extends Controller
{
use SortsQuery;
public function index(): View
{
return view('admin.setup.daerah.index', [
'daerahs' => $this->sortedQuery(
DaerahMengundi::query()->with(['election', 'bahagianPilihanraya']),
['code', 'name'],
'code'
)->paginate(15)->withQueryString(),
]);
}
public function create(): View
{
return view('admin.setup.daerah.create', [
'daerah' => new DaerahMengundi,
'elections' => Election::query()->orderBy('name')->get(),
'bahagians' => BahagianPilihanraya::query()->orderBy('name')->get(),
]);
}
public function store(UpsertDaerahMengundiRequest $request): RedirectResponse
{
DaerahMengundi::query()->create($request->validated());
return redirect()->route('admin.setup.daerah.index')
->with('status', 'Daerah Mengundi telah disimpan.');
}
public function edit(DaerahMengundi $daerahMengundi): View
{
return view('admin.setup.daerah.edit', [
'daerah' => $daerahMengundi,
'elections' => Election::query()->orderBy('name')->get(),
'bahagians' => BahagianPilihanraya::query()->orderBy('name')->get(),
]);
}
public function update(UpsertDaerahMengundiRequest $request, DaerahMengundi $daerahMengundi): RedirectResponse
{
$daerahMengundi->update($request->validated());
return redirect()->route('admin.setup.daerah.index')
->with('status', 'Daerah Mengundi telah dikemas kini.');
}
public function destroy(DaerahMengundi $daerahMengundi): RedirectResponse
{
$daerahMengundi->forceDelete();
return redirect()->route('admin.setup.daerah.index')
->with('status', 'Daerah Mengundi telah dipadam.');
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Admin\Setup;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Setup\UpsertElectionSettingsRequest;
use App\Models\Election;
use App\Services\Admin\ElectionSettingsService;
use App\Services\RegistrationPeriodService;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class ElectionSettingsController extends Controller
{
public function edit(Election $election, RegistrationPeriodService $registrationPeriodService): View
{
return view('admin.setup.settings.edit', [
'election' => $election->load('settings'),
'isRegistrationOpen' => $election->settings
? $registrationPeriodService->isOpen($election)
: false,
]);
}
public function store(
UpsertElectionSettingsRequest $request,
Election $election,
ElectionSettingsService $service,
): RedirectResponse {
abort_if($election->settings !== null, 409);
$service->create($election, $request->validated(), $request->user());
return redirect()->route('admin.setup.settings.edit', $election)
->with('status', 'Tetapan pilihanraya telah dicipta.');
}
public function update(
UpsertElectionSettingsRequest $request,
Election $election,
ElectionSettingsService $service,
): RedirectResponse {
abort_unless($election->settings !== null, 404);
$service->update($election, $request->validated(), $request->user());
return redirect()->route('admin.setup.settings.edit', $election)
->with('status', 'Tetapan pilihanraya telah dikemas kini.');
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers\Admin\Setup;
use App\Http\Concerns\SortsQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Setup\UpsertPositionRequest;
use App\Models\Position;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class PositionController extends Controller
{
use SortsQuery;
public function index(): View
{
return view('admin.setup.positions.index', [
'positions' => $this->sortedQuery(
Position::query(),
['code', 'name', 'scope', 'sort_order'],
'sort_order'
)->paginate(20)->withQueryString(),
]);
}
public function create(): View
{
return view('admin.setup.positions.create', [
'position' => new Position,
]);
}
public function store(UpsertPositionRequest $request): RedirectResponse
{
Position::query()->create($this->payload($request));
return redirect()->route('admin.setup.positions.index')
->with('status', 'Jawatan telah disimpan.');
}
public function edit(Position $position): View
{
return view('admin.setup.positions.edit', [
'position' => $position,
]);
}
public function update(UpsertPositionRequest $request, Position $position): RedirectResponse
{
$position->update($this->payload($request));
return redirect()->route('admin.setup.positions.index')
->with('status', 'Jawatan telah dikemas kini.');
}
public function destroy(Position $position): RedirectResponse
{
$position->forceDelete();
return redirect()->route('admin.setup.positions.index')
->with('status', 'Jawatan telah dipadam.');
}
/**
* @return array<string, mixed>
*/
private function payload(UpsertPositionRequest $request): array
{
return [
...$request->safe()->except([
'is_public_applyable',
'is_assignable',
'allows_dual_role',
]),
'is_public_applyable' => $request->boolean('is_public_applyable'),
'is_assignable' => $request->boolean('is_assignable'),
'allows_dual_role' => $request->boolean('allows_dual_role'),
];
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Admin\Setup;
use App\Http\Concerns\SortsQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Setup\UpsertPositionQuotaRequest;
use App\Models\Position;
use App\Models\PositionQuota;
use App\Models\PusatMengundi;
use App\Models\SaluranMengundi;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class PositionQuotaController extends Controller
{
use SortsQuery;
public function index(PusatMengundi $pusatMengundi, SaluranMengundi $saluranMengundi): View
{
abort_unless($saluranMengundi->pusat_mengundi_id === $pusatMengundi->id, 404);
return view('admin.setup.quotas.index', [
'pusat' => $pusatMengundi,
'saluran' => $saluranMengundi,
'quotas' => $this->sortedQuery(
PositionQuota::query()
->with(['position'])
->where('saluran_mengundi_id', $saluranMengundi->id),
['quota'],
'id'
)->paginate(25)->withQueryString(),
]);
}
public function create(PusatMengundi $pusatMengundi, SaluranMengundi $saluranMengundi): View
{
abort_unless($saluranMengundi->pusat_mengundi_id === $pusatMengundi->id, 404);
return view('admin.setup.quotas.create', [
'pusat' => $pusatMengundi,
'saluran' => $saluranMengundi,
'quota' => new PositionQuota,
'positions' => Position::query()->orderBy('sort_order')->orderBy('code')->get(),
]);
}
public function store(UpsertPositionQuotaRequest $request, PusatMengundi $pusatMengundi, SaluranMengundi $saluranMengundi): RedirectResponse
{
abort_unless($saluranMengundi->pusat_mengundi_id === $pusatMengundi->id, 404);
PositionQuota::query()->updateOrCreate(
[
'election_id' => $saluranMengundi->election_id,
'pusat_mengundi_id' => $pusatMengundi->id,
'saluran_mengundi_id' => $saluranMengundi->id,
'position_id' => $request->validated('position_id'),
],
['quota' => $request->validated('quota')],
);
return redirect()->route('admin.setup.pusat.saluran.kuota.index', [$pusatMengundi, $saluranMengundi])
->with('status', 'Kekosongan jawatan telah disimpan.');
}
public function edit(PusatMengundi $pusatMengundi, SaluranMengundi $saluranMengundi, PositionQuota $quota): View
{
abort_unless($saluranMengundi->pusat_mengundi_id === $pusatMengundi->id, 404);
abort_unless($quota->saluran_mengundi_id === $saluranMengundi->id, 404);
return view('admin.setup.quotas.edit', [
'pusat' => $pusatMengundi,
'saluran' => $saluranMengundi,
'quota' => $quota->load('position'),
'positions' => Position::query()->orderBy('sort_order')->orderBy('code')->get(),
]);
}
public function update(UpsertPositionQuotaRequest $request, PusatMengundi $pusatMengundi, SaluranMengundi $saluranMengundi, PositionQuota $quota): RedirectResponse
{
abort_unless($saluranMengundi->pusat_mengundi_id === $pusatMengundi->id, 404);
abort_unless($quota->saluran_mengundi_id === $saluranMengundi->id, 404);
$quota->update(['quota' => $request->validated('quota')]);
return redirect()->route('admin.setup.pusat.saluran.kuota.index', [$pusatMengundi, $saluranMengundi])
->with('status', 'Kekosongan jawatan telah dikemas kini.');
}
public function destroy(PusatMengundi $pusatMengundi, SaluranMengundi $saluranMengundi, PositionQuota $quota): RedirectResponse
{
abort_unless($saluranMengundi->pusat_mengundi_id === $pusatMengundi->id, 404);
abort_unless($quota->saluran_mengundi_id === $saluranMengundi->id, 404);
$quota->forceDelete();
return redirect()->route('admin.setup.pusat.saluran.kuota.index', [$pusatMengundi, $saluranMengundi])
->with('status', 'Kekosongan jawatan telah dipadam.');
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Admin\Setup;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Setup\ImportPusatRequest;
use App\Models\Dun;
use App\Models\Election;
use App\Services\Admin\PusatImportService;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class PusatImportController extends Controller
{
public function create(): View
{
return view('admin.setup.import.create', [
'elections' => Election::query()->orderByDesc('id')->get(),
]);
}
public function store(ImportPusatRequest $request, PusatImportService $importService): RedirectResponse
{
/** @var Election $election */
$election = Election::query()->findOrFail($request->validated('election_id'));
$dunCode = strtoupper(trim((string) $request->validated('dun_code')));
$dunName = trim((string) $request->validated('dun_name'));
$dun = Dun::query()->updateOrCreate(
['election_id' => $election->id, 'code' => $dunCode],
['name' => $dunName],
);
$result = $importService->import($election, $dun, $request->file('file'));
$message = "Import selesai: {$result['imported']} Pusat Mengundi, {$result['salurans']} Saluran dicipta untuk DUN {$dunCode} - {$dunName}.";
if (! empty($result['errors'])) {
$message .= ' '.count($result['errors']).' baris gagal.';
return redirect()->route('admin.setup.import.create')
->with('status', $message)
->with('import_errors', $result['errors']);
}
return redirect()->route('admin.setup.pusat.index')
->with('status', $message);
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Admin\Setup;
use App\Http\Concerns\SortsQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Setup\UpsertPositionQuotaRequest;
use App\Models\Position;
use App\Models\PositionQuota;
use App\Models\PusatMengundi;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class PusatLevelQuotaController extends Controller
{
use SortsQuery;
public function index(PusatMengundi $pusatMengundi): View
{
return view('admin.setup.quotas.pusat-index', [
'pusat' => $pusatMengundi,
'quotas' => $this->sortedQuery(
PositionQuota::query()
->with(['position'])
->where('pusat_mengundi_id', $pusatMengundi->id)
->whereNull('saluran_mengundi_id'),
['quota'],
'id'
)->paginate(25)->withQueryString(),
]);
}
public function create(PusatMengundi $pusatMengundi): View
{
return view('admin.setup.quotas.pusat-create', [
'pusat' => $pusatMengundi,
'quota' => new PositionQuota,
'positions' => Position::query()->orderBy('sort_order')->orderBy('code')->get(),
]);
}
public function store(UpsertPositionQuotaRequest $request, PusatMengundi $pusatMengundi): RedirectResponse
{
PositionQuota::query()->updateOrCreate(
[
'election_id' => $pusatMengundi->election_id,
'pusat_mengundi_id' => $pusatMengundi->id,
'saluran_mengundi_id' => null,
'position_id' => $request->validated('position_id'),
],
['quota' => $request->validated('quota')],
);
return redirect()->route('admin.setup.pusat.kuota.index', $pusatMengundi)
->with('status', 'Kuota peringkat pusat telah disimpan.');
}
public function edit(PusatMengundi $pusatMengundi, PositionQuota $quota): View
{
abort_unless($quota->pusat_mengundi_id === $pusatMengundi->id && $quota->saluran_mengundi_id === null, 404);
return view('admin.setup.quotas.pusat-edit', [
'pusat' => $pusatMengundi,
'quota' => $quota->load('position'),
'positions' => Position::query()->orderBy('sort_order')->orderBy('code')->get(),
]);
}
public function update(UpsertPositionQuotaRequest $request, PusatMengundi $pusatMengundi, PositionQuota $quota): RedirectResponse
{
abort_unless($quota->pusat_mengundi_id === $pusatMengundi->id && $quota->saluran_mengundi_id === null, 404);
$quota->update(['quota' => $request->validated('quota')]);
return redirect()->route('admin.setup.pusat.kuota.index', $pusatMengundi)
->with('status', 'Kuota peringkat pusat telah dikemas kini.');
}
public function destroy(PusatMengundi $pusatMengundi, PositionQuota $quota): RedirectResponse
{
abort_unless($quota->pusat_mengundi_id === $pusatMengundi->id && $quota->saluran_mengundi_id === null, 404);
$quota->forceDelete();
return redirect()->route('admin.setup.pusat.kuota.index', $pusatMengundi)
->with('status', 'Kuota peringkat pusat telah dipadam.');
}
}

View File

@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers\Admin\Setup;
use App\Http\Concerns\SortsQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Setup\AssignPpmRequest;
use App\Http\Requests\Admin\Setup\UpsertPusatMengundiRequest;
use App\Models\Dun;
use App\Models\Election;
use App\Models\Position;
use App\Models\PusatMengundi;
use App\Models\User;
use App\Services\Admin\PpmAssignmentService;
use App\Services\Admin\PusatOperationalSetupService;
use App\Services\Admin\PusatQrCodeService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
class PusatMengundiController extends Controller
{
use SortsQuery;
public function index(): View
{
return view('admin.setup.pusat.index', [
'pusats' => $this->sortedQuery(
PusatMengundi::query()->with(['election', 'dun', 'saluranMengundis']),
['code', 'name'],
'code'
)->paginate(15)->withQueryString(),
]);
}
public function create(): View
{
return view('admin.setup.pusat.create', [
'pusat' => new PusatMengundi,
'elections' => Election::query()->orderBy('name')->get(),
'duns' => Dun::query()->orderBy('name')->get(),
]);
}
public function store(
UpsertPusatMengundiRequest $request,
PusatQrCodeService $qrCodeService,
PusatOperationalSetupService $operationalSetupService,
): RedirectResponse {
$pusat = DB::transaction(function () use ($request, $operationalSetupService): PusatMengundi {
$pusat = PusatMengundi::query()->create([
...$request->safe()->only([
'election_id',
'dun_id',
'code',
'name',
'address',
]),
'public_uuid' => (string) Str::uuid(),
]);
$operationalSetupService->configure($pusat, $request->validated());
return $pusat;
});
$qrCodeService->generate($pusat);
return redirect()->route('admin.setup.pusat.index')
->with('status', 'Pusat Mengundi telah disimpan dan QR telah dijana.');
}
public function show(PusatMengundi $pusatMengundi): View
{
$ppmPosition = Position::query()->where('code', 'PPM')->first();
return view('admin.setup.pusat.show', [
'pusat' => $pusatMengundi->load([
'election',
'dun',
'saluranMengundis',
'positionQuotas.position',
]),
'ppmAssignment' => $ppmPosition
? $pusatMengundi->staffAssignments()
->with('user')
->where('position_id', $ppmPosition->id)
->where('status', 'active')
->whereNull('saluran_mengundi_id')
->first()
: null,
'users' => User::query()->orderBy('name')->get(),
]);
}
public function edit(PusatMengundi $pusatMengundi): View
{
return view('admin.setup.pusat.edit', [
'pusat' => $pusatMengundi,
'elections' => Election::query()->orderBy('name')->get(),
'duns' => Dun::query()->orderBy('name')->get(),
]);
}
public function update(UpsertPusatMengundiRequest $request, PusatMengundi $pusatMengundi): RedirectResponse
{
$pusatMengundi->update($request->safe()->only([
'election_id',
'dun_id',
'code',
'name',
'address',
]));
return redirect()->route('admin.setup.pusat.show', $pusatMengundi)
->with('status', 'Pusat Mengundi telah dikemas kini.');
}
public function destroy(PusatMengundi $pusatMengundi): RedirectResponse
{
$pusatMengundi->forceDelete();
return redirect()->route('admin.setup.pusat.index')
->with('status', 'Pusat Mengundi telah dipadam.');
}
public function generateQr(PusatMengundi $pusatMengundi, PusatQrCodeService $qrCodeService): RedirectResponse
{
$qrCodeService->generate($pusatMengundi);
return redirect()->route('admin.setup.pusat.show', $pusatMengundi)
->with('status', 'QR pendaftaran telah dijana semula.');
}
public function qrSvg(PusatMengundi $pusatMengundi): Response
{
abort_unless($pusatMengundi->qr_code_path && Storage::disk('local')->exists($pusatMengundi->qr_code_path), 404);
return response(Storage::disk('local')->get($pusatMengundi->qr_code_path), 200, [
'Content-Type' => 'image/svg+xml',
]);
}
public function assignPpm(AssignPpmRequest $request, PusatMengundi $pusatMengundi, PpmAssignmentService $assignmentService): RedirectResponse
{
$user = User::query()->findOrFail($request->validated('user_id'));
$assignmentService->assign($pusatMengundi, $user);
return redirect()->route('admin.setup.pusat.show', $pusatMengundi)
->with('status', 'PPM telah ditetapkan.');
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers\Admin\Setup;
use App\Http\Concerns\SortsQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Setup\UpsertSaluranMengundiRequest;
use App\Models\PusatMengundi;
use App\Models\SaluranMengundi;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class SaluranMengundiController extends Controller
{
use SortsQuery;
public function index(PusatMengundi $pusatMengundi): View
{
return view('admin.setup.saluran.index', [
'pusat' => $pusatMengundi,
'salurans' => $this->sortedQuery(
SaluranMengundi::query()->where('pusat_mengundi_id', $pusatMengundi->id),
['number', 'voter_count'],
'number'
)->paginate(20)->withQueryString(),
]);
}
public function create(PusatMengundi $pusatMengundi): View
{
return view('admin.setup.saluran.create', [
'pusat' => $pusatMengundi,
'saluran' => new SaluranMengundi,
]);
}
public function store(UpsertSaluranMengundiRequest $request, PusatMengundi $pusatMengundi): RedirectResponse
{
$pusatMengundi->saluranMengundis()->create([
'election_id' => $pusatMengundi->election_id,
'number' => $request->validated('number'),
'voter_count' => $request->validated('voter_count'),
]);
return redirect()->route('admin.setup.pusat.saluran.index', $pusatMengundi)
->with('status', 'Saluran Mengundi telah disimpan.');
}
public function edit(PusatMengundi $pusatMengundi, SaluranMengundi $saluranMengundi): View
{
abort_unless($saluranMengundi->pusat_mengundi_id === $pusatMengundi->id, 404);
return view('admin.setup.saluran.edit', [
'pusat' => $pusatMengundi,
'saluran' => $saluranMengundi,
]);
}
public function update(UpsertSaluranMengundiRequest $request, PusatMengundi $pusatMengundi, SaluranMengundi $saluranMengundi): RedirectResponse
{
abort_unless($saluranMengundi->pusat_mengundi_id === $pusatMengundi->id, 404);
$saluranMengundi->update([
'number' => $request->validated('number'),
'voter_count' => $request->validated('voter_count'),
]);
return redirect()->route('admin.setup.pusat.saluran.index', $pusatMengundi)
->with('status', 'Saluran Mengundi telah dikemas kini.');
}
public function destroy(PusatMengundi $pusatMengundi, SaluranMengundi $saluranMengundi): RedirectResponse
{
abort_unless($saluranMengundi->pusat_mengundi_id === $pusatMengundi->id, 404);
$saluranMengundi->forceDelete();
return redirect()->route('admin.setup.pusat.saluran.index', $pusatMengundi)
->with('status', 'Saluran Mengundi telah dipadam.');
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*/
public function create(): View
{
return view('auth.login');
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*/
public function show(): View
{
return view('auth.confirm-password');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request): RedirectResponse|View
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false))
: view('auth.verify-email');
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): View
{
return view('auth.reset-password', ['request' => $request]);
}
/**
* Handle an incoming new password request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $status == Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validateWithBag('updatePassword', [
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('status', 'password-updated');
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): View
{
return view('auth.forgot-password');
}
/**
* Handle an incoming password reset link request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => ['required', 'email'],
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
return $status == Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): View
{
return view('auth.register');
}
/**
* Handle an incoming registration request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return redirect(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}

View File

@@ -0,0 +1,163 @@
<?php
namespace App\Http\Controllers;
use App\Models\Application;
use App\Models\Attendance;
use App\Models\BankVerification;
use App\Models\PusatMengundi;
use App\Models\SaluranMengundi;
use App\Models\StaffAssignment;
use App\Services\Admin\Management\PennempatanService;
use App\Services\Ktm\KtmScopeService;
use App\Services\Ppm\PpmScopeService;
use App\Services\Public\KtmVacancyService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DashboardController extends Controller
{
public function index(Request $request): RedirectResponse|View
{
$user = $request->user();
if ($user->hasRole('Admin')) {
return redirect()->route('admin.dashboard');
}
if ($user->hasRole('Admin Kewangan')) {
return redirect()->route('kewangan.dashboard');
}
if ($user->hasRole('PPM')) {
return redirect()->route('ppm.dashboard');
}
if ($user->hasRole('KTM')) {
return redirect()->route('ktm.dashboard');
}
if ($user->hasRole('Pemohon')) {
return redirect()->route('pemohon.dashboard');
}
return view('dashboards.index', $this->payload(
'Dashboard',
'Akaun ini belum mempunyai peranan sistem.',
'Hubungi Admin untuk penetapan peranan.',
[
['label' => 'Peranan aktif', 'value' => '0', 'icon' => 'bi-shield-lock', 'tone' => 'secondary'],
['label' => 'Akses modul', 'value' => 'Belum aktif', 'icon' => 'bi-door-closed', 'tone' => 'secondary'],
],
));
}
public function admin(PennempatanService $pennempatanService): View
{
$pusatsWithVacancy = $pennempatanService->pusatsWithVacancySummary();
return view('dashboards.admin', [
'cards' => [
['label' => 'Pusat Mengundi', 'value' => (string) PusatMengundi::query()->count(), 'icon' => 'bi-building', 'tone' => 'primary'],
['label' => 'Saluran', 'value' => (string) SaluranMengundi::query()->count(), 'icon' => 'bi-diagram-3', 'tone' => 'info'],
['label' => 'Pemohon', 'value' => (string) Application::query()->count(), 'icon' => 'bi-people', 'tone' => 'success'],
['label' => 'Staf Aktif', 'value' => (string) StaffAssignment::query()->where('status', 'active')->count(), 'icon' => 'bi-person-check', 'tone' => 'success'],
],
'pusatsWithVacancy' => $pusatsWithVacancy,
]);
}
public function kewangan(): View
{
$missingBankStatement = BankVerification::query()
->whereHas('application', fn ($query) => $query->whereIn('status', ['approved', 'assigned']))
->whereHas('application', fn ($query) => $query->whereDoesntHave('documents', fn ($documentQuery) => $documentQuery->where('document_type', 'bank_statement')))
->count();
return view('dashboards.index', $this->payload(
'Dashboard Admin Kewangan',
'Semakan bank dan dokumen kewangan',
'Paparan asas untuk status verifikasi bank tanpa akses perubahan penempatan.',
[
['label' => 'Pending bank', 'value' => (string) BankVerification::query()->where('status', 'pending')->whereHas('application', fn ($query) => $query->whereIn('status', ['approved', 'assigned']))->count(), 'icon' => 'bi-bank', 'tone' => 'warning'],
['label' => 'Dokumen hilang', 'value' => (string) $missingBankStatement, 'icon' => 'bi-file-earmark-x', 'tone' => 'danger'],
['label' => 'Ditolak', 'value' => (string) BankVerification::query()->where('status', 'rejected')->count(), 'icon' => 'bi-x-circle', 'tone' => 'danger'],
['label' => 'Perlu pembetulan', 'value' => (string) BankVerification::query()->where('status', 'requires_correction')->count(), 'icon' => 'bi-pencil-square', 'tone' => 'info'],
],
[
['label' => 'Senarai staf diluluskan', 'route' => 'kewangan.bank.index'],
['label' => 'Semakan akaun bank', 'route' => 'kewangan.bank.index'],
['label' => 'Export finance list', 'route' => 'kewangan.bank.export'],
],
));
}
public function ppm(Request $request, PpmScopeService $scopeService): View
{
$pusatIds = $scopeService->assignedPusatIds($request->user());
return view('dashboards.index', $this->payload(
'Dashboard PPM',
'Pengurusan Pusat Mengundi sendiri',
'Paparan asas untuk semakan permohonan, tugasan saluran, wakil agensi, dan kehadiran.',
[
['label' => 'Pusat jagaan', 'value' => (string) $pusatIds->count(), 'icon' => 'bi-geo-alt', 'tone' => 'primary'],
['label' => 'Permohonan pending', 'value' => (string) Application::query()->whereIn('pusat_mengundi_id', $pusatIds)->whereIn('status', ['submitted', 'under_ppm_review'])->count(), 'icon' => 'bi-inbox', 'tone' => 'warning'],
['label' => 'Staf aktif', 'value' => (string) StaffAssignment::query()->whereIn('pusat_mengundi_id', $pusatIds)->where('status', 'active')->count(), 'icon' => 'bi-person-check', 'tone' => 'success'],
['label' => 'Kehadiran hadir', 'value' => (string) Attendance::query()->whereIn('pusat_mengundi_id', $pusatIds)->where('status', 'present')->count(), 'icon' => 'bi-check2-circle', 'tone' => 'info'],
],
[
['label' => 'Semak permohonan', 'route' => 'ppm.applications.index'],
['label' => 'Rekod kehadiran', 'route' => 'ppm.attendance.index'],
'Assign KTM/KP/KPDP',
],
));
}
public function ktm(Request $request, KtmScopeService $scopeService, KtmVacancyService $vacancyService): View
{
$assignments = $scopeService->assignmentsFor($request->user());
$assignmentIds = $assignments->pluck('id');
$kpVacancy = $assignments->sum(fn (StaffAssignment $assignment): int => $vacancyService->remainingForAssignment($assignment, 'KP'));
return view('dashboards.index', $this->payload(
'Dashboard KTM',
'Pengurusan pasukan KP sendiri',
'Paparan asas untuk saluran ditugaskan, kekosongan KP, dan pendaftaran KP.',
[
['label' => 'Saluran ditugaskan', 'value' => (string) $assignments->count(), 'icon' => 'bi-signpost', 'tone' => 'primary'],
['label' => 'KP aktif', 'value' => (string) StaffAssignment::query()->whereIn('reports_to_assignment_id', $assignmentIds)->where('status', 'active')->count(), 'icon' => 'bi-people', 'tone' => 'success'],
['label' => 'Kekosongan KP', 'value' => (string) $kpVacancy, 'icon' => 'bi-person-plus', 'tone' => 'warning'],
],
[
['label' => 'Senarai KP', 'route' => 'ktm.applications.index'],
['label' => 'Daftar KP', 'route' => 'ktm.applications.create'],
'Semak permohonan KP',
],
));
}
public function pemohon(): View
{
return view('dashboards.index', $this->payload(
'Dashboard Pemohon',
'Status permohonan',
'Akaun Pemohon akan digunakan jika portal status kendiri diaktifkan dalam fasa akan datang.',
[
['label' => 'Permohonan', 'value' => '0', 'icon' => 'bi-file-text', 'tone' => 'secondary'],
['label' => 'Status', 'value' => 'Belum aktif', 'icon' => 'bi-info-circle', 'tone' => 'info'],
],
));
}
/**
* @param array<int, array{label: string, value: string, icon: string, tone: string}> $cards
* @param array<int, string|array{label: string, route: string}> $actions
* @return array<string, mixed>
*/
private function payload(string $title, string $eyebrow, string $description, array $cards, array $actions = []): array
{
return compact('title', 'eyebrow', 'description', 'cards', 'actions');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Models\ApplicationDocument;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DocumentDownloadController extends Controller
{
public function admin(ApplicationDocument $document): StreamedResponse
{
abort_unless(
in_array($document->document_type, ApplicationDocument::ALLOWED_FOR_ADMIN, true),
403
);
abort_unless(Storage::disk($document->disk)->exists($document->path), 404);
activity('documents')
->performedOn($document)
->causedBy(request()->user())
->withProperties(['document_type' => $document->document_type])
->log('document_downloaded_by_admin');
return Storage::disk($document->disk)->download($document->path, $document->original_name);
}
public function finance(ApplicationDocument $document): StreamedResponse
{
abort_unless(
in_array($document->document_type, ApplicationDocument::ALLOWED_FOR_FINANCE, true),
403
);
abort_unless(Storage::disk($document->disk)->exists($document->path), 404);
activity('documents')
->performedOn($document)
->causedBy(request()->user())
->withProperties(['document_type' => $document->document_type])
->log('bank_statement_downloaded_by_finance');
return Storage::disk($document->disk)->download($document->path, $document->original_name);
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers\Kewangan;
use App\Http\Concerns\SortsQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Kewangan\UpdateBankVerificationRequest;
use App\Models\BankVerification;
use App\Models\Position;
use App\Models\PusatMengundi;
use App\Services\Finance\BankVerificationService;
use App\Services\Finance\FinanceExportService;
use App\Services\Finance\FinanceVerificationQuery;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class BankVerificationController extends Controller
{
use SortsQuery;
public function index(Request $request, FinanceVerificationQuery $verificationQuery): View
{
$filters = $this->filters($request);
return view('kewangan.bank.index', [
'verifications' => $this->sortedQuery(
$verificationQuery->build($filters),
['status'],
'id',
'desc'
)->paginate(15)->withQueryString(),
'positions' => Position::query()->orderBy('sort_order')->orderBy('name')->get(),
'pusats' => PusatMengundi::query()->orderBy('name')->get(),
'filters' => $filters,
]);
}
public function show(BankVerification $bankVerification): View
{
return view('kewangan.bank.show', [
'verification' => $bankVerification->load([
'application.election',
'application.pusatMengundi',
'application.approvedPosition',
'application.requestedPosition',
'application.documents',
'application.staffAssignments.position',
'application.staffAssignments.saluranMengundi',
'verifiedBy',
]),
]);
}
public function update(UpdateBankVerificationRequest $request, BankVerification $bankVerification, BankVerificationService $service): RedirectResponse
{
$service->update($bankVerification, $request->user(), $request->validated());
return redirect()->route('kewangan.bank.show', $bankVerification)
->with('status', 'Status verifikasi bank telah dikemas kini.');
}
public function export(Request $request, FinanceExportService $exportService): StreamedResponse
{
$exportLog = $exportService->export($request->user(), $this->filters($request));
return Storage::disk($exportLog->disk ?? 'local')
->download($exportLog->path, $exportLog->file_name);
}
/**
* @return array<string, mixed>
*/
private function filters(Request $request): array
{
return [
'status' => $request->filled('status') ? $request->string('status')->toString() : null,
'pusat_mengundi_id' => $request->filled('pusat_mengundi_id') ? $request->integer('pusat_mengundi_id') : null,
'role_id' => $request->filled('role_id') ? $request->integer('role_id') : null,
'missing_bank_statement' => $request->boolean('missing_bank_statement'),
'missing_account_number' => $request->boolean('missing_account_number'),
'q' => $request->filled('q') ? $request->string('q')->toString() : null,
];
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Http\Controllers\Ktm;
use App\Http\Concerns\SortsQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Ktm\RegisterKpApplicantRequest;
use App\Models\Application;
use App\Models\Election;
use App\Models\StaffAssignment;
use App\Services\Ktm\KtmApplicationService;
use App\Services\Ktm\KtmScopeService;
use App\Services\Public\KtmVacancyService;
use App\Services\RegistrationPeriodService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class TeamController extends Controller
{
public function index(Request $request, KtmScopeService $scopeService, KtmVacancyService $vacancyService): View
{
$assignments = $scopeService->assignmentsFor($request->user());
$assignmentIds = $assignments->pluck('id');
return view('ktm.applications.index', [
'assignments' => $assignments,
'applications' => $this->sortedQuery(
Application::query()
->with(['pusatMengundi', 'requestedPosition', 'approvedPosition', 'selectedKtmAssignment.saluranMengundi'])
->whereIn('selected_ktm_assignment_id', $assignmentIds),
['name', 'status', 'created_at'],
'created_at',
'desc'
)->paginate(15)->withQueryString(),
'vacancyService' => $vacancyService,
]);
}
public function create(Request $request, KtmScopeService $scopeService, RegistrationPeriodService $registrationPeriodService): View
{
$assignments = $scopeService->assignmentsFor($request->user());
return view('ktm.applications.create', [
'assignments' => $assignments,
'registrationOpenByAssignment' => $assignments
->mapWithKeys(fn (StaffAssignment $assignment): array => [
$assignment->id => $registrationPeriodService->isOpen($this->assignmentElection($assignment)),
])
->all(),
]);
}
public function store(RegisterKpApplicantRequest $request, KtmApplicationService $service): RedirectResponse
{
$assignment = StaffAssignment::query()->findOrFail($request->validated('ktm_assignment_id'));
$application = $service->registerKp($assignment, $request->user(), $request->validated());
return redirect()
->route('ktm.applications.show', $application->public_uuid)
->with('status', 'Rekod KP telah didaftarkan dan emel makluman telah dihantar.');
}
public function show(Application $application, Request $request, KtmScopeService $scopeService): View
{
abort_unless($scopeService->canAccessApplication($request->user(), $application), 403);
return view('ktm.applications.show', [
'application' => $application->load([
'pusatMengundi',
'requestedPosition',
'approvedPosition',
'selectedKtmAssignment.user',
'selectedKtmAssignment.saluranMengundi',
'staffAssignments.position',
'statusHistories.changedBy',
]),
]);
}
public function approve(Application $application, Request $request, KtmApplicationService $service): RedirectResponse
{
$service->approveKp($application, $request->user());
return redirect()
->route('ktm.applications.show', $application->public_uuid)
->with('status', 'Permohonan KP telah diluluskan dan diassign kepada team anda.');
}
public function destroy(Application $application, Request $request, KtmApplicationService $service): RedirectResponse
{
$service->deleteCreatedApplication($application, $request->user());
return redirect()
->route('ktm.applications.index')
->with('status', 'Rekod KTM-created telah dipadam. Pemohon kini boleh mendaftar sendiri.');
}
private function assignmentElection(StaffAssignment $assignment): Election
{
/** @var Election $election */
$election = $assignment->election()->with('settings')->firstOrFail();
return $election;
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers\Ppm;
use App\Http\Concerns\SortsQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Ppm\ApproveApplicationRequest;
use App\Http\Requests\Ppm\RejectApplicationRequest;
use App\Models\Application;
use App\Models\ApplicationDocument;
use App\Models\Position;
use App\Models\PusatMengundi;
use App\Services\Ppm\AssignmentVacancyService;
use App\Services\Ppm\PpmApplicationReviewService;
use App\Services\Ppm\PpmScopeService;
use App\Services\Public\KtmVacancyService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ApplicationReviewController extends Controller
{
use SortsQuery;
public function index(Request $request, PpmScopeService $scopeService): View
{
$pusatIds = $scopeService->assignedPusatIds($request->user());
$status = $request->string('status')->toString();
$query = Application::query()
->with(['pusatMengundi', 'requestedPosition', 'approvedPosition'])
->whereIn('pusat_mengundi_id', $pusatIds)
->when($status !== '', fn ($q) => $q->where('status', $status));
return view('ppm.applications.index', [
'applications' => $this->sortedQuery($query, ['name', 'status', 'created_at'], 'created_at', 'desc')
->paginate(15)
->withQueryString(),
'status' => $status,
]);
}
public function show(
Application $application,
AssignmentVacancyService $assignmentVacancyService,
KtmVacancyService $ktmVacancyService,
): View {
$this->authorize('view', $application);
$application->load([
'pusatMengundi.daerahMengundi',
'pusatMengundi.saluranMengundis',
'pusatMengundi.policeEscorts.saluranMengundi',
'pusatMengundi.kkmRepresentatives',
'pusatMengundi.jkmRepresentatives',
'requestedPosition',
'approvedPosition',
'documents',
'selectedKtmAssignment.user',
'selectedKtmAssignment.saluranMengundi',
'staffAssignments.position',
'staffAssignments.saluranMengundi',
'staffAssignments.reportsTo.user',
'statusHistories.changedBy',
]);
/** @var PusatMengundi $pusatMengundi */
$pusatMengundi = $application->pusatMengundi;
return view('ppm.applications.show', [
'application' => $application,
'roles' => Position::query()
->whereIn('code', ['KTM', 'KP', 'KPDP', 'PAPM'])
->where('is_public_applyable', true)
->orderBy('sort_order')
->get(),
'availableKtmSalurans' => $assignmentVacancyService->availableSaluransForRole($pusatMengundi, 'KTM'),
'ktmOptionsByRole' => $ktmVacancyService->optionsForRoles($pusatMengundi, ['KP', 'KPDP'], $application->id),
]);
}
public function approve(
ApproveApplicationRequest $request,
Application $application,
PpmApplicationReviewService $reviewService,
): RedirectResponse {
$this->authorize('review', $application);
$reviewService->approve($application, $request->user(), $request->validated());
return redirect()
->route('ppm.applications.show', $application->public_uuid)
->with('status', 'Permohonan telah diluluskan dan assignment telah disimpan.');
}
public function reject(
RejectApplicationRequest $request,
Application $application,
PpmApplicationReviewService $reviewService,
): RedirectResponse {
$this->authorize('review', $application);
$reviewService->reject($application, $request->user(), $request->validated('reason'));
return redirect()
->route('ppm.applications.show', $application->public_uuid)
->with('status', 'Permohonan telah ditolak.');
}
public function downloadDocument(Application $application, ApplicationDocument $document): StreamedResponse
{
$this->authorize('downloadDocument', $application);
abort_unless($document->application_id === $application->id, 403);
abort_unless(
in_array($document->document_type, ApplicationDocument::ALLOWED_FOR_PPM, true),
403
);
abort_unless(Storage::disk($document->disk)->exists($document->path), 404);
activity('documents')
->performedOn($document)
->causedBy(request()->user())
->withProperties(['document_type' => $document->document_type])
->log('document_downloaded_by_ppm');
return Storage::disk($document->disk)->download($document->path, $document->original_name);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Ppm;
use App\Http\Controllers\Controller;
use App\Http\Requests\Ppm\RecordAttendanceRequest;
use App\Models\PusatMengundi;
use App\Models\StaffAssignment;
use App\Services\Attendance\AttendanceRecordingService;
use App\Services\Attendance\AttendanceSummaryService;
use App\Services\Ppm\PpmScopeService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class AttendanceController extends Controller
{
public function index(Request $request, PpmScopeService $scopeService, AttendanceSummaryService $summaryService): View
{
$pusatIds = $scopeService->assignedPusatIds($request->user());
$pusats = PusatMengundi::query()
->with(['election.settings'])
->whereIn('id', $pusatIds)
->orderBy('name')
->get();
return view('ppm.attendance.index', [
'pusats' => $pusats,
'summaryService' => $summaryService,
]);
}
public function show(PusatMengundi $pusatMengundi, Request $request, PpmScopeService $scopeService, AttendanceSummaryService $summaryService): View
{
if (! $scopeService->assignedPusatIds($request->user())->contains((int) $pusatMengundi->id)) {
abort(403);
}
$assignments = StaffAssignment::query()
->with(['application', 'user', 'position', 'saluranMengundi', 'attendance'])
->where('pusat_mengundi_id', $pusatMengundi->id)
->where('status', 'active')
->orderBy('position_id')
->orderBy('saluran_mengundi_id')
->get();
return view('ppm.attendance.show', [
'pusat' => $pusatMengundi->load('election.settings'),
'assignments' => $assignments,
'summary' => $summaryService->totalsForPusat($pusatMengundi),
]);
}
public function store(PusatMengundi $pusatMengundi, RecordAttendanceRequest $request, AttendanceRecordingService $service): RedirectResponse
{
try {
$service->recordBulk($pusatMengundi, $request->user(), $request->validated('attendance'));
} catch (ValidationException $exception) {
throw $exception;
}
return redirect()->route('ppm.attendance.show', $pusatMengundi)
->with('status', 'Kehadiran telah direkodkan.');
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): View
{
return view('profile.edit', [
'user' => $request->user(),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return Redirect::route('profile.edit')->with('status', 'profile-updated');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\Public\StorePublicApplicationRequest;
use App\Models\Application;
use App\Models\Election;
use App\Models\Position;
use App\Models\PusatMengundi;
use App\Services\Public\KtmVacancyService;
use App\Services\Public\PublicApplicationSubmissionService;
use App\Services\RegistrationPeriodService;
use App\Support\SensitiveData;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class PublicApplicationController extends Controller
{
public function create(
PusatMengundi $pusatMengundi,
RegistrationPeriodService $registrationPeriodService,
KtmVacancyService $ktmVacancyService,
): View {
$pusatMengundi->loadMissing('daerahMengundi');
/** @var Election $election */
$election = $pusatMengundi->election()->with('settings')->firstOrFail();
if (! $registrationPeriodService->isOpen($election)) {
return view('public.applications.closed', [
'pusatMengundi' => $pusatMengundi,
'message' => $registrationPeriodService->closedMessage($election),
]);
}
return view('public.applications.create', [
'pusatMengundi' => $pusatMengundi,
'roles' => Position::query()
->whereIn('code', ['KTM', 'KP', 'KPDP', 'PAPM'])
->where('is_public_applyable', true)
->orderBy('sort_order')
->get(),
'ktmOptionsByRole' => $ktmVacancyService->optionsForRoles($pusatMengundi),
]);
}
public function store(
StorePublicApplicationRequest $request,
PusatMengundi $pusatMengundi,
PublicApplicationSubmissionService $submissionService,
RegistrationPeriodService $registrationPeriodService,
): RedirectResponse {
/** @var Election $election */
$election = $pusatMengundi->election()->with('settings')->firstOrFail();
if (! $registrationPeriodService->isOpen($election)) {
return redirect()
->route('public.applications.create', $pusatMengundi->public_uuid)
->withErrors(['registration' => $registrationPeriodService->closedMessage($election)]);
}
$application = $submissionService->submit($pusatMengundi, $request->validated());
return redirect()->route('public.applications.success', $application->public_uuid);
}
public function success(Application $application): View
{
return view('public.applications.success', [
'application' => $application->load(['pusatMengundi', 'requestedPosition']),
]);
}
public function status(string $publicUuid): View
{
// Query with withTrashed so soft-deleted records don't 404, allowing a uniform
// response for all unknown/invalid UUIDs to prevent enumeration.
$application = Application::withTrashed()
->where('public_uuid', $publicUuid)
->with(['pusatMengundi', 'requestedPosition', 'approvedPosition'])
->first();
// Treat soft-deleted and KTM-internal records as not found to avoid leaking
// information about whether a UUID ever existed.
if (! $application instanceof Application
|| $application->trashed()
|| $application->status === 'deleted_by_ktm'
) {
return view('public.applications.status', ['application' => null]);
}
return view('public.applications.status', [
'application' => $application,
'maskedName' => SensitiveData::maskName($application->name),
]);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Http\Controllers;
use App\Models\PusatMengundi;
use Illuminate\View\View;
class PublicApplicationPlaceholderController extends Controller
{
public function create(PusatMengundi $pusatMengundi): View
{
return view('public.application-placeholder', [
'pusatMengundi' => $pusatMengundi->load('daerahMengundi'),
]);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Middleware;
use App\Models\Election;
use App\Services\RegistrationPeriodService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RequireAdminPostCloseNote
{
public function __construct(
private readonly RegistrationPeriodService $registrationPeriodService,
) {}
public function handle(Request $request, Closure $next): Response
{
// Only intercept write operations.
if (! in_array($request->method(), ['POST', 'PATCH', 'PUT', 'DELETE'], true)) {
return $next($request);
}
$election = $this->resolveElection($request);
if ($election instanceof Election && ! $this->registrationPeriodService->isOpen($election)) {
$note = $request->input('note') ?? $request->input('catatan');
if (blank($note)) {
return back()
->withInput()
->withErrors([
'note' => 'Catatan wajib diisi untuk perubahan selepas tempoh pendaftaran ditutup.',
]);
}
}
return $next($request);
}
// For create routes the election_id is in the request body.
// For edit/delete routes the election is resolved from the active election,
// as those route models carry their own election context inside the service.
private function resolveElection(Request $request): ?Election
{
$electionId = $request->input('election_id');
if ($electionId) {
return Election::query()->with('settings')->find($electionId);
}
return Election::query()
->with('settings')
->where('status', 'active')
->first();
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests\Admin\Management;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AssignApplicationRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('Admin') === true;
}
protected function prepareForValidation(): void
{
$this->merge([
'saluran_mengundi_id' => blank($this->input('saluran_mengundi_id')) ? null : $this->input('saluran_mengundi_id'),
'reports_to_assignment_id' => blank($this->input('reports_to_assignment_id')) ? null : $this->input('reports_to_assignment_id'),
]);
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'position_id' => ['required', 'integer', 'exists:positions,id'],
'pusat_mengundi_id' => ['required', 'integer', 'exists:pusat_mengundis,id'],
'saluran_mengundi_id' => ['nullable', 'integer', 'exists:saluran_mengundis,id'],
'reports_to_assignment_id' => ['nullable', 'integer', 'exists:staff_assignments,id'],
'status' => ['nullable', Rule::in(['active', 'inactive', 'replaced', 'cancelled'])],
'note' => ['nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Admin\Management;
use Illuminate\Foundation\Http\FormRequest;
class DeleteRepresentativeRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('Admin') === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'note' => ['nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Requests\Admin\Management;
use Illuminate\Foundation\Http\FormRequest;
class StoreManualApplicationRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('Admin') === true;
}
protected function prepareForValidation(): void
{
$this->merge([
'ic_number' => preg_replace('/\D+/', '', (string) $this->input('ic_number')),
'phone_number' => trim((string) $this->input('phone_number')),
'email' => blank($this->input('email')) ? null : strtolower(trim((string) $this->input('email'))),
'selected_ktm_assignment_id' => blank($this->input('selected_ktm_assignment_id')) ? null : $this->input('selected_ktm_assignment_id'),
'bank_name' => blank($this->input('bank_name')) ? null : trim((string) $this->input('bank_name')),
'bank_account_number' => blank($this->input('bank_account_number')) ? null : preg_replace('/\D+/', '', (string) $this->input('bank_account_number')),
]);
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'election_id' => ['required', 'integer', 'exists:elections,id'],
'pusat_mengundi_id' => ['required', 'integer', 'exists:pusat_mengundis,id'],
'selected_ktm_assignment_id' => ['nullable', 'integer', 'exists:staff_assignments,id'],
'requested_position_id' => ['required', 'integer', 'exists:positions,id'],
'name' => ['required', 'string', 'max:255'],
'ic_number' => ['required', 'digits:12'],
'phone_number' => ['required', 'string', 'max:30'],
'email' => ['nullable', 'email', 'max:255'],
'address' => ['nullable', 'string', 'max:2000'],
'bank_name' => ['nullable', 'string', 'max:255'],
'bank_account_number' => ['nullable', 'string', 'max:50'],
'note' => ['nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests\Admin\Management;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreRepresentativeRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('Admin') === true;
}
protected function prepareForValidation(): void
{
$this->merge([
'ic_number' => blank($this->input('ic_number')) ? null : preg_replace('/\D+/', '', (string) $this->input('ic_number')),
'saluran_mengundi_id' => blank($this->input('saluran_mengundi_id')) ? null : $this->input('saluran_mengundi_id'),
]);
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'type' => ['required', Rule::in(['police', 'kkm', 'jkm'])],
'election_id' => ['required', 'integer', 'exists:elections,id'],
'pusat_mengundi_id' => ['required', 'integer', 'exists:pusat_mengundis,id'],
'saluran_mengundi_id' => ['nullable', 'integer', 'exists:saluran_mengundis,id'],
'name' => ['required', 'string', 'max:255'],
'ic_number' => ['nullable', 'digits:12'],
'phone_number' => ['nullable', 'string', 'max:30'],
'rank' => ['nullable', 'string', 'max:255'],
'station' => ['nullable', 'string', 'max:255'],
'agency' => ['nullable', 'string', 'max:255'],
'notes' => ['nullable', 'string', 'max:2000'],
'note' => ['nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Admin\Management;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreReturnTransactionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('Admin') === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'quantity' => ['required', 'integer', 'min:1', 'max:9999'],
'returned_at' => ['nullable', 'date'],
'returned_by_name' => ['required', 'string', 'max:255'],
'return_condition' => ['required', Rule::in(['baik', 'rosak'])],
'notes' => ['nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Admin\Management;
use Illuminate\Foundation\Http\FormRequest;
class StoreTakenTransactionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('Admin') === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'quantity' => ['required', 'integer', 'min:1', 'max:9999'],
'taken_at' => ['nullable', 'date'],
'taken_by_name' => ['required', 'string', 'max:255'],
'notes' => ['nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Admin\Management;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreWheelchairTransactionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('Admin') === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'transaction_type' => ['required', Rule::in(['taken', 'returned'])],
'quantity' => ['required', 'integer', 'min:1', 'max:9999'],
'taken_at' => ['nullable', 'date'],
'taken_by_name' => ['nullable', 'string', 'max:255', 'required_if:transaction_type,taken'],
'returned_at' => ['nullable', 'date'],
'return_condition' => ['nullable', Rule::in(['baik', 'rosak']), 'required_if:transaction_type,returned'],
'notes' => ['nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Requests\Admin\Management;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateManagedApplicationRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('Admin') === true;
}
protected function prepareForValidation(): void
{
$this->merge([
'ic_number' => preg_replace('/\D+/', '', (string) $this->input('ic_number')),
'phone_number' => trim((string) $this->input('phone_number')),
'email' => blank($this->input('email')) ? null : strtolower(trim((string) $this->input('email'))),
'selected_ktm_assignment_id' => blank($this->input('selected_ktm_assignment_id')) ? null : $this->input('selected_ktm_assignment_id'),
'approved_position_id' => blank($this->input('approved_position_id')) ? null : $this->input('approved_position_id'),
'bank_name' => blank($this->input('bank_name')) ? null : trim((string) $this->input('bank_name')),
'bank_account_number' => blank($this->input('bank_account_number')) ? null : preg_replace('/\D+/', '', (string) $this->input('bank_account_number')),
]);
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'pusat_mengundi_id' => ['required', 'integer', 'exists:pusat_mengundis,id'],
'selected_ktm_assignment_id' => ['nullable', 'integer', 'exists:staff_assignments,id'],
'requested_position_id' => ['required', 'integer', 'exists:positions,id'],
'approved_position_id' => ['nullable', 'integer', 'exists:positions,id'],
'status' => ['required', Rule::in(['draft', 'submitted', 'under_ppm_review', 'approved', 'rejected', 'assigned', 'cancelled'])],
'name' => ['required', 'string', 'max:255'],
'ic_number' => ['required', 'digits:12'],
'phone_number' => ['required', 'string', 'max:30'],
'email' => ['nullable', 'email', 'max:255'],
'address' => ['nullable', 'string', 'max:2000'],
'bank_name' => ['nullable', 'string', 'max:255'],
'bank_account_number' => ['nullable', 'string', 'max:50'],
'note' => ['nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests\Admin\Management;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateStaffAssignmentRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('Admin') === true;
}
protected function prepareForValidation(): void
{
$this->merge([
'saluran_mengundi_id' => blank($this->input('saluran_mengundi_id')) ? null : $this->input('saluran_mengundi_id'),
'reports_to_assignment_id' => blank($this->input('reports_to_assignment_id')) ? null : $this->input('reports_to_assignment_id'),
]);
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'position_id' => ['required', 'integer', 'exists:positions,id'],
'pusat_mengundi_id' => ['required', 'integer', 'exists:pusat_mengundis,id'],
'saluran_mengundi_id' => ['nullable', 'integer', 'exists:saluran_mengundis,id'],
'reports_to_assignment_id' => ['nullable', 'integer', 'exists:staff_assignments,id'],
'status' => ['required', Rule::in(['active', 'inactive', 'replaced', 'cancelled'])],
'note' => ['nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Admin\Management;
use Illuminate\Foundation\Http\FormRequest;
class UpsertWheelchairAllocationRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('Admin') === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'election_id' => ['required', 'integer', 'exists:elections,id'],
'pusat_mengundi_id' => ['required', 'integer', 'exists:pusat_mengundis,id'],
'allocated_quantity' => ['required', 'integer', 'min:0', 'max:9999'],
'notes' => ['nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Admin\Setup;
use Illuminate\Foundation\Http\FormRequest;
class AssignPpmRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('Admin') === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'user_id' => ['required', 'exists:users,id'],
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests\Admin\Setup;
use Illuminate\Foundation\Http\FormRequest;
class ImportPusatRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('Admin') === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'election_id' => ['required', 'integer', 'exists:elections,id'],
'dun_code' => ['required', 'string', 'max:20'],
'dun_name' => ['required', 'string', 'max:255'],
'file' => ['required', 'file', 'mimes:xlsx', 'max:10240'],
];
}
/**
* @return array<string, string>
*/
public function attributes(): array
{
return [
'election_id' => 'pilihan raya',
'dun_code' => 'kod DUN',
'dun_name' => 'nama DUN',
'file' => 'fail XLSX',
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\Admin\Setup;
use App\Models\BahagianPilihanraya;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpsertBahagianPilihanrayaRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('Admin') === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$bahagian = $this->route('bahagianPilihanraya');
return [
'election_id' => ['required', 'exists:elections,id'],
'code' => [
'required',
'string',
'max:50',
Rule::unique('bahagian_pilihanrayas', 'code')
->where('election_id', $this->input('election_id'))
->ignore($bahagian instanceof BahagianPilihanraya ? $bahagian->id : null),
],
'name' => ['required', 'string', 'max:255'],
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests\Admin\Setup;
use App\Models\DaerahMengundi;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpsertDaerahMengundiRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('Admin') === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$daerah = $this->route('daerahMengundi');
return [
'election_id' => ['required', 'exists:elections,id'],
'bahagian_pilihanraya_id' => ['required', 'exists:bahagian_pilihanrayas,id'],
'code' => [
'required',
'string',
'max:50',
Rule::unique('daerah_mengundis', 'code')
->where('election_id', $this->input('election_id'))
->ignore($daerah instanceof DaerahMengundi ? $daerah->id : null),
],
'name' => ['required', 'string', 'max:255'],
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Requests\Admin\Setup;
use Illuminate\Foundation\Http\FormRequest;
class UpsertElectionSettingsRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('Admin') === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'registration_start_date' => ['required', 'date'],
'registration_end_date' => ['required', 'date', 'after_or_equal:registration_start_date'],
'polling_date' => ['required', 'date', 'after_or_equal:registration_end_date'],
'is_attendance_active' => ['required', 'boolean'],
'is_registration_open_override' => ['nullable', 'in:,0,1'],
];
}
protected function prepareForValidation(): void
{
$this->merge([
'is_attendance_active' => $this->boolean('is_attendance_active'),
'is_registration_open_override' => $this->resolveOverride(),
]);
}
private function resolveOverride(): ?bool
{
$value = $this->input('is_registration_open_override');
if ($value === '' || $value === null) {
return null;
}
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Admin\Setup;
use Illuminate\Foundation\Http\FormRequest;
class UpsertPositionQuotaRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('Admin') === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'position_id' => ['required', 'exists:positions,id'],
'quota' => ['required', 'integer', 'min:0', 'max:9999'],
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\Admin\Setup;
use App\Models\Position;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpsertPositionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('Admin') === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$position = $this->route('position');
return [
'code' => [
'required',
'string',
'max:50',
Rule::unique('positions', 'code')->ignore($position instanceof Position ? $position->id : null),
],
'name' => ['required', 'string', 'max:255'],
'scope' => ['required', Rule::in(['pusat', 'saluran', 'external'])],
'is_public_applyable' => ['nullable', 'boolean'],
'is_assignable' => ['nullable', 'boolean'],
'allows_dual_role' => ['nullable', 'boolean'],
'sort_order' => ['nullable', 'integer', 'min:0', 'max:999'],
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Requests\Admin\Setup;
use App\Models\PusatMengundi;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpsertPusatMengundiRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('Admin') === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$pusatMengundi = $this->route('pusatMengundi');
$pusatId = $pusatMengundi instanceof PusatMengundi ? $pusatMengundi->id : null;
$operationalQuantity = $this->isMethod('post') ? ['required', 'integer'] : ['sometimes', 'integer'];
return [
'election_id' => ['required', 'integer', 'exists:elections,id'],
'dun_id' => ['nullable', 'integer', 'exists:duns,id'],
'code' => [
'required',
'string',
'max:50',
Rule::unique('pusat_mengundis', 'code')
->where(fn ($query) => $query->where('election_id', $this->input('election_id')))
->ignore($pusatId),
],
'name' => ['required', 'string', 'max:255'],
'address' => ['nullable', 'string', 'max:2000'],
'saluran_count' => [...$operationalQuantity, 'min:1', 'max:100'],
'ktm_count' => [...$operationalQuantity, 'min:0', 'max:100'],
'polis_iring_count' => [...$operationalQuantity, 'min:0', 'max:100'],
'kp_count' => [...$operationalQuantity, 'min:0', 'max:100'],
'ppm_count' => [...$operationalQuantity, 'min:0', 'max:100'],
'kpdp_count' => [...$operationalQuantity, 'min:0', 'max:100'],
'papm_count' => [...$operationalQuantity, 'min:0', 'max:100'],
'jkm_count' => [...$operationalQuantity, 'min:0', 'max:100'],
'kkm_count' => [...$operationalQuantity, 'min:0', 'max:100'],
'calon_tambahan_count' => [...$operationalQuantity, 'min:0', 'max:100'],
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests\Admin\Setup;
use App\Models\PusatMengundi;
use App\Models\SaluranMengundi;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpsertSaluranMengundiRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('Admin') === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$pusatMengundi = $this->route('pusatMengundi');
$pusatId = $pusatMengundi instanceof PusatMengundi ? $pusatMengundi->id : null;
$saluranMengundi = $this->route('saluranMengundi');
$saluranId = $saluranMengundi instanceof SaluranMengundi ? $saluranMengundi->id : null;
return [
'number' => [
'required',
'string',
'max:20',
Rule::unique('saluran_mengundis', 'number')
->where(fn ($query) => $query->where('pusat_mengundi_id', $pusatId))
->ignore($saluranId),
],
'voter_count' => ['nullable', 'integer', 'min:0'],
];
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Kewangan;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateBankVerificationRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasAnyRole(['Admin Kewangan', 'Admin']) === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'status' => ['required', Rule::in(['pending', 'verified', 'rejected', 'requires_correction'])],
'finance_note' => ['nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\Ktm;
use Illuminate\Foundation\Http\FormRequest;
class RegisterKpApplicantRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('KTM') === true;
}
protected function prepareForValidation(): void
{
$this->merge([
'ic_number' => preg_replace('/\D+/', '', (string) $this->input('ic_number')),
'phone_number' => trim((string) $this->input('phone_number')),
]);
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'ktm_assignment_id' => ['required', 'integer', 'exists:staff_assignments,id'],
'name' => ['required', 'string', 'max:255'],
'ic_number' => ['required', 'digits:12'],
'phone_number' => ['required', 'string', 'max:30'],
'email' => ['required', 'email', 'max:255'],
'address' => ['nullable', 'string', 'max:1000'],
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests\Ppm;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ApproveApplicationRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('PPM') === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'approved_position_code' => ['required', Rule::in(['KTM', 'KP', 'KPDP', 'PAPM'])],
'saluran_mengundi_id' => [
Rule::requiredIf(fn (): bool => $this->input('approved_position_code') === 'KTM'),
'nullable',
'integer',
],
'ktm_assignment_id' => [
Rule::requiredIf(fn (): bool => in_array($this->input('approved_position_code'), ['KP', 'KPDP'], true)),
'nullable',
'integer',
],
'note' => ['nullable', 'string', 'max:1000'],
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Ppm;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class RecordAttendanceRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('PPM') === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'attendance' => ['required', 'array'],
'attendance.*.status' => ['required', Rule::in(['present', 'absent', 'not_recorded'])],
'attendance.*.check_in_time' => ['nullable', 'date'],
'attendance.*.note' => ['nullable', 'string', 'max:1000'],
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Ppm;
use Illuminate\Foundation\Http\FormRequest;
class RejectApplicationRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasRole('PPM') === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'reason' => ['required', 'string', 'max:1000'],
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace App\Http\Requests\Public;
use App\Models\Application;
use App\Models\Election;
use App\Models\Position;
use App\Models\PusatMengundi;
use App\Services\Public\KtmVacancyService;
use App\Services\RegistrationPeriodService;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class StorePublicApplicationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$this->merge([
'ic_number' => preg_replace('/\D+/', '', (string) $this->input('ic_number')),
'phone_number' => trim((string) $this->input('phone_number')),
'bank_account_number' => preg_replace('/[\s-]+/', '', (string) $this->input('bank_account_number')),
]);
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'ic_number' => ['required', 'digits:12'],
'phone_number' => ['required', 'string', 'max:30'],
'email' => ['nullable', 'email', 'max:255'],
'address' => ['required', 'string', 'max:1000'],
'requested_position_code' => ['required', Rule::in(['KTM', 'KP', 'KPDP', 'PAPM'])],
'selected_ktm_assignment_id' => [
Rule::requiredIf(fn (): bool => in_array($this->input('requested_position_code'), ['KP', 'KPDP'], true)),
'nullable',
'integer',
],
'bank_name' => ['required', 'string', 'max:255'],
'bank_account_number' => ['required', 'string', 'max:50'],
'ic_document' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:5120'],
'bank_statement' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:5120'],
];
}
public function after(): array
{
return [
function (Validator $validator): void {
$pusatMengundi = $this->route('pusatMengundi');
if (! $pusatMengundi instanceof PusatMengundi) {
return;
}
/** @var Election $election */
$election = $pusatMengundi->election()->with('settings')->firstOrFail();
if (! app(RegistrationPeriodService::class)->isOpen($election)) {
$validator->errors()->add('registration', app(RegistrationPeriodService::class)->closedMessage($election));
}
$roleCode = (string) $this->input('requested_position_code');
$position = Position::query()
->where('code', $roleCode)
->where('is_public_applyable', true)
->first();
if (! $position instanceof Position) {
$validator->errors()->add('requested_position_code', 'Jawatan yang dipilih tidak dibuka untuk permohonan awam.');
return;
}
$this->validateDuplicateIc($validator, $pusatMengundi);
if (in_array($roleCode, ['KP', 'KPDP'], true)) {
$this->validateKtmVacancy($validator, $pusatMengundi, $roleCode);
}
},
];
}
/**
* @return array<string, string>
*/
public function attributes(): array
{
return [
'name' => 'nama',
'ic_number' => 'no. kad pengenalan',
'phone_number' => 'no. telefon',
'email' => 'emel',
'address' => 'alamat',
'requested_position_code' => 'jawatan dipohon',
'selected_ktm_assignment_id' => 'KTM',
'bank_name' => 'nama bank',
'bank_account_number' => 'no. akaun bank',
'ic_document' => 'dokumen kad pengenalan',
'bank_statement' => 'penyata bank',
];
}
private function validateDuplicateIc(Validator $validator, PusatMengundi $pusatMengundi): void
{
// SoftDeletes scope automatically excludes deleted_at IS NOT NULL records (e.g. soft-deleted
// KTM-created applicants). The whereNotIn further excludes 'cancelled' records (which are
// NOT soft-deleted) and 'deleted_by_ktm' records as an explicit safety layer.
$existing = Application::query()
->where('election_id', $pusatMengundi->election_id)
->where('ic_number', $this->input('ic_number'))
->whereNotIn('status', ['cancelled', 'deleted_by_ktm'])
->first();
if (! $existing instanceof Application) {
return;
}
if ($existing->source === 'created_by_ktm') {
$validator->errors()->add(
'ic_number',
'No. kad pengenalan ini telah didaftarkan oleh KTM. KTM perlu memadam rekod tersebut sebelum pemohon boleh mendaftar sendiri.',
);
return;
}
$validator->errors()->add(
'ic_number',
'No. kad pengenalan ini telah mempunyai permohonan untuk pilihanraya semasa.',
);
}
private function validateKtmVacancy(Validator $validator, PusatMengundi $pusatMengundi, string $roleCode): void
{
$assignmentId = (int) $this->input('selected_ktm_assignment_id');
if ($assignmentId <= 0) {
return;
}
if (! app(KtmVacancyService::class)->hasVacancyForAssignment($assignmentId, $pusatMengundi, $roleCode)) {
$validator->errors()->add(
'selected_ktm_assignment_id',
'KTM yang dipilih tidak sah atau kekosongan untuk jawatan ini telah penuh.',
);
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Mail;
use App\Models\Application;
use App\Models\StaffAssignment;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class KtmCreatedApplicantMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public Application $application,
public StaffAssignment $ktmAssignment,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Makluman Pendaftaran KP oleh KTM',
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.ktm.created-applicant',
);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class OperationalAccountCreatedMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public User $user,
public string $roleCode,
public string $tempPassword,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Akaun Login Sistem Pengurusan Pilihanraya — '.$this->roleCode,
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.staff.operational-account-created',
);
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Models;
use Database\Factories\ApplicationFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
class Application extends Model
{
/** @use HasFactory<ApplicationFactory> */
use HasFactory, SoftDeletes;
protected $guarded = [];
protected function casts(): array
{
return [
'approved_at' => 'datetime',
'rejected_at' => 'datetime',
];
}
public function election(): BelongsTo
{
return $this->belongsTo(Election::class);
}
public function pusatMengundi(): BelongsTo
{
return $this->belongsTo(PusatMengundi::class);
}
public function selectedKtmAssignment(): BelongsTo
{
return $this->belongsTo(StaffAssignment::class, 'selected_ktm_assignment_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function requestedPosition(): BelongsTo
{
return $this->belongsTo(Position::class, 'requested_position_id');
}
public function approvedPosition(): BelongsTo
{
return $this->belongsTo(Position::class, 'approved_position_id');
}
public function documents(): HasMany
{
return $this->hasMany(ApplicationDocument::class);
}
public function statusHistories(): HasMany
{
return $this->hasMany(ApplicationStatusHistory::class);
}
public function staffAssignments(): HasMany
{
return $this->hasMany(StaffAssignment::class);
}
public function bankVerification(): HasOne
{
return $this->hasOne(BankVerification::class);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class ApplicationDocument extends Model
{
use SoftDeletes;
public const TYPE_IC_DOCUMENT = 'ic_document';
public const TYPE_BANK_STATEMENT = 'bank_statement';
/** @var list<string> */
public const ALLOWED_FOR_ADMIN = [self::TYPE_IC_DOCUMENT, self::TYPE_BANK_STATEMENT];
/** @var list<string> */
public const ALLOWED_FOR_PPM = [self::TYPE_IC_DOCUMENT, self::TYPE_BANK_STATEMENT];
/** @var list<string> */
public const ALLOWED_FOR_FINANCE = [self::TYPE_BANK_STATEMENT];
protected $guarded = [];
public function application(): BelongsTo
{
return $this->belongsTo(Application::class);
}
public function uploadedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'uploaded_by_user_id');
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ApplicationStatusHistory extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'metadata' => 'array',
];
}
public function application(): BelongsTo
{
return $this->belongsTo(Application::class);
}
public function changedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'changed_by_user_id');
}
}

48
app/Models/Attendance.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Attendance extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'check_in_time' => 'datetime',
];
}
public function election(): BelongsTo
{
return $this->belongsTo(Election::class);
}
public function staffAssignment(): BelongsTo
{
return $this->belongsTo(StaffAssignment::class);
}
public function pusatMengundi(): BelongsTo
{
return $this->belongsTo(PusatMengundi::class);
}
public function saluranMengundi(): BelongsTo
{
return $this->belongsTo(SaluranMengundi::class);
}
public function position(): BelongsTo
{
return $this->belongsTo(Position::class);
}
public function recordedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'recorded_by_user_id');
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Database\Factories\BahagianPilihanrayaFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class BahagianPilihanraya extends Model
{
/** @use HasFactory<BahagianPilihanrayaFactory> */
use HasFactory, SoftDeletes;
protected $guarded = [];
public function election(): BelongsTo
{
return $this->belongsTo(Election::class);
}
public function daerahMengundis(): HasMany
{
return $this->hasMany(DaerahMengundi::class);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BankVerification extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'verified_at' => 'datetime',
];
}
public function application(): BelongsTo
{
return $this->belongsTo(Application::class);
}
public function verifiedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'verified_by_user_id');
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Database\Factories\DaerahMengundiFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class DaerahMengundi extends Model
{
/** @use HasFactory<DaerahMengundiFactory> */
use HasFactory, SoftDeletes;
protected $guarded = [];
public function election(): BelongsTo
{
return $this->belongsTo(Election::class);
}
public function bahagianPilihanraya(): BelongsTo
{
return $this->belongsTo(BahagianPilihanraya::class);
}
public function pusatMengundis(): HasMany
{
return $this->hasMany(PusatMengundi::class);
}
}

28
app/Models/Dun.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Database\Factories\DunFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Dun extends Model
{
/** @use HasFactory<DunFactory> */
use HasFactory, SoftDeletes;
protected $guarded = [];
public function election(): BelongsTo
{
return $this->belongsTo(Election::class);
}
public function pusatMengundis(): HasMany
{
return $this->hasMany(PusatMengundi::class);
}
}

66
app/Models/Election.php Normal file
View File

@@ -0,0 +1,66 @@
<?php
namespace App\Models;
use Database\Factories\ElectionFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
class Election extends Model
{
/** @use HasFactory<ElectionFactory> */
use HasFactory, SoftDeletes;
protected $guarded = [];
protected function casts(): array
{
return [
'starts_at' => 'date',
'polling_date' => 'date',
];
}
public function settings(): HasOne
{
return $this->hasOne(ElectionSetting::class);
}
public function duns(): HasMany
{
return $this->hasMany(Dun::class);
}
public function bahagianPilihanrayas(): HasMany
{
return $this->hasMany(BahagianPilihanraya::class);
}
public function daerahMengundis(): HasMany
{
return $this->hasMany(DaerahMengundi::class);
}
public function pusatMengundis(): HasMany
{
return $this->hasMany(PusatMengundi::class);
}
public function saluranMengundis(): HasMany
{
return $this->hasMany(SaluranMengundi::class);
}
public function applications(): HasMany
{
return $this->hasMany(Application::class);
}
public function staffAssignments(): HasMany
{
return $this->hasMany(StaffAssignment::class);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ElectionSetting extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'registration_start_date' => 'date',
'registration_end_date' => 'date',
'polling_date' => 'date',
'is_registration_open' => 'boolean',
'is_registration_open_override' => 'boolean',
'is_attendance_active' => 'boolean',
];
}
public function election(): BelongsTo
{
return $this->belongsTo(Election::class);
}
}

35
app/Models/ExportLog.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ExportLog extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'generated_at' => 'datetime',
'filter_parameters' => 'array',
'purged_at' => 'datetime',
];
}
public function election(): BelongsTo
{
return $this->belongsTo(Election::class);
}
public function generatedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'generated_by_user_id');
}
public function purgedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'purged_by_user_id');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class JkmRepresentative extends Model
{
use SoftDeletes;
protected $guarded = [];
public function election(): BelongsTo
{
return $this->belongsTo(Election::class);
}
public function pusatMengundi(): BelongsTo
{
return $this->belongsTo(PusatMengundi::class);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class KkmRepresentative extends Model
{
use SoftDeletes;
protected $guarded = [];
public function election(): BelongsTo
{
return $this->belongsTo(Election::class);
}
public function pusatMengundi(): BelongsTo
{
return $this->belongsTo(PusatMengundi::class);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class PoliceEscort extends Model
{
use SoftDeletes;
protected $guarded = [];
public function election(): BelongsTo
{
return $this->belongsTo(Election::class);
}
public function pusatMengundi(): BelongsTo
{
return $this->belongsTo(PusatMengundi::class);
}
public function saluranMengundi(): BelongsTo
{
return $this->belongsTo(SaluranMengundi::class);
}
}

41
app/Models/Position.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Database\Factories\PositionFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Position extends Model
{
/** @use HasFactory<PositionFactory> */
use HasFactory, SoftDeletes;
protected $guarded = [];
protected function casts(): array
{
return [
'is_public_applyable' => 'boolean',
'is_assignable' => 'boolean',
'allows_dual_role' => 'boolean',
];
}
public function quotas(): HasMany
{
return $this->hasMany(PositionQuota::class);
}
public function applications(): HasMany
{
return $this->hasMany(Application::class, 'requested_position_id');
}
public function staffAssignments(): HasMany
{
return $this->hasMany(StaffAssignment::class);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class PositionQuota extends Model
{
use SoftDeletes;
protected $guarded = [];
public function election(): BelongsTo
{
return $this->belongsTo(Election::class);
}
public function pusatMengundi(): BelongsTo
{
return $this->belongsTo(PusatMengundi::class);
}
public function saluranMengundi(): BelongsTo
{
return $this->belongsTo(SaluranMengundi::class);
}
public function position(): BelongsTo
{
return $this->belongsTo(Position::class);
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Models;
use Database\Factories\PusatMengundiFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
class PusatMengundi extends Model
{
/** @use HasFactory<PusatMengundiFactory> */
use HasFactory, SoftDeletes;
protected $guarded = [];
public function election(): BelongsTo
{
return $this->belongsTo(Election::class);
}
public function dun(): BelongsTo
{
return $this->belongsTo(Dun::class);
}
public function daerahMengundi(): BelongsTo
{
return $this->belongsTo(DaerahMengundi::class);
}
public function saluranMengundis(): HasMany
{
return $this->hasMany(SaluranMengundi::class);
}
public function positionQuotas(): HasMany
{
return $this->hasMany(PositionQuota::class);
}
public function applications(): HasMany
{
return $this->hasMany(Application::class);
}
public function policeEscorts(): HasMany
{
return $this->hasMany(PoliceEscort::class);
}
public function kkmRepresentatives(): HasMany
{
return $this->hasMany(KkmRepresentative::class);
}
public function jkmRepresentatives(): HasMany
{
return $this->hasMany(JkmRepresentative::class);
}
public function staffAssignments(): HasMany
{
return $this->hasMany(StaffAssignment::class);
}
public function wheelchairAllocation(): HasOne
{
return $this->hasOne(WheelchairAllocation::class);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Database\Factories\SaluranMengundiFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class SaluranMengundi extends Model
{
/** @use HasFactory<SaluranMengundiFactory> */
use HasFactory, SoftDeletes;
protected $guarded = [];
public function election(): BelongsTo
{
return $this->belongsTo(Election::class);
}
public function pusatMengundi(): BelongsTo
{
return $this->belongsTo(PusatMengundi::class);
}
public function positionQuotas(): HasMany
{
return $this->hasMany(PositionQuota::class);
}
public function staffAssignments(): HasMany
{
return $this->hasMany(StaffAssignment::class);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Models;
use Database\Factories\StaffAssignmentFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
class StaffAssignment extends Model
{
/** @use HasFactory<StaffAssignmentFactory> */
use HasFactory, SoftDeletes;
protected $guarded = [];
protected function casts(): array
{
return [
'assigned_at' => 'datetime',
];
}
public function election(): BelongsTo
{
return $this->belongsTo(Election::class);
}
public function application(): BelongsTo
{
return $this->belongsTo(Application::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function position(): BelongsTo
{
return $this->belongsTo(Position::class);
}
public function pusatMengundi(): BelongsTo
{
return $this->belongsTo(PusatMengundi::class);
}
public function saluranMengundi(): BelongsTo
{
return $this->belongsTo(SaluranMengundi::class);
}
public function reportsTo(): BelongsTo
{
return $this->belongsTo(self::class, 'reports_to_assignment_id');
}
public function teamMembers(): HasMany
{
return $this->hasMany(self::class, 'reports_to_assignment_id');
}
public function histories(): HasMany
{
return $this->hasMany(StaffAssignmentHistory::class);
}
public function attendance(): HasOne
{
return $this->hasOne(Attendance::class);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StaffAssignmentHistory extends Model
{
protected $guarded = [];
public function staffAssignment(): BelongsTo
{
return $this->belongsTo(StaffAssignment::class);
}
public function changedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'changed_by_user_id');
}
}

27
app/Models/SystemNote.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class SystemNote extends Model
{
protected $guarded = [];
public function election(): BelongsTo
{
return $this->belongsTo(Election::class);
}
public function noteable(): MorphTo
{
return $this->morphTo();
}
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
}

50
app/Models/User.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable implements MustVerifyEmail
{
/** @use HasFactory<UserFactory> */
use HasFactory, HasRoles, Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function profile(): HasOne
{
return $this->hasOne(UserProfile::class);
}
public function applications(): HasMany
{
return $this->hasMany(Application::class);
}
public function staffAssignments(): HasMany
{
return $this->hasMany(StaffAssignment::class);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserProfile extends Model
{
protected $guarded = [];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

Some files were not shown because too many files have changed in this diff Show More