commit a14d43fe34f53a43875f44ebf7197d57f04c8bb5 Author: Saufi Date: Wed Jun 3 08:51:22 2026 +0800 first diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..36ed4a2 --- /dev/null +++ b/.claude/settings.json @@ -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" + ] + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6df8428 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..feb2534 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00e6d21 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..495a6af --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +ignore-scripts=true +audit=true diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ad1377 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +

Laravel Logo

+ +

+Build Status +Total Downloads +Latest Stable Version +License +

+ +## 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). diff --git a/app/Console/Commands/ExportPurgeCommand.php b/app/Console/Commands/ExportPurgeCommand.php new file mode 100644 index 0000000..3c8d767 --- /dev/null +++ b/app/Console/Commands/ExportPurgeCommand.php @@ -0,0 +1,68 @@ +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; + } +} diff --git a/app/Exports/AttendanceDetailExport.php b/app/Exports/AttendanceDetailExport.php new file mode 100644 index 0000000..7a5a2d5 --- /dev/null +++ b/app/Exports/AttendanceDetailExport.php @@ -0,0 +1,61 @@ + $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(); + } +} diff --git a/app/Exports/FinanceVerificationExport.php b/app/Exports/FinanceVerificationExport.php new file mode 100644 index 0000000..19ff6f9 --- /dev/null +++ b/app/Exports/FinanceVerificationExport.php @@ -0,0 +1,68 @@ + $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(); + } +} diff --git a/app/Http/Concerns/SortsQuery.php b/app/Http/Concerns/SortsQuery.php new file mode 100644 index 0000000..3caa662 --- /dev/null +++ b/app/Http/Concerns/SortsQuery.php @@ -0,0 +1,27 @@ + $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); + } +} diff --git a/app/Http/Controllers/Admin/AttendanceController.php b/app/Http/Controllers/Admin/AttendanceController.php new file mode 100644 index 0000000..34dbc88 --- /dev/null +++ b/app/Http/Controllers/Admin/AttendanceController.php @@ -0,0 +1,57 @@ + $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); + } +} diff --git a/app/Http/Controllers/Admin/Management/AdminManagementController.php b/app/Http/Controllers/Admin/Management/AdminManagementController.php new file mode 100644 index 0000000..f454af7 --- /dev/null +++ b/app/Http/Controllers/Admin/Management/AdminManagementController.php @@ -0,0 +1,39 @@ +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)), + ], + ]); + } +} diff --git a/app/Http/Controllers/Admin/Management/ManagedApplicationController.php b/app/Http/Controllers/Admin/Management/ManagedApplicationController.php new file mode 100644 index 0000000..9ca8b30 --- /dev/null +++ b/app/Http/Controllers/Admin/Management/ManagedApplicationController.php @@ -0,0 +1,120 @@ +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 $extra + * @return array + */ + 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(), + ]; + } +} diff --git a/app/Http/Controllers/Admin/Management/PennempatanController.php b/app/Http/Controllers/Admin/Management/PennempatanController.php new file mode 100644 index 0000000..03e15dc --- /dev/null +++ b/app/Http/Controllers/Admin/Management/PennempatanController.php @@ -0,0 +1,107 @@ +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.'.'); + } +} diff --git a/app/Http/Controllers/Admin/Management/RepresentativeController.php b/app/Http/Controllers/Admin/Management/RepresentativeController.php new file mode 100644 index 0000000..d07761c --- /dev/null +++ b/app/Http/Controllers/Admin/Management/RepresentativeController.php @@ -0,0 +1,58 @@ + 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), + }; + } +} diff --git a/app/Http/Controllers/Admin/Management/StaffAssignmentController.php b/app/Http/Controllers/Admin/Management/StaffAssignmentController.php new file mode 100644 index 0000000..73c43d4 --- /dev/null +++ b/app/Http/Controllers/Admin/Management/StaffAssignmentController.php @@ -0,0 +1,68 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Admin/Management/WheelchairController.php b/app/Http/Controllers/Admin/Management/WheelchairController.php new file mode 100644 index 0000000..d372db6 --- /dev/null +++ b/app/Http/Controllers/Admin/Management/WheelchairController.php @@ -0,0 +1,86 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Admin/Setup/AdminSetupController.php b/app/Http/Controllers/Admin/Setup/AdminSetupController.php new file mode 100644 index 0000000..73320f3 --- /dev/null +++ b/app/Http/Controllers/Admin/Setup/AdminSetupController.php @@ -0,0 +1,34 @@ + [ + '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(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Admin/Setup/BahagianPilihanrayaController.php b/app/Http/Controllers/Admin/Setup/BahagianPilihanrayaController.php new file mode 100644 index 0000000..4a4dc9a --- /dev/null +++ b/app/Http/Controllers/Admin/Setup/BahagianPilihanrayaController.php @@ -0,0 +1,67 @@ + $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.'); + } +} diff --git a/app/Http/Controllers/Admin/Setup/DaerahMengundiController.php b/app/Http/Controllers/Admin/Setup/DaerahMengundiController.php new file mode 100644 index 0000000..44bcf92 --- /dev/null +++ b/app/Http/Controllers/Admin/Setup/DaerahMengundiController.php @@ -0,0 +1,70 @@ + $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.'); + } +} diff --git a/app/Http/Controllers/Admin/Setup/ElectionSettingsController.php b/app/Http/Controllers/Admin/Setup/ElectionSettingsController.php new file mode 100644 index 0000000..ae46b57 --- /dev/null +++ b/app/Http/Controllers/Admin/Setup/ElectionSettingsController.php @@ -0,0 +1,50 @@ + $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.'); + } +} diff --git a/app/Http/Controllers/Admin/Setup/PositionController.php b/app/Http/Controllers/Admin/Setup/PositionController.php new file mode 100644 index 0000000..85cfb03 --- /dev/null +++ b/app/Http/Controllers/Admin/Setup/PositionController.php @@ -0,0 +1,81 @@ + $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 + */ + 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'), + ]; + } +} diff --git a/app/Http/Controllers/Admin/Setup/PositionQuotaController.php b/app/Http/Controllers/Admin/Setup/PositionQuotaController.php new file mode 100644 index 0000000..db165ac --- /dev/null +++ b/app/Http/Controllers/Admin/Setup/PositionQuotaController.php @@ -0,0 +1,100 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Admin/Setup/PusatImportController.php b/app/Http/Controllers/Admin/Setup/PusatImportController.php new file mode 100644 index 0000000..5b7a310 --- /dev/null +++ b/app/Http/Controllers/Admin/Setup/PusatImportController.php @@ -0,0 +1,50 @@ + 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); + } +} diff --git a/app/Http/Controllers/Admin/Setup/PusatLevelQuotaController.php b/app/Http/Controllers/Admin/Setup/PusatLevelQuotaController.php new file mode 100644 index 0000000..96897e0 --- /dev/null +++ b/app/Http/Controllers/Admin/Setup/PusatLevelQuotaController.php @@ -0,0 +1,88 @@ + $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.'); + } +} diff --git a/app/Http/Controllers/Admin/Setup/PusatMengundiController.php b/app/Http/Controllers/Admin/Setup/PusatMengundiController.php new file mode 100644 index 0000000..c5468f6 --- /dev/null +++ b/app/Http/Controllers/Admin/Setup/PusatMengundiController.php @@ -0,0 +1,156 @@ + $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.'); + } +} diff --git a/app/Http/Controllers/Admin/Setup/SaluranMengundiController.php b/app/Http/Controllers/Admin/Setup/SaluranMengundiController.php new file mode 100644 index 0000000..ca4dcda --- /dev/null +++ b/app/Http/Controllers/Admin/Setup/SaluranMengundiController.php @@ -0,0 +1,81 @@ + $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.'); + } +} diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php new file mode 100644 index 0000000..613bcd9 --- /dev/null +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -0,0 +1,47 @@ +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('/'); + } +} diff --git a/app/Http/Controllers/Auth/ConfirmablePasswordController.php b/app/Http/Controllers/Auth/ConfirmablePasswordController.php new file mode 100644 index 0000000..712394a --- /dev/null +++ b/app/Http/Controllers/Auth/ConfirmablePasswordController.php @@ -0,0 +1,40 @@ +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)); + } +} diff --git a/app/Http/Controllers/Auth/EmailVerificationNotificationController.php b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php new file mode 100644 index 0000000..f64fa9b --- /dev/null +++ b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php @@ -0,0 +1,24 @@ +user()->hasVerifiedEmail()) { + return redirect()->intended(route('dashboard', absolute: false)); + } + + $request->user()->sendEmailVerificationNotification(); + + return back()->with('status', 'verification-link-sent'); + } +} diff --git a/app/Http/Controllers/Auth/EmailVerificationPromptController.php b/app/Http/Controllers/Auth/EmailVerificationPromptController.php new file mode 100644 index 0000000..ee3cb6f --- /dev/null +++ b/app/Http/Controllers/Auth/EmailVerificationPromptController.php @@ -0,0 +1,21 @@ +user()->hasVerifiedEmail() + ? redirect()->intended(route('dashboard', absolute: false)) + : view('auth.verify-email'); + } +} diff --git a/app/Http/Controllers/Auth/NewPasswordController.php b/app/Http/Controllers/Auth/NewPasswordController.php new file mode 100644 index 0000000..fc16e14 --- /dev/null +++ b/app/Http/Controllers/Auth/NewPasswordController.php @@ -0,0 +1,63 @@ + $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)]); + } +} diff --git a/app/Http/Controllers/Auth/PasswordController.php b/app/Http/Controllers/Auth/PasswordController.php new file mode 100644 index 0000000..6916409 --- /dev/null +++ b/app/Http/Controllers/Auth/PasswordController.php @@ -0,0 +1,29 @@ +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'); + } +} diff --git a/app/Http/Controllers/Auth/PasswordResetLinkController.php b/app/Http/Controllers/Auth/PasswordResetLinkController.php new file mode 100644 index 0000000..1bc9c11 --- /dev/null +++ b/app/Http/Controllers/Auth/PasswordResetLinkController.php @@ -0,0 +1,45 @@ +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)]); + } +} diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php new file mode 100644 index 0000000..44a3930 --- /dev/null +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -0,0 +1,51 @@ +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)); + } +} diff --git a/app/Http/Controllers/Auth/VerifyEmailController.php b/app/Http/Controllers/Auth/VerifyEmailController.php new file mode 100644 index 0000000..784765e --- /dev/null +++ b/app/Http/Controllers/Auth/VerifyEmailController.php @@ -0,0 +1,27 @@ +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'); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..e7f7c94 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,10 @@ +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 $cards + * @param array $actions + * @return array + */ + private function payload(string $title, string $eyebrow, string $description, array $cards, array $actions = []): array + { + return compact('title', 'eyebrow', 'description', 'cards', 'actions'); + } +} diff --git a/app/Http/Controllers/DocumentDownloadController.php b/app/Http/Controllers/DocumentDownloadController.php new file mode 100644 index 0000000..71e4a75 --- /dev/null +++ b/app/Http/Controllers/DocumentDownloadController.php @@ -0,0 +1,44 @@ +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); + } +} diff --git a/app/Http/Controllers/Kewangan/BankVerificationController.php b/app/Http/Controllers/Kewangan/BankVerificationController.php new file mode 100644 index 0000000..ede58d1 --- /dev/null +++ b/app/Http/Controllers/Kewangan/BankVerificationController.php @@ -0,0 +1,87 @@ +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 + */ + 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, + ]; + } +} diff --git a/app/Http/Controllers/Ktm/TeamController.php b/app/Http/Controllers/Ktm/TeamController.php new file mode 100644 index 0000000..8846120 --- /dev/null +++ b/app/Http/Controllers/Ktm/TeamController.php @@ -0,0 +1,107 @@ +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; + } +} diff --git a/app/Http/Controllers/Ppm/ApplicationReviewController.php b/app/Http/Controllers/Ppm/ApplicationReviewController.php new file mode 100644 index 0000000..13018ec --- /dev/null +++ b/app/Http/Controllers/Ppm/ApplicationReviewController.php @@ -0,0 +1,131 @@ +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); + } +} diff --git a/app/Http/Controllers/Ppm/AttendanceController.php b/app/Http/Controllers/Ppm/AttendanceController.php new file mode 100644 index 0000000..fc3f35c --- /dev/null +++ b/app/Http/Controllers/Ppm/AttendanceController.php @@ -0,0 +1,66 @@ +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.'); + } +} diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php new file mode 100644 index 0000000..a48eb8d --- /dev/null +++ b/app/Http/Controllers/ProfileController.php @@ -0,0 +1,60 @@ + $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('/'); + } +} diff --git a/app/Http/Controllers/PublicApplicationController.php b/app/Http/Controllers/PublicApplicationController.php new file mode 100644 index 0000000..eff15e5 --- /dev/null +++ b/app/Http/Controllers/PublicApplicationController.php @@ -0,0 +1,96 @@ +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), + ]); + } +} diff --git a/app/Http/Controllers/PublicApplicationPlaceholderController.php b/app/Http/Controllers/PublicApplicationPlaceholderController.php new file mode 100644 index 0000000..25c9076 --- /dev/null +++ b/app/Http/Controllers/PublicApplicationPlaceholderController.php @@ -0,0 +1,16 @@ + $pusatMengundi->load('daerahMengundi'), + ]); + } +} diff --git a/app/Http/Middleware/RequireAdminPostCloseNote.php b/app/Http/Middleware/RequireAdminPostCloseNote.php new file mode 100644 index 0000000..e6b5df7 --- /dev/null +++ b/app/Http/Middleware/RequireAdminPostCloseNote.php @@ -0,0 +1,57 @@ +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(); + } +} diff --git a/app/Http/Requests/Admin/Management/AssignApplicationRequest.php b/app/Http/Requests/Admin/Management/AssignApplicationRequest.php new file mode 100644 index 0000000..8f00e85 --- /dev/null +++ b/app/Http/Requests/Admin/Management/AssignApplicationRequest.php @@ -0,0 +1,37 @@ +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 + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Management/DeleteRepresentativeRequest.php b/app/Http/Requests/Admin/Management/DeleteRepresentativeRequest.php new file mode 100644 index 0000000..9329997 --- /dev/null +++ b/app/Http/Requests/Admin/Management/DeleteRepresentativeRequest.php @@ -0,0 +1,23 @@ +user()?->hasRole('Admin') === true; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'note' => ['nullable', 'string', 'max:2000'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Management/StoreManualApplicationRequest.php b/app/Http/Requests/Admin/Management/StoreManualApplicationRequest.php new file mode 100644 index 0000000..d71adc4 --- /dev/null +++ b/app/Http/Requests/Admin/Management/StoreManualApplicationRequest.php @@ -0,0 +1,46 @@ +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 + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Management/StoreRepresentativeRequest.php b/app/Http/Requests/Admin/Management/StoreRepresentativeRequest.php new file mode 100644 index 0000000..7778b44 --- /dev/null +++ b/app/Http/Requests/Admin/Management/StoreRepresentativeRequest.php @@ -0,0 +1,43 @@ +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 + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Management/StoreReturnTransactionRequest.php b/app/Http/Requests/Admin/Management/StoreReturnTransactionRequest.php new file mode 100644 index 0000000..874e9de --- /dev/null +++ b/app/Http/Requests/Admin/Management/StoreReturnTransactionRequest.php @@ -0,0 +1,28 @@ +user()?->hasRole('Admin') === true; + } + + /** + * @return array + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Management/StoreTakenTransactionRequest.php b/app/Http/Requests/Admin/Management/StoreTakenTransactionRequest.php new file mode 100644 index 0000000..62d24be --- /dev/null +++ b/app/Http/Requests/Admin/Management/StoreTakenTransactionRequest.php @@ -0,0 +1,26 @@ +user()?->hasRole('Admin') === true; + } + + /** + * @return array + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Management/StoreWheelchairTransactionRequest.php b/app/Http/Requests/Admin/Management/StoreWheelchairTransactionRequest.php new file mode 100644 index 0000000..c839422 --- /dev/null +++ b/app/Http/Requests/Admin/Management/StoreWheelchairTransactionRequest.php @@ -0,0 +1,30 @@ +user()?->hasRole('Admin') === true; + } + + /** + * @return array + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Management/UpdateManagedApplicationRequest.php b/app/Http/Requests/Admin/Management/UpdateManagedApplicationRequest.php new file mode 100644 index 0000000..ed2f130 --- /dev/null +++ b/app/Http/Requests/Admin/Management/UpdateManagedApplicationRequest.php @@ -0,0 +1,49 @@ +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 + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Management/UpdateStaffAssignmentRequest.php b/app/Http/Requests/Admin/Management/UpdateStaffAssignmentRequest.php new file mode 100644 index 0000000..0834c49 --- /dev/null +++ b/app/Http/Requests/Admin/Management/UpdateStaffAssignmentRequest.php @@ -0,0 +1,37 @@ +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 + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Management/UpsertWheelchairAllocationRequest.php b/app/Http/Requests/Admin/Management/UpsertWheelchairAllocationRequest.php new file mode 100644 index 0000000..a58ec76 --- /dev/null +++ b/app/Http/Requests/Admin/Management/UpsertWheelchairAllocationRequest.php @@ -0,0 +1,26 @@ +user()?->hasRole('Admin') === true; + } + + /** + * @return array + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Setup/AssignPpmRequest.php b/app/Http/Requests/Admin/Setup/AssignPpmRequest.php new file mode 100644 index 0000000..1b0025d --- /dev/null +++ b/app/Http/Requests/Admin/Setup/AssignPpmRequest.php @@ -0,0 +1,23 @@ +user()?->hasRole('Admin') === true; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'user_id' => ['required', 'exists:users,id'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Setup/ImportPusatRequest.php b/app/Http/Requests/Admin/Setup/ImportPusatRequest.php new file mode 100644 index 0000000..d0ba2a2 --- /dev/null +++ b/app/Http/Requests/Admin/Setup/ImportPusatRequest.php @@ -0,0 +1,39 @@ +user()?->hasRole('Admin') === true; + } + + /** + * @return array + */ + 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 + */ + public function attributes(): array + { + return [ + 'election_id' => 'pilihan raya', + 'dun_code' => 'kod DUN', + 'dun_name' => 'nama DUN', + 'file' => 'fail XLSX', + ]; + } +} diff --git a/app/Http/Requests/Admin/Setup/UpsertBahagianPilihanrayaRequest.php b/app/Http/Requests/Admin/Setup/UpsertBahagianPilihanrayaRequest.php new file mode 100644 index 0000000..06d2903 --- /dev/null +++ b/app/Http/Requests/Admin/Setup/UpsertBahagianPilihanrayaRequest.php @@ -0,0 +1,36 @@ +user()?->hasRole('Admin') === true; + } + + /** + * @return array + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Setup/UpsertDaerahMengundiRequest.php b/app/Http/Requests/Admin/Setup/UpsertDaerahMengundiRequest.php new file mode 100644 index 0000000..c9d061b --- /dev/null +++ b/app/Http/Requests/Admin/Setup/UpsertDaerahMengundiRequest.php @@ -0,0 +1,37 @@ +user()?->hasRole('Admin') === true; + } + + /** + * @return array + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Setup/UpsertElectionSettingsRequest.php b/app/Http/Requests/Admin/Setup/UpsertElectionSettingsRequest.php new file mode 100644 index 0000000..96a7f3e --- /dev/null +++ b/app/Http/Requests/Admin/Setup/UpsertElectionSettingsRequest.php @@ -0,0 +1,46 @@ +user()?->hasRole('Admin') === true; + } + + /** + * @return array + */ + 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); + } +} diff --git a/app/Http/Requests/Admin/Setup/UpsertPositionQuotaRequest.php b/app/Http/Requests/Admin/Setup/UpsertPositionQuotaRequest.php new file mode 100644 index 0000000..17d8e59 --- /dev/null +++ b/app/Http/Requests/Admin/Setup/UpsertPositionQuotaRequest.php @@ -0,0 +1,24 @@ +user()?->hasRole('Admin') === true; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'position_id' => ['required', 'exists:positions,id'], + 'quota' => ['required', 'integer', 'min:0', 'max:9999'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Setup/UpsertPositionRequest.php b/app/Http/Requests/Admin/Setup/UpsertPositionRequest.php new file mode 100644 index 0000000..942ff0d --- /dev/null +++ b/app/Http/Requests/Admin/Setup/UpsertPositionRequest.php @@ -0,0 +1,38 @@ +user()?->hasRole('Admin') === true; + } + + /** + * @return array + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Setup/UpsertPusatMengundiRequest.php b/app/Http/Requests/Admin/Setup/UpsertPusatMengundiRequest.php new file mode 100644 index 0000000..4304e50 --- /dev/null +++ b/app/Http/Requests/Admin/Setup/UpsertPusatMengundiRequest.php @@ -0,0 +1,50 @@ +user()?->hasRole('Admin') === true; + } + + /** + * @return array + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Setup/UpsertSaluranMengundiRequest.php b/app/Http/Requests/Admin/Setup/UpsertSaluranMengundiRequest.php new file mode 100644 index 0000000..55cc928 --- /dev/null +++ b/app/Http/Requests/Admin/Setup/UpsertSaluranMengundiRequest.php @@ -0,0 +1,40 @@ +user()?->hasRole('Admin') === true; + } + + /** + * @return array + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php new file mode 100644 index 0000000..711e0a1 --- /dev/null +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -0,0 +1,86 @@ +|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()); + } +} diff --git a/app/Http/Requests/Kewangan/UpdateBankVerificationRequest.php b/app/Http/Requests/Kewangan/UpdateBankVerificationRequest.php new file mode 100644 index 0000000..883292c --- /dev/null +++ b/app/Http/Requests/Kewangan/UpdateBankVerificationRequest.php @@ -0,0 +1,25 @@ +user()?->hasAnyRole(['Admin Kewangan', 'Admin']) === true; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'status' => ['required', Rule::in(['pending', 'verified', 'rejected', 'requires_correction'])], + 'finance_note' => ['nullable', 'string', 'max:2000'], + ]; + } +} diff --git a/app/Http/Requests/Ktm/RegisterKpApplicantRequest.php b/app/Http/Requests/Ktm/RegisterKpApplicantRequest.php new file mode 100644 index 0000000..7edf90e --- /dev/null +++ b/app/Http/Requests/Ktm/RegisterKpApplicantRequest.php @@ -0,0 +1,36 @@ +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 + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Ppm/ApproveApplicationRequest.php b/app/Http/Requests/Ppm/ApproveApplicationRequest.php new file mode 100644 index 0000000..30aefaf --- /dev/null +++ b/app/Http/Requests/Ppm/ApproveApplicationRequest.php @@ -0,0 +1,35 @@ +user()?->hasRole('PPM') === true; + } + + /** + * @return array + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Ppm/RecordAttendanceRequest.php b/app/Http/Requests/Ppm/RecordAttendanceRequest.php new file mode 100644 index 0000000..dbb3bcc --- /dev/null +++ b/app/Http/Requests/Ppm/RecordAttendanceRequest.php @@ -0,0 +1,27 @@ +user()?->hasRole('PPM') === true; + } + + /** + * @return array + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Ppm/RejectApplicationRequest.php b/app/Http/Requests/Ppm/RejectApplicationRequest.php new file mode 100644 index 0000000..0af27ef --- /dev/null +++ b/app/Http/Requests/Ppm/RejectApplicationRequest.php @@ -0,0 +1,23 @@ +user()?->hasRole('PPM') === true; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'reason' => ['required', 'string', 'max:1000'], + ]; + } +} diff --git a/app/Http/Requests/ProfileUpdateRequest.php b/app/Http/Requests/ProfileUpdateRequest.php new file mode 100644 index 0000000..e2202dd --- /dev/null +++ b/app/Http/Requests/ProfileUpdateRequest.php @@ -0,0 +1,31 @@ +|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), + ], + ]; + } +} diff --git a/app/Http/Requests/Public/StorePublicApplicationRequest.php b/app/Http/Requests/Public/StorePublicApplicationRequest.php new file mode 100644 index 0000000..4340692 --- /dev/null +++ b/app/Http/Requests/Public/StorePublicApplicationRequest.php @@ -0,0 +1,158 @@ +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 + */ + 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 + */ + 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.', + ); + } + } +} diff --git a/app/Mail/KtmCreatedApplicantMail.php b/app/Mail/KtmCreatedApplicantMail.php new file mode 100644 index 0000000..6acc580 --- /dev/null +++ b/app/Mail/KtmCreatedApplicantMail.php @@ -0,0 +1,36 @@ +roleCode, + ); + } + + public function content(): Content + { + return new Content( + markdown: 'emails.staff.operational-account-created', + ); + } +} diff --git a/app/Models/Application.php b/app/Models/Application.php new file mode 100644 index 0000000..4b313a2 --- /dev/null +++ b/app/Models/Application.php @@ -0,0 +1,77 @@ + */ + 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); + } +} diff --git a/app/Models/ApplicationDocument.php b/app/Models/ApplicationDocument.php new file mode 100644 index 0000000..0386f5f --- /dev/null +++ b/app/Models/ApplicationDocument.php @@ -0,0 +1,37 @@ + */ + public const ALLOWED_FOR_ADMIN = [self::TYPE_IC_DOCUMENT, self::TYPE_BANK_STATEMENT]; + + /** @var list */ + public const ALLOWED_FOR_PPM = [self::TYPE_IC_DOCUMENT, self::TYPE_BANK_STATEMENT]; + + /** @var list */ + 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'); + } +} diff --git a/app/Models/ApplicationStatusHistory.php b/app/Models/ApplicationStatusHistory.php new file mode 100644 index 0000000..5bffc40 --- /dev/null +++ b/app/Models/ApplicationStatusHistory.php @@ -0,0 +1,28 @@ + 'array', + ]; + } + + public function application(): BelongsTo + { + return $this->belongsTo(Application::class); + } + + public function changedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'changed_by_user_id'); + } +} diff --git a/app/Models/Attendance.php b/app/Models/Attendance.php new file mode 100644 index 0000000..1ffb9f2 --- /dev/null +++ b/app/Models/Attendance.php @@ -0,0 +1,48 @@ + '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'); + } +} diff --git a/app/Models/BahagianPilihanraya.php b/app/Models/BahagianPilihanraya.php new file mode 100644 index 0000000..9601d83 --- /dev/null +++ b/app/Models/BahagianPilihanraya.php @@ -0,0 +1,28 @@ + */ + use HasFactory, SoftDeletes; + + protected $guarded = []; + + public function election(): BelongsTo + { + return $this->belongsTo(Election::class); + } + + public function daerahMengundis(): HasMany + { + return $this->hasMany(DaerahMengundi::class); + } +} diff --git a/app/Models/BankVerification.php b/app/Models/BankVerification.php new file mode 100644 index 0000000..bb660a9 --- /dev/null +++ b/app/Models/BankVerification.php @@ -0,0 +1,28 @@ + 'datetime', + ]; + } + + public function application(): BelongsTo + { + return $this->belongsTo(Application::class); + } + + public function verifiedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'verified_by_user_id'); + } +} diff --git a/app/Models/DaerahMengundi.php b/app/Models/DaerahMengundi.php new file mode 100644 index 0000000..f527ef0 --- /dev/null +++ b/app/Models/DaerahMengundi.php @@ -0,0 +1,33 @@ + */ + 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); + } +} diff --git a/app/Models/Dun.php b/app/Models/Dun.php new file mode 100644 index 0000000..157fecf --- /dev/null +++ b/app/Models/Dun.php @@ -0,0 +1,28 @@ + */ + use HasFactory, SoftDeletes; + + protected $guarded = []; + + public function election(): BelongsTo + { + return $this->belongsTo(Election::class); + } + + public function pusatMengundis(): HasMany + { + return $this->hasMany(PusatMengundi::class); + } +} diff --git a/app/Models/Election.php b/app/Models/Election.php new file mode 100644 index 0000000..00fd1b9 --- /dev/null +++ b/app/Models/Election.php @@ -0,0 +1,66 @@ + */ + 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); + } +} diff --git a/app/Models/ElectionSetting.php b/app/Models/ElectionSetting.php new file mode 100644 index 0000000..3f32820 --- /dev/null +++ b/app/Models/ElectionSetting.php @@ -0,0 +1,28 @@ + '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); + } +} diff --git a/app/Models/ExportLog.php b/app/Models/ExportLog.php new file mode 100644 index 0000000..38a21b7 --- /dev/null +++ b/app/Models/ExportLog.php @@ -0,0 +1,35 @@ + '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'); + } +} diff --git a/app/Models/JkmRepresentative.php b/app/Models/JkmRepresentative.php new file mode 100644 index 0000000..3443ac0 --- /dev/null +++ b/app/Models/JkmRepresentative.php @@ -0,0 +1,24 @@ +belongsTo(Election::class); + } + + public function pusatMengundi(): BelongsTo + { + return $this->belongsTo(PusatMengundi::class); + } +} diff --git a/app/Models/KkmRepresentative.php b/app/Models/KkmRepresentative.php new file mode 100644 index 0000000..b8b4fd0 --- /dev/null +++ b/app/Models/KkmRepresentative.php @@ -0,0 +1,24 @@ +belongsTo(Election::class); + } + + public function pusatMengundi(): BelongsTo + { + return $this->belongsTo(PusatMengundi::class); + } +} diff --git a/app/Models/PoliceEscort.php b/app/Models/PoliceEscort.php new file mode 100644 index 0000000..92fcc9c --- /dev/null +++ b/app/Models/PoliceEscort.php @@ -0,0 +1,29 @@ +belongsTo(Election::class); + } + + public function pusatMengundi(): BelongsTo + { + return $this->belongsTo(PusatMengundi::class); + } + + public function saluranMengundi(): BelongsTo + { + return $this->belongsTo(SaluranMengundi::class); + } +} diff --git a/app/Models/Position.php b/app/Models/Position.php new file mode 100644 index 0000000..a411e58 --- /dev/null +++ b/app/Models/Position.php @@ -0,0 +1,41 @@ + */ + 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); + } +} diff --git a/app/Models/PositionQuota.php b/app/Models/PositionQuota.php new file mode 100644 index 0000000..e6dbf5e --- /dev/null +++ b/app/Models/PositionQuota.php @@ -0,0 +1,34 @@ +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); + } +} diff --git a/app/Models/PusatMengundi.php b/app/Models/PusatMengundi.php new file mode 100644 index 0000000..8345c0c --- /dev/null +++ b/app/Models/PusatMengundi.php @@ -0,0 +1,74 @@ + */ + 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); + } +} diff --git a/app/Models/SaluranMengundi.php b/app/Models/SaluranMengundi.php new file mode 100644 index 0000000..88521d3 --- /dev/null +++ b/app/Models/SaluranMengundi.php @@ -0,0 +1,38 @@ + */ + 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); + } +} diff --git a/app/Models/StaffAssignment.php b/app/Models/StaffAssignment.php new file mode 100644 index 0000000..fc98616 --- /dev/null +++ b/app/Models/StaffAssignment.php @@ -0,0 +1,76 @@ + */ + 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); + } +} diff --git a/app/Models/StaffAssignmentHistory.php b/app/Models/StaffAssignmentHistory.php new file mode 100644 index 0000000..860ccf8 --- /dev/null +++ b/app/Models/StaffAssignmentHistory.php @@ -0,0 +1,21 @@ +belongsTo(StaffAssignment::class); + } + + public function changedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'changed_by_user_id'); + } +} diff --git a/app/Models/SystemNote.php b/app/Models/SystemNote.php new file mode 100644 index 0000000..003e5df --- /dev/null +++ b/app/Models/SystemNote.php @@ -0,0 +1,27 @@ +belongsTo(Election::class); + } + + public function noteable(): MorphTo + { + return $this->morphTo(); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..b71e16e --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,50 @@ + */ + use HasFactory, HasRoles, Notifiable; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + 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); + } +} diff --git a/app/Models/UserProfile.php b/app/Models/UserProfile.php new file mode 100644 index 0000000..691bad3 --- /dev/null +++ b/app/Models/UserProfile.php @@ -0,0 +1,16 @@ +belongsTo(User::class); + } +} diff --git a/app/Models/WheelchairAllocation.php b/app/Models/WheelchairAllocation.php new file mode 100644 index 0000000..1f71069 --- /dev/null +++ b/app/Models/WheelchairAllocation.php @@ -0,0 +1,27 @@ +belongsTo(Election::class); + } + + public function pusatMengundi(): BelongsTo + { + return $this->belongsTo(PusatMengundi::class); + } + + public function transactions(): HasMany + { + return $this->hasMany(WheelchairTransaction::class); + } +} diff --git a/app/Models/WheelchairTransaction.php b/app/Models/WheelchairTransaction.php new file mode 100644 index 0000000..784aa5a --- /dev/null +++ b/app/Models/WheelchairTransaction.php @@ -0,0 +1,29 @@ + 'datetime', + 'returned_at' => 'datetime', + ]; + } + + public function allocation(): BelongsTo + { + return $this->belongsTo(WheelchairAllocation::class, 'wheelchair_allocation_id'); + } + + public function recordedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'recorded_by_user_id'); + } +} diff --git a/app/Policies/ApplicationPolicy.php b/app/Policies/ApplicationPolicy.php new file mode 100644 index 0000000..ffe18ad --- /dev/null +++ b/app/Policies/ApplicationPolicy.php @@ -0,0 +1,26 @@ +canAccessApplication($user, $application); + } + + public function review(User $user, Application $application): bool + { + return $this->view($user, $application) + && in_array($application->status, ['submitted', 'under_ppm_review', 'approved'], true); + } + + public function downloadDocument(User $user, Application $application): bool + { + return $this->view($user, $application); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..90be278 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,16 @@ +settings()->create([ + 'registration_start_date' => $data['registration_start_date'], + 'registration_end_date' => $data['registration_end_date'], + 'polling_date' => $data['polling_date'], + 'is_attendance_active' => $data['is_attendance_active'], + 'is_registration_open_override' => $data['is_registration_open_override'], + ]); + + activity('admin_settings') + ->performedOn($settings) + ->causedBy($actingUser) + ->withProperties(['election_id' => $election->id, 'after' => $data]) + ->log('election_settings_created'); + + return $settings; + } + + public function update(Election $election, array $data, User $actingUser): void + { + /** @var ElectionSetting $settings */ + $settings = $election->settings; + + $before = $settings->only([ + 'registration_start_date', + 'registration_end_date', + 'polling_date', + 'is_attendance_active', + 'is_registration_open_override', + ]); + + $settings->update([ + 'registration_start_date' => $data['registration_start_date'], + 'registration_end_date' => $data['registration_end_date'], + 'polling_date' => $data['polling_date'], + 'is_attendance_active' => $data['is_attendance_active'], + 'is_registration_open_override' => $data['is_registration_open_override'], + ]); + + activity('admin_settings') + ->performedOn($settings) + ->causedBy($actingUser) + ->withProperties([ + 'election_id' => $election->id, + 'before' => $before, + 'after' => $settings->only([ + 'registration_start_date', + 'registration_end_date', + 'polling_date', + 'is_attendance_active', + 'is_registration_open_override', + ]), + ]) + ->log('election_settings_updated'); + } +} diff --git a/app/Services/Admin/Management/AdminApplicationManagementService.php b/app/Services/Admin/Management/AdminApplicationManagementService.php new file mode 100644 index 0000000..8fe7b5d --- /dev/null +++ b/app/Services/Admin/Management/AdminApplicationManagementService.php @@ -0,0 +1,289 @@ + $data + */ + public function create(User $actor, array $data): Application + { + return DB::transaction(function () use ($actor, $data): Application { + $election = Election::query()->findOrFail($data['election_id']); + $this->postCloseNoteService->requireIfClosed($election, $data['note'] ?? null); + /** @var PusatMengundi $pusat */ + $pusat = PusatMengundi::query()->findOrFail($data['pusat_mengundi_id']); + + if ((int) $pusat->election_id !== (int) $election->id) { + throw ValidationException::withMessages([ + 'pusat_mengundi_id' => 'Pusat Mengundi tidak berada dalam pilihanraya yang sama.', + ]); + } + + $application = Application::query()->create([ + 'election_id' => $election->id, + 'pusat_mengundi_id' => $pusat->id, + 'selected_ktm_assignment_id' => $data['selected_ktm_assignment_id'] ?? null, + 'public_uuid' => (string) Str::uuid(), + 'source' => 'admin_manual', + 'status' => 'submitted', + 'name' => $data['name'], + 'ic_number' => $this->normalizeIc($data['ic_number']), + 'phone_number' => $data['phone_number'], + 'email' => $data['email'] ?? null, + 'address' => $data['address'] ?? null, + 'requested_position_id' => $data['requested_position_id'], + 'bank_name' => $data['bank_name'] ?? null, + 'bank_account_number' => $data['bank_account_number'] ?? null, + 'created_by_user_id' => $actor->id, + ]); + + $application->statusHistories()->create([ + 'from_status' => null, + 'to_status' => 'submitted', + 'changed_by_user_id' => $actor->id, + 'note' => $data['note'] ?? 'Manual entry by Admin.', + ]); + + BankVerification::query()->firstOrCreate( + ['application_id' => $application->id], + ['status' => 'pending'], + ); + + $this->postCloseNoteService->recordIfClosed($election, $application, $actor, $data['note'] ?? null, 'admin_manual_entry_after_close'); + + activity('admin_management') + ->performedOn($application) + ->causedBy($actor) + ->withProperties(['source' => 'admin_manual']) + ->log('application_created_by_admin'); + + return $application; + }); + } + + /** + * @param array $data + */ + public function update(Application $application, User $actor, array $data): Application + { + return DB::transaction(function () use ($application, $actor, $data): Application { + $application->loadMissing('election'); + $election = $application->election; + if (! $election instanceof Election) { + throw ValidationException::withMessages([ + 'election_id' => 'Pilihanraya permohonan tidak sah.', + ]); + } + $this->postCloseNoteService->requireIfClosed($election, $data['note'] ?? null); + + $before = $application->only([ + 'pusat_mengundi_id', + 'selected_ktm_assignment_id', + 'name', + 'ic_number', + 'phone_number', + 'email', + 'address', + 'requested_position_id', + 'approved_position_id', + 'bank_name', + 'bank_account_number', + 'status', + ]); + + $application->forceFill([ + 'pusat_mengundi_id' => $data['pusat_mengundi_id'], + 'selected_ktm_assignment_id' => $data['selected_ktm_assignment_id'] ?? null, + 'name' => $data['name'], + 'ic_number' => $this->normalizeIc($data['ic_number']), + 'phone_number' => $data['phone_number'], + 'email' => $data['email'] ?? null, + 'address' => $data['address'] ?? null, + 'requested_position_id' => $data['requested_position_id'], + 'approved_position_id' => $data['approved_position_id'] ?? null, + 'bank_name' => $data['bank_name'] ?? null, + 'bank_account_number' => $data['bank_account_number'] ?? null, + 'status' => $data['status'], + ])->save(); + + $after = $application->only(array_keys($before)); + + $application->statusHistories()->create([ + 'from_status' => $before['status'], + 'to_status' => $application->status, + 'changed_by_user_id' => $actor->id, + 'note' => $data['note'] ?? 'Application updated by Admin.', + 'metadata' => [ + 'before' => $before, + 'after' => $after, + ], + ]); + + $this->postCloseNoteService->recordIfClosed($election, $application, $actor, $data['note'] ?? null, 'admin_application_edit_after_close'); + + activity('admin_management') + ->performedOn($application) + ->causedBy($actor) + ->withProperties(['before' => $before, 'after' => $after]) + ->log('application_updated_by_admin'); + + return $application; + }); + } + + /** + * @param array $data + */ + public function assign(Application $application, User $actor, array $data): StaffAssignment + { + return DB::transaction(function () use ($application, $actor, $data): StaffAssignment { + $application->loadMissing('election'); + $election = $application->election; + if (! $election instanceof Election) { + throw ValidationException::withMessages([ + 'election_id' => 'Pilihanraya permohonan tidak sah.', + ]); + } + $this->postCloseNoteService->requireIfClosed($election, $data['note'] ?? null); + + /** @var Position $position */ + $position = Position::query()->findOrFail($data['position_id']); + /** @var PusatMengundi $pusat */ + $pusat = PusatMengundi::query()->findOrFail($data['pusat_mengundi_id']); + + if ((int) $pusat->election_id !== (int) $election->id) { + throw ValidationException::withMessages([ + 'pusat_mengundi_id' => 'Pusat Mengundi tidak berada dalam pilihanraya yang sama.', + ]); + } + + $reportsToAssignmentId = $data['reports_to_assignment_id'] ?? null; + $saluranMengundiId = $data['saluran_mengundi_id'] ?? null; + + if ($saluranMengundiId) { + /** @var SaluranMengundi $saluran */ + $saluran = SaluranMengundi::query()->findOrFail($saluranMengundiId); + if ((int) $saluran->pusat_mengundi_id !== (int) $pusat->id) { + throw ValidationException::withMessages([ + 'saluran_mengundi_id' => 'Saluran Mengundi tidak berada di bawah Pusat Mengundi yang dipilih.', + ]); + } + } + + if ($reportsToAssignmentId) { + /** @var StaffAssignment $ktmAssignment */ + $ktmAssignment = StaffAssignment::query() + ->with('position') + ->whereKey($reportsToAssignmentId) + ->where('election_id', $election->id) + ->where('pusat_mengundi_id', $pusat->id) + ->where('status', 'active') + ->firstOrFail(); + + $ktmPosition = $ktmAssignment->position; + if (! $ktmPosition instanceof Position || $ktmPosition->code !== 'KTM') { + throw ValidationException::withMessages([ + 'reports_to_assignment_id' => 'Ketua rujukan mestilah tugasan KTM aktif.', + ]); + } + + $saluranMengundiId = $ktmAssignment->saluran_mengundi_id; + } + + $previousStatus = $application->status; + $previousPositionId = $application->approved_position_id ?: $application->requested_position_id; + + $application->forceFill([ + 'pusat_mengundi_id' => $pusat->id, + 'selected_ktm_assignment_id' => $reportsToAssignmentId, + 'approved_position_id' => $position->id, + 'approved_by_user_id' => $actor->id, + 'approved_at' => now(), + 'status' => 'assigned', + ])->save(); + + $assignment = StaffAssignment::query()->updateOrCreate( + ['application_id' => $application->id], + [ + 'election_id' => $election->id, + 'user_id' => $application->user_id, + 'position_id' => $position->id, + 'pusat_mengundi_id' => $pusat->id, + 'saluran_mengundi_id' => $saluranMengundiId, + 'reports_to_assignment_id' => $reportsToAssignmentId, + 'status' => $data['status'] ?? 'active', + 'assigned_by_user_id' => $actor->id, + 'assigned_at' => now(), + 'source' => 'admin_direct', + ], + ); + + $application->statusHistories()->create([ + 'from_status' => $previousStatus, + 'to_status' => 'assigned', + 'changed_by_user_id' => $actor->id, + 'note' => $data['note'] ?? 'Direct assignment by Admin.', + 'metadata' => [ + 'approved_position_id' => $position->id, + 'previous_position_id' => $previousPositionId, + 'pusat_mengundi_id' => $pusat->id, + 'saluran_mengundi_id' => $saluranMengundiId, + 'reports_to_assignment_id' => $reportsToAssignmentId, + ], + ]); + + $assignment->histories()->create([ + 'from_position_id' => $previousPositionId, + 'to_position_id' => $position->id, + 'to_pusat_mengundi_id' => $pusat->id, + 'to_saluran_mengundi_id' => $saluranMengundiId, + 'to_reports_to_assignment_id' => $reportsToAssignmentId, + 'changed_by_user_id' => $actor->id, + 'note' => $data['note'] ?? 'Direct assignment by Admin.', + ]); + + $this->operationalAccountService->provisionForApplication($application, $position); + $this->postCloseNoteService->recordIfClosed($election, $assignment, $actor, $data['note'] ?? null, 'admin_assignment_after_close'); + + activity('admin_management') + ->performedOn($assignment) + ->causedBy($actor) + ->withProperties([ + 'application_id' => $application->id, + 'position_code' => $position->code, + 'pusat_mengundi_id' => $pusat->id, + 'saluran_mengundi_id' => $saluranMengundiId, + 'reports_to_assignment_id' => $reportsToAssignmentId, + ]) + ->log('staff_assigned_by_admin'); + + return $assignment; + }); + } + + private function normalizeIc(mixed $icNumber): string + { + return preg_replace('/\D+/', '', (string) $icNumber) ?? ''; + } + + +} diff --git a/app/Services/Admin/Management/AdminAssignmentManagementService.php b/app/Services/Admin/Management/AdminAssignmentManagementService.php new file mode 100644 index 0000000..a164b8d --- /dev/null +++ b/app/Services/Admin/Management/AdminAssignmentManagementService.php @@ -0,0 +1,151 @@ + $data + */ + public function update(StaffAssignment $assignment, User $actor, array $data): StaffAssignment + { + return DB::transaction(function () use ($assignment, $actor, $data): StaffAssignment { + $assignment->loadMissing(['election', 'position']); + $election = $assignment->election; + if (! $election instanceof Election) { + throw ValidationException::withMessages([ + 'election_id' => 'Pilihanraya tugasan tidak sah.', + ]); + } + $this->postCloseNoteService->requireIfClosed($election, $data['note'] ?? null); + + /** @var Position $position */ + $position = Position::query()->findOrFail($data['position_id']); + /** @var PusatMengundi $pusat */ + $pusat = PusatMengundi::query()->findOrFail($data['pusat_mengundi_id']); + + if ((int) $pusat->election_id !== (int) $election->id) { + throw ValidationException::withMessages([ + 'pusat_mengundi_id' => 'Pusat Mengundi tidak berada dalam pilihanraya yang sama.', + ]); + } + + $before = [ + 'position_id' => $assignment->position_id, + 'pusat_mengundi_id' => $assignment->pusat_mengundi_id, + 'saluran_mengundi_id' => $assignment->saluran_mengundi_id, + 'reports_to_assignment_id' => $assignment->reports_to_assignment_id, + 'status' => $assignment->status, + ]; + + $reportsToAssignmentId = $data['reports_to_assignment_id'] ?? null; + $saluranMengundiId = $data['saluran_mengundi_id'] ?? null; + + if ($saluranMengundiId) { + /** @var SaluranMengundi $saluran */ + $saluran = SaluranMengundi::query()->findOrFail($saluranMengundiId); + if ((int) $saluran->pusat_mengundi_id !== (int) $pusat->id) { + throw ValidationException::withMessages([ + 'saluran_mengundi_id' => 'Saluran Mengundi tidak berada di bawah Pusat Mengundi yang dipilih.', + ]); + } + } + + if ($reportsToAssignmentId) { + /** @var StaffAssignment $ktmAssignment */ + $ktmAssignment = StaffAssignment::query() + ->with('position') + ->whereKey($reportsToAssignmentId) + ->where('election_id', $election->id) + ->where('pusat_mengundi_id', $pusat->id) + ->where('status', 'active') + ->firstOrFail(); + + $ktmPosition = $ktmAssignment->position; + if (! $ktmPosition instanceof Position || $ktmPosition->code !== 'KTM') { + throw ValidationException::withMessages([ + 'reports_to_assignment_id' => 'Ketua rujukan mestilah tugasan KTM aktif.', + ]); + } + + if ((int) $ktmAssignment->id === (int) $assignment->id) { + throw ValidationException::withMessages([ + 'reports_to_assignment_id' => 'KTM tidak boleh melapor kepada tugasan sendiri.', + ]); + } + + $saluranMengundiId = $ktmAssignment->saluran_mengundi_id; + } + + $assignment->forceFill([ + 'position_id' => $position->id, + 'pusat_mengundi_id' => $pusat->id, + 'saluran_mengundi_id' => $saluranMengundiId, + 'reports_to_assignment_id' => $reportsToAssignmentId, + 'status' => $data['status'], + 'assigned_by_user_id' => $actor->id, + 'assigned_at' => now(), + 'source' => 'admin_update', + ])->save(); + + $assignment->histories()->create([ + 'from_position_id' => $before['position_id'], + 'to_position_id' => $position->id, + 'from_pusat_mengundi_id' => $before['pusat_mengundi_id'], + 'to_pusat_mengundi_id' => $pusat->id, + 'from_saluran_mengundi_id' => $before['saluran_mengundi_id'], + 'to_saluran_mengundi_id' => $saluranMengundiId, + 'from_reports_to_assignment_id' => $before['reports_to_assignment_id'], + 'to_reports_to_assignment_id' => $reportsToAssignmentId, + 'changed_by_user_id' => $actor->id, + 'note' => $data['note'] ?? 'Assignment updated by Admin.', + ]); + + $application = $assignment->application; + if ($application instanceof Application) { + $application->forceFill([ + 'pusat_mengundi_id' => $pusat->id, + 'approved_position_id' => $position->id, + 'selected_ktm_assignment_id' => $reportsToAssignmentId, + 'status' => $data['status'] === 'active' ? 'assigned' : $application->status, + ])->save(); + } + + $this->operationalAccountService->provisionForAssignment($assignment, $position); + + $this->postCloseNoteService->recordIfClosed($election, $assignment, $actor, $data['note'] ?? null, 'admin_assignment_edit_after_close'); + + activity('admin_management') + ->performedOn($assignment) + ->causedBy($actor) + ->withProperties([ + 'before' => $before, + 'after' => [ + 'position_id' => $assignment->position_id, + 'pusat_mengundi_id' => $assignment->pusat_mengundi_id, + 'saluran_mengundi_id' => $assignment->saluran_mengundi_id, + 'reports_to_assignment_id' => $assignment->reports_to_assignment_id, + 'status' => $assignment->status, + ], + ]) + ->log('staff_assignment_updated_by_admin'); + + return $assignment; + }); + } +} diff --git a/app/Services/Admin/Management/AdminPostCloseNoteService.php b/app/Services/Admin/Management/AdminPostCloseNoteService.php new file mode 100644 index 0000000..1951a39 --- /dev/null +++ b/app/Services/Admin/Management/AdminPostCloseNoteService.php @@ -0,0 +1,46 @@ +registrationPeriodService->isOpen($election)) { + return; + } + + if (blank($note)) { + throw ValidationException::withMessages([ + 'note' => 'Catatan wajib diisi untuk perubahan selepas tempoh pendaftaran ditutup.', + ]); + } + } + + public function recordIfClosed(Election $election, Model $noteable, User $actor, ?string $note, string $type): ?SystemNote + { + if ($this->registrationPeriodService->isOpen($election) || blank($note)) { + return null; + } + + return SystemNote::query()->create([ + 'election_id' => $election->id, + 'noteable_type' => $noteable::class, + 'noteable_id' => $noteable->getKey(), + 'note_type' => $type, + 'note' => $note, + 'created_by_user_id' => $actor->id, + ]); + } +} diff --git a/app/Services/Admin/Management/AdminRepresentativeService.php b/app/Services/Admin/Management/AdminRepresentativeService.php new file mode 100644 index 0000000..81b13e4 --- /dev/null +++ b/app/Services/Admin/Management/AdminRepresentativeService.php @@ -0,0 +1,111 @@ + $data + */ + public function store(User $actor, array $data): Model + { + return DB::transaction(function () use ($actor, $data): Model { + $election = Election::query()->findOrFail($data['election_id']); + $this->postCloseNoteService->requireIfClosed($election, $data['note'] ?? null); + /** @var PusatMengundi $pusat */ + $pusat = PusatMengundi::query()->findOrFail($data['pusat_mengundi_id']); + + if ((int) $pusat->election_id !== (int) $election->id) { + throw new \InvalidArgumentException('Pusat Mengundi tidak berada dalam pilihanraya yang sama.'); + } + + if (! empty($data['saluran_mengundi_id'])) { + /** @var SaluranMengundi $saluran */ + $saluran = SaluranMengundi::query()->findOrFail($data['saluran_mengundi_id']); + if ((int) $saluran->pusat_mengundi_id !== (int) $pusat->id) { + throw new \InvalidArgumentException('Saluran Mengundi tidak berada di bawah Pusat Mengundi yang dipilih.'); + } + } + + $payload = [ + 'election_id' => $election->id, + 'pusat_mengundi_id' => $pusat->id, + 'name' => $data['name'], + 'ic_number' => $data['ic_number'] ?? null, + 'phone_number' => $data['phone_number'] ?? null, + 'notes' => $data['notes'] ?? null, + ]; + + $type = (string) $data['type']; + + $representative = match ($type) { + 'police' => PoliceEscort::query()->create([ + ...$payload, + 'saluran_mengundi_id' => $data['saluran_mengundi_id'] ?? null, + 'rank' => $data['rank'] ?? null, + 'station' => $data['station'] ?? null, + ]), + 'kkm' => KkmRepresentative::query()->create([ + ...$payload, + 'agency' => $data['agency'] ?? null, + ]), + 'jkm' => JkmRepresentative::query()->create([ + ...$payload, + 'agency' => $data['agency'] ?? null, + ]), + default => throw new \InvalidArgumentException('Jenis wakil agensi tidak sah.'), + }; + + $this->postCloseNoteService->recordIfClosed($election, $representative, $actor, $data['note'] ?? null, 'admin_representative_after_close'); + + activity('admin_management') + ->performedOn($representative) + ->causedBy($actor) + ->withProperties(['type' => $type]) + ->log('representative_created_by_admin'); + + return $representative; + }); + } + + public function delete(Model $representative, User $actor, ?string $note = null): void + { + DB::transaction(function () use ($representative, $actor, $note): void { + $election = match (true) { + $representative instanceof PoliceEscort => $representative->election, + $representative instanceof KkmRepresentative => $representative->election, + $representative instanceof JkmRepresentative => $representative->election, + default => null, + }; + + if (! $election instanceof Election) { + throw new \InvalidArgumentException('Pilihanraya wakil agensi tidak sah.'); + } + $this->postCloseNoteService->requireIfClosed($election, $note); + + $this->postCloseNoteService->recordIfClosed($election, $representative, $actor, $note, 'admin_representative_delete_after_close'); + + activity('admin_management') + ->performedOn($representative) + ->causedBy($actor) + ->withProperties(['note' => $note]) + ->log('representative_deleted_by_admin'); + + $representative->delete(); + }); + } +} diff --git a/app/Services/Admin/Management/PennempatanService.php b/app/Services/Admin/Management/PennempatanService.php new file mode 100644 index 0000000..a530c48 --- /dev/null +++ b/app/Services/Admin/Management/PennempatanService.php @@ -0,0 +1,131 @@ + + */ + public function pusatsWithVacancySummary(): Collection + { + return PusatMengundi::query() + ->with([ + 'dun', + 'election', + 'saluranMengundis', + 'positionQuotas', + ]) + ->withCount(['saluranMengundis']) + ->get() + ->each(function (PusatMengundi $pusat): void { + $totalQuota = $pusat->positionQuotas->sum('quota'); + $filled = StaffAssignment::query() + ->where('pusat_mengundi_id', $pusat->id) + ->where('status', 'active') + ->count(); + + $pusat->setAttribute('total_quota', $totalQuota); + $pusat->setAttribute('filled', $filled); + $pusat->setAttribute('vacant', max(0, $totalQuota - $filled)); + + // PPM name + $ppmPosition = Position::query()->where('code', 'PPM')->first(); + $ppmAssignment = $ppmPosition + ? StaffAssignment::query() + ->with('application') + ->where('pusat_mengundi_id', $pusat->id) + ->where('position_id', $ppmPosition->id) + ->where('status', 'active') + ->whereNull('saluran_mengundi_id') + ->first() + : null; + + $pusat->setAttribute('ppm_name', $ppmAssignment?->application?->name ?? '-'); + }) + ->filter(fn (PusatMengundi $p): bool => $p->vacant > 0) + ->sortByDesc('vacant') + ->values(); + } + + /** + * Structured breakdown of quotas + assignments for a specific pusat. + * + * @return BaseCollection + */ + public function detailForPusat(PusatMengundi $pusat): BaseCollection + { + $quotas = PositionQuota::query() + ->with('position') + ->where('pusat_mengundi_id', $pusat->id) + ->get() + ->groupBy(fn (PositionQuota $q): string => $q->saluran_mengundi_id ?? 'pusat'); + + $assignments = StaffAssignment::query() + ->with(['application', 'position']) + ->where('pusat_mengundi_id', $pusat->id) + ->where('status', 'active') + ->get() + ->groupBy(fn (StaffAssignment $a): string => ($a->saluran_mengundi_id ?? 'pusat').'_'.$a->position_id); + + $salurans = SaluranMengundi::query() + ->where('pusat_mengundi_id', $pusat->id) + ->orderBy('number') + ->get() + ->keyBy('id'); + + $sections = collect(); + + // Pusat-level section first + if ($quotas->has('pusat')) { + $sections->push($this->buildSection(null, 'Peringkat Pusat (Saluran 0)', $quotas->get('pusat'), $assignments)); + } + + // Per-saluran sections + foreach ($salurans as $saluran) { + $saluranQuotas = $quotas->get((string) $saluran->id, collect()); + $sections->push($this->buildSection($saluran, 'Saluran '.$saluran->number, $saluranQuotas, $assignments)); + } + + return $sections; + } + + /** + * @param Collection $quotas + * @param BaseCollection $assignments + * @return array{saluran: SaluranMengundi|null, label: string, entries: BaseCollection} + */ + private function buildSection(?SaluranMengundi $saluran, string $label, $quotas, BaseCollection $assignments): array + { + $scopeKey = $saluran ? (string) $saluran->id : 'pusat'; + + $entries = $quotas->map(function (PositionQuota $quota) use ($scopeKey, $assignments) { + $key = $scopeKey.'_'.$quota->position_id; + $filled = $assignments->get($key, collect()); + $vacant = max(0, $quota->quota - $filled->count()); + + return [ + 'quota' => $quota, + 'position' => $quota->position, + 'assignments' => $filled, + 'vacant' => $vacant, + ]; + })->values(); + + return [ + 'saluran' => $saluran, + 'label' => $label, + 'entries' => $entries, + ]; + } +} diff --git a/app/Services/Admin/Management/WheelchairManagementService.php b/app/Services/Admin/Management/WheelchairManagementService.php new file mode 100644 index 0000000..3e4180a --- /dev/null +++ b/app/Services/Admin/Management/WheelchairManagementService.php @@ -0,0 +1,139 @@ +findOrFail($data['election_id']); + /** @var PusatMengundi $pusat */ + $pusat = PusatMengundi::query()->findOrFail($data['pusat_mengundi_id']); + + if ((int) $pusat->election_id !== (int) $election->id) { + throw ValidationException::withMessages([ + 'pusat_mengundi_id' => 'Pusat Mengundi tidak berada dalam pilihanraya yang sama.', + ]); + } + + $allocation = WheelchairAllocation::query()->firstOrNew([ + 'election_id' => $election->id, + 'pusat_mengundi_id' => $pusat->id, + ]); + + $outstanding = $allocation->exists ? $this->outstandingQuantity($allocation) : 0; + + if ($data['allocated_quantity'] < $outstanding) { + throw ValidationException::withMessages([ + 'allocated_quantity' => 'Peruntukan tidak boleh kurang daripada jumlah kerusi roda yang masih belum dipulang.', + ]); + } + + $before = $allocation->exists ? $allocation->only(['allocated_quantity', 'notes']) : null; + + $allocation->forceFill([ + 'allocated_quantity' => $data['allocated_quantity'], + 'notes' => $data['notes'] ?? null, + ])->save(); + + activity('wheelchairs') + ->performedOn($allocation) + ->causedBy($actor) + ->withProperties([ + 'before' => $before, + 'after' => $allocation->only(['allocated_quantity', 'notes']), + ]) + ->log('wheelchair_allocation_upserted'); + + return $allocation; + }); + } + + /** + * @param array{transaction_type: string, quantity: int, taken_at?: string|null, taken_by_name?: string|null, returned_at?: string|null, return_condition?: string|null, notes?: string|null} $data + */ + public function recordTransaction(WheelchairAllocation $allocation, User $actor, array $data): WheelchairTransaction + { + return DB::transaction(function () use ($allocation, $actor, $data): WheelchairTransaction { + $allocation->refresh(); + $type = $data['transaction_type']; + $quantity = $data['quantity']; + + if ($type === 'taken' && $quantity > $this->availableQuantity($allocation)) { + throw ValidationException::withMessages([ + 'quantity' => 'Jumlah diambil melebihi baki peruntukan kerusi roda.', + ]); + } + + if ($type === 'returned' && $quantity > $this->outstandingQuantity($allocation)) { + throw ValidationException::withMessages([ + 'quantity' => 'Jumlah dipulang melebihi jumlah kerusi roda yang masih belum dipulang.', + ]); + } + + $transaction = WheelchairTransaction::query()->create([ + 'wheelchair_allocation_id' => $allocation->id, + 'transaction_type' => $type, + 'quantity' => $quantity, + 'taken_at' => $type === 'taken' ? ($data['taken_at'] ?? now()) : null, + 'taken_by_name' => $type === 'taken' ? ($data['taken_by_name'] ?? null) : null, + 'returned_at' => $type === 'returned' ? ($data['returned_at'] ?? now()) : null, + 'return_condition' => $type === 'returned' ? ($data['return_condition'] ?? null) : null, + 'returned_by_name' => $type === 'returned' ? ($data['returned_by_name'] ?? null) : null, + 'recorded_by_user_id' => $actor->id, + 'notes' => $data['notes'] ?? null, + ]); + + activity('wheelchairs') + ->performedOn($transaction) + ->causedBy($actor) + ->withProperties([ + 'allocation_id' => $allocation->id, + 'transaction_type' => $type, + 'quantity' => $quantity, + 'available_after' => $this->availableQuantity($allocation->fresh()), + 'outstanding_after' => $this->outstandingQuantity($allocation->fresh()), + ]) + ->log('wheelchair_transaction_recorded'); + + return $transaction; + }); + } + + public function takenQuantity(WheelchairAllocation $allocation): int + { + return (int) $allocation->transactions() + ->where('transaction_type', 'taken') + ->sum('quantity'); + } + + public function returnedQuantity(WheelchairAllocation $allocation): int + { + return (int) $allocation->transactions() + ->where('transaction_type', 'returned') + ->sum('quantity'); + } + + public function outstandingQuantity(WheelchairAllocation $allocation): int + { + return max(0, $this->takenQuantity($allocation) - $this->returnedQuantity($allocation)); + } + + public function availableQuantity(WheelchairAllocation $allocation): int + { + return max(0, (int) $allocation->allocated_quantity - $this->outstandingQuantity($allocation)); + } +} diff --git a/app/Services/Admin/OperationalAccountService.php b/app/Services/Admin/OperationalAccountService.php new file mode 100644 index 0000000..f2bd461 --- /dev/null +++ b/app/Services/Admin/OperationalAccountService.php @@ -0,0 +1,89 @@ +code, self::ROLES_NEEDING_ACCOUNT, true)) { + return; + } + + if (! $application->email) { + return; + } + + [$user, $tempPassword] = $this->findOrCreateUser($application->name, $application->email); + + // Link user to application if not already linked + if ($application->user_id !== $user->id) { + $application->forceFill(['user_id' => $user->id])->save(); + } + + $user->assignRole($position->code); + + if ($tempPassword !== null) { + Mail::to($user->email)->queue( + new OperationalAccountCreatedMail($user, $position->code, $tempPassword) + ); + } + } + + public function provisionForAssignment(StaffAssignment $assignment, Position $position): void + { + if (! in_array($position->code, self::ROLES_NEEDING_ACCOUNT, true)) { + return; + } + + $application = $assignment->application; + + if ($application) { + $this->provisionForApplication($application, $position); + + // Re-link user_id on assignment after provisioning + $assignment->loadMissing('application'); + if ($assignment->application?->user_id && $assignment->user_id !== $assignment->application->user_id) { + $assignment->forceFill(['user_id' => $assignment->application->user_id])->save(); + } + + return; + } + + // Assignment without application (direct assignment) — skip provisioning + } + + /** + * @return array{User, string|null} [$user, $tempPassword|null] + */ + private function findOrCreateUser(string $name, string $email): array + { + $existing = User::query()->where('email', $email)->first(); + + if ($existing) { + return [$existing, null]; + } + + $tempPassword = Str::random(10); + + $user = User::query()->create([ + 'name' => $name, + 'email' => $email, + 'password' => Hash::make($tempPassword), + 'email_verified_at' => now(), + ]); + + return [$user, $tempPassword]; + } +} diff --git a/app/Services/Admin/PpmAssignmentService.php b/app/Services/Admin/PpmAssignmentService.php new file mode 100644 index 0000000..9180c5b --- /dev/null +++ b/app/Services/Admin/PpmAssignmentService.php @@ -0,0 +1,65 @@ +where('code', 'PPM')->firstOrFail(); + + StaffAssignment::query() + ->where('election_id', $pusatMengundi->election_id) + ->where('pusat_mengundi_id', $pusatMengundi->id) + ->where('position_id', $position->id) + ->whereNull('saluran_mengundi_id') + ->where('status', 'active') + ->where('user_id', '!=', $user->id) + ->update(['status' => 'replaced']); + + $assignment = StaffAssignment::query()->updateOrCreate( + [ + 'election_id' => $pusatMengundi->election_id, + 'pusat_mengundi_id' => $pusatMengundi->id, + 'position_id' => $position->id, + 'saluran_mengundi_id' => null, + 'user_id' => $user->id, + ], + [ + 'status' => 'active', + 'assigned_by_user_id' => auth()->id(), + 'assigned_at' => now(), + 'source' => 'admin_setup', + ], + ); + + $assignment->histories()->create([ + 'to_position_id' => $position->id, + 'to_pusat_mengundi_id' => $pusatMengundi->id, + 'changed_by_user_id' => auth()->id(), + 'note' => 'PPM assigned from Admin setup module.', + ]); + + $user->assignRole('PPM'); + + activity('admin_setup') + ->performedOn($assignment) + ->causedBy(auth()->user()) + ->withProperties([ + 'pusat_mengundi_id' => $pusatMengundi->id, + 'user_id' => $user->id, + 'position_code' => 'PPM', + ]) + ->log('ppm_assigned'); + + return $assignment; + }); + } +} diff --git a/app/Services/Admin/PusatImportService.php b/app/Services/Admin/PusatImportService.php new file mode 100644 index 0000000..af12e33 --- /dev/null +++ b/app/Services/Admin/PusatImportService.php @@ -0,0 +1,180 @@ +} + */ + public function import(Election $election, Dun $dun, UploadedFile $file): array + { + $rows = $this->parseXlsx($file->getRealPath()); + $errors = []; + $imported = 0; + $saluranTotal = 0; + + DB::transaction(function () use ($election, $dun, $rows, &$imported, &$saluranTotal, &$errors): void { + // Replace: delete all existing pusat for this DUN (cascade to saluran + quotas). + PusatMengundi::query() + ->where('dun_id', $dun->id) + ->each(function (PusatMengundi $pusat): void { + $pusat->saluranMengundis()->delete(); + $pusat->positionQuotas()->delete(); + $pusat->delete(); + }); + + $positions = Position::query()->get()->keyBy('code'); + + foreach ($rows as $index => $row) { + $rowNum = $index + 2; // +2: 1-based + skip header + + $namaPusat = trim((string) ($row[self::COL_NAMA_PUSAT] ?? '')); + if ($namaPusat === '') { + continue; // skip empty rows + } + + $saluranCount = (int) ($row[self::COL_SALURAN] ?? 0); + if ($saluranCount < 1) { + $errors[] = "Baris {$rowNum}: Bilangan saluran mesti sekurang-kurangnya 1 untuk '{$namaPusat}'."; + continue; + } + + $code = sprintf('PM%03d', $imported + 1); + + $pusat = PusatMengundi::query()->create([ + 'election_id' => $election->id, + 'dun_id' => $dun->id, + 'public_uuid' => (string) Str::uuid(), + 'code' => $code, + 'name' => $namaPusat, + 'address' => null, + 'ppm_name' => trim((string) ($row[self::COL_NAMA_PPM] ?? '')), + 'ppm_phone' => trim((string) ($row[self::COL_PPM_PHONE] ?? '')), + 'registration_url' => null, + 'qr_code_path' => null, + ]); + + // Generate QR code for this pusat + app(PusatQrCodeService::class)->generate($pusat); + + // Create numbered salurans + $salurans = []; + for ($n = 1; $n <= $saluranCount; $n++) { + $salurans[] = SaluranMengundi::query()->create([ + 'election_id' => $election->id, + 'pusat_mengundi_id' => $pusat->id, + 'number' => (string) $n, + 'name' => null, + 'voter_count' => null, + ]); + } + $saluranTotal += $saluranCount; + + // Pusat-level quotas + $pusatQuotas = [ + 'PPM' => (int) ($row[self::COL_PPM_COUNT] ?? 1), + 'PAPM' => (int) ($row[self::COL_PAPM] ?? 0), + 'JKM' => (int) ($row[self::COL_JKM] ?? 1), + 'KKM' => (int) ($row[self::COL_KKM] ?? 2), + ]; + + foreach ($pusatQuotas as $code => $quota) { + $position = $positions->get($code); + if ($position) { + PositionQuota::query()->create([ + 'election_id' => $election->id, + 'pusat_mengundi_id' => $pusat->id, + 'saluran_mengundi_id' => null, + 'position_id' => $position->id, + 'quota' => $quota, + ]); + } + } + + // Saluran-level quotas (standard per saluran) + $saluranQuotaCodes = [ + 'KTM' => 1, + 'POLIS' => 1, + 'KP' => 4, + 'KPDP' => 1, + 'CALON_TAMBAHAN' => 1, + ]; + + foreach ($salurans as $saluran) { + foreach ($saluranQuotaCodes as $posCode => $quota) { + $position = $positions->get($posCode); + if ($position) { + PositionQuota::query()->create([ + 'election_id' => $election->id, + 'pusat_mengundi_id' => $pusat->id, + 'saluran_mengundi_id' => $saluran->id, + 'position_id' => $position->id, + 'quota' => $quota, + ]); + } + } + } + + $imported++; + } + }); + + return [ + 'imported' => $imported, + 'salurans' => $saluranTotal, + 'errors' => $errors, + ]; + } + + /** + * @return list> + */ + private function parseXlsx(string $path): array + { + $reader = new Reader(); + $reader->open($path); + + $rows = []; + $isHeader = true; + + foreach ($reader->getSheetIterator() as $sheet) { + foreach ($sheet->getRowIterator() as $row) { + if ($isHeader) { + $isHeader = false; + continue; + } + $rows[] = $row->toArray(); + } + break; // only first sheet + } + + $reader->close(); + + return $rows; + } +} diff --git a/app/Services/Admin/PusatOperationalSetupService.php b/app/Services/Admin/PusatOperationalSetupService.php new file mode 100644 index 0000000..8f6792e --- /dev/null +++ b/app/Services/Admin/PusatOperationalSetupService.php @@ -0,0 +1,96 @@ + $data + */ + public function configure(PusatMengundi $pusatMengundi, array $data): void + { + DB::transaction(function () use ($pusatMengundi, $data): void { + $salurans = collect(); + + foreach (range(1, (int) $data['saluran_count']) as $number) { + $salurans->push(SaluranMengundi::query()->updateOrCreate( + [ + 'pusat_mengundi_id' => $pusatMengundi->id, + 'number' => (string) $number, + ], + [ + 'election_id' => $pusatMengundi->election_id, + 'name' => null, + ], + )); + } + + $this->upsertPusatQuotas($pusatMengundi, $data); + + foreach ($salurans as $saluran) { + $this->upsertSaluranQuotas($pusatMengundi, $saluran, $data); + } + }); + } + + /** + * @param array $data + */ + private function upsertPusatQuotas(PusatMengundi $pusatMengundi, array $data): void + { + foreach ([ + 'PPM' => 'ppm_count', + 'PAPM' => 'papm_count', + 'JKM' => 'jkm_count', + 'KKM' => 'kkm_count', + ] as $code => $field) { + $this->upsertQuota($pusatMengundi, null, $code, (int) $data[$field]); + } + } + + /** + * @param array $data + */ + private function upsertSaluranQuotas(PusatMengundi $pusatMengundi, SaluranMengundi $saluran, array $data): void + { + foreach ([ + 'KTM' => 'ktm_count', + 'POLIS' => 'polis_iring_count', + 'KP' => 'kp_count', + 'KPDP' => 'kpdp_count', + 'CALON_TAMBAHAN' => 'calon_tambahan_count', + ] as $code => $field) { + $this->upsertQuota($pusatMengundi, $saluran, $code, (int) $data[$field]); + } + } + + private function upsertQuota(PusatMengundi $pusatMengundi, ?SaluranMengundi $saluran, string $positionCode, int $quota): void + { + $position = Position::query()->where('code', $positionCode)->first(); + + if (! $position instanceof Position) { + throw ValidationException::withMessages([ + 'positions' => 'Jawatan '.$positionCode.' belum dikonfigurasi.', + ]); + } + + PositionQuota::query()->updateOrCreate( + [ + 'election_id' => $pusatMengundi->election_id, + 'pusat_mengundi_id' => $pusatMengundi->id, + 'saluran_mengundi_id' => $saluran?->id, + 'position_id' => $position->id, + ], + [ + 'quota' => $quota, + ], + ); + } +} diff --git a/app/Services/Admin/PusatQrCodeService.php b/app/Services/Admin/PusatQrCodeService.php new file mode 100644 index 0000000..9aa3109 --- /dev/null +++ b/app/Services/Admin/PusatQrCodeService.php @@ -0,0 +1,40 @@ + $pusatMengundi->public_uuid, + ]); + + $path = 'qrcodes/pusat-mengundi/'.$pusatMengundi->public_uuid.'.svg'; + + Storage::disk('local')->put($path, QrCode::format('svg') + ->size(320) + ->margin(2) + ->generate($url)); + + $pusatMengundi->forceFill([ + 'registration_url' => $url, + 'qr_code_path' => $path, + ])->save(); + + activity('admin_setup') + ->performedOn($pusatMengundi) + ->causedBy(auth()->user()) + ->withProperties([ + 'registration_url' => $url, + 'qr_code_path' => $path, + ]) + ->log('pusat_qr_generated'); + + return $pusatMengundi; + } +} diff --git a/app/Services/Attendance/AttendanceExportService.php b/app/Services/Attendance/AttendanceExportService.php new file mode 100644 index 0000000..2ebba91 --- /dev/null +++ b/app/Services/Attendance/AttendanceExportService.php @@ -0,0 +1,62 @@ +with(['application', 'user', 'position', 'pusatMengundi', 'saluranMengundi', 'attendance']) + ->where('pusat_mengundi_id', $pusatMengundi->id) + ->where('status', 'active') + ->orderBy('position_id') + ->orderBy('saluran_mengundi_id') + ->get(); + + $fileName = 'attendance-'.$pusatMengundi->code.'-'.now()->format('Ymd-His').'.xlsx'; + $path = 'exports/attendance/'.$fileName; + + Storage::disk('local')->makeDirectory('exports/attendance'); + $absolutePath = Storage::disk('local')->path($path); + + $this->export->write($assignments, $absolutePath); + + $log = ExportLog::query()->create([ + 'election_id' => $pusatMengundi->election_id, + 'generated_by_user_id' => $actor->id, + 'generated_at' => now(), + 'report_type' => 'attendance_detail_by_pusat', + 'filter_parameters' => [ + 'pusat_mengundi_id' => $pusatMengundi->id, + 'pusat_mengundi_code' => $pusatMengundi->code, + ], + 'file_name' => $fileName, + 'disk' => 'local', + 'path' => $path, + ]); + + activity('exports') + ->performedOn($log) + ->causedBy($actor) + ->withProperties([ + 'report_type' => 'attendance_detail_by_pusat', + 'pusat_mengundi_id' => $pusatMengundi->id, + 'row_count' => $assignments->count(), + ]) + ->log('attendance_export_generated'); + + return $log; + } +} diff --git a/app/Services/Attendance/AttendanceRecordingService.php b/app/Services/Attendance/AttendanceRecordingService.php new file mode 100644 index 0000000..3c271e0 --- /dev/null +++ b/app/Services/Attendance/AttendanceRecordingService.php @@ -0,0 +1,100 @@ +election; + $settings = $election instanceof Election ? $election->settings : null; + + if (! $settings instanceof ElectionSetting || ! $settings->is_attendance_active) { + throw ValidationException::withMessages([ + 'attendance' => 'Modul kehadiran belum diaktifkan untuk pilihanraya ini.', + ]); + } + } + + public function ensurePpmCanRecord(User $actor, PusatMengundi $pusatMengundi): void + { + if (! $this->ppmScopeService->assignedPusatIds($actor)->contains((int) $pusatMengundi->id)) { + throw ValidationException::withMessages([ + 'pusat_mengundi_id' => 'PPM hanya boleh merekod kehadiran untuk Pusat Mengundi sendiri.', + ]); + } + } + + /** + * @param array $records + */ + public function recordBulk(PusatMengundi $pusatMengundi, User $actor, array $records): void + { + $this->ensureActive($pusatMengundi); + $this->ensurePpmCanRecord($actor, $pusatMengundi); + + DB::transaction(function () use ($pusatMengundi, $actor, $records): void { + $assignments = StaffAssignment::query() + ->with(['position']) + ->where('pusat_mengundi_id', $pusatMengundi->id) + ->where('status', 'active') + ->whereIn('id', array_keys($records)) + ->get() + ->keyBy('id'); + + foreach ($records as $assignmentId => $record) { + /** @var StaffAssignment|null $assignment */ + $assignment = $assignments->get((int) $assignmentId); + + if (! $assignment instanceof StaffAssignment) { + continue; + } + + $status = $record['status']; + $checkInTime = $status === 'present' + ? ($record['check_in_time'] ?? now()) + : null; + + $attendance = Attendance::query()->updateOrCreate( + [ + 'election_id' => $assignment->election_id, + 'staff_assignment_id' => $assignment->id, + ], + [ + 'pusat_mengundi_id' => $assignment->pusat_mengundi_id, + 'saluran_mengundi_id' => $assignment->saluran_mengundi_id, + 'position_id' => $assignment->position_id, + 'status' => $status, + 'check_in_time' => $checkInTime, + 'recorded_by_user_id' => $actor->id, + 'note' => $record['note'] ?? null, + ], + ); + + activity('attendance') + ->performedOn($attendance) + ->causedBy($actor) + ->withProperties([ + 'staff_assignment_id' => $assignment->id, + 'status' => $status, + 'check_in_time' => $checkInTime, + ]) + ->log('attendance_updated_by_ppm'); + } + }); + } +} diff --git a/app/Services/Attendance/AttendanceSummaryService.php b/app/Services/Attendance/AttendanceSummaryService.php new file mode 100644 index 0000000..4451c98 --- /dev/null +++ b/app/Services/Attendance/AttendanceSummaryService.php @@ -0,0 +1,72 @@ +where('pusat_mengundi_id', $pusatMengundi->id) + ->where('status', 'active') + ->count(); + + $present = Attendance::query() + ->where('pusat_mengundi_id', $pusatMengundi->id) + ->where('status', 'present') + ->count(); + + $absent = Attendance::query() + ->where('pusat_mengundi_id', $pusatMengundi->id) + ->where('status', 'absent') + ->count(); + + return [ + 'total_staff' => $totalStaff, + 'present' => $present, + 'absent' => $absent, + 'not_recorded' => max(0, $totalStaff - $present - $absent), + ]; + } + + /** + * @return Collection + */ + public function roleTotalsForPusat(PusatMengundi $pusatMengundi): Collection + { + $assignments = StaffAssignment::query() + ->with(['position', 'attendance']) + ->where('pusat_mengundi_id', $pusatMengundi->id) + ->where('status', 'active') + ->get(); + + return $assignments + ->groupBy(function (StaffAssignment $assignment): string { + $position = $assignment->position; + + return $position instanceof Position ? $position->code : 'UNKNOWN'; + }) + ->map(function (Collection $items, string $role): array { + $present = $items->filter(fn (StaffAssignment $assignment): bool => $assignment->attendance instanceof Attendance && $assignment->attendance->status === 'present')->count(); + $absent = $items->filter(fn (StaffAssignment $assignment): bool => $assignment->attendance instanceof Attendance && $assignment->attendance->status === 'absent')->count(); + + return [ + 'role' => $role, + 'total_staff' => $items->count(), + 'present' => $present, + 'absent' => $absent, + 'not_recorded' => max(0, $items->count() - $present - $absent), + ]; + }) + ->values(); + } +} diff --git a/app/Services/Finance/BankVerificationService.php b/app/Services/Finance/BankVerificationService.php new file mode 100644 index 0000000..1461f5d --- /dev/null +++ b/app/Services/Finance/BankVerificationService.php @@ -0,0 +1,39 @@ +only(['status', 'finance_note', 'verified_by_user_id', 'verified_at']); + + $verification->forceFill([ + 'status' => $data['status'], + 'finance_note' => $data['finance_note'] ?? null, + 'verified_by_user_id' => $actor->id, + 'verified_at' => now(), + ])->save(); + + activity('finance') + ->performedOn($verification) + ->causedBy($actor) + ->withProperties([ + 'before' => $before, + 'after' => $verification->only(['status', 'finance_note', 'verified_by_user_id', 'verified_at']), + 'application_id' => $verification->application_id, + ]) + ->log('bank_verification_updated'); + + return $verification; + }); + } +} diff --git a/app/Services/Finance/FinanceExportService.php b/app/Services/Finance/FinanceExportService.php new file mode 100644 index 0000000..11df14c --- /dev/null +++ b/app/Services/Finance/FinanceExportService.php @@ -0,0 +1,58 @@ + $filters + */ + public function export(User $actor, array $filters): ExportLog + { + $verifications = $this->verificationQuery->build($filters)->get(); + $fileName = 'finance-verification-'.now()->format('Ymd-His').'.xlsx'; + $path = 'exports/finance/'.$fileName; + + Storage::disk('local')->makeDirectory('exports/finance'); + $absolutePath = Storage::disk('local')->path($path); + + $this->export->write($verifications, $absolutePath); + + $firstApplication = $verifications->first()?->application; + $electionId = $firstApplication instanceof Application ? $firstApplication->election_id : null; + + $log = ExportLog::query()->create([ + 'election_id' => $electionId, + 'generated_by_user_id' => $actor->id, + 'generated_at' => now(), + 'report_type' => 'finance_verification_list', + 'filter_parameters' => $filters, + 'file_name' => $fileName, + 'disk' => 'local', + 'path' => $path, + ]); + + activity('exports') + ->performedOn($log) + ->causedBy($actor) + ->withProperties([ + 'report_type' => 'finance_verification_list', + 'filters' => $filters, + 'row_count' => $verifications->count(), + ]) + ->log('finance_verification_export_generated'); + + return $log; + } +} diff --git a/app/Services/Finance/FinanceVerificationQuery.php b/app/Services/Finance/FinanceVerificationQuery.php new file mode 100644 index 0000000..207388d --- /dev/null +++ b/app/Services/Finance/FinanceVerificationQuery.php @@ -0,0 +1,58 @@ + $filters + * @return Builder + */ + public function build(array $filters = []): Builder + { + return BankVerification::query() + ->with([ + 'application.election', + 'application.pusatMengundi', + 'application.approvedPosition', + 'application.requestedPosition', + 'application.documents', + 'application.staffAssignments.position', + 'verifiedBy', + ]) + ->whereHas('application', function (Builder $query) use ($filters): void { + $query->whereIn('status', ['approved', 'assigned']) + ->when(! empty($filters['pusat_mengundi_id']), fn (Builder $nested) => $nested->where('pusat_mengundi_id', $filters['pusat_mengundi_id'])) + ->when(! empty($filters['role_id']), function (Builder $nested) use ($filters): void { + $nested->where(function (Builder $roleQuery) use ($filters): void { + $roleQuery->where('approved_position_id', $filters['role_id']) + ->orWhere(function (Builder $fallback) use ($filters): void { + $fallback->whereNull('approved_position_id') + ->where('requested_position_id', $filters['role_id']); + }); + }); + }) + ->when(! empty($filters['missing_account_number']), function (Builder $nested): void { + $nested->where(function (Builder $accountQuery): void { + $accountQuery->whereNull('bank_account_number') + ->orWhere('bank_account_number', ''); + }); + }) + ->when(! empty($filters['missing_bank_statement']), function (Builder $nested): void { + $nested->whereDoesntHave('documents', fn (Builder $documentQuery) => $documentQuery->where('document_type', 'bank_statement')); + }) + ->when(! empty($filters['q']), function (Builder $nested) use ($filters): void { + $term = '%'.$filters['q'].'%'; + $nested->where(function (Builder $keywordQuery) use ($term): void { + $keywordQuery->where('name', 'like', $term) + ->orWhere('ic_number', 'like', $term) + ->orWhere('email', 'like', $term); + }); + }); + }) + ->when(! empty($filters['status']), fn (Builder $query) => $query->where('status', $filters['status'])); + } +} diff --git a/app/Services/Ktm/KtmApplicationService.php b/app/Services/Ktm/KtmApplicationService.php new file mode 100644 index 0000000..2dca550 --- /dev/null +++ b/app/Services/Ktm/KtmApplicationService.php @@ -0,0 +1,270 @@ +loadMissing(['election.settings', 'pusatMengundi', 'user']); + + if (! app(KtmScopeService::class)->ownsAssignment($actor, $ktmAssignment)) { + throw ValidationException::withMessages([ + 'assignment' => 'Anda tidak dibenarkan mendaftar KP untuk saluran ini.', + ]); + } + + $election = $this->assignmentElection($ktmAssignment); + $pusatMengundi = $this->assignmentPusat($ktmAssignment); + + if (! app(RegistrationPeriodService::class)->isOpen($election)) { + throw ValidationException::withMessages([ + 'registration' => app(RegistrationPeriodService::class)->closedMessage($election), + ]); + } + + $kpPosition = Position::query()->where('code', 'KP')->firstOrFail(); + + if (! app(KtmVacancyService::class)->hasVacancyForAssignment($ktmAssignment->id, $pusatMengundi, 'KP')) { + throw ValidationException::withMessages([ + 'ktm_assignment_id' => 'Kekosongan KP untuk saluran ini telah penuh.', + ]); + } + + // SoftDeletes scope excludes soft-deleted records. whereNotIn covers 'cancelled' records + // (not soft-deleted) and 'deleted_by_ktm' as an explicit redundant safety layer. + $existing = Application::query() + ->where('election_id', $ktmAssignment->election_id) + ->where('ic_number', $data['ic_number']) + ->whereNotIn('status', ['cancelled', 'deleted_by_ktm']) + ->first(); + + if ($existing instanceof Application) { + throw ValidationException::withMessages([ + 'ic_number' => 'No. kad pengenalan ini telah mempunyai rekod permohonan aktif untuk pilihanraya semasa.', + ]); + } + + $application = Application::query()->create([ + 'election_id' => $ktmAssignment->election_id, + 'pusat_mengundi_id' => $ktmAssignment->pusat_mengundi_id, + 'selected_ktm_assignment_id' => $ktmAssignment->id, + 'public_uuid' => (string) Str::uuid(), + 'source' => 'created_by_ktm', + 'status' => 'submitted', + 'name' => $data['name'], + 'ic_number' => $data['ic_number'], + 'phone_number' => $data['phone_number'], + 'email' => $data['email'], + 'address' => $data['address'] ?? null, + 'requested_position_id' => $kpPosition->id, + 'created_by_user_id' => $actor->id, + ]); + + $application->statusHistories()->create([ + 'to_status' => 'submitted', + 'changed_by_user_id' => $actor->id, + 'note' => 'KP applicant created by KTM.', + 'metadata' => [ + 'source' => 'created_by_ktm', + 'ktm_assignment_id' => $ktmAssignment->id, + ], + ]); + + Mail::to($application->email)->queue(new KtmCreatedApplicantMail($application->load('pusatMengundi'), $ktmAssignment)); + + activity('ktm_flow') + ->performedOn($application) + ->causedBy($actor) + ->withProperties([ + 'ktm_assignment_id' => $ktmAssignment->id, + 'position_code' => 'KP', + ]) + ->log('kp_applicant_created_by_ktm'); + + return $application; + }); + } + + public function approveKp(Application $application, User $actor): StaffAssignment + { + return DB::transaction(function () use ($application, $actor): StaffAssignment { + $application->loadMissing(['selectedKtmAssignment.pusatMengundi', 'selectedKtmAssignment.election.settings', 'requestedPosition']); + $ktmAssignment = $application->selectedKtmAssignment; + + if (! $ktmAssignment instanceof StaffAssignment || ! app(KtmScopeService::class)->ownsAssignment($actor, $ktmAssignment)) { + throw ValidationException::withMessages([ + 'application' => 'Anda tidak dibenarkan meluluskan permohonan ini.', + ]); + } + + $election = $this->assignmentElection($ktmAssignment); + $pusatMengundi = $this->assignmentPusat($ktmAssignment); + + if (! app(RegistrationPeriodService::class)->isOpen($election)) { + throw ValidationException::withMessages([ + 'registration' => app(RegistrationPeriodService::class)->closedMessage($election), + ]); + } + + $kpPosition = Position::query()->where('code', 'KP')->firstOrFail(); + + if ((int) $application->requested_position_id !== (int) $kpPosition->id) { + throw ValidationException::withMessages([ + 'requested_position_id' => 'KTM hanya boleh meluluskan permohonan KP.', + ]); + } + + if (! in_array($application->status, ['submitted', 'under_ppm_review', 'approved'], true)) { + throw ValidationException::withMessages([ + 'status' => 'Status permohonan tidak membenarkan kelulusan KTM.', + ]); + } + + if (! app(KtmVacancyService::class)->hasVacancyForAssignment($ktmAssignment->id, $pusatMengundi, 'KP', $application->id)) { + throw ValidationException::withMessages([ + 'ktm_assignment_id' => 'Kekosongan KP untuk saluran ini telah penuh.', + ]); + } + + $previousStatus = $application->status; + + $application->forceFill([ + 'status' => 'assigned', + 'approved_position_id' => $kpPosition->id, + 'approved_by_user_id' => $actor->id, + 'approved_at' => now(), + ])->save(); + + $assignment = StaffAssignment::query()->updateOrCreate( + ['application_id' => $application->id], + [ + 'election_id' => $application->election_id, + 'user_id' => $application->user_id, + 'position_id' => $kpPosition->id, + 'pusat_mengundi_id' => $application->pusat_mengundi_id, + 'saluran_mengundi_id' => $ktmAssignment->saluran_mengundi_id, + 'reports_to_assignment_id' => $ktmAssignment->id, + 'status' => 'active', + 'assigned_by_user_id' => $actor->id, + 'assigned_at' => now(), + 'source' => 'ktm_approval', + ], + ); + + $application->statusHistories()->create([ + 'from_status' => $previousStatus, + 'to_status' => 'assigned', + 'changed_by_user_id' => $actor->id, + 'note' => 'KP application approved by KTM.', + 'metadata' => [ + 'ktm_assignment_id' => $ktmAssignment->id, + 'position_code' => 'KP', + ], + ]); + + $assignment->histories()->create([ + 'to_position_id' => $kpPosition->id, + 'to_pusat_mengundi_id' => $application->pusat_mengundi_id, + 'to_saluran_mengundi_id' => $ktmAssignment->saluran_mengundi_id, + 'to_reports_to_assignment_id' => $ktmAssignment->id, + 'changed_by_user_id' => $actor->id, + 'note' => 'KP assigned by KTM approval.', + ]); + + activity('ktm_flow') + ->performedOn($application) + ->causedBy($actor) + ->withProperties([ + 'assignment_id' => $assignment->id, + 'ktm_assignment_id' => $ktmAssignment->id, + ]) + ->log('kp_application_approved_by_ktm'); + + return $assignment; + }); + } + + public function deleteCreatedApplication(Application $application, User $actor): Application + { + return DB::transaction(function () use ($application, $actor): Application { + if (! app(KtmScopeService::class)->canAccessApplication($actor, $application)) { + throw ValidationException::withMessages([ + 'application' => 'Anda tidak dibenarkan memadam rekod ini.', + ]); + } + + if ($application->source !== 'created_by_ktm') { + throw ValidationException::withMessages([ + 'application' => 'Hanya rekod yang dicipta oleh KTM boleh dipadam oleh KTM.', + ]); + } + + if ($application->status === 'assigned') { + throw ValidationException::withMessages([ + 'application' => 'Rekod yang telah diassign tidak boleh dipadam oleh KTM.', + ]); + } + + $previousStatus = $application->status; + + $application->forceFill([ + 'status' => 'deleted_by_ktm', + ])->save(); + + $application->statusHistories()->create([ + 'from_status' => $previousStatus, + 'to_status' => 'deleted_by_ktm', + 'changed_by_user_id' => $actor->id, + 'note' => 'KTM deleted pre-created applicant record.', + ]); + + activity('ktm_flow') + ->performedOn($application) + ->causedBy($actor) + ->withProperties([ + 'from_status' => $previousStatus, + 'to_status' => 'deleted_by_ktm', + ]) + ->log('ktm_created_applicant_deleted'); + + $application->delete(); + + return $application; + }); + } + + private function assignmentElection(StaffAssignment $assignment): Election + { + /** @var Election $election */ + $election = $assignment->election()->with('settings')->firstOrFail(); + + return $election; + } + + private function assignmentPusat(StaffAssignment $assignment): PusatMengundi + { + /** @var PusatMengundi $pusatMengundi */ + $pusatMengundi = $assignment->pusatMengundi()->firstOrFail(); + + return $pusatMengundi; + } +} diff --git a/app/Services/Ktm/KtmScopeService.php b/app/Services/Ktm/KtmScopeService.php new file mode 100644 index 0000000..86fbf19 --- /dev/null +++ b/app/Services/Ktm/KtmScopeService.php @@ -0,0 +1,55 @@ + + */ + public function assignmentsFor(User $user): EloquentCollection + { + $ktmPosition = Position::query()->where('code', 'KTM')->first(); + + if ($ktmPosition === null) { + return new EloquentCollection; + } + + return StaffAssignment::query() + ->with(['pusatMengundi', 'saluranMengundi', 'teamMembers.application', 'teamMembers.position']) + ->where('user_id', $user->id) + ->where('position_id', $ktmPosition->id) + ->where('status', 'active') + ->whereNotNull('saluran_mengundi_id') + ->get(); + } + + /** + * @return Collection + */ + public function assignmentIdsFor(User $user): Collection + { + return $this->assignmentsFor($user) + ->pluck('id') + ->map(fn (mixed $id): int => (int) $id) + ->values(); + } + + public function ownsAssignment(User $user, StaffAssignment $assignment): bool + { + return $this->assignmentIdsFor($user)->contains((int) $assignment->id); + } + + public function canAccessApplication(User $user, Application $application): bool + { + return $application->selected_ktm_assignment_id !== null + && $this->assignmentIdsFor($user)->contains((int) $application->selected_ktm_assignment_id); + } +} diff --git a/app/Services/Ppm/AssignmentVacancyService.php b/app/Services/Ppm/AssignmentVacancyService.php new file mode 100644 index 0000000..746a3f2 --- /dev/null +++ b/app/Services/Ppm/AssignmentVacancyService.php @@ -0,0 +1,98 @@ +where('code', $roleCode)->first(); + + if ($position === null) { + return false; + } + + $quota = PositionQuota::query() + ->where('election_id', $pusatMengundi->election_id) + ->where('pusat_mengundi_id', $pusatMengundi->id) + ->whereNull('saluran_mengundi_id') + ->where('position_id', $position->id) + ->value('quota'); + + if ($quota === null) { + return false; + } + + $used = StaffAssignment::query() + ->where('election_id', $pusatMengundi->election_id) + ->where('pusat_mengundi_id', $pusatMengundi->id) + ->whereNull('saluran_mengundi_id') + ->where('position_id', $position->id) + ->where('status', 'active') + ->count(); + + return $used < (int) $quota; + } + + public function hasSaluranVacancy(PusatMengundi $pusatMengundi, int $saluranMengundiId, string $roleCode): bool + { + $position = Position::query()->where('code', $roleCode)->first(); + + if ($position === null) { + return false; + } + + $saluran = SaluranMengundi::query() + ->where('id', $saluranMengundiId) + ->where('pusat_mengundi_id', $pusatMengundi->id) + ->first(); + + if (! $saluran instanceof SaluranMengundi) { + return false; + } + + $quota = PositionQuota::query() + ->where('election_id', $pusatMengundi->election_id) + ->where('pusat_mengundi_id', $pusatMengundi->id) + ->where('saluran_mengundi_id', $saluran->id) + ->where('position_id', $position->id) + ->value('quota'); + + if ($quota === null) { + return false; + } + + $used = StaffAssignment::query() + ->where('election_id', $pusatMengundi->election_id) + ->where('pusat_mengundi_id', $pusatMengundi->id) + ->where('saluran_mengundi_id', $saluran->id) + ->where('position_id', $position->id) + ->where('status', 'active') + ->count(); + + return $used < (int) $quota; + } + + /** + * @return Collection + */ + public function availableSaluransForRole(PusatMengundi $pusatMengundi, string $roleCode): Collection + { + /** @var Collection $salurans */ + $salurans = $pusatMengundi->saluranMengundis() + ->orderBy('number') + ->get() + ->filter(fn (mixed $saluran): bool => $saluran instanceof SaluranMengundi + && $this->hasSaluranVacancy($pusatMengundi, $saluran->id, $roleCode)) + ->values(); + + return $salurans; + } +} diff --git a/app/Services/Ppm/PpmApplicationReviewService.php b/app/Services/Ppm/PpmApplicationReviewService.php new file mode 100644 index 0000000..2908b30 --- /dev/null +++ b/app/Services/Ppm/PpmApplicationReviewService.php @@ -0,0 +1,228 @@ +loadMissing(['documents', 'pusatMengundi', 'requestedPosition']); + + if (! $this->hasRequiredDocuments($application)) { + throw ValidationException::withMessages([ + 'documents' => 'Permohonan hanya boleh diluluskan jika dokumen IC dan penyata bank telah dimuat naik.', + ]); + } + + if (! in_array($application->status, ['submitted', 'under_ppm_review', 'approved'], true)) { + throw ValidationException::withMessages([ + 'status' => 'Status permohonan tidak membenarkan kelulusan.', + ]); + } + + $position = Position::query() + ->where('code', $data['approved_position_code']) + ->whereIn('code', ['KTM', 'KP', 'KPDP', 'PAPM']) + ->firstOrFail(); + + $assignmentContext = $this->assignmentContext($application, $position, $data); + $previousStatus = $application->status; + $previousPositionId = $application->approved_position_id ?: $application->requested_position_id; + + $application->forceFill([ + 'status' => 'assigned', + 'approved_position_id' => $position->id, + 'approved_by_user_id' => $actor->id, + 'approved_at' => now(), + 'selected_ktm_assignment_id' => $assignmentContext['reports_to_assignment_id'], + ])->save(); + + $assignment = StaffAssignment::query()->updateOrCreate( + ['application_id' => $application->id], + [ + 'election_id' => $application->election_id, + 'user_id' => $application->user_id, + 'position_id' => $position->id, + 'pusat_mengundi_id' => $application->pusat_mengundi_id, + 'saluran_mengundi_id' => $assignmentContext['saluran_mengundi_id'], + 'reports_to_assignment_id' => $assignmentContext['reports_to_assignment_id'], + 'status' => 'active', + 'assigned_by_user_id' => $actor->id, + 'assigned_at' => now(), + 'source' => 'ppm_approval', + ], + ); + + $application->statusHistories()->create([ + 'from_status' => $previousStatus, + 'to_status' => 'assigned', + 'changed_by_user_id' => $actor->id, + 'note' => $data['note'] ?? null, + 'metadata' => [ + 'approved_position_code' => $position->code, + 'previous_position_id' => $previousPositionId, + 'saluran_mengundi_id' => $assignmentContext['saluran_mengundi_id'], + 'reports_to_assignment_id' => $assignmentContext['reports_to_assignment_id'], + ], + ]); + + $assignment->histories()->create([ + 'from_position_id' => $previousPositionId, + 'to_position_id' => $position->id, + 'to_pusat_mengundi_id' => $application->pusat_mengundi_id, + 'to_saluran_mengundi_id' => $assignmentContext['saluran_mengundi_id'], + 'to_reports_to_assignment_id' => $assignmentContext['reports_to_assignment_id'], + 'changed_by_user_id' => $actor->id, + 'note' => $data['note'] ?? 'Application approved by PPM.', + ]); + + if ($previousPositionId !== $position->id) { + activity('ppm_review') + ->performedOn($application) + ->causedBy($actor) + ->withProperties([ + 'from_position_id' => $previousPositionId, + 'to_position_id' => $position->id, + ]) + ->log('application_role_changed_by_ppm'); + } + + activity('ppm_review') + ->performedOn($application) + ->causedBy($actor) + ->withProperties([ + 'assignment_id' => $assignment->id, + 'approved_position_code' => $position->code, + 'saluran_mengundi_id' => $assignmentContext['saluran_mengundi_id'], + 'reports_to_assignment_id' => $assignmentContext['reports_to_assignment_id'], + ]) + ->log('application_approved_assigned_by_ppm'); + + return $assignment; + }); + } + + public function reject(Application $application, User $actor, string $reason): Application + { + return DB::transaction(function () use ($application, $actor, $reason): Application { + if (! in_array($application->status, ['submitted', 'under_ppm_review', 'approved'], true)) { + throw ValidationException::withMessages([ + 'status' => 'Status permohonan tidak membenarkan penolakan.', + ]); + } + + $previousStatus = $application->status; + + $application->forceFill([ + 'status' => 'rejected', + 'rejected_by_user_id' => $actor->id, + 'rejected_at' => now(), + 'rejection_reason' => $reason, + ])->save(); + + $application->statusHistories()->create([ + 'from_status' => $previousStatus, + 'to_status' => 'rejected', + 'changed_by_user_id' => $actor->id, + 'note' => $reason, + ]); + + activity('ppm_review') + ->performedOn($application) + ->causedBy($actor) + ->withProperties([ + 'reason' => $reason, + ]) + ->log('application_rejected_by_ppm'); + + return $application; + }); + } + + private function hasRequiredDocuments(Application $application): bool + { + $types = $application->documents() + ->whereIn('document_type', ['ic_document', 'bank_statement']) + ->pluck('document_type') + ->unique(); + + return $types->contains('ic_document') && $types->contains('bank_statement'); + } + + /** + * @param array{approved_position_code: string, saluran_mengundi_id?: int|null, ktm_assignment_id?: int|null, note?: string|null} $data + * @return array{saluran_mengundi_id: int|null, reports_to_assignment_id: int|null} + */ + private function assignmentContext(Application $application, Position $position, array $data): array + { + $pusatMengundi = $application->pusatMengundi; + + if (! $pusatMengundi instanceof PusatMengundi) { + throw ValidationException::withMessages([ + 'pusat_mengundi_id' => 'Pusat Mengundi permohonan tidak sah.', + ]); + } + + if ($position->code === 'PAPM') { + if (! app(AssignmentVacancyService::class)->hasPusatVacancy($pusatMengundi, 'PAPM')) { + throw ValidationException::withMessages([ + 'approved_position_code' => 'Kekosongan PAPM untuk Pusat Mengundi ini telah penuh.', + ]); + } + + return [ + 'saluran_mengundi_id' => null, + 'reports_to_assignment_id' => null, + ]; + } + + if ($position->code === 'KTM') { + $saluranId = (int) ($data['saluran_mengundi_id'] ?? 0); + + if (! app(AssignmentVacancyService::class)->hasSaluranVacancy($pusatMengundi, $saluranId, 'KTM')) { + throw ValidationException::withMessages([ + 'saluran_mengundi_id' => 'Saluran Mengundi yang dipilih tidak sah atau kekosongan KTM telah penuh.', + ]); + } + + return [ + 'saluran_mengundi_id' => $saluranId, + 'reports_to_assignment_id' => null, + ]; + } + + if (in_array($position->code, ['KP', 'KPDP'], true)) { + $ktmAssignmentId = (int) ($data['ktm_assignment_id'] ?? 0); + + if (! app(KtmVacancyService::class)->hasVacancyForAssignment($ktmAssignmentId, $pusatMengundi, $position->code, $application->id)) { + throw ValidationException::withMessages([ + 'ktm_assignment_id' => 'KTM yang dipilih tidak sah atau kekosongan jawatan ini telah penuh.', + ]); + } + + $ktmAssignment = StaffAssignment::query()->findOrFail($ktmAssignmentId); + + return [ + 'saluran_mengundi_id' => $ktmAssignment->saluran_mengundi_id, + 'reports_to_assignment_id' => $ktmAssignment->id, + ]; + } + + throw ValidationException::withMessages([ + 'approved_position_code' => 'Jawatan yang dipilih tidak sah untuk kelulusan PPM.', + ]); + } +} diff --git a/app/Services/Ppm/PpmScopeService.php b/app/Services/Ppm/PpmScopeService.php new file mode 100644 index 0000000..5322aa8 --- /dev/null +++ b/app/Services/Ppm/PpmScopeService.php @@ -0,0 +1,46 @@ + + */ + public function assignedPusatIds(User $user): Collection + { + $ppmPosition = Position::query()->where('code', 'PPM')->first(); + + if ($ppmPosition === null) { + return collect(); + } + + return StaffAssignment::query() + ->where('user_id', $user->id) + ->where('position_id', $ppmPosition->id) + ->where('status', 'active') + ->whereNull('saluran_mengundi_id') + ->pluck('pusat_mengundi_id') + ->map(fn (mixed $id): int => (int) $id) + ->values(); + } + + public function canAccessApplication(User $user, Application $application): bool + { + if ($user->hasRole('Admin')) { + return true; + } + + if (! $user->hasRole('PPM')) { + return false; + } + + return $this->assignedPusatIds($user)->contains((int) $application->pusat_mengundi_id); + } +} diff --git a/app/Services/Public/KtmVacancyService.php b/app/Services/Public/KtmVacancyService.php new file mode 100644 index 0000000..706663a --- /dev/null +++ b/app/Services/Public/KtmVacancyService.php @@ -0,0 +1,152 @@ + $roleCodes + * @return array> + */ + public function optionsForRoles(PusatMengundi $pusatMengundi, array $roleCodes = ['KP', 'KPDP'], ?int $ignoreApplicationId = null): array + { + $options = []; + + foreach ($roleCodes as $roleCode) { + $options[$roleCode] = $this->availableKtmAssignments($pusatMengundi, $roleCode, $ignoreApplicationId) + ->map(function (StaffAssignment $assignment) use ($roleCode, $ignoreApplicationId): array { + $user = $assignment->user; + $saluranMengundi = $assignment->saluranMengundi; + + return [ + 'id' => $assignment->id, + 'label' => trim( + ($user instanceof User ? $user->name : 'KTM tanpa nama') + .' - Saluran ' + .($saluranMengundi instanceof SaluranMengundi ? $saluranMengundi->number : '-'), + ), + 'remaining' => $this->remainingVacancy($assignment, $roleCode, $ignoreApplicationId), + ]; + }) + ->values() + ->all(); + } + + return $options; + } + + public function hasVacancyForAssignment(int $assignmentId, PusatMengundi $pusatMengundi, string $roleCode, ?int $ignoreApplicationId = null): bool + { + $ktmPosition = Position::query()->where('code', 'KTM')->first(); + + if ($ktmPosition === null) { + return false; + } + + $assignment = StaffAssignment::query() + ->with(['saluranMengundi', 'user']) + ->where('id', $assignmentId) + ->where('election_id', $pusatMengundi->election_id) + ->where('pusat_mengundi_id', $pusatMengundi->id) + ->where('position_id', $ktmPosition->id) + ->where('status', 'active') + ->whereNotNull('saluran_mengundi_id') + ->first(); + + return $assignment instanceof StaffAssignment + && $this->remainingVacancy($assignment, $roleCode, $ignoreApplicationId) > 0; + } + + public function remainingForAssignment(StaffAssignment $assignment, string $roleCode, ?int $ignoreApplicationId = null): int + { + return $this->remainingVacancy($assignment, $roleCode, $ignoreApplicationId); + } + + /** + * @return Collection + */ + private function availableKtmAssignments(PusatMengundi $pusatMengundi, string $roleCode, ?int $ignoreApplicationId = null): Collection + { + return $this->ktmAssignments($pusatMengundi) + ->filter(fn (StaffAssignment $assignment): bool => $this->remainingVacancy($assignment, $roleCode, $ignoreApplicationId) > 0) + ->values(); + } + + /** + * @return EloquentCollection + */ + private function ktmAssignments(PusatMengundi $pusatMengundi): EloquentCollection + { + $ktmPosition = Position::query()->where('code', 'KTM')->first(); + + if ($ktmPosition === null) { + return new EloquentCollection; + } + + return StaffAssignment::query() + ->with(['saluranMengundi', 'user']) + ->where('election_id', $pusatMengundi->election_id) + ->where('pusat_mengundi_id', $pusatMengundi->id) + ->where('position_id', $ktmPosition->id) + ->where('status', 'active') + ->whereNotNull('saluran_mengundi_id') + ->whereNotNull('user_id') + ->orderBy('saluran_mengundi_id') + ->get(); + } + + private function remainingVacancy(StaffAssignment $ktmAssignment, string $roleCode, ?int $ignoreApplicationId = null): int + { + $position = Position::query()->where('code', $roleCode)->first(); + + if ($position === null || $ktmAssignment->saluran_mengundi_id === null) { + return 0; + } + + $quota = PositionQuota::query() + ->where('election_id', $ktmAssignment->election_id) + ->where('pusat_mengundi_id', $ktmAssignment->pusat_mengundi_id) + ->where('saluran_mengundi_id', $ktmAssignment->saluran_mengundi_id) + ->where('position_id', $position->id) + ->value('quota'); + + if ($quota === null) { + return 0; + } + + $assignedCount = StaffAssignment::query() + ->where('reports_to_assignment_id', $ktmAssignment->id) + ->where('position_id', $position->id) + ->where('status', 'active') + ->count(); + + $reservedQuery = Application::query() + ->where('selected_ktm_assignment_id', $ktmAssignment->id) + ->where('requested_position_id', $position->id) + ->whereIn('status', self::RESERVED_APPLICATION_STATUSES); + + if ($ignoreApplicationId !== null) { + $reservedQuery->where('id', '!=', $ignoreApplicationId); + } + + $reservedCount = $reservedQuery->count(); + + return max(0, (int) $quota - $assignedCount - $reservedCount); + } +} diff --git a/app/Services/Public/PublicApplicationSubmissionService.php b/app/Services/Public/PublicApplicationSubmissionService.php new file mode 100644 index 0000000..6538c59 --- /dev/null +++ b/app/Services/Public/PublicApplicationSubmissionService.php @@ -0,0 +1,106 @@ + $data + */ + public function submit(PusatMengundi $pusatMengundi, array $data): Application + { + return DB::transaction(function () use ($pusatMengundi, $data): Application { + $position = Position::query() + ->where('code', $data['requested_position_code']) + ->where('is_public_applyable', true) + ->firstOrFail(); + + $application = Application::query()->create([ + 'election_id' => $pusatMengundi->election_id, + 'pusat_mengundi_id' => $pusatMengundi->id, + 'selected_ktm_assignment_id' => in_array($position->code, ['KP', 'KPDP'], true) + ? $data['selected_ktm_assignment_id'] + : null, + 'public_uuid' => (string) Str::uuid(), + 'source' => 'public', + 'status' => 'submitted', + 'name' => $data['name'], + 'ic_number' => $data['ic_number'], + 'phone_number' => $data['phone_number'], + 'email' => $data['email'] ?? null, + 'address' => $data['address'], + 'requested_position_id' => $position->id, + 'bank_name' => $data['bank_name'], + 'bank_account_number' => $data['bank_account_number'], + ]); + + $this->storeDocument($application, 'ic_document', $data['ic_document']); + $this->storeDocument($application, 'bank_statement', $data['bank_statement']); + + $application->statusHistories()->create([ + 'to_status' => 'submitted', + 'note' => 'Public QR application submitted.', + 'metadata' => [ + 'source' => 'public', + 'requested_position_code' => $position->code, + ], + ]); + + $application->bankVerification()->create([ + 'status' => 'pending', + ]); + + activity('public_application') + ->performedOn($application) + ->withProperties([ + 'pusat_mengundi_id' => $pusatMengundi->id, + 'requested_position_code' => $position->code, + 'document_types' => ['ic_document', 'bank_statement'], + ]) + ->log('application_submitted'); + + return $application; + }); + } + + private function storeDocument(Application $application, string $documentType, mixed $file): void + { + if (! $file instanceof UploadedFile) { + throw new RuntimeException('Invalid uploaded file.'); + } + + $path = $file->storeAs( + 'applications/'.$application->public_uuid, + $documentType.'-'.$file->hashName(), + 'local', + ); + + if (! is_string($path)) { + throw new RuntimeException('Document upload failed.'); + } + + $application->documents()->create([ + 'document_type' => $documentType, + 'disk' => 'local', + 'path' => $path, + 'original_name' => $file->getClientOriginalName(), + 'mime_type' => $file->getClientMimeType() ?: $file->getMimeType(), + 'size' => $file->getSize(), + ]); + + activity('public_application') + ->performedOn($application) + ->withProperties([ + 'document_type' => $documentType, + ]) + ->log('application_document_uploaded'); + } +} diff --git a/app/Services/RegistrationPeriodService.php b/app/Services/RegistrationPeriodService.php new file mode 100644 index 0000000..568cf3a --- /dev/null +++ b/app/Services/RegistrationPeriodService.php @@ -0,0 +1,53 @@ +settings; + + if ($setting === null) { + return false; + } + + if ($setting->is_registration_open_override !== null) { + return $setting->is_registration_open_override; + } + + if (! $setting->is_registration_open) { + return false; + } + + $currentDate = ($date ?? now())->toDateString(); + + return Carbon::parse($setting->registration_start_date)->toDateString() <= $currentDate + && Carbon::parse($setting->registration_end_date)->toDateString() >= $currentDate; + } + + public function closedMessage(Election $election): string + { + /** @var ElectionSetting|null $setting */ + $setting = $election->settings; + + if ($setting === null) { + return 'Pendaftaran belum dibuka untuk pilihanraya ini.'; + } + + if ($setting->is_registration_open_override === false || ! $setting->is_registration_open) { + return 'Pendaftaran permohonan petugas telah ditutup.'; + } + + return 'Pendaftaran dibuka dari ' + .Carbon::parse($setting->registration_start_date)->format('d/m/Y') + .' hingga ' + .Carbon::parse($setting->registration_end_date)->format('d/m/Y') + .'.'; + } +} diff --git a/app/Support/SensitiveData.php b/app/Support/SensitiveData.php new file mode 100644 index 0000000..5392731 --- /dev/null +++ b/app/Support/SensitiveData.php @@ -0,0 +1,54 @@ +handleCommand(new ArgvInput); + +exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..5f33bcb --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,30 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->alias([ + 'permission' => PermissionMiddleware::class, + 'role' => RoleMiddleware::class, + 'role_or_permission' => RoleOrPermissionMiddleware::class, + 'admin.post_close_note' => RequireAdminPostCloseNote::class, + ]); + }) + ->withExceptions(function (Exceptions $exceptions): void { + $exceptions->shouldRenderJsonWhen( + fn (Request $request) => $request->is('api/*'), + ); + })->create(); diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/bootstrap/providers.php b/bootstrap/providers.php new file mode 100644 index 0000000..fc94ae6 --- /dev/null +++ b/bootstrap/providers.php @@ -0,0 +1,7 @@ +=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "dasprid/enum", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:43:20+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.5", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "7c8d84b39e680315f687e8662a9d6fb0865c5148" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7c8d84b39e680315f687e8662a9d6fb0865c5148", + "reference": "7c8d84b39e680315f687e8662a9d6fb0865c5148", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.4", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-05-27T11:53:46+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2", + "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.4.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-05-20T22:57:30+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.10.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "7c1472269227dc6f18930bd903d7a88fe6c52130" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7c1472269227dc6f18930bd903d7a88fe6c52130", + "reference": "7c1472269227dc6f18930bd903d7a88fe6c52130", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.10.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-05-27T11:48:20+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.6", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "eef7f87bab6f204eba3c39224d8075c70c637946" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/eef7f87bab6f204eba3c39224d8075c70c637946", + "reference": "eef7f87bab6f204eba3c39224d8075c70c637946", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.6" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2026-05-23T22:00:21+00:00" + }, + { + "name": "laravel/framework", + "version": "v13.12.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "6ac27a7fcfa728250c9f77921cb8fb955546b591" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/6ac27a7fcfa728250c9f77921cb8fb955546b591", + "reference": "6ac27a7fcfa728250c9f77921cb8fb955546b591", + "shasum": "" + }, + "require": { + "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/uri-template": "^1.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^2.0.10", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.0", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.4.0 || ^8.0.0", + "symfony/error-handler": "^7.4.0 || ^8.0.0", + "symfony/finder": "^7.4.0 || ^8.0.0", + "symfony/http-foundation": "^7.4.0 || ^8.0.0", + "symfony/http-kernel": "^7.4.0 || ^8.0.0", + "symfony/mailer": "^7.4.0 || ^8.0.0", + "symfony/mime": "^7.4.0 || ^8.0.0", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", + "symfony/polyfill-php86": "^1.36", + "symfony/process": "^7.4.5 || ^8.0.5", + "symfony/routing": "^7.4.0 || ^8.0.0", + "symfony/uid": "^7.4.0 || ^8.0.0", + "symfony/var-dumper": "^7.4.0 || ^8.0.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1 || 2.0", + "psr/log-implementation": "1.0 || 2.0 || 3.0", + "psr/simple-cache-implementation": "1.0 || 2.0 || 3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "guzzlehttp/psr7": "^2.9", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^11.0.0", + "pda/pheanstalk": "^7.0.0 || ^8.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", + "predis/predis": "^2.3 || ^3.0", + "rector/rector": "^2.3", + "resend/resend-php": "^1.0", + "symfony/cache": "^7.4.0 || ^8.0.0", + "symfony/http-client": "^7.4.0 || ^8.0.0", + "symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0", + "symfony/translation": "^7.4.0 || ^8.0.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0 || ^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0 || ^5.0 || ^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^7.0 || ^8.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^11.5.50 || ^12.5.8 || ^13.0.3).", + "predis/predis": "Required to use the predis connector (^2.3 || ^3.0).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0 || ^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0 || ^1.0).", + "spatie/fork": "Required to use the 'fork' concurrency driver (^1.2).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.4 || ^8.0).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-05-26T23:39:26+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.18", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/a19af51bb144bf87f08397921fa619f85c7d4e72", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.18" + }, + "time": "2026-05-19T00:47:18+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.13", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-04-16T14:03:50+00:00" + }, + { + "name": "laravel/tinker", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "4faba77764bd33411735936acdf30446d058c78b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/4faba77764bd33411735936acdf30446d058c78b", + "reference": "4faba77764bd33411735936acdf30446d058c78b", + "shasum": "" + }, + "require": { + "illuminate/console": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "psy/psysh": "^0.12.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5|^11.5" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^8.0|^9.0|^10.0|^11.0|^12.0|^13.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v3.0.2" + }, + "time": "2026-03-17T14:54:13+00:00" + }, + { + "name": "league/commonmark", + "version": "2.8.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-03-19T13:16:38+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.34.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.34.0" + }, + "time": "2026-05-14T10:28:08+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.31.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + }, + "time": "2026-01-23T15:30:45+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-09-21T08:32:55+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.11.4", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-04-07T09:57:54+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.5", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.5" + }, + "time": "2026-02-23T03:47:12+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.4", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.4" + }, + "time": "2026-05-11T20:49:54+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "openspout/openspout", + "version": "v5.7.1", + "source": { + "type": "git", + "url": "https://github.com/openspout/openspout.git", + "reference": "5e5bcdf10a15b77bd6295be8e932767246c286b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/openspout/openspout/zipball/5e5bcdf10a15b77bd6295be8e932767246c286b2", + "reference": "5e5bcdf10a15b77bd6295be8e932767246c286b2", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-libxml": "*", + "ext-xmlreader": "*", + "ext-zip": "*", + "php": "~8.4.0 || ~8.5.0" + }, + "require-dev": { + "ext-fileinfo": "*", + "ext-zlib": "*", + "friendsofphp/php-cs-fixer": "^3.95.2", + "infection/infection": "^0.33.2", + "phpbench/phpbench": "^1.6.1", + "phpstan/phpstan": "^2.1.55", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.11", + "phpunit/phpunit": "^13.1.12" + }, + "suggest": { + "ext-iconv": "To handle non UTF-8 CSV files (if \"php-mbstring\" is not already installed or is too limited)", + "ext-mbstring": "To handle non UTF-8 CSV files (if \"iconv\" is not already installed)" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "OpenSpout\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Adrien Loison", + "email": "adrien@box.com" + } + ], + "description": "PHP Library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way", + "homepage": "https://github.com/openspout/openspout", + "keywords": [ + "OOXML", + "csv", + "excel", + "memory", + "odf", + "ods", + "office", + "open", + "php", + "read", + "scale", + "spreadsheet", + "stream", + "write", + "xlsx" + ], + "support": { + "issues": "https://github.com/openspout/openspout/issues", + "source": "https://github.com/openspout/openspout/tree/v5.7.1" + }, + "funding": [ + { + "url": "https://paypal.me/filippotessarotto", + "type": "custom" + }, + { + "url": "https://github.com/Slamdunk", + "type": "github" + } + ], + "time": "2026-05-26T06:16:49+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.23", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.23" + }, + "time": "2026-05-23T13:41:31+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.2", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "8429c78ca35a09f27565311b98101e2826affde0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.2" + }, + "time": "2025-12-14T04:43:48+00:00" + }, + { + "name": "simplesoftwareio/simple-qrcode", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/SimpleSoftwareIO/simple-qrcode.git", + "reference": "916db7948ca6772d54bb617259c768c9cdc8d537" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SimpleSoftwareIO/simple-qrcode/zipball/916db7948ca6772d54bb617259c768c9cdc8d537", + "reference": "916db7948ca6772d54bb617259c768c9cdc8d537", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^2.0", + "ext-gd": "*", + "php": ">=7.2|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1", + "phpunit/phpunit": "~9" + }, + "suggest": { + "ext-imagick": "Allows the generation of PNG QrCodes.", + "illuminate/support": "Allows for use within Laravel." + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "QrCode": "SimpleSoftwareIO\\QrCode\\Facades\\QrCode" + }, + "providers": [ + "SimpleSoftwareIO\\QrCode\\QrCodeServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "SimpleSoftwareIO\\QrCode\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Simple Software LLC", + "email": "support@simplesoftware.io" + } + ], + "description": "Simple QrCode is a QR code generator made for Laravel.", + "homepage": "https://www.simplesoftware.io/#/docs/simple-qrcode", + "keywords": [ + "Simple", + "generator", + "laravel", + "qrcode", + "wrapper" + ], + "support": { + "issues": "https://github.com/SimpleSoftwareIO/simple-qrcode/issues", + "source": "https://github.com/SimpleSoftwareIO/simple-qrcode/tree/4.2.0" + }, + "time": "2021-02-08T20:43:55+00:00" + }, + { + "name": "spatie/laravel-activitylog", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-activitylog.git", + "reference": "0e00fe74fd071cc572a045459f6d4c9de33130bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/0e00fe74fd071cc572a045459f6d4c9de33130bd", + "reference": "0e00fe74fd071cc572a045459f6d4c9de33130bd", + "shasum": "" + }, + "require": { + "illuminate/config": "^12.0 || ^13.0", + "illuminate/database": "^12.0 || ^13.0", + "illuminate/support": "^12.0 || ^13.0", + "php": "^8.4", + "spatie/laravel-package-tools": "^1.6.3" + }, + "require-dev": { + "ext-json": "*", + "larastan/larastan": "^3.0", + "laravel/pint": "^1.29", + "orchestra/testbench": "^10.0 || ^11.0", + "pestphp/pest": "^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Activitylog\\ActivitylogServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Activitylog\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Sebastian De Deyne", + "email": "sebastian@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Tom Witkowski", + "email": "dev.gummibeer@gmail.com", + "homepage": "https://gummibeer.de", + "role": "Developer" + } + ], + "description": "A very simple activity logger to monitor the users of your website or application", + "homepage": "https://github.com/spatie/activitylog", + "keywords": [ + "activity", + "laravel", + "log", + "spatie", + "user" + ], + "support": { + "issues": "https://github.com/spatie/laravel-activitylog/issues", + "source": "https://github.com/spatie/laravel-activitylog/tree/5.0.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-03-25T10:04:54+00:00" + }, + { + "name": "spatie/laravel-package-tools", + "version": "1.93.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-package-tools.git", + "reference": "d5552849801f2642aea710557463234b59ef65eb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb", + "reference": "d5552849801f2642aea710557463234b59ef65eb", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "orchestra/testbench": "^8.0|^9.2|^10.0|^11.0", + "pestphp/pest": "^2.1|^3.1|^4.0", + "phpunit/php-code-coverage": "^10.0|^11.0|^12.0", + "phpunit/phpunit": "^10.5|^11.5|^12.5", + "spatie/pest-plugin-test-time": "^2.2|^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\LaravelPackageTools\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Tools for creating Laravel packages", + "homepage": "https://github.com/spatie/laravel-package-tools", + "keywords": [ + "laravel-package-tools", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-package-tools/issues", + "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-05-19T14:06:37+00:00" + }, + { + "name": "spatie/laravel-permission", + "version": "7.4.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-permission.git", + "reference": "ef42ecb781e5534d368a3853fa161e420ad51397" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/ef42ecb781e5534d368a3853fa161e420ad51397", + "reference": "ef42ecb781e5534d368a3853fa161e420ad51397", + "shasum": "" + }, + "require": { + "illuminate/auth": "^12.0|^13.0", + "illuminate/container": "^12.0|^13.0", + "illuminate/contracts": "^12.0|^13.0", + "illuminate/database": "^12.0|^13.0", + "php": "^8.3", + "spatie/laravel-package-tools": "^1.0" + }, + "require-dev": { + "larastan/larastan": "^3.9", + "laravel/passport": "^13.0", + "laravel/pint": "^1.0", + "orchestra/testbench": "^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "pestphp/pest-plugin-laravel": "^3.0|^4.1", + "phpstan/phpstan": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Permission\\PermissionServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "7.x-dev", + "dev-master": "7.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Permission\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Permission handling for Laravel 12 and up", + "homepage": "https://github.com/spatie/laravel-permission", + "keywords": [ + "acl", + "laravel", + "permission", + "permissions", + "rbac", + "roles", + "security", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-permission/issues", + "source": "https://github.com/spatie/laravel-permission/tree/7.4.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-04-29T07:59:45+00:00" + }, + { + "name": "symfony/clock", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/b55a638b189a6faa875e0ccdb00908fb87af95b3", + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/console", + "version": "v8.0.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "f200be111431cff4aeae8d086b91180bc4d7efd7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/f200be111431cff4aeae8d086b91180bc4d7efd7", + "reference": "f200be111431cff4aeae8d086b91180bc4d7efd7", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4|^8.0" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v8.0.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T08:59:15+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v8.0.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "3665cfade90565430909b906394c73c8739e57d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/3665cfade90565430909b906394c73c8739e57d0", + "reference": "3665cfade90565430909b906394c73c8739e57d0", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v8.0.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-18T13:51:42+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/c1119fe8dcfc3825ec74ec061b96ef0c8f281517", + "reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^7.4|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v8.0.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0c3c1a17604c4dbbec4b93fe162c538482096e1f", + "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-18T13:51:42+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/finder", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "8da41214757b87d97f181e3d14a4179286151007" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/8da41214757b87d97f181e3d14a4179286151007", + "reference": "8da41214757b87d97f181e3d14a4179286151007", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v8.0.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "30459bd82094c2572f3f78c04132e92f257a5f76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/30459bd82094c2572f3f78c04132e92f257a5f76", + "reference": "30459bd82094c2572f3f78c04132e92f257a5f76", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<4.3" + }, + "require-dev": { + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v8.0.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T11:21:57+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v8.0.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "0f2b114380b21d8736a496ab4672b012e3822ee7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/0f2b114380b21d8736a496ab4672b012e3822ee7", + "reference": "0f2b114380b21d8736a496ab4672b012e3822ee7", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/log": "^1|^2|^3", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/flex": "<2.10", + "symfony/http-client-contracts": "<2.5", + "symfony/translation-contracts": "<2.5", + "twig/twig": "<3.21" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "twig/twig": "^3.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v8.0.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T08:48:59+00:00" + }, + { + "name": "symfony/mailer", + "version": "v8.0.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "5266d594e83593dff3492b5655ff6e8f38d67cfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/5266d594e83593dff3492b5655ff6e8f38d67cfc", + "reference": "5266d594e83593dff3492b5655ff6e8f38d67cfc", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.4", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v8.0.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-20T07:22:03+00:00" + }, + { + "name": "symfony/mime", + "version": "v8.0.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "7c078c464c5ce36e8bcaf54b3b3e3c51d30a9646" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/7c078c464c5ce36e8bcaf54b3b3e3c51d30a9646", + "reference": "7c078c464c5ce36e8bcaf54b3b3e3c51d30a9646", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v8.0.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T18:05:53+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T05:58:03+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "dc21118016c039a66235cf93d96b435ffb282412" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T15:22:23+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T02:25:22+00:00" + }, + { + "name": "symfony/polyfill-php86", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad", + "reference": "fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php86\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T11:52:35+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/process", + "version": "v8.0.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "4f23d9c7637ead9ed19f697fe93cb87fd9b379d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/4f23d9c7637ead9ed19f697fe93cb87fd9b379d4", + "reference": "4f23d9c7637ead9ed19f697fe93cb87fd9b379d4", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v8.0.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T18:05:53+00:00" + }, + { + "name": "symfony/routing", + "version": "v8.0.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "18e6213e536842285dfdd29604e43ac8f784d17d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/18e6213e536842285dfdd29604e43ac8f784d17d", + "reference": "18e6213e536842285dfdd29604e43ac8f784d17d", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v8.0.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T11:21:57+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-28T09:44:51+00:00" + }, + { + "name": "symfony/string", + "version": "v8.0.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "f2e3e4d33579350d1b12001ef2872f86b27ed3dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/f2e3e4d33579350d1b12001ef2872f86b27ed3dc", + "reference": "f2e3e4d33579350d1b12001ef2872f86b27ed3dc", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.0.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T18:05:53+00:00" + }, + { + "name": "symfony/translation", + "version": "v8.0.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v8.0.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-06T11:30:54+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/uid", + "version": "v8.0.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "4d9d6510bbe88ebb4608b7200d18606cdf80825c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/4d9d6510bbe88ebb4608b7200d18606cdf80825c", + "reference": "4d9d6510bbe88ebb4608b7200d18606cdf80825c", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v8.0.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-30T16:10:06+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", + "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-31T07:15:36+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.3", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "955e7815d677a3eaa7075231212f2110983adecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:49:13+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + } + ], + "packages-dev": [ + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "iamcal/sql-parser", + "version": "v0.7", + "source": { + "type": "git", + "url": "https://github.com/iamcal/SQLParser.git", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8", + "shasum": "" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^1.0", + "phpunit/phpunit": "^5|^6|^7|^8|^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "iamcal\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cal Henderson", + "email": "cal@iamcal.com" + } + ], + "description": "MySQL schema parser", + "support": { + "issues": "https://github.com/iamcal/SQLParser/issues", + "source": "https://github.com/iamcal/SQLParser/tree/v0.7" + }, + "time": "2026-01-28T22:20:33+00:00" + }, + { + "name": "larastan/larastan", + "version": "v3.9.6", + "source": { + "type": "git", + "url": "https://github.com/larastan/larastan.git", + "reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/larastan/larastan/zipball/9ad17e83e96b63536cb6ac39c3d40d29ff9cf636", + "reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636", + "shasum": "" + }, + "require": { + "ext-json": "*", + "iamcal/sql-parser": "^0.7.0", + "illuminate/console": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/container": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/database": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/http": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/support": "^11.44.2 || ^12.4.1 || ^13", + "php": "^8.2", + "phpstan/phpstan": "^2.1.44" + }, + "require-dev": { + "doctrine/coding-standard": "^13", + "laravel/framework": "^11.44.2 || ^12.7.2 || ^13", + "mockery/mockery": "^1.6.12", + "nikic/php-parser": "^5.4", + "orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11", + "orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8" + }, + "suggest": { + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", + "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Larastan\\Larastan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Can Vural", + "email": "can9119@gmail.com" + } + ], + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "larastan", + "laravel", + "package", + "php", + "static analysis" + ], + "support": { + "issues": "https://github.com/larastan/larastan/issues", + "source": "https://github.com/larastan/larastan/tree/v3.9.6" + }, + "funding": [ + { + "url": "https://github.com/canvural", + "type": "github" + } + ], + "time": "2026-04-16T10:02:43+00:00" + }, + { + "name": "laravel/agent-detector", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/agent-detector.git", + "reference": "90694b9256099591cf9e55d08c18ba7a00bf099f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/agent-detector/zipball/90694b9256099591cf9e55d08c18ba7a00bf099f", + "reference": "90694b9256099591cf9e55d08c18ba7a00bf099f", + "shasum": "" + }, + "require": { + "php": "^8.2.0" + }, + "require-dev": { + "laravel/pint": "^1.24.0", + "pestphp/pest": "^3.8.5|^4.1.0", + "pestphp/pest-plugin-type-coverage": "^3.0|^4.0.2", + "phpstan/phpstan": "^2.1.26", + "rector/rector": "^2.1.7", + "symfony/var-dumper": "^7.3.3" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Laravel\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Detect if code is running in an AI agent or automated development environment", + "homepage": "https://github.com/laravel/agent-detector", + "keywords": [ + "Agent", + "ai", + "automation", + "claude", + "cursor", + "detection", + "devin", + "php" + ], + "support": { + "issues": "https://github.com/laravel/agent-detector/issues", + "source": "https://github.com/laravel/agent-detector" + }, + "time": "2026-04-29T18:32:34+00:00" + }, + { + "name": "laravel/breeze", + "version": "v2.4.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/breeze.git", + "reference": "4f20e7b2cc8d25daa85d8647241a89c8e0930305" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/breeze/zipball/4f20e7b2cc8d25daa85d8647241a89c8e0930305", + "reference": "4f20e7b2cc8d25daa85d8647241a89c8e0930305", + "shasum": "" + }, + "require": { + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/filesystem": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "illuminate/validation": "^11.0|^12.0|^13.0", + "php": "^8.2.0", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^11.0|^12.0|^13.0", + "orchestra/testbench-core": "^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Breeze\\BreezeServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Breeze\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Minimal Laravel authentication scaffolding with Blade and Tailwind.", + "keywords": [ + "auth", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/breeze/issues", + "source": "https://github.com/laravel/breeze" + }, + "time": "2026-05-14T16:54:25+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-05-20T22:24:57+00:00" + }, + { + "name": "laravel/pao", + "version": "v1.0.6", + "source": { + "type": "git", + "url": "https://github.com/laravel/pao.git", + "reference": "02f62a64c2b60af44a418ee490fee193590d8269" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pao/zipball/02f62a64c2b60af44a418ee490fee193590d8269", + "reference": "02f62a64c2b60af44a418ee490fee193590d8269", + "shasum": "" + }, + "require": { + "laravel/agent-detector": "^2.0.0", + "php": "^8.3" + }, + "conflict": { + "laravel/framework": "<12.0.0", + "nunomaduro/collision": "<8.9.3", + "pestphp/pest": "<4.6.3 || >=6.0.0", + "phpunit/phpunit": "<12.5.23 || >=13.0.0 <13.1.7 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.20.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench": "^10.11.0 || ^11.1.0", + "pestphp/pest": "^4.6.3 || ^5.0.0", + "pestphp/pest-plugin-type-coverage": "^4.0.4 || ^5.0.0", + "phpstan/phpstan": "^2.1.51", + "rector/rector": "^2.4.2", + "symfony/process": "^7.4.8 || ^8.1.0", + "symfony/var-dumper": "^7.4.8 || ^8.0.8" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Laravel\\Pao\\Drivers\\Pest\\Plugin" + ] + }, + "laravel": { + "providers": [ + "Laravel\\Pao\\Laravel\\ServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Laravel\\Pao\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Agent-optimized output for PHP testing tools", + "keywords": [ + "Agent", + "PHPStan", + "ai", + "dev", + "paratest", + "pest", + "php", + "phpunit", + "testing" + ], + "support": { + "issues": "https://github.com/laravel/pao/issues", + "source": "https://github.com/laravel/pao" + }, + "time": "2026-04-27T22:37:26+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.29.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95.1", + "illuminate/view": "^12.56.0", + "larastan/larastan": "^3.9.6", + "laravel-zero/framework": "^12.1.0", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.3" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-04-20T15:26:14+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.9.4", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.8 || ^8.0.8" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.6", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-04-21T14:04:20+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.1.56", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/93a603c9fc3be8c3c93bbc8d22170ad766685537", + "reference": "93a603c9fc3be8c3c93bbc8d22170ad766685537", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-05-26T17:04:57+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.5.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "876099a072646c7745f673d7aeab5382c4439691" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691", + "reference": "876099a072646c7745f673d7aeab5382c4439691", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.0.3", + "sebastian/lines-of-code": "^4.0", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-04-15T08:23:17+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T14:04:18+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:16+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.28", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "5895d05f5bf421ed230fbd76e1277e4b8955def4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5895d05f5bf421ed230fbd76e1277e4b8955def4", + "reference": "5895d05f5bf421ed230fbd76e1277e4b8955def4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.6", + "phpunit/php-file-iterator": "^6.0.1", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.0", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.2", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.28" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-05-27T14:01:10+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-05-17T05:29:34+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:45:25+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:46+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.26" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:40:20+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-20T04:37:17+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2025-08-29T11:29:25+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:22:07+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:17+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:44:59+00:00" + }, + { + "name": "sebastian/type", + "version": "6.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:45:45+00:00" + }, + { + "name": "sebastian/version", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T05:00:38+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.3" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config/activitylog.php b/config/activitylog.php new file mode 100644 index 0000000..27cd6aa --- /dev/null +++ b/config/activitylog.php @@ -0,0 +1,73 @@ + env('ACTIVITYLOG_ENABLED', true), + + /* + * When the clean command is executed, all recording activities older than + * the number of days specified here will be deleted. + */ + 'clean_after_days' => 365, + + /* + * If no log name is passed to the activity() helper + * we use this default log name. + */ + 'default_log_name' => 'default', + + /* + * You can specify an auth driver here that gets user models. + * If this is null we'll use the current Laravel auth driver. + */ + 'default_auth_driver' => null, + + /* + * If set to true, the subject relationship on activities + * will include soft deleted models. + */ + 'include_soft_deleted_subjects' => false, + + /* + * This model will be used to log activity. + * It should implement the Spatie\Activitylog\Contracts\Activity interface + * and extend Illuminate\Database\Eloquent\Model. + */ + 'activity_model' => Activity::class, + + /* + * These attributes will be excluded from logging for all models. + * Model-specific exclusions via logExcept() are merged with these. + */ + 'default_except_attributes' => [], + + /* + * When enabled, activities are buffered in memory and inserted in a + * single bulk query after the response has been sent to the client. + * This can significantly reduce the number of database queries when + * many activities are logged during a single request. + * + * Only enable this if your application logs a high volume of activities + * per request. Buffered activities will not have an ID until the + * buffer is flushed. + */ + 'buffer' => [ + 'enabled' => env('ACTIVITYLOG_BUFFER_ENABLED', false), + ], + + /* + * These action classes can be overridden to customize how activities + * are logged and cleaned. Your custom classes must extend the originals. + */ + 'actions' => [ + 'log_activity' => LogActivityAction::class, + 'clean_log' => CleanActivityLogAction::class, + ], +]; diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..5ada68f --- /dev/null +++ b/config/app.php @@ -0,0 +1,126 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => env('APP_TIMEZONE', 'Asia/Kuala_Lumpur'), + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'en'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', (string) env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..d7568ff --- /dev/null +++ b/config/auth.php @@ -0,0 +1,117 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', User::class), + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the number of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..d7eec61 --- /dev/null +++ b/config/cache.php @@ -0,0 +1,136 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "storage", "octane", + | "session", "failover", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'storage' => [ + 'driver' => 'storage', + 'disk' => env('CACHE_STORAGE_DISK'), + 'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + + /* + |-------------------------------------------------------------------------- + | Serializable Classes + |-------------------------------------------------------------------------- + | + | This value determines the classes that can be unserialized from cache + | storage. By default, no PHP classes will be unserialized from your + | cache to prevent gadget chain attacks if your APP_KEY is leaked. + | + */ + + 'serializable_classes' => false, + +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..abbb88e --- /dev/null +++ b/config/database.php @@ -0,0 +1,184 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => env('DB_SSLMODE', 'prefer'), + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + ], + +]; diff --git a/config/election.php b/config/election.php new file mode 100644 index 0000000..124bda4 --- /dev/null +++ b/config/election.php @@ -0,0 +1,9 @@ + [ + 'name' => env('SEED_ADMIN_NAME', 'Admin Sistem'), + 'email' => env('SEED_ADMIN_EMAIL', 'admin@prn.local'), + 'password' => env('SEED_ADMIN_PASSWORD', 'password'), + ], +]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 0000000..37d8fca --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,80 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..b09cb25 --- /dev/null +++ b/config/logging.php @@ -0,0 +1,132 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', (string) env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..e32e88d --- /dev/null +++ b/config/mail.php @@ -0,0 +1,118 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => env('MAIL_SCHEME'), + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), + ], + +]; diff --git a/config/permission.php b/config/permission.php new file mode 100644 index 0000000..8f1f452 --- /dev/null +++ b/config/permission.php @@ -0,0 +1,219 @@ + [ + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * Eloquent model should be used to retrieve your permissions. Of course, it + * is often just the "Permission" model but you may use whatever you like. + * + * The model you want to use as a Permission model needs to implement the + * `Spatie\Permission\Contracts\Permission` contract. + */ + + 'permission' => Permission::class, + + /* + * When using the "HasRoles" trait from this package, we need to know which + * Eloquent model should be used to retrieve your roles. Of course, it + * is often just the "Role" model but you may use whatever you like. + * + * The model you want to use as a Role model needs to implement the + * `Spatie\Permission\Contracts\Role` contract. + */ + + 'role' => Role::class, + + /* + * When using the "Teams" feature from this package, we need to know which + * Eloquent model should be used to retrieve your teams. Of course, it + * is often just the "Team" model but you may use whatever you like. + */ + 'team' => null, + + /* + * When using the "HasModels" trait and passing raw IDs to syncModels, + * attachModels, or detachModels, this model class will be used to + * resolve those IDs. If null, defaults to the guard's model. + */ + 'default_model' => null, + ], + + 'table_names' => [ + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'roles' => 'roles', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your permissions. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'permissions' => 'permissions', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your models permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_permissions' => 'model_has_permissions', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your models roles. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_roles' => 'model_has_roles', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'role_has_permissions' => 'role_has_permissions', + ], + + 'column_names' => [ + /* + * Change this if you want to name the related pivots other than defaults + */ + 'role_pivot_key' => null, // default 'role_id', + 'permission_pivot_key' => null, // default 'permission_id', + + /* + * Change this if you want to name the related model primary key other than + * `model_id`. + * + * For example, this would be nice if your primary keys are all UUIDs. In + * that case, name this `model_uuid`. + */ + + 'model_morph_key' => 'model_id', + + /* + * Change this if you want to use the teams feature and your related model's + * foreign key is other than `team_id`. + */ + + 'team_foreign_key' => 'team_id', + ], + + /* + * When set to true, the method for checking permissions will be registered on the gate. + * Set this to false if you want to implement custom logic for checking permissions. + */ + + 'register_permission_check_method' => true, + + /* + * When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered + * this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated + * NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it. + */ + 'register_octane_reset_listener' => false, + + /* + * Events will fire when a role or permission is assigned/unassigned: + * \Spatie\Permission\Events\RoleAttachedEvent + * \Spatie\Permission\Events\RoleDetachedEvent + * \Spatie\Permission\Events\PermissionAttachedEvent + * \Spatie\Permission\Events\PermissionDetachedEvent + * + * To enable, set to true, and then create listeners to watch these events. + */ + 'events_enabled' => false, + + /* + * Teams Feature. + * When set to true the package implements teams using the 'team_foreign_key'. + * If you want the migrations to register the 'team_foreign_key', you must + * set this to true before doing the migration. + * If you already did the migration then you must make a new migration to also + * add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions' + * (view the latest version of this package's migration file) + */ + + 'teams' => false, + + /* + * The class to use to resolve the permissions team id + */ + 'team_resolver' => DefaultTeamResolver::class, + + /* + * Passport Client Credentials Grant + * When set to true the package will use Passports Client to check permissions + */ + + 'use_passport_client_credentials' => false, + + /* + * When set to true, the required permission names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_permission_in_exception' => false, + + /* + * When set to true, the required role names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_role_in_exception' => false, + + /* + * By default wildcard permission lookups are disabled. + * See documentation to understand supported syntax. + */ + + 'enable_wildcard_permission' => false, + + /* + * The class to use for interpreting wildcard permissions. + * If you need to modify delimiters, override the class and specify its name here. + */ + // 'wildcard_permission' => Spatie\Permission\WildcardPermission::class, + + /* Cache-specific settings */ + + 'cache' => [ + + /* + * By default all permissions are cached for 24 hours to speed up performance. + * When permissions or roles are updated the cache is flushed automatically. + */ + + 'expiration_time' => DateInterval::createFromDateString('24 hours'), + + /* + * The cache key used to store all permissions. + */ + + 'key' => 'spatie.permission.cache', + + /* + * You may optionally indicate a specific cache driver to use for permission and + * role caching using any of the `store` drivers listed in the cache.php config + * file. Using 'default' here means to use the `default` set in cache.php. + */ + + 'store' => 'default', + ], +]; diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 0000000..79c2c0a --- /dev/null +++ b/config/queue.php @@ -0,0 +1,129 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", + | "deferred", "background", "failover", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + 'deferred' => [ + 'driver' => 'deferred', + ], + + 'background' => [ + 'driver' => 'background', + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..6a90eb8 --- /dev/null +++ b/config/services.php @@ -0,0 +1,38 @@ + [ + 'key' => env('POSTMARK_API_KEY'), + ], + + 'resend' => [ + 'key' => env('RESEND_API_KEY'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 0000000..f574482 --- /dev/null +++ b/config/session.php @@ -0,0 +1,233 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => (int) env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug((string) env('APP_NAME', 'laravel')).'-session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain without subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + + /* + |-------------------------------------------------------------------------- + | Session Serialization + |-------------------------------------------------------------------------- + | + | This value controls the serialization strategy for session data, which + | is JSON by default. Setting this to "php" allows the storage of PHP + | objects in the session but can make an application vulnerable to + | "gadget chain" serialization attacks if the APP_KEY is leaked. + | + | Supported: "json", "php" + | + */ + + 'serialization' => 'json', + +]; diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/database/factories/ApplicationFactory.php b/database/factories/ApplicationFactory.php new file mode 100644 index 0000000..c00775e --- /dev/null +++ b/database/factories/ApplicationFactory.php @@ -0,0 +1,44 @@ + + */ +class ApplicationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $pusat = PusatMengundi::factory()->create(); + $position = Position::factory()->create([ + 'is_public_applyable' => true, + ]); + + return [ + 'election_id' => $pusat->election_id, + 'pusat_mengundi_id' => $pusat->id, + 'public_uuid' => (string) Str::uuid(), + 'source' => 'public', + 'status' => 'submitted', + 'name' => fake()->name(), + 'ic_number' => fake()->unique()->numerify('############'), + 'phone_number' => fake()->numerify('01########'), + 'email' => fake()->safeEmail(), + 'address' => fake()->address(), + 'requested_position_id' => $position->id, + 'bank_name' => 'Maybank', + 'bank_account_number' => fake()->numerify('############'), + ]; + } +} diff --git a/database/factories/BahagianPilihanrayaFactory.php b/database/factories/BahagianPilihanrayaFactory.php new file mode 100644 index 0000000..ae35b08 --- /dev/null +++ b/database/factories/BahagianPilihanrayaFactory.php @@ -0,0 +1,27 @@ + + */ +class BahagianPilihanrayaFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'election_id' => Election::factory(), + 'code' => strtoupper(fake()->unique()->bothify('B##')), + 'name' => 'Bahagian '.fake()->city(), + ]; + } +} diff --git a/database/factories/DaerahMengundiFactory.php b/database/factories/DaerahMengundiFactory.php new file mode 100644 index 0000000..9cf3261 --- /dev/null +++ b/database/factories/DaerahMengundiFactory.php @@ -0,0 +1,39 @@ + + */ +class DaerahMengundiFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $bahagian = BahagianPilihanraya::factory()->create(); + + return [ + 'election_id' => $bahagian->election_id, + 'bahagian_pilihanraya_id' => $bahagian->id, + 'code' => strtoupper(fake()->unique()->bothify('D##')), + 'name' => 'Daerah Mengundi '.fake()->streetName(), + ]; + } + + public function forElection(Election $election): static + { + return $this->state(fn () => [ + 'election_id' => $election->id, + 'bahagian_pilihanraya_id' => BahagianPilihanraya::factory()->for($election)->create()->id, + ]); + } +} diff --git a/database/factories/DunFactory.php b/database/factories/DunFactory.php new file mode 100644 index 0000000..c879d79 --- /dev/null +++ b/database/factories/DunFactory.php @@ -0,0 +1,25 @@ + + */ +class DunFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'election_id' => Election::factory(), + 'code' => strtoupper(fake()->unique()->bothify('N##')), + 'name' => fake()->city(), + ]; + } +} diff --git a/database/factories/ElectionFactory.php b/database/factories/ElectionFactory.php new file mode 100644 index 0000000..239e623 --- /dev/null +++ b/database/factories/ElectionFactory.php @@ -0,0 +1,32 @@ + + */ +class ElectionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $pollingDate = now()->addWeeks(4)->toDateString(); + + return [ + 'public_uuid' => (string) Str::uuid(), + 'name' => 'Pilihanraya Negeri '.fake()->year(), + 'code' => strtoupper(fake()->unique()->bothify('PRN-####')), + 'status' => 'active', + 'starts_at' => now()->toDateString(), + 'polling_date' => $pollingDate, + ]; + } +} diff --git a/database/factories/PositionFactory.php b/database/factories/PositionFactory.php new file mode 100644 index 0000000..e98c9e7 --- /dev/null +++ b/database/factories/PositionFactory.php @@ -0,0 +1,32 @@ + + */ +class PositionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $code = strtoupper(fake()->unique()->lexify('???')); + + return [ + 'code' => $code, + 'name' => 'Jawatan '.$code, + 'scope' => fake()->randomElement(['pusat', 'saluran', 'external']), + 'is_public_applyable' => false, + 'is_assignable' => true, + 'allows_dual_role' => false, + 'sort_order' => fake()->numberBetween(1, 99), + ]; + } +} diff --git a/database/factories/PusatMengundiFactory.php b/database/factories/PusatMengundiFactory.php new file mode 100644 index 0000000..39f7f01 --- /dev/null +++ b/database/factories/PusatMengundiFactory.php @@ -0,0 +1,33 @@ + + */ +class PusatMengundiFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + $dun = Dun::factory()->create(); + + return [ + 'election_id' => $dun->election_id, + 'dun_id' => $dun->id, + 'public_uuid' => (string) Str::uuid(), + 'code' => strtoupper(fake()->unique()->bothify('PM###')), + 'name' => 'SK '.fake()->city(), + 'address' => fake()->address(), + 'registration_url' => null, + 'qr_code_path' => null, + ]; + } +} diff --git a/database/factories/SaluranMengundiFactory.php b/database/factories/SaluranMengundiFactory.php new file mode 100644 index 0000000..3c418c2 --- /dev/null +++ b/database/factories/SaluranMengundiFactory.php @@ -0,0 +1,31 @@ + + */ +class SaluranMengundiFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $pusat = PusatMengundi::factory()->create(); + + return [ + 'election_id' => $pusat->election_id, + 'pusat_mengundi_id' => $pusat->id, + 'number' => (string) fake()->numberBetween(1, 10), + 'name' => 'Saluran '.fake()->numberBetween(1, 10), + 'voter_count' => fake()->numberBetween(250, 750), + ]; + } +} diff --git a/database/factories/StaffAssignmentFactory.php b/database/factories/StaffAssignmentFactory.php new file mode 100644 index 0000000..f18a4a8 --- /dev/null +++ b/database/factories/StaffAssignmentFactory.php @@ -0,0 +1,33 @@ + + */ +class StaffAssignmentFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $pusat = PusatMengundi::factory()->create(); + + return [ + 'election_id' => $pusat->election_id, + 'position_id' => Position::factory(), + 'pusat_mengundi_id' => $pusat->id, + 'status' => 'active', + 'assigned_at' => now(), + 'source' => 'factory', + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..c4ceb07 --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,45 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..05fb5d9 --- /dev/null +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,49 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..06dc7a5 --- /dev/null +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->bigInteger('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->bigInteger('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..edac6fe --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,59 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedSmallInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->string('connection'); + $table->string('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + + $table->index(['connection', 'queue', 'failed_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/migrations/2026_05_28_022928_create_activity_log_table.php b/database/migrations/2026_05_28_022928_create_activity_log_table.php new file mode 100644 index 0000000..5c17c24 --- /dev/null +++ b/database/migrations/2026_05_28_022928_create_activity_log_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('log_name')->nullable()->index(); + $table->text('description'); + $table->nullableMorphs('subject', 'subject'); + $table->string('event')->nullable(); + $table->nullableMorphs('causer', 'causer'); + $table->json('attribute_changes')->nullable(); + $table->json('properties')->nullable(); + $table->timestamps(); + }); + } +}; diff --git a/database/migrations/2026_05_28_022928_create_permission_tables.php b/database/migrations/2026_05_28_022928_create_permission_tables.php new file mode 100644 index 0000000..8986275 --- /dev/null +++ b/database/migrations/2026_05_28_022928_create_permission_tables.php @@ -0,0 +1,137 @@ +id(); // permission id + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + + $table->unique(['name', 'guard_name']); + }); + + /** + * See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered. + */ + Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) { + $table->id(); // role id + if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing + $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); + $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); + } + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + if ($teams || config('permission.testing')) { + $table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']); + } else { + $table->unique(['name', 'guard_name']); + } + }); + + Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) { + $table->unsignedBigInteger($pivotPermission); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index'); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } else { + $table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } + }); + + Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) { + $table->unsignedBigInteger($pivotRole); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index'); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->cascadeOnDelete(); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } else { + $table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } + }); + + Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) { + $table->unsignedBigInteger($pivotPermission); + $table->unsignedBigInteger($pivotRole); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->cascadeOnDelete(); + + $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary'); + }); + + app('cache') + ->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null) + ->forget(config('permission.cache.key')); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $tableNames = config('permission.table_names'); + + throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.'); + + Schema::dropIfExists($tableNames['role_has_permissions']); + Schema::dropIfExists($tableNames['model_has_roles']); + Schema::dropIfExists($tableNames['model_has_permissions']); + Schema::dropIfExists($tableNames['roles']); + Schema::dropIfExists($tableNames['permissions']); + } +}; diff --git a/database/migrations/2026_05_28_030000_create_phase2_core_domain_tables.php b/database/migrations/2026_05_28_030000_create_phase2_core_domain_tables.php new file mode 100644 index 0000000..0719026 --- /dev/null +++ b/database/migrations/2026_05_28_030000_create_phase2_core_domain_tables.php @@ -0,0 +1,376 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('ic_number', 20)->nullable()->index(); + $table->string('phone_number', 30)->nullable(); + $table->text('address')->nullable(); + $table->string('bank_name')->nullable(); + $table->string('bank_account_number')->nullable(); + $table->timestamps(); + }); + + Schema::create('elections', function (Blueprint $table) { + $table->id(); + $table->uuid('public_uuid')->unique(); + $table->string('name'); + $table->string('code')->unique(); + $table->string('status')->default('draft')->index(); + $table->date('starts_at')->nullable(); + $table->date('polling_date')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('election_settings', function (Blueprint $table) { + $table->id(); + $table->foreignId('election_id')->unique()->constrained()->cascadeOnDelete(); + $table->date('registration_start_date'); + $table->date('registration_end_date'); + $table->date('polling_date'); + $table->boolean('is_registration_open')->default(true); + $table->boolean('is_registration_open_override')->nullable(); + $table->boolean('is_attendance_active')->default(false); + $table->timestamps(); + }); + + Schema::create('bahagian_pilihanrayas', function (Blueprint $table) { + $table->id(); + $table->foreignId('election_id')->constrained()->cascadeOnDelete(); + $table->string('code'); + $table->string('name'); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['election_id', 'code']); + }); + + Schema::create('daerah_mengundis', function (Blueprint $table) { + $table->id(); + $table->foreignId('election_id')->constrained()->cascadeOnDelete(); + $table->foreignId('bahagian_pilihanraya_id')->constrained('bahagian_pilihanrayas')->cascadeOnDelete(); + $table->string('code'); + $table->string('name'); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['election_id', 'code']); + }); + + Schema::create('pusat_mengundis', function (Blueprint $table) { + $table->id(); + $table->foreignId('election_id')->constrained()->cascadeOnDelete(); + $table->foreignId('daerah_mengundi_id')->constrained()->cascadeOnDelete(); + $table->uuid('public_uuid')->unique(); + $table->string('code'); + $table->string('name'); + $table->text('address')->nullable(); + $table->string('registration_url', 2048)->nullable(); + $table->string('qr_code_path')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['election_id', 'code']); + }); + + Schema::create('saluran_mengundis', function (Blueprint $table) { + $table->id(); + $table->foreignId('election_id')->constrained()->cascadeOnDelete(); + $table->foreignId('pusat_mengundi_id')->constrained()->cascadeOnDelete(); + $table->string('number'); + $table->string('name')->nullable(); + $table->unsignedInteger('voter_count')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['pusat_mengundi_id', 'number']); + }); + + Schema::create('positions', function (Blueprint $table) { + $table->id(); + $table->string('code')->unique(); + $table->string('name'); + $table->string('scope')->default('pusat')->index(); + $table->boolean('is_public_applyable')->default(false); + $table->boolean('is_assignable')->default(true); + $table->boolean('allows_dual_role')->default(false); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('position_quotas', function (Blueprint $table) { + $table->id(); + $table->foreignId('election_id')->constrained()->cascadeOnDelete(); + $table->foreignId('pusat_mengundi_id')->constrained()->cascadeOnDelete(); + $table->foreignId('saluran_mengundi_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('position_id')->constrained()->restrictOnDelete(); + $table->unsignedInteger('quota')->default(0); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['election_id', 'pusat_mengundi_id', 'position_id'], 'pq_election_pusat_position_idx'); + $table->index(['election_id', 'saluran_mengundi_id', 'position_id'], 'pq_election_saluran_position_idx'); + }); + + Schema::create('applications', function (Blueprint $table) { + $table->id(); + $table->foreignId('election_id')->constrained()->cascadeOnDelete(); + $table->foreignId('pusat_mengundi_id')->constrained()->cascadeOnDelete(); + $table->foreignId('selected_ktm_assignment_id')->nullable(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->uuid('public_uuid')->unique(); + $table->string('source')->default('public')->index(); + $table->string('status')->default('submitted')->index(); + $table->string('name'); + $table->string('ic_number', 20); + $table->string('phone_number', 30); + $table->string('email')->nullable(); + $table->text('address')->nullable(); + $table->foreignId('requested_position_id')->constrained('positions')->restrictOnDelete(); + $table->foreignId('approved_position_id')->nullable()->constrained('positions')->nullOnDelete(); + $table->string('bank_name')->nullable(); + $table->string('bank_account_number')->nullable(); + $table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('approved_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('approved_at')->nullable(); + $table->foreignId('rejected_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('rejected_at')->nullable(); + $table->text('rejection_reason')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['election_id', 'ic_number']); + $table->index(['election_id', 'pusat_mengundi_id', 'status']); + }); + + Schema::create('application_documents', function (Blueprint $table) { + $table->id(); + $table->foreignId('application_id')->constrained()->cascadeOnDelete(); + $table->string('document_type')->index(); + $table->string('disk')->default('local'); + $table->string('path'); + $table->string('original_name'); + $table->string('mime_type', 120); + $table->unsignedBigInteger('size'); + $table->foreignId('uploaded_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('application_status_histories', function (Blueprint $table) { + $table->id(); + $table->foreignId('application_id')->constrained()->cascadeOnDelete(); + $table->string('from_status')->nullable(); + $table->string('to_status'); + $table->foreignId('changed_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->text('note')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + + Schema::create('staff_assignments', function (Blueprint $table) { + $table->id(); + $table->foreignId('election_id')->constrained()->cascadeOnDelete(); + $table->foreignId('application_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('position_id')->constrained()->restrictOnDelete(); + $table->foreignId('pusat_mengundi_id')->constrained()->cascadeOnDelete(); + $table->foreignId('saluran_mengundi_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('reports_to_assignment_id')->nullable()->constrained('staff_assignments')->nullOnDelete(); + $table->string('status')->default('active')->index(); + $table->foreignId('assigned_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('assigned_at')->nullable(); + $table->string('source')->default('system'); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['election_id', 'pusat_mengundi_id', 'position_id'], 'sa_election_pusat_position_idx'); + $table->index(['election_id', 'saluran_mengundi_id', 'position_id'], 'sa_election_saluran_position_idx'); + }); + + Schema::table('applications', function (Blueprint $table) { + $table->foreign('selected_ktm_assignment_id')->references('id')->on('staff_assignments')->nullOnDelete(); + }); + + Schema::create('staff_assignment_histories', function (Blueprint $table) { + $table->id(); + $table->foreignId('staff_assignment_id')->constrained()->cascadeOnDelete(); + $table->foreignId('from_position_id')->nullable()->constrained('positions')->nullOnDelete(); + $table->foreignId('to_position_id')->nullable()->constrained('positions')->nullOnDelete(); + $table->foreignId('from_pusat_mengundi_id')->nullable()->constrained('pusat_mengundis')->nullOnDelete(); + $table->foreignId('to_pusat_mengundi_id')->nullable()->constrained('pusat_mengundis')->nullOnDelete(); + $table->foreignId('from_saluran_mengundi_id')->nullable()->constrained('saluran_mengundis')->nullOnDelete(); + $table->foreignId('to_saluran_mengundi_id')->nullable()->constrained('saluran_mengundis')->nullOnDelete(); + $table->foreignId('from_reports_to_assignment_id')->nullable()->constrained('staff_assignments')->nullOnDelete(); + $table->foreignId('to_reports_to_assignment_id')->nullable()->constrained('staff_assignments')->nullOnDelete(); + $table->foreignId('changed_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->text('note')->nullable(); + $table->timestamps(); + }); + + Schema::create('police_escorts', function (Blueprint $table) { + $table->id(); + $table->foreignId('election_id')->constrained()->cascadeOnDelete(); + $table->foreignId('pusat_mengundi_id')->constrained()->cascadeOnDelete(); + $table->foreignId('saluran_mengundi_id')->nullable()->constrained()->nullOnDelete(); + $table->string('name'); + $table->string('ic_number', 20)->nullable(); + $table->string('phone_number', 30)->nullable(); + $table->string('rank')->nullable(); + $table->string('station')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('kkm_representatives', function (Blueprint $table) { + $table->id(); + $table->foreignId('election_id')->constrained()->cascadeOnDelete(); + $table->foreignId('pusat_mengundi_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('ic_number', 20)->nullable(); + $table->string('phone_number', 30)->nullable(); + $table->string('agency')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('jkm_representatives', function (Blueprint $table) { + $table->id(); + $table->foreignId('election_id')->constrained()->cascadeOnDelete(); + $table->foreignId('pusat_mengundi_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('ic_number', 20)->nullable(); + $table->string('phone_number', 30)->nullable(); + $table->string('agency')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('wheelchair_allocations', function (Blueprint $table) { + $table->id(); + $table->foreignId('election_id')->constrained()->cascadeOnDelete(); + $table->foreignId('pusat_mengundi_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('allocated_quantity')->default(0); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->unique(['election_id', 'pusat_mengundi_id']); + }); + + Schema::create('wheelchair_transactions', function (Blueprint $table) { + $table->id(); + $table->foreignId('wheelchair_allocation_id')->constrained()->cascadeOnDelete(); + $table->string('transaction_type')->index(); + $table->unsignedInteger('quantity')->default(0); + $table->timestamp('taken_at')->nullable(); + $table->string('taken_by_name')->nullable(); + $table->timestamp('returned_at')->nullable(); + $table->string('return_condition')->nullable(); + $table->foreignId('recorded_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + + Schema::create('attendances', function (Blueprint $table) { + $table->id(); + $table->foreignId('election_id')->constrained()->cascadeOnDelete(); + $table->foreignId('staff_assignment_id')->constrained()->cascadeOnDelete(); + $table->foreignId('pusat_mengundi_id')->constrained()->cascadeOnDelete(); + $table->foreignId('saluran_mengundi_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('position_id')->constrained()->restrictOnDelete(); + $table->string('status')->default('not_recorded')->index(); + $table->timestamp('check_in_time')->nullable(); + $table->foreignId('recorded_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->text('note')->nullable(); + $table->timestamps(); + + $table->unique(['election_id', 'staff_assignment_id'], 'att_election_staff_unique'); + $table->index(['election_id', 'pusat_mengundi_id', 'status'], 'att_election_pusat_status_idx'); + }); + + Schema::create('bank_verifications', function (Blueprint $table) { + $table->id(); + $table->foreignId('application_id')->unique()->constrained()->cascadeOnDelete(); + $table->string('status')->default('pending')->index(); + $table->foreignId('verified_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('verified_at')->nullable(); + $table->text('finance_note')->nullable(); + $table->timestamps(); + }); + + Schema::create('export_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('election_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('generated_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('generated_at')->nullable(); + $table->string('report_type')->index(); + $table->json('filter_parameters')->nullable(); + $table->string('file_name'); + $table->string('disk')->nullable(); + $table->string('path')->nullable(); + $table->timestamps(); + + $table->index(['generated_by_user_id', 'generated_at'], 'exports_user_generated_idx'); + }); + + Schema::create('system_notes', function (Blueprint $table) { + $table->id(); + $table->foreignId('election_id')->nullable()->constrained()->nullOnDelete(); + $table->morphs('noteable'); + $table->string('note_type')->index(); + $table->text('note'); + $table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::disableForeignKeyConstraints(); + + Schema::dropIfExists('system_notes'); + Schema::dropIfExists('export_logs'); + Schema::dropIfExists('bank_verifications'); + Schema::dropIfExists('attendances'); + Schema::dropIfExists('wheelchair_transactions'); + Schema::dropIfExists('wheelchair_allocations'); + Schema::dropIfExists('jkm_representatives'); + Schema::dropIfExists('kkm_representatives'); + Schema::dropIfExists('police_escorts'); + Schema::dropIfExists('staff_assignment_histories'); + Schema::dropIfExists('staff_assignments'); + Schema::dropIfExists('application_status_histories'); + Schema::dropIfExists('application_documents'); + Schema::dropIfExists('applications'); + Schema::dropIfExists('position_quotas'); + Schema::dropIfExists('positions'); + Schema::dropIfExists('saluran_mengundis'); + Schema::dropIfExists('pusat_mengundis'); + Schema::dropIfExists('daerah_mengundis'); + Schema::dropIfExists('bahagian_pilihanrayas'); + Schema::dropIfExists('election_settings'); + Schema::dropIfExists('elections'); + Schema::dropIfExists('user_profiles'); + + Schema::enableForeignKeyConstraints(); + } +}; diff --git a/database/migrations/2026_05_31_000000_rename_calon_simpanan_position_to_calon_tambahan.php b/database/migrations/2026_05_31_000000_rename_calon_simpanan_position_to_calon_tambahan.php new file mode 100644 index 0000000..65ee44a --- /dev/null +++ b/database/migrations/2026_05_31_000000_rename_calon_simpanan_position_to_calon_tambahan.php @@ -0,0 +1,27 @@ +where('code', 'CALON_SIMPANAN') + ->update([ + 'code' => 'CALON_TAMBAHAN', + 'name' => 'Calon Tambahan', + ]); + } + + public function down(): void + { + DB::table('positions') + ->where('code', 'CALON_TAMBAHAN') + ->update([ + 'code' => 'CALON_SIMPANAN', + 'name' => 'Calon Simpanan', + ]); + } +}; diff --git a/database/migrations/2026_06_02_000000_add_performance_indexes.php b/database/migrations/2026_06_02_000000_add_performance_indexes.php new file mode 100644 index 0000000..4473f3e --- /dev/null +++ b/database/migrations/2026_06_02_000000_add_performance_indexes.php @@ -0,0 +1,43 @@ +index(['election_id', 'status'], 'app_election_status_idx'); + $table->index( + ['selected_ktm_assignment_id', 'requested_position_id'], + 'app_ktm_position_idx' + ); + }); + + // staff_assignments: (reports_to_assignment_id, position_id, status) for + // KtmVacancyService active-assignment count under a KTM + Schema::table('staff_assignments', function (Blueprint $table): void { + $table->index( + ['reports_to_assignment_id', 'position_id', 'status'], + 'sa_reports_to_pos_status_idx' + ); + }); + } + + public function down(): void + { + Schema::table('applications', function (Blueprint $table): void { + $table->dropIndex('app_election_status_idx'); + $table->dropIndex('app_ktm_position_idx'); + }); + + Schema::table('staff_assignments', function (Blueprint $table): void { + $table->dropIndex('sa_reports_to_pos_status_idx'); + }); + } +}; diff --git a/database/migrations/2026_06_02_000001_add_purged_at_to_export_logs.php b/database/migrations/2026_06_02_000001_add_purged_at_to_export_logs.php new file mode 100644 index 0000000..5f3b3a6 --- /dev/null +++ b/database/migrations/2026_06_02_000001_add_purged_at_to_export_logs.php @@ -0,0 +1,24 @@ +timestamp('purged_at')->nullable()->after('path'); + $table->foreignId('purged_by_user_id')->nullable()->constrained('users')->nullOnDelete()->after('purged_at'); + }); + } + + public function down(): void + { + Schema::table('export_logs', function (Blueprint $table): void { + $table->dropConstrainedForeignId('purged_by_user_id'); + $table->dropColumn('purged_at'); + }); + } +}; diff --git a/database/migrations/2026_06_02_000002_create_duns_table.php b/database/migrations/2026_06_02_000002_create_duns_table.php new file mode 100644 index 0000000..8c61f1c --- /dev/null +++ b/database/migrations/2026_06_02_000002_create_duns_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('election_id')->constrained()->cascadeOnDelete(); + $table->string('code', 20); + $table->string('name'); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['election_id', 'code']); + }); + } + + public function down(): void + { + Schema::dropIfExists('duns'); + } +}; diff --git a/database/migrations/2026_06_02_000003_modify_pusat_mengundis_for_dun.php b/database/migrations/2026_06_02_000003_modify_pusat_mengundis_for_dun.php new file mode 100644 index 0000000..f978754 --- /dev/null +++ b/database/migrations/2026_06_02_000003_modify_pusat_mengundis_for_dun.php @@ -0,0 +1,31 @@ +unsignedBigInteger('daerah_mengundi_id')->nullable()->change(); + + $table->foreignId('dun_id')->nullable()->after('election_id') + ->constrained('duns')->nullOnDelete(); + + $table->string('ppm_name')->nullable()->after('address'); + $table->string('ppm_phone', 30)->nullable()->after('ppm_name'); + }); + } + + public function down(): void + { + Schema::table('pusat_mengundis', function (Blueprint $table): void { + $table->dropConstrainedForeignId('dun_id'); + $table->dropColumn(['ppm_name', 'ppm_phone']); + $table->unsignedBigInteger('daerah_mengundi_id')->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/2026_06_03_020212_add_returned_by_name_to_wheelchair_transactions.php b/database/migrations/2026_06_03_020212_add_returned_by_name_to_wheelchair_transactions.php new file mode 100644 index 0000000..014577e --- /dev/null +++ b/database/migrations/2026_06_03_020212_add_returned_by_name_to_wheelchair_transactions.php @@ -0,0 +1,25 @@ +string('returned_by_name')->nullable()->after('return_condition'); + }); + } + + public function down(): void + { + Schema::table('wheelchair_transactions', function (Blueprint $table) { + $table->dropColumn('returned_by_name'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..1c4ca55 --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,38 @@ +call(RolePermissionSeeder::class); + $this->call(PositionSeeder::class); + + $admin = User::query()->firstOrCreate([ + 'email' => config('election.seed_admin.email'), + ], [ + 'name' => config('election.seed_admin.name'), + 'password' => Hash::make(config('election.seed_admin.password')), + 'email_verified_at' => now(), + ]); + + if ($admin->email_verified_at === null) { + $admin->forceFill(['email_verified_at' => now()])->save(); + } + + $admin->assignRole('Admin'); + + $this->call(SampleElectionSeeder::class); + } +} diff --git a/database/seeders/PositionSeeder.php b/database/seeders/PositionSeeder.php new file mode 100644 index 0000000..797280e --- /dev/null +++ b/database/seeders/PositionSeeder.php @@ -0,0 +1,41 @@ + 'PPM', 'name' => 'Penyelia Pusat Mengundi', 'scope' => 'pusat', 'is_public_applyable' => false, 'is_assignable' => true, 'allows_dual_role' => true, 'sort_order' => 10], + ['code' => 'KTM', 'name' => 'Ketua Tempat Mengundi', 'scope' => 'saluran', 'is_public_applyable' => true, 'is_assignable' => true, 'allows_dual_role' => true, 'sort_order' => 20], + ['code' => 'KPDP', 'name' => 'Kerani Penyemak Daftar Pemilih', 'scope' => 'saluran', 'is_public_applyable' => true, 'is_assignable' => true, 'allows_dual_role' => false, 'sort_order' => 30], + ['code' => 'KP', 'name' => 'Kerani Pengundian', 'scope' => 'saluran', 'is_public_applyable' => true, 'is_assignable' => true, 'allows_dual_role' => false, 'sort_order' => 40], + ['code' => 'PAPM', 'name' => 'Penolong Penyelia Pusat Mengundi', 'scope' => 'pusat', 'is_public_applyable' => true, 'is_assignable' => true, 'allows_dual_role' => false, 'sort_order' => 50], + ['code' => 'POLIS', 'name' => 'Polis Pengiring', 'scope' => 'external', 'is_public_applyable' => false, 'is_assignable' => false, 'allows_dual_role' => false, 'sort_order' => 60], + ['code' => 'KKM', 'name' => 'Wakil KKM', 'scope' => 'external', 'is_public_applyable' => false, 'is_assignable' => false, 'allows_dual_role' => false, 'sort_order' => 70], + ['code' => 'JKM', 'name' => 'Wakil JKM', 'scope' => 'external', 'is_public_applyable' => false, 'is_assignable' => false, 'allows_dual_role' => false, 'sort_order' => 80], + ['code' => 'CALON_SIMPANAN', 'name' => 'Calon Simpanan', 'scope' => 'saluran', 'is_public_applyable' => false, 'is_assignable' => true, 'allows_dual_role' => false, 'sort_order' => 90], + ]; + + foreach ($positions as $position) { + Position::query()->updateOrCreate( + ['code' => $position['code']], + $position, + ); + } + + Position::query() + ->where('code', 'CALON_SIMPANAN') + ->update([ + 'code' => 'CALON_TAMBAHAN', + 'name' => 'Calon Tambahan', + ]); + } +} diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php new file mode 100644 index 0000000..dec2304 --- /dev/null +++ b/database/seeders/RolePermissionSeeder.php @@ -0,0 +1,119 @@ +forgetCachedPermissions(); + + $permissions = [ + 'admin.dashboard.view', + 'users.manage', + 'roles.manage', + 'election.manage', + 'hierarchy.manage', + 'positions.manage', + 'quotas.manage', + 'qr.manage', + 'applications.view_all', + 'applications.create_manual', + 'applications.update', + 'applications.approve', + 'applications.reject', + 'assignments.manage_all', + 'representatives.manage', + 'wheelchairs.manage', + 'attendance.view_all', + 'attendance.record_all', + 'finance.dashboard.view', + 'finance.view', + 'finance.verify', + 'finance.export', + 'applications.view_finance_fields', + 'exports.generate_all', + 'audit.view', + 'system_notes.create', + 'ppm.dashboard.view', + 'applications.view_own_pusat', + 'applications.review_own_pusat', + 'applications.approve_own_pusat', + 'applications.reject_own_pusat', + 'applications.change_role_own_pusat', + 'assignments.manage_own_pusat', + 'representatives.view_own_pusat', + 'attendance.record_own_pusat', + 'attendance.view_own_pusat', + 'exports.generate_own_pusat', + 'ktm.dashboard.view', + 'ktm.team.view', + 'ktm.kp.create', + 'ktm.kp.approve', + 'ktm.kp.delete_created', + 'applications.view_own_team', + 'public_application.create', + 'public_application.view_own_status', + ]; + + $permissionModels = []; + + foreach ($permissions as $permission) { + $permissionModels[$permission] = Permission::findOrCreate($permission, 'web'); + } + + $rolePermissions = [ + 'Admin' => $permissions, + 'Admin Kewangan' => [ + 'finance.dashboard.view', + 'finance.view', + 'finance.verify', + 'finance.export', + 'applications.view_finance_fields', + ], + 'PPM' => [ + 'ppm.dashboard.view', + 'applications.view_own_pusat', + 'applications.review_own_pusat', + 'applications.approve_own_pusat', + 'applications.reject_own_pusat', + 'applications.change_role_own_pusat', + 'assignments.manage_own_pusat', + 'representatives.view_own_pusat', + 'attendance.record_own_pusat', + 'attendance.view_own_pusat', + 'exports.generate_own_pusat', + ], + 'KTM' => [ + 'ktm.dashboard.view', + 'ktm.team.view', + 'ktm.kp.create', + 'ktm.kp.approve', + 'ktm.kp.delete_created', + 'applications.view_own_team', + ], + 'Pemohon' => [ + 'public_application.create', + 'public_application.view_own_status', + ], + ]; + + foreach ($rolePermissions as $roleName => $permissionNames) { + $role = Role::findOrCreate($roleName, 'web'); + $role->syncPermissions(array_map( + fn (string $permission) => $permissionModels[$permission], + $permissionNames, + )); + } + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } +} diff --git a/database/seeders/SampleElectionSeeder.php b/database/seeders/SampleElectionSeeder.php new file mode 100644 index 0000000..9eaf338 --- /dev/null +++ b/database/seeders/SampleElectionSeeder.php @@ -0,0 +1,248 @@ +cleanupSampleData(); + + $election = Election::query()->firstOrNew(['code' => 'PRN2026']); + + if (! $election->exists) { + $election->public_uuid = (string) Str::uuid(); + } + + $election->forceFill([ + 'name' => 'Pilihanraya Negeri 2026', + 'status' => 'active', + 'starts_at' => now()->toDateString(), + 'polling_date' => now()->addWeeks(4)->toDateString(), + ])->save(); + + $election->settings()->updateOrCreate( + ['election_id' => $election->id], + [ + 'registration_start_date' => now()->toDateString(), + 'registration_end_date' => now()->addWeeks(3)->toDateString(), + 'polling_date' => $election->polling_date, + 'is_registration_open' => true, + 'is_registration_open_override' => null, + 'is_attendance_active' => false, + ], + ); + + $dun = Dun::query()->updateOrCreate( + ['election_id' => $election->id, 'code' => 'N49'], + ['name' => 'Kota Iskandar'], + ); + + $pusat = new PusatMengundi; + $pusat->public_uuid = (string) Str::uuid(); + $pusat->forceFill([ + 'election_id' => $election->id, + 'code' => 'PM001', + 'dun_id' => $dun->id, + 'name' => 'SK Seri Contoh', + 'address' => 'Jalan Contoh 1, 43000 Kajang, Selangor', + ])->save(); + + $pusat->forceFill([ + 'registration_url' => url('/pohon/'.$pusat->public_uuid), + ])->save(); + + $this->seedSaluran($election, $pusat); + $this->seedQuotas($election, $pusat); + $this->seedOperationalUsersAndAssignments($election, $pusat); + + $pusat->wheelchairAllocation()->updateOrCreate( + ['election_id' => $election->id], + [ + 'allocated_quantity' => 2, + 'notes' => 'Sample allocation for Phase 2.', + ], + ); + } + + private function cleanupSampleData(): void + { + $election = Election::query()->where('code', 'PRN2026')->first(); + + if (! $election) { + return; + } + + PositionQuota::query()->where('election_id', $election->id)->forceDelete(); + SaluranMengundi::query()->where('election_id', $election->id)->forceDelete(); + PusatMengundi::query()->where('election_id', $election->id)->forceDelete(); + } + + private function seedSaluran(Election $election, PusatMengundi $pusat): void + { + foreach (range(1, 3) as $number) { + $pusat->saluranMengundis()->updateOrCreate( + ['number' => (string) $number], + [ + 'election_id' => $election->id, + 'name' => null, + 'voter_count' => 450 + ($number * 25), + ], + ); + } + } + + private function seedQuotas(Election $election, PusatMengundi $pusat): void + { + $positions = Position::query()->whereIn('code', [ + 'PPM', + 'PAPM', + 'KTM', + 'KPDP', + 'KP', + 'POLIS', + 'CALON_TAMBAHAN', + ])->get()->keyBy('code'); + + PositionQuota::query()->updateOrCreate( + [ + 'election_id' => $election->id, + 'pusat_mengundi_id' => $pusat->id, + 'saluran_mengundi_id' => null, + 'position_id' => $positions['PPM']->id, + ], + ['quota' => 1], + ); + + PositionQuota::query()->updateOrCreate( + [ + 'election_id' => $election->id, + 'pusat_mengundi_id' => $pusat->id, + 'saluran_mengundi_id' => null, + 'position_id' => $positions['PAPM']->id, + ], + ['quota' => 3], + ); + + /** @var Collection $salurans */ + $salurans = $pusat->saluranMengundis()->get(); + + foreach ($salurans as $saluran) { + foreach ([ + 'KTM' => 1, + 'KPDP' => 1, + 'KP' => 4, + 'POLIS' => 1, + 'CALON_TAMBAHAN' => 1, + ] as $code => $quota) { + PositionQuota::query()->updateOrCreate( + [ + 'election_id' => $election->id, + 'pusat_mengundi_id' => $pusat->id, + 'saluran_mengundi_id' => $saluran->id, + 'position_id' => $positions[$code]->id, + ], + ['quota' => $quota], + ); + } + } + } + + private function seedOperationalUsersAndAssignments(Election $election, PusatMengundi $pusat): void + { + $ppm = $this->seedUser('PPM Contoh', 'ppm@prn.local', ['PPM', 'KTM']); + $ktm = $this->seedUser('KTM Contoh', 'ktm@prn.local', ['KTM']); + $this->seedUser('Admin Kewangan Contoh', 'kewangan@prn.local', ['Admin Kewangan']); + + $positions = Position::query()->whereIn('code', ['PPM', 'KTM'])->get()->keyBy('code'); + /** @var SaluranMengundi $saluranPertama */ + $saluranPertama = $pusat->saluranMengundis()->where('number', '1')->firstOrFail(); + + /** @var SaluranMengundi $saluranKedua */ + $saluranKedua = $pusat->saluranMengundis()->where('number', '2')->firstOrFail(); + + StaffAssignment::query()->updateOrCreate( + [ + 'election_id' => $election->id, + 'user_id' => $ppm->id, + 'position_id' => $positions['PPM']->id, + 'pusat_mengundi_id' => $pusat->id, + 'saluran_mengundi_id' => null, + ], + [ + 'status' => 'active', + 'assigned_at' => now(), + 'source' => 'sample_seed', + ], + ); + + StaffAssignment::query()->updateOrCreate( + [ + 'election_id' => $election->id, + 'user_id' => $ppm->id, + 'position_id' => $positions['KTM']->id, + 'pusat_mengundi_id' => $pusat->id, + 'saluran_mengundi_id' => $saluranPertama->id, + ], + [ + 'status' => 'active', + 'assigned_at' => now(), + 'source' => 'sample_seed_dual_role', + ], + ); + + StaffAssignment::query()->updateOrCreate( + [ + 'election_id' => $election->id, + 'user_id' => $ktm->id, + 'position_id' => $positions['KTM']->id, + 'pusat_mengundi_id' => $pusat->id, + 'saluran_mengundi_id' => $saluranKedua->id, + ], + [ + 'status' => 'active', + 'assigned_at' => now(), + 'source' => 'sample_seed', + ], + ); + } + + /** + * @param array $roles + */ + private function seedUser(string $name, string $email, array $roles): User + { + $user = User::query()->firstOrCreate( + ['email' => $email], + [ + 'name' => $name, + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + ], + ); + + if ($user->email_verified_at === null) { + $user->forceFill(['email_verified_at' => now()])->save(); + } + + $user->syncRoles($roles); + + return $user; + } +} diff --git a/docs/00-project-brief.md b/docs/00-project-brief.md new file mode 100644 index 0000000..7a6ad7e --- /dev/null +++ b/docs/00-project-brief.md @@ -0,0 +1,98 @@ +# Sistem Pengurusan Pusat Mengundi Pilihanraya Negeri - Project Brief + +## Purpose + +This project is a Laravel and MySQL web application for managing staffing, assignments, documentation, attendance, finance verification, exports, and operational records for Pusat Mengundi during a Pilihanraya Negeri. + +The application supports a practical Malaysian government/PBT-style workflow where Admin users configure election geography and staffing needs, PPM users review and assign staff at their own Pusat Mengundi, KTM users manage KP under their own team, Admin Kewangan verifies banking data, and public Pemohon users apply through QR links tied to a specific Pusat Mengundi. + +## Workspace Inspection + +Inspection date: 2026-05-28 + +Original findings: + +- Workspace path: `C:\Users\User\Aplikasi\prn2026` +- No existing Laravel application was present. +- No `.git` repository was initialized. +- No `AGENTS.md` project instruction file was present. +- PHP CLI was available: PHP 8.5.1. +- Composer was available: Composer 2.9.4. +- Node was available: Node 25.1.0. +- PowerShell blocks `npm.ps1`; use `npm.cmd` for npm commands. +- MySQL CLI was not available in PATH. + +Current implementation findings: + +- Laravel application has been scaffolded. +- Installed Laravel skeleton version: 13.8.0. +- Installed Laravel framework version: 13.12.0. +- Local `.env` is configured for MySQL database `prn2026`. +- MySQL connection was confirmed through Laravel/PDO during Phase 2. + +## Phase 1 Snapshot + +Update date: 2026-05-28 + +- Authentication scaffolding is available through Laravel Breeze. +- Generic public account registration route is disabled. +- Bootstrap 5 layout and role-based dashboard routes are available. +- RBAC package, roles, permissions, and default Admin seeder are available. +- `.env.example` is configured for MySQL. + +## Phase 2 Snapshot + +Update date: 2026-05-28 + +- Core election-domain migrations are implemented. +- Domain models and Eloquent relationships are implemented. +- Factory baseline is available for core hierarchy/application/assignment models. +- Seeders create roles, permissions, positions, sample election hierarchy, quotas, sample role users, sample dual-role PPM/KTM assignment, and wheelchair allocation. + +## Target Users + +- Admin +- Admin Kewangan +- PPM +- KTM +- Pemohon + +## Core Hierarchy + +The system is built around this election hierarchy: + +```text +Bahagian Pilihanraya +└── Daerah Mengundi + └── Pusat Mengundi + └── Saluran Mengundi +``` + +Each Pusat Mengundi can have one PPM, multiple PAPM, KKM representative, JKM representative, Police escort data, wheelchair allocation, and multiple Saluran Mengundi. Each Saluran Mengundi can have one KTM, one Polis Pengiring, one KPDP, four KP, and one calon simpanan. + +## Main Business Outcomes + +- Configure election event, registration period, polling date, hierarchy, positions, and quotas. +- Generate secure QR links for public application per Pusat Mengundi. +- Collect applicant details and required documents through a public-facing form. +- Enforce vacancy, role, period, and duplicate IC rules. +- Allow PPM to approve, reject, change roles, and assign staff only within their own Pusat Mengundi. +- Allow KTM to register and approve KP only under their own team. +- Allow Admin to manage all data, including post-closing changes with required notes. +- Allow Admin Kewangan to verify banking information without changing assignments. +- Track wheelchairs and polling-day attendance. +- Export operational and finance reports while logging every export. +- Audit important changes, including previous values, new values, actor, timestamp, and notes where applicable. + +## Phase 0 Scope + +Phase 0 was documentation and planning only. No Laravel project scaffolding, package installation, migrations, or feature code was created in Phase 0. + +## Phase 1 Scope + +Phase 1 delivered Laravel setup, authentication, Bootstrap base layout, role and permission setup, and base dashboards. + +## Phase 2 Scope + +Phase 2 delivered the core database, models, relationships, factories, and seeders needed for the election domain. + diff --git a/docs/01-development-plan.md b/docs/01-development-plan.md new file mode 100644 index 0000000..71c5a15 --- /dev/null +++ b/docs/01-development-plan.md @@ -0,0 +1,458 @@ +# Development Plan + +## Delivery Approach + +The system will be built in controlled phases. Each phase must update: + +- `docs/progress-log.md` +- `docs/changelog.md` +- `docs/decision-log.md` when architecture, package, database, or security decisions change + +Controllers must remain thin. Business rules should live in service classes, policies, form requests, action classes, and model relationships. + +## Phase 0: Project Inspection and Planning + +Status: Completed on 2026-05-28. + +Tasks: + +- Inspect current workspace. +- Confirm local toolchain. +- Create required documentation files. +- Record assumptions. +- Propose architecture. +- Propose database schema. +- Document RBAC, workflows, UI/UX, tests, deployment notes, and package choices. + +Exit criteria: + +- All required Phase 0 documentation files exist. +- No application code has been created. + +## Phase 1: Laravel Setup and Authentication + +Status: Completed on 2026-05-28. + +Tasks: + +- Create Laravel application using latest stable version compatible with PHP 8.5 and the available Composer environment. +- Configure MySQL connection. +- Install authentication scaffolding using Laravel Breeze Blade stack. +- Install Bootstrap 5, Bootstrap Icons, and Vite asset pipeline. +- Install Phase 1 package baseline: + - `spatie/laravel-permission` + - `spatie/laravel-activitylog` + - `maatwebsite/excel` + - compatible QR code package + - Laravel Pint if not already included + - Larastan/PHPStan if compatible + - SweetAlert2 + - Tom Select or Select2 + - Flatpickr +- Configure base responsive layout. +- Install and configure `spatie/laravel-permission`. +- Create roles and permissions seeders. +- Create role-based dashboard routes. +- Add middleware for authenticated and role-protected areas. +- Configure Laravel Pint and testing baseline. + +Expected deliverables: + +- Running Laravel app. +- Login flow. +- Optional public registration path disabled for generic account creation unless needed for Pemohon flow. +- Base dashboard per role. +- Seeded roles and permissions. +- Package choices recorded in `docs/decision-log.md`. + +Manual testing: + +- Login works. +- Seeded Admin user can access admin dashboard. +- Users cannot access dashboards for roles they do not have. +- Mobile navigation works. + +## Phase 2: Core Database and Seeders + +Status: Completed on 2026-05-28. + +Tasks: + +- Create migrations for elections, settings, hierarchy, positions, quotas, applications, documents, assignments, histories, representatives, wheelchair records, attendance, finance verification, export logs, and system notes. +- Create Eloquent models and relationships. +- Implement factories for test data. +- Seed default positions, roles, permissions, sample election, sample Bahagian, Daerah, Pusat, and Saluran. + +Expected deliverables: + +- Core schema migrated successfully. +- Seeders create a usable baseline. +- Model relationships verified with tests or tinker checks. + +Manual testing: + +- Admin can see seeded hierarchy once UI exists. +- Relationships return expected records. + +## Phase 3: Admin Setup Module + +Status: Completed on 2026-05-29. + +Tasks: + +- CRUD for Bahagian Pilihanraya, Daerah Mengundi, Pusat Mengundi, and Saluran Mengundi. +- CRUD for Jawatan and quotas. +- Assign PPM to Pusat Mengundi through the assignment model. +- Generate or refresh QR public UUID links per Pusat Mengundi. +- Add server-side pagination for setup lists. +- Provide a public QR placeholder route until the full Phase 4 applicant form is implemented. +- Keep KKM, JKM, and Police detail management for Phase 7 Admin management because those operational forms need the richer post-registration edit-note and audit flow. + +Expected deliverables: + +- Admin can prepare election structure and registration links. +- QR link format uses public UUID/ULID, not internal IDs. +- PPM assignment writes to `staff_assignments` and assigns the `PPM` RBAC role to the selected user. + +Manual testing: + +- Admin can create hierarchy from top to bottom. +- QR URL resolves to intended Pusat Mengundi. +- Internal IDs are not exposed in public URLs. +- Admin can create or update position quotas for pusat-level and saluran-level roles. +- Non-Admin users cannot access setup routes. + +## Phase 4: Public Application Module + +Status: Completed on 2026-05-29. + +Tasks: + +- Self-registers with public QR link. +- Public QR route `/pohon/{pusat_mengundi_uuid}`. +- Application form for KTM, KP, KPDP, and PAPM. +- Dynamic KTM selection for KP/KPDP based on approved KTM with vacancy. +- Secure private upload for IC document and bank statement. +- Duplicate IC validation within current election. +- Block self-registration when a KTM-created applicant exists for the same IC. +- Registration period enforcement. +- Create pending bank verification record for submitted public applications. +- Record application submission, document upload, and status history. + +Expected deliverables: + +- Public applicant form works during registration period only. +- Required documents are stored privately. +- Clear blocked/closed messages. +- QR route continues to use public UUID and never exposes internal Pusat Mengundi IDs. + +Manual testing: + +- Submit valid application. +- Try duplicate IC. +- Try missing documents. +- Try after registration closes. +- Try KP with a KTM that has remaining vacancy. +- Try KP with a KTM whose KP quota is full. + +## Phase 5: PPM Module + +Status: Completed on 2026-05-29. + +Tasks: + +- PPM dashboard scoped to assigned Pusat Mengundi. +- Application review listing and details. +- Approve/reject applications. +- Change applicant role before approval. +- Assign KTM to Saluran. +- Assign KP/KPDP to approved KTM under same Pusat Mengundi. +- View Police, KKM, and JKM information. +- Audit role and assignment changes. +- Download uploaded applicant documents through authorized PPM route. +- Record-level authorization through policy and PPM assignment scope. +- Polling-day attendance remains in Phase 10 to keep the later attendance reporting/export design together. + +Expected deliverables: + +- PPM can manage only own Pusat Mengundi. +- Approval requires IC and bank statement documents. +- Approval creates active `staff_assignments` and assignment history. + +Manual testing: + +- PPM cannot view another Pusat Mengundi. +- PPM cannot approve missing-document applicants. +- Assignment changes are audited. +- PPM can approve KTM into an available Saluran. +- PPM can change role and assign KP/KPDP to an active KTM with vacancy. + +## Phase 6: KTM Module + +Status: Completed on 2026-05-29. + +Tasks: + +- KTM dashboard for assigned Saluran. +- View KP team. +- Register KP only. +- Approve KP only under own team. +- Delete KTM-created applicant where appropriate. +- Send email notification to applicant for KTM-created record. +- Enforce registration period. +- Prevent KTM from managing non-KP roles or other KTM teams. +- Prevent KTM from deleting assigned applications. + +Expected deliverables: + +- KTM cannot register or approve non-KP roles. +- KTM cannot manage another KTM team. +- Email notification is queued or sent through Laravel mail. +- KTM-created records use `source = created_by_ktm`. + +Manual testing: + +- KTM creates KP. +- Applicant receives email in local mail log/mailpit equivalent. +- KTM cannot create KP after registration closes. +- KTM can approve only KP under own Saluran/team. +- KTM can delete own unassigned KTM-created applicant so public self-registration can proceed. + +## Phase 7: Admin Management and Manual Entry + +Status: Completed on 2026-05-29. + +Tasks: + +- Admin global list of applications and assignments. +- Manual staff entry. +- Edit approved staff records. +- Direct assignment to Pusat and Saluran. +- Police, KKM, and JKM management. +- Post-registration edit notes. +- Full audit trail for Admin changes. + +Expected deliverables: + +- Admin can correct and maintain all operational data. +- Edits after registration period require `catatan`. +- Manual Admin-created records use `source = admin_manual` and do not trigger KTM/applicant bank request email behavior. +- Direct Admin assignment uses `source = admin_direct` or `admin_update` and writes assignment history, application status history, system notes when required, and activity log entries. + +Manual testing: + +- Admin edits before and after registration closing. +- Post-closing edit without note is rejected. +- Audit record includes note. +- Admin can create Police, KKM, and JKM representative records. +- Admin Kewangan cannot access placement management routes. + +## Phase 8: Finance Module + +Status: Completed on 2026-05-29. + +Tasks: + +- Admin Kewangan dashboard. +- Bank verification listing and filters. +- Mark bank record as pending, verified, rejected, or requires correction. +- Add finance note. +- Export finance checking list. +- Prevent assignment modification by Admin Kewangan. + +Expected deliverables: + +- Finance users can verify bank data but cannot alter placement. +- Finance list includes approved/assigned staff only. +- Export creates `export_logs` row and stores XLSX file in private storage. + +Manual testing: + +- Finance status changes are audited. +- Finance user cannot access assignment edit routes. +- Filter by Pusat Mengundi, role, bank status, missing bank statement, missing account number, and keyword. +- Download finance verification export and confirm export log is created. + +## Phase 9: Wheelchair Module + +Status: Completed on 2026-05-29. + +Tasks: + +- Admin manages wheelchair allocation per Pusat Mengundi. +- Record taken quantity, taken time, person taking. +- Record return quantity, return time, condition, and notes. +- Audit wheelchair transactions. + +Expected deliverables: + +- Wheelchair movement is traceable by Pusat Mengundi and election. +- Taken quantity cannot exceed allocation available balance. +- Returned quantity cannot exceed outstanding taken quantity. +- Allocation cannot be reduced below outstanding quantity. + +Manual testing: + +- Quantity cannot exceed allocation without an explicit business exception. +- Transaction history displays clearly. +- Admin Kewangan cannot access wheelchair management routes. + +## Phase 10: Attendance Module + +Status: Completed on 2026-05-29. + +Tasks: + +- PPM attendance screen scoped to own Pusat Mengundi. +- Attendance status: present, absent, not recorded. +- Check-in time, recorded by, and note. +- Admin attendance dashboard by Pusat Mengundi. +- Attendance summary and detail exports. + +Expected deliverables: + +- PPM can record polling-day attendance. +- Admin can monitor attendance by role and Pusat. +- Attendance recording is blocked unless `election_settings.is_attendance_active` is true. +- Attendance detail export creates `export_logs` row. + +Manual testing: + +- PPM cannot record attendance outside own Pusat. +- Attendance summary totals match detail records. +- Excel export creates export log. +- Admin Kewangan cannot access Admin attendance dashboard. + +## Phase 11: Testing, Polish, and Hardening + +Status: Completed on 2026-05-29. + +Tasks: + +- Complete feature and unit tests. +- Review authorization coverage. +- Review upload and private document access. +- Review masking of sensitive data. +- Run Laravel Pint. +- Run static analysis if installed and compatible. +- Performance review for large lists and exports. +- Final documentation update. + +Expected deliverables: + +- Production-ready baseline with documented deployment steps. +- Authorized Admin and Admin Kewangan document downloads for sensitive uploaded files. +- Final quality gate run with tests, Pint, PHPStan, route list, and Vite build. + +Manual testing: + +- End-to-end rehearsal from setup to attendance export. +- Mobile checks for Admin, PPM, KTM, and public application forms. +- Verify Admin can download IC and bank documents. +- Verify Admin Kewangan can download bank statement only. + +## Phase 12: Critical Fixes and Security Hardening + +Status: Not started. + +Scope: Address issues identified during post-Phase 11 code review that could cause data inconsistency, silent authorization bypass, or require direct database edits in production. Full detail in `docs/09-improvement-plan.md`. + +Tasks: + +- 12-A: Add Admin Settings UI for toggling `is_attendance_active` and `is_registration_open_override`. +- 12-B: Fix IC duplicate check to explicitly exclude soft-deleted records. +- 12-C: Harden `AdminPostCloseNoteService` catatan enforcement so it cannot be bypassed by new controllers. +- 12-D: Audit and fix `SampleElectionSeeder` for stale position name references after Phase 11 rename migration. +- 12-E: Centralize and consistently enforce document type checks across all download paths. + +Expected deliverables: + +- Admin can toggle attendance activation and registration override from UI without touching the database. +- Soft-deleted KTM-created records do not block public re-registration. +- Post-closing catatan enforcement is consistent across all Admin management routes. +- Seeder runs cleanly on a fresh database without errors. +- All document download routes enforce role-specific type restrictions consistently. + +Exit criteria: + +- `php artisan migrate:fresh --seed` passes. +- `php artisan test` passes with new tests for each fix. +- `vendor\bin\pint.bat --test` passes. +- `vendor\bin\phpstan.bat analyse --memory-limit=1G` passes. + +Docs to update on completion: `docs/progress-log.md`, `docs/changelog.md`, `docs/decision-log.md`, `docs/04-rbac-permission-matrix.md`, `docs/05-workflow-design.md`. + +## Phase 13: Database Indexes and Performance + +Status: Not started. + +Scope: Add composite indexes before real election data is loaded. Full detail in `docs/09-improvement-plan.md`. + +Tasks: + +- 13-A: Add composite indexes on `applications`, `staff_assignments`, and `bank_verifications` for common Admin and service-layer queries. + +Expected deliverables: + +- New migration with explicit short-named indexes. +- Admin global list and vacancy service queries confirmed to use indexes. + +Exit criteria: + +- `php artisan migrate` passes. +- `php artisan test` passes. +- Key queries verified with MySQL `EXPLAIN`. + +Docs to update on completion: `docs/03-database-design.md`, `docs/08-deployment-notes.md`, `docs/decision-log.md`, `docs/progress-log.md`, `docs/changelog.md`. + +## Phase 14: Applicant Status Portal + +Status: Not started. + +Scope: Give public applicants a read-only status page accessible via their application UUID, with no login required. Full detail in `docs/09-improvement-plan.md`. + +Tasks: + +- 14-A: Add `GET /permohonan/{application:public_uuid}/status` route and controller. +- Display limited, masked applicant data: partial name, role applied, pusat, current status, rejection reason if applicable. +- Do not expose sensitive fields (full IC, bank account) or internal IDs. +- Return 200 with a generic message for unknown UUIDs rather than 404 to avoid enumeration. + +Expected deliverables: + +- Applicants can check their status without logging in. +- Sensitive data is not revealed. + +Exit criteria: + +- Feature tests for submitted, approved, assigned, rejected, and unknown UUID states. +- `php artisan test` passes. + +Docs to update on completion: `docs/05-workflow-design.md`, `docs/06-ui-ux-plan.md`, `docs/decision-log.md`, `docs/progress-log.md`, `docs/changelog.md`. + +## Phase 15: Export Retention and Sensitive Data Purge + +Status: Not started. + +Scope: Automatically purge old export files that contain sensitive bank and personal data. Full detail in `docs/09-improvement-plan.md`. + +Tasks: + +- 15-A: Add `ExportPurgeCommand` Artisan command with configurable `--days=` retention period. +- Command deletes files from private storage and marks `export_logs` rows with `purged_at`. +- Add `purged_at` column to `export_logs` via new migration. +- Register command as a scheduled daily task. + +Expected deliverables: + +- Old export files are purged automatically. +- `export_logs` reflects purge status. +- Scheduler configuration is documented in deployment notes. + +Exit criteria: + +- `php artisan exports:purge` runs without error. +- Files older than retention period are deleted; newer files are kept. +- `php artisan test` passes. + +Docs to update on completion: `docs/03-database-design.md`, `docs/08-deployment-notes.md`, `docs/decision-log.md`, `docs/progress-log.md`, `docs/changelog.md`. diff --git a/docs/02-architecture.md b/docs/02-architecture.md new file mode 100644 index 0000000..e7a2d4b --- /dev/null +++ b/docs/02-architecture.md @@ -0,0 +1,209 @@ +# Initial Architecture + +## Technology Stack + +- Backend: Laravel 13.12.0, installed through `laravel/laravel` 13.8.0. +- Database: MySQL. +- Views: Blade. +- Styling: Bootstrap 5 with Bootstrap Icons. +- JavaScript: Vite-managed assets, jQuery where it improves progressive enhancement, plus selected UI helpers. +- Queue: Laravel queue system for email and heavier export-related tasks where useful. +- Mail: Laravel Mail and Notifications with Markdown mail templates. +- Authorization: Laravel policies, gates, middleware, and `spatie/laravel-permission`. +- Auditing: `spatie/laravel-activitylog` plus domain-specific history tables where a structured audit timeline is needed. +- Export: OpenSpout for current PHP 8.5 compatibility. `maatwebsite/excel` remains the preferred package from the original requirement but was not installable in this environment during Phase 1. + +## Phase 1 Implemented Structure + +Implemented: + +- Breeze auth controllers and routes. +- Generic `/register` routes disabled. +- Bootstrap-based `layouts.app`, `layouts.guest`, and navigation. +- Role-aware dashboards: + - `/admin/dashboard` + - `/kewangan/dashboard` + - `/ppm/dashboard` + - `/ktm/dashboard` + - `/pemohon/dashboard` +- `/dashboard` role redirect. +- Spatie role middleware aliases: + - `role` + - `permission` + - `role_or_permission` +- `RolePermissionSeeder`. +- Default Admin seed account from `config/prn.php`. + +## Application Structure + +Planned directories: + +```text +app/ + Actions/ + Exports/ + Http/ + Controllers/ + Requests/ + Middleware/ + Models/ + Notifications/ + Policies/ + Services/ + Support/ +``` + +Guidelines: + +- Controllers orchestrate requests and responses only. +- Form Request classes validate input and authorize request-specific actions. +- Policies enforce model-level access. +- Services hold business rules and transactional workflows. +- Action classes may be used for high-value operations such as approving applications, changing assignments, registering KTM-created KP, and recording attendance. +- Export classes live in `app/Exports`. +- Report filters and query preparation live in services, not controllers. + +## Architectural Layers + +### Presentation Layer + +- Blade templates with Bootstrap 5. +- Responsive layouts for desktop, tablet, and mobile. +- Shared layout components for navigation, cards, filters, badges, breadcrumbs, tables, empty states, and modals. +- Mobile-friendly table alternatives for high-density data. + +### HTTP Layer + +- Named routes. +- Route groups by role and module. +- Middleware for authentication, role, registration period, and public QR validation. +- Route model binding using internal IDs for authenticated admin areas where safe. +- Public routes use UUID/ULID identifiers only. + +### Domain Layer + +Core services: + +- `RegistrationPeriodService` +- `VacancyService` +- `ApplicationService` +- `AssignmentService` +- `KtmTeamService` +- `PpmScopeService` +- `BankVerificationService` +- `AttendanceService` +- `WheelchairService` +- `ExportLoggingService` +- `SensitiveDataMaskingService` + +Potential action classes: + +- `SubmitPublicApplication` +- `CreateKtmApplicant` +- `ApproveApplication` +- `RejectApplication` +- `ChangeApplicantRole` +- `AssignStaffToSaluran` +- `RecordAttendance` +- `VerifyBankDetails` +- `RecordWheelchairTransaction` + +### Data Layer + +- Eloquent models and relationships. +- Foreign keys for domain integrity. +- Soft deletes for records that should remain auditable. +- Domain history tables for applications and assignments. +- Activity log for actor/value snapshots and operational traceability. + +## Role-Based Areas + +Planned route groups: + +```text +/dashboard +/admin/... +/ppm/... +/ktm/... +/kewangan/... +/pohon/{pusat_mengundi_uuid} +``` + +Expected access: + +- `Admin`: full operational management. +- `Admin Kewangan`: finance verification and finance exports only. +- `PPM`: assigned Pusat Mengundi only. +- `KTM`: assigned Saluran/team only. +- `Pemohon`: public form and own submission flow only if login is later required. + +## Public Identifier Strategy + +- Public QR links must use UUID or ULID stored on `pusat_mengundis.public_uuid` or equivalent. +- Internal auto-increment IDs must not appear in public application URLs. +- Uploaded documents are accessed only through authenticated authorization checks and signed/temporary download responses where needed. + +## Dual-Role Assignment Strategy + +The system must support a PPM who is also KTM. This should not be modeled as a single role column on a user or application. + +Planned approach: + +- Use `users` for login identity. +- Use `applications` or staff records for applicant/staff biographical and banking data. +- Use `staff_assignments` as the canonical placement table. +- A person can have multiple active `staff_assignments` within the same election when business rules allow it. +- `staff_assignments` references `position_id`, `pusat_mengundi_id`, optional `saluran_mengundi_id`, and optional supervisor/team references. +- Uniqueness constraints prevent invalid duplicate active assignments, while allowing explicitly permitted dual role such as PPM + KTM. + +## Audit Strategy + +Use two audit levels: + +- Structured domain histories for application status and assignment changes. +- General activity log for before/after data changes, actor, timestamp, source, and notes. + +Audit targets: + +- Application approval/rejection. +- Role changes. +- Assignment changes. +- Document upload/change. +- Bank verification. +- Admin edits after registration closes. +- Attendance updates. +- Wheelchair transactions. +- Excel exports. + +## File Storage Strategy + +- IC documents and bank statements are stored on Laravel private storage disk. +- Public symlink storage must not expose sensitive uploads. +- File validation must restrict MIME type, size, and extension. +- Document access must pass policy checks. +- File records should be stored in `application_documents` with document type, disk, path, original name, MIME type, size, uploader, and timestamps. + +## Export Strategy + +- `maatwebsite/excel` will generate XLSX exports. +- Every export inserts a row into `export_logs`. +- Export logs include generated_by, generated_at, report_type, filter parameters, and file name. +- Export services prepare queries and filters. +- Export classes only shape spreadsheet data. + +## Queue and Mail Strategy + +- KTM-created applicant email notification should use Laravel Notifications or Mailables. +- Queue should be enabled for mail in production. +- Local development can use log mailer or local mail catcher. +- Admin-keyed records do not trigger bank account email requests. + +## Security Principles + +- Use CSRF protection for all form submissions. +- Use policies and middleware for every protected module. +- Validate uploads strictly. +- Store sensitive files privately. +- Mask IC, bank account, phone, and email where full values are unnecessary. +- Avoid raw SQL unless justified and documented. +- Use transactions for approval, assignment, finance verification, and attendance updates. diff --git a/docs/03-database-design.md b/docs/03-database-design.md new file mode 100644 index 0000000..6f20752 --- /dev/null +++ b/docs/03-database-design.md @@ -0,0 +1,748 @@ +# Proposed Database Design + +## Phase 2 Implementation Status + +Status: Implemented on 2026-05-28. + +Implemented in migration: + +- `user_profiles` +- `elections` +- `election_settings` +- `bahagian_pilihanrayas` +- `daerah_mengundis` +- `pusat_mengundis` +- `saluran_mengundis` +- `positions` +- `position_quotas` +- `applications` +- `application_documents` +- `application_status_histories` +- `staff_assignments` +- `staff_assignment_histories` +- `police_escorts` +- `kkm_representatives` +- `jkm_representatives` +- `wheelchair_allocations` +- `wheelchair_transactions` +- `attendances` +- `bank_verifications` +- `export_logs` +- `system_notes` + +Implemented models and relationships are available under `app/Models`. + +Implementation notes: + +- Public-facing identifiers use UUID columns. +- MySQL index names are explicitly shortened where Laravel's generated names would exceed MySQL's identifier limit. +- Complex business constraints such as "four KP per KTM" are intentionally enforced by future services rather than only by database constraints. +- A PPM plus KTM dual-role assignment is represented through two active rows in `staff_assignments`. + +## Export Log Purge Columns (Fasa 15 — 2026-06-02) + +Added via migration `2026_06_02_000001_add_purged_at_to_export_logs.php`. + +| Column | Type | Purpose | +|---|---|---| +| `purged_at` | nullable timestamp | Set when the export file is deleted by `exports:purge` command | +| `purged_by_user_id` | nullable FK → users | Reserved for future manual purge UI attribution | + +Records with `purged_at IS NOT NULL` are skipped on subsequent purge runs. + +## Performance Indexes (Fasa 13 — 2026-06-02) + +Added via migration `2026_06_02_000000_add_performance_indexes.php`. + +### `applications` table + +| Index name | Columns | Purpose | +|---|---|---| +| `app_election_status_idx` | `(election_id, status)` | Dashboard count queries and admin list filtered by election | +| `app_ktm_position_idx` | `(selected_ktm_assignment_id, requested_position_id)` | `KtmVacancyService` reserved-application count (called per KP/KPDP vacancy check) | + +### `staff_assignments` table + +| Index name | Columns | Purpose | +|---|---|---| +| `sa_reports_to_pos_status_idx` | `(reports_to_assignment_id, position_id, status)` | `KtmVacancyService` active-assignment count under a KTM (called per vacancy check) | + +### Existing indexes (Phase 2) + +| Table | Columns | Name | +|---|---|---| +| `applications` | `(election_id, ic_number)` | auto | +| `applications` | `(election_id, pusat_mengundi_id, status)` | auto | +| `staff_assignments` | `(election_id, pusat_mengundi_id, position_id)` | `sa_election_pusat_position_idx` | +| `staff_assignments` | `(election_id, saluran_mengundi_id, position_id)` | `sa_election_saluran_position_idx` | +| `attendances` | `(election_id, pusat_mengundi_id, status)` | `att_election_pusat_status_idx` | +| `attendances` | `(election_id, staff_assignment_id)` | `att_election_staff_unique` (unique) | + +### Why `bank_verifications` was not indexed with `election_id` + +`bank_verifications` does not have a direct `election_id` column — it joins through `application_id` (unique FK). All finance queries that need election filtering use `whereHas('application', ...)` subqueries. The existing `status` index and unique `application_id` index are sufficient for the current query patterns. + +## Design Goals + +- Support one election or multiple election events over time. +- Keep public identifiers separate from internal database IDs. +- Support dual-role assignment, especially PPM also acting as KTM. +- Keep sensitive documents private and access-controlled. +- Preserve auditability through history tables and activity log. +- Support exports, finance verification, attendance, wheelchair tracking, and post-closing notes. + +## Naming Conventions + +- Table names use Laravel plural snake_case where practical. +- Existing Malay domain terms are retained for clarity. +- Public UUID/ULID columns use names such as `public_uuid`. +- Status fields use lowercase snake_case values. +- Foreign keys use `foreignId()->constrained()` where possible. + +## Core Identity Tables + +### users + +Laravel default user authentication table with additions as needed. + +Key fields: + +- `id` +- `name` +- `email` +- `password` +- `email_verified_at` +- `remember_token` +- timestamps +- soft deletes if operationally useful + +Notes: + +- Roles are assigned through `spatie/laravel-permission`. +- Not every public applicant must become a login user unless later required. + +### user_profiles + +Stores extended identity details for staff/admin users. + +Key fields: + +- `id` +- `user_id` +- `ic_number` +- `phone_number` +- `address` +- `bank_name` +- `bank_account_number` +- timestamps + +Notes: + +- Sensitive fields should be masked in views where full values are not required. +- IC uniqueness at profile level should be evaluated with election-specific application rules. + +## Election and Settings + +### elections + +Represents each election event. + +Key fields: + +- `id` +- `public_uuid` +- `name` +- `code` +- `status` +- `starts_at` +- `polling_date` +- timestamps +- soft deletes + +Suggested statuses: + +- `draft` +- `active` +- `closed` +- `archived` + +### election_settings + +Stores event-specific operational settings. + +Key fields: + +- `id` +- `election_id` +- `registration_start_date` +- `registration_end_date` +- `polling_date` +- `is_registration_open` +- `is_registration_open_override` nullable +- `is_attendance_active` +- timestamps + +Notes: + +- Registration open state is computed from dates and `is_registration_open`. +- `is_registration_open_override` can be used later if the system needs to distinguish manual override from normal date-based availability. +- Attendance can be toggled separately for rehearsal or polling day. + +## Election Hierarchy + +### bahagian_pilihanrayas + +Key fields: + +- `id` +- `election_id` +- `code` +- `name` +- timestamps +- soft deletes + +Relationships: + +- belongs to election. +- has many daerah mengundi. + +### daerah_mengundis + +Key fields: + +- `id` +- `election_id` +- `bahagian_pilihanraya_id` +- `code` +- `name` +- timestamps +- soft deletes + +Relationships: + +- belongs to election. +- belongs to bahagian pilihanraya. +- has many pusat mengundi. + +### pusat_mengundis + +Key fields: + +- `id` +- `election_id` +- `daerah_mengundi_id` +- `public_uuid` +- `code` +- `name` +- `address` +- `registration_url` +- `qr_code_path` +- timestamps +- soft deletes + +Relationships: + +- belongs to election. +- belongs to daerah mengundi. +- has many saluran mengundi. +- has many position quotas. +- has many staff assignments. +- has one wheelchair allocation. + +Notes: + +- `public_uuid` is used by `/pohon/{pusat_mengundi_uuid}`. +- `registration_url` can be generated dynamically; storing it is optional. If stored, it must be refreshed if domain changes. + +### saluran_mengundis + +Key fields: + +- `id` +- `election_id` +- `pusat_mengundi_id` +- `number` +- `name` +- `voter_count` +- timestamps +- soft deletes + +Relationships: + +- belongs to election. +- belongs to pusat mengundi. +- has many staff assignments. + +## Roles and Permissions + +Managed by `spatie/laravel-permission`: + +- `roles` +- `permissions` +- `model_has_roles` +- `model_has_permissions` +- `role_has_permissions` + +Application roles: + +- Admin +- Admin Kewangan +- PPM +- KTM +- Pemohon + +## Positions and Quotas + +### positions + +Stores operational staff positions. + +Key fields: + +- `id` +- `code` +- `name` +- `scope` +- `is_public_applyable` +- `is_assignable` +- `allows_dual_role` +- timestamps +- soft deletes + +Suggested position codes: + +- `PPM` +- `KTM` +- `KP` +- `KPDP` +- `PAPM` +- `POLIS` +- `KKM` +- `JKM` +- `CALON_SIMPANAN` + +Suggested scopes: + +- `pusat` +- `saluran` +- `external` + +### position_quotas + +Defines vacancy requirements by election, pusat, saluran, and role. + +Key fields: + +- `id` +- `election_id` +- `pusat_mengundi_id` +- `saluran_mengundi_id` nullable +- `position_id` +- `quota` +- timestamps +- soft deletes + +Notes: + +- PPM, PAPM, KKM, JKM, and wheelchair-related staffing are usually Pusat scope. +- KTM, KP, KPDP, Police escort, and calon simpanan are usually Saluran scope. +- Use nullable `saluran_mengundi_id` for Pusat-level quota. + +## Applications + +### applications + +Represents public, KTM-created, or Admin-keyed applicant/staff application record. + +Key fields: + +- `id` +- `election_id` +- `pusat_mengundi_id` +- `selected_ktm_assignment_id` nullable +- `user_id` nullable +- `public_uuid` +- `source` +- `status` +- `name` +- `ic_number` +- `phone_number` +- `email` +- `address` +- `requested_position_id` +- `approved_position_id` nullable +- `bank_name` +- `bank_account_number` +- `created_by_user_id` nullable +- `approved_by_user_id` nullable +- `approved_at` nullable +- `rejected_by_user_id` nullable +- `rejected_at` nullable +- `rejection_reason` nullable +- timestamps +- soft deletes + +Suggested source values: + +- `public` +- `created_by_ktm` +- `admin_manual` + +Suggested status values are documented in `docs/05-workflow-design.md`. + +Rules: + +- IC uniqueness is enforced per election among active/non-cancelled records. +- Public self-registration is blocked if an active `created_by_ktm` record exists for the same election and IC. +- Admin may manually enter records after registration closes with required note. +- Phase 4 public submissions create `source = public`, `status = submitted`, and a public UUID reference. +- For KP and KPDP public applications, `selected_ktm_assignment_id` stores the selected active KTM assignment if vacancy remains. + +### application_documents + +Stores private uploaded documents. + +Key fields: + +- `id` +- `application_id` +- `document_type` +- `disk` +- `path` +- `original_name` +- `mime_type` +- `size` +- `uploaded_by_user_id` nullable +- timestamps +- soft deletes + +Document types: + +- `ic_document` +- `bank_statement` +- `other` + +Rules: + +- IC and bank statement documents are required before PPM approval. +- Documents are stored on private disk. +- Phase 4 public uploads store `ic_document` and `bank_statement` under private local storage using the application public UUID path. + +### application_status_histories + +Tracks application lifecycle status changes. + +Key fields: + +- `id` +- `application_id` +- `from_status` nullable +- `to_status` +- `changed_by_user_id` nullable +- `note` nullable +- `metadata` JSON nullable +- timestamps + +## Staff Assignments + +### staff_assignments + +Canonical table for approved staff placement. + +Key fields: + +- `id` +- `election_id` +- `application_id` nullable +- `user_id` nullable +- `position_id` +- `pusat_mengundi_id` +- `saluran_mengundi_id` nullable +- `reports_to_assignment_id` nullable +- `status` +- `assigned_by_user_id` +- `assigned_at` +- `source` +- timestamps +- soft deletes + +Suggested statuses: + +- `active` +- `pending` +- `cancelled` +- `replaced` + +Notes: + +- Allows one person to hold multiple assignments, including PPM and KTM. +- `reports_to_assignment_id` can link KP/KPDP to the approved KTM assignment. +- A KTM assignment to Saluran is created by PPM or Admin, not chosen by KTM. +- Unique constraints should prevent duplicate active position assignment to the same role/scope where business rules require. + +### staff_assignment_histories + +Tracks assignment changes. + +Key fields: + +- `id` +- `staff_assignment_id` +- `from_position_id` nullable +- `to_position_id` nullable +- `from_pusat_mengundi_id` nullable +- `to_pusat_mengundi_id` nullable +- `from_saluran_mengundi_id` nullable +- `to_saluran_mengundi_id` nullable +- `from_reports_to_assignment_id` nullable +- `to_reports_to_assignment_id` nullable +- `changed_by_user_id` +- `note` nullable +- timestamps + +## Representatives and External Operational Data + +### police_escorts + +Key fields: + +- `id` +- `election_id` +- `pusat_mengundi_id` +- `saluran_mengundi_id` nullable +- `name` +- `ic_number` nullable +- `phone_number` nullable +- `rank` nullable +- `station` nullable +- `notes` nullable +- timestamps +- soft deletes + +### kkm_representatives + +Key fields: + +- `id` +- `election_id` +- `pusat_mengundi_id` +- `name` +- `ic_number` nullable +- `phone_number` nullable +- `agency` nullable +- `notes` nullable +- timestamps +- soft deletes + +### jkm_representatives + +Key fields: + +- `id` +- `election_id` +- `pusat_mengundi_id` +- `name` +- `ic_number` nullable +- `phone_number` nullable +- `agency` nullable +- `notes` nullable +- timestamps +- soft deletes + +## Wheelchair Management + +### wheelchair_allocations + +Key fields: + +- `id` +- `election_id` +- `pusat_mengundi_id` +- `allocated_quantity` +- `notes` nullable +- timestamps + +### wheelchair_transactions + +Key fields: + +- `id` +- `wheelchair_allocation_id` +- `transaction_type` +- `quantity` +- `taken_at` nullable +- `taken_by_name` nullable +- `returned_at` nullable +- `return_condition` nullable +- `recorded_by_user_id` +- `notes` nullable +- timestamps + +Suggested transaction types: + +- `taken` +- `returned` +- `adjustment` + +Return condition values: + +- `baik` +- `rosak` + +## Attendance + +### attendances + +Key fields: + +- `id` +- `election_id` +- `staff_assignment_id` +- `pusat_mengundi_id` +- `saluran_mengundi_id` nullable +- `position_id` +- `status` +- `check_in_time` nullable +- `recorded_by_user_id` +- `note` nullable +- timestamps + +Attendance statuses: + +- `present` +- `absent` +- `not_recorded` + +Notes: + +- Store denormalized pusat/saluran/position references for simpler reporting. +- A unique key should prevent multiple active attendance rows for the same election and staff assignment. + +## Finance + +### bank_verifications + +Key fields: + +- `id` +- `application_id` +- `status` +- `verified_by_user_id` nullable +- `verified_at` nullable +- `finance_note` nullable +- timestamps + +Statuses: + +- `pending` +- `verified` +- `rejected` +- `requires_correction` + +Notes: + +- Admin Kewangan can update this table only. +- Assignment and placement edits are outside Admin Kewangan scope. +- Phase 4 public submissions create an initial `pending` bank verification row. + +## Exports and Notes + +### export_logs + +Key fields: + +- `id` +- `election_id` nullable +- `generated_by_user_id` +- `generated_at` +- `report_type` +- `filter_parameters` JSON nullable +- `file_name` +- `disk` nullable +- `path` nullable +- timestamps + +Report types: + +- `full_staff_list` +- `staff_list_by_pusat` +- `staff_list_by_role` +- `missing_document_list` +- `finance_verification_list` +- `attendance_summary` +- `attendance_detail_by_pusat` + +### system_notes + +Stores structured operational notes, especially post-registration changes. + +Key fields: + +- `id` +- `election_id` +- `noteable_type` +- `noteable_id` +- `note_type` +- `note` +- `created_by_user_id` +- timestamps + +Suggested note types: + +- `post_registration_change` +- `admin_correction` +- `finance_note` +- `general` + +## Audit Log + +### activity_log + +Managed by `spatie/laravel-activitylog`. + +Required logged details: + +- actor +- subject +- event name +- previous values +- new values +- timestamp +- note or reason where available + +## Key Constraints and Indexes + +Planned indexes: + +- `elections.public_uuid` +- `pusat_mengundis.public_uuid` +- `applications.public_uuid` +- `applications.election_id, ic_number` +- `applications.election_id, pusat_mengundi_id, status` +- `staff_assignments.election_id, pusat_mengundi_id, position_id` +- `staff_assignments.election_id, saluran_mengundi_id, position_id` +- `bank_verifications.status` +- `attendances.election_id, pusat_mengundi_id, status` +- `export_logs.generated_by_user_id, generated_at` + +Planned uniqueness: + +- Unique active public UUIDs. +- One active PPM per Pusat Mengundi. +- One active KTM per Saluran Mengundi. +- One active KPDP per KTM or Saluran based on final rule implementation. +- Four active KP per KTM/team enforced by quota service, not only DB constraint. +- Application IC uniqueness per election for active public/admin records, with special handling for deleted/cancelled KTM-created records. + +## Documented Trade-Offs + +- `staff_assignments` is used instead of a single role column because the system must support dual-role assignment. +- Domain-specific history tables are retained in addition to generic activity log because workflows need reportable status/assignment timelines. +- Representative tables are separate from applications because Police, KKM, and JKM records may not follow the public applicant workflow. +- Attendance denormalizes role and location references to make election-day reporting faster and more stable. +## Kemas kini operasi Pusat Mengundi - 2026-05-31 + +- `saluran_mengundis.name` dikekalkan sebagai nullable untuk keserasian rekod lama, tetapi tidak lagi diminta pada UI. +- Semasa Pusat Mengundi baharu didaftarkan, Saluran bernombor `1..N` dijana secara automatik. +- `position_quotas` menyimpan `PPM`, `PAPM`, `JKM` dan `KKM` pada skop Pusat serta `KTM`, `POLIS`, `KP`, `KPDP` dan `CALON_TAMBAHAN` pada skop Saluran. +- Kod jawatan `CALON_SIMPANAN` dimigrasikan kepada `CALON_TAMBAHAN`. diff --git a/docs/04-rbac-permission-matrix.md b/docs/04-rbac-permission-matrix.md new file mode 100644 index 0000000..54a3878 --- /dev/null +++ b/docs/04-rbac-permission-matrix.md @@ -0,0 +1,187 @@ +# RBAC and Permission Matrix + +## Roles + +- Admin +- Admin Kewangan +- PPM +- KTM +- Pemohon + +Permissions will be implemented with `spatie/laravel-permission`. Policies will still enforce record-level ownership, such as PPM only accessing assigned Pusat Mengundi and KTM only accessing own team. + +## Permission Naming Pattern + +Suggested naming: + +```text +module.action +``` + +Examples: + +- `admin.dashboard.view` +- `election.manage` +- `application.review` +- `assignment.manage` +- `finance.verify` +- `attendance.record` +- `export.generate` + +## Matrix + +| Capability | Admin | Admin Kewangan | PPM | KTM | Pemohon | +|---|---:|---:|---:|---:|---:| +| View own dashboard | Yes | Yes | Yes | Yes | Optional | +| Manage users | Yes | No | No | No | No | +| Manage roles and permissions | Yes | No | No | No | No | +| Manage election settings (toggle attendance, override registration) | Yes (UI) | No | No | No | No | +| Manage Bahagian Pilihanraya | Yes | No | No | No | No | +| Manage Daerah Mengundi | Yes | No | No | No | No | +| Manage Pusat Mengundi | Yes | No | No | No | No | +| Manage Saluran Mengundi | Yes | No | No | No | No | +| Manage Jawatan and quotas | Yes | No | No | No | No | +| Generate QR links | Yes | No | View own only | No | No | +| Submit public application | Manual only | No | No | No | Yes | +| View all applications | Yes | Finance data only | No | No | No | +| View own Pusat applications | Yes | No | Yes | No | No | +| View own KTM team KP applications | Yes | No | Yes if same Pusat | Yes | No | +| Create manual application | Yes | No | No after final rule; PPM reviews only | KP only | Public flow only | +| Create KP applicant | Yes | No | No | Yes, own team only | No | +| Approve/reject KTM application | Yes | No | Yes | No | No | +| Approve/reject KP application | Yes | No | Yes | Yes, own team only | No | +| Approve/reject KPDP application | Yes | No | Yes | No | No | +| Approve/reject PAPM application | Yes | No | Yes | No | No | +| Change applicant role before approval | Yes | No | Yes, own Pusat only | No | No | +| Assign PPM | Yes | No | No | No | No | +| Assign KTM to Saluran | Yes | No | Yes, own Pusat only | No | No | +| Assign KP/KPDP to KTM | Yes | No | Yes, own Pusat only | No | No | +| Assign self as KTM | Admin can assign if valid | No | No direct self-assignment | No | No | +| Manage Police escort | Yes | No | View own Pusat only | View own Saluran only | No | +| Manage KKM representative | Yes | No | View own Pusat only | No | No | +| Manage JKM representative | Yes | No | View own Pusat only | No | No | +| Manage wheelchair allocation | Yes | No | View or record if delegated later | No | No | +| Record wheelchair transaction | Yes | No | Optional only if delegated | No | No | +| Record attendance | Yes | No | Yes, own Pusat only | No | No | +| View attendance dashboard | Yes | No | Own Pusat only | Own Saluran/team only if needed | No | +| Verify bank account | View and override if needed | Yes | No | No | No | +| Change assignment or placement | Yes | No | Own Pusat only | No | No | +| Export full staff list | Yes | No | No | No | No | +| Export staff list by Pusat | Yes | No | Own Pusat only if enabled | No | No | +| Export missing document list | Yes | No | Own Pusat only if enabled | No | No | +| Export finance verification list | Yes | Yes | No | No | No | +| Export attendance list | Yes | No | Own Pusat only if enabled | No | No | +| View audit trail | Yes | Limited finance-related if needed | Own Pusat events if needed | Own actions if needed | No | +| Download ic_document | Yes (all) | No | Yes (own Pusat applications only) | No | No | +| Download bank_statement | Yes (all) | Yes (all) | Yes (own Pusat applications only) | No | No | + +## Initial Permission List + +Admin: + +- `admin.dashboard.view` +- `users.manage` +- `roles.manage` +- `election.manage` +- `hierarchy.manage` +- `positions.manage` +- `quotas.manage` +- `qr.manage` +- `applications.view_all` +- `applications.create_manual` +- `applications.update` +- `applications.approve` +- `applications.reject` +- `assignments.manage_all` +- `representatives.manage` +- `wheelchairs.manage` +- `attendance.view_all` +- `attendance.record_all` +- `finance.view` +- `finance.verify` +- `exports.generate_all` +- `audit.view` +- `system_notes.create` + +Admin Kewangan: + +- `finance.dashboard.view` +- `finance.view` +- `finance.verify` +- `finance.export` +- `applications.view_finance_fields` + +PPM: + +- `ppm.dashboard.view` +- `applications.view_own_pusat` +- `applications.review_own_pusat` +- `applications.approve_own_pusat` +- `applications.reject_own_pusat` +- `applications.change_role_own_pusat` +- `assignments.manage_own_pusat` +- `representatives.view_own_pusat` +- `attendance.record_own_pusat` +- `attendance.view_own_pusat` +- `exports.generate_own_pusat` + +KTM: + +- `ktm.dashboard.view` +- `ktm.team.view` +- `ktm.kp.create` +- `ktm.kp.approve` +- `ktm.kp.delete_created` +- `applications.view_own_team` + +Pemohon: + +- `public_application.create` +- `public_application.view_own_status` if applicant portal is implemented later + +## Record-Level Authorization Rules + +### PPM Scope + +- PPM can only view and manage applications where `applications.pusat_mengundi_id` is assigned to their active PPM staff assignment. +- PPM can only assign staff to Saluran under their assigned Pusat Mengundi. +- PPM can approve only applications with required IC and bank statement documents. +- Phase 5 implements this through `ApplicationPolicy` plus active PPM `staff_assignments` scope. +- PPM document download is limited to documents attached to applications inside their assigned Pusat Mengundi. +- Phase 10 allows PPM to record attendance only for active staff assignments under their own Pusat Mengundi and only when the attendance module is active. + +### KTM Scope + +- KTM can only view KP under their own KTM assignment. +- KTM can only register KP, not KTM, KPDP, PAPM, PPM, Police, KKM, or JKM. +- KTM can approve KP only where the KP belongs to their own team. +- KTM cannot assign himself/herself to Saluran. +- Phase 6 implements this through active KTM `staff_assignments` scope. +- KTM-created records are marked `source = created_by_ktm`. +- KTM can delete only their own unassigned `created_by_ktm` applicant records. + +### Admin Kewangan Scope + +- Admin Kewangan can view approved staff list and bank-related fields. +- Admin Kewangan can update `bank_verifications`. +- Admin Kewangan cannot update placement, position, Pusat Mengundi, or Saluran Mengundi. +- Phase 7 confirms Admin Kewangan is blocked from Admin management routes such as assignment edit/listing. +- Phase 8 implements `/kewangan/bank` for bank checking, status update, finance note, filters, and finance export. +- Admin can also access finance bank routes for oversight, but assignment changes remain outside the finance module. + +### Admin Management Scope + +- Admin can view global application and assignment lists. +- Admin can create manual application records. +- Admin can edit applicant/staff details and direct assignment records. +- Admin can key in Police, KKM, and JKM representatives. +- Admin can manage wheelchair allocation and taken/returned transactions. +- Admin can view attendance dashboard, attendance details by Pusat Mengundi, and generate attendance detail exports. +- If registration is closed for the election, Admin create/edit/assign/representative changes require `catatan`. +- Admin management changes write domain history/activity logs and `system_notes` for post-closing changes. + +### Public Pemohon Scope + +- Public users can access only QR application routes. +- Public application is allowed only during registration period. +- Public users cannot list other applicants or staff. diff --git a/docs/05-workflow-design.md b/docs/05-workflow-design.md new file mode 100644 index 0000000..18ab48d --- /dev/null +++ b/docs/05-workflow-design.md @@ -0,0 +1,299 @@ +# Workflow Design + +## Application Status Lifecycle + +Final initial statuses: + +```text +draft +submitted +under_ppm_review +approved +rejected +assigned +cancelled +deleted_by_ktm +``` + +## Application Status Meaning + +| Status | Meaning | +|---|---| +| `draft` | Created but not fully submitted. Used only if partial saves are implemented. | +| `submitted` | Applicant or actor submitted the application for review. | +| `under_ppm_review` | PPM has opened or started reviewing the application. | +| `approved` | Application approved for a position but not necessarily assigned to final Saluran/team. | +| `rejected` | Application rejected with reason. | +| `assigned` | Approved applicant has active staff assignment. | +| `cancelled` | Application cancelled by Admin or workflow rule. | +| `deleted_by_ktm` | KTM-created placeholder/application removed so applicant can self-register. | + +## Public QR Application Flow + +Phase 4 implementation behavior: + +- `/pohon/{pusat_mengundi_uuid}` resolves by public UUID and shows the public application form when registration is open. +- If registration is closed, the same QR route shows a closed-registration page. +- Successful submissions redirect to `/permohonan/{application_uuid}/berjaya`. +- Uploaded IC documents and bank statements are stored on the private local disk under the application UUID path. +- Submitted applications immediately create `application_documents`, `application_status_histories`, and pending `bank_verifications` records. + +1. Applicant scans QR code for a Pusat Mengundi. +2. System resolves `/pohon/{pusat_mengundi_uuid}`. +3. System checks election and registration period. +4. If registration is closed, show clear closed-registration message. +5. Applicant selects role: KTM, KP, KPDP, or PAPM. +6. If selected role is KP or KPDP, system shows approved KTM list for that Pusat Mengundi with available vacancy. +7. Applicant fills personal, contact, bank, and address details. +8. Applicant uploads IC document and bank statement. +9. System validates IC uniqueness for current election. +10. If an active KTM-created applicant exists for the same IC, system blocks self-registration and explains that KTM must delete the pre-created record first. +11. System stores documents privately. +12. Application status becomes `submitted`. +13. Bank verification starts as `pending`. +14. PPM reviews the application in Phase 5. + +## Registration Period Rules + +During registration period: + +- Public Pemohon can register through QR. +- KTM can register KP. +- PPM can review and assign. + +After registration period: + +- Public self-registration is closed. +- KTM cannot register new KP. +- PPM cannot register new applicants. +- Only Admin can register, edit, or assign staff. +- Admin changes after closing require catatan. +- System must show clear closed-registration messages. + +Registration open calculation: + +- `registration_start_date <= today <= registration_end_date` +- plus `is_registration_open = true`. +- if `is_registration_open_override` is not null, it explicitly opens or closes registration regardless of the normal date window. + +## Applicant Status Portal + +Pemohon awam boleh semak status permohonan melalui `/permohonan/{uuid}/status` tanpa perlu log masuk. + +- UUID awam permohonan digunakan sebagai pengecam — tiada login diperlukan. +- Pautan status dipaparkan pada halaman kejayaan permohonan dan boleh disalin. +- Maklumat yang dipaparkan: nama (disamarkan 3 aksara pertama), jawatan dipohon, pusat mengundi, status, sebab penolakan (jika ditolak), nombor rujukan UUID. +- Maklumat yang TIDAK dipaparkan: IC, akaun bank, nombor telefon, ID dalaman, saluran, nama KTM. +- UUID tidak dikenali → 200 dengan mesej generik ("status tidak dapat dipaparkan") — tidak mendedahkan sama ada UUID pernah wujud. +- Rekod `deleted_by_ktm` dan rekod soft-deleted juga mengembalikan mesej generik. + +Status display mapping: + +| Status DB | Paparan | +|---|---| +| `submitted` | Menunggu Semakan | +| `under_ppm_review` | Sedang Disemak | +| `approved` | Diluluskan | +| `assigned` | Diluluskan & Ditempatkan | +| `rejected` | Ditolak (+ sebab penolakan) | +| `cancelled` | Dibatalkan | + +## Admin Election Settings + +Admin can manage the following settings per election via `/admin/setup/settings/{election}`: + +- `is_attendance_active` — Toggle attendance recording on/off. Should only be activated on polling day. PPM cannot record attendance when this is false. Toggling is recorded in activity log with before/after values. +- `is_registration_open_override` — Three states: null (Auto — follows date window), true (Force Open — opens even outside date window), false (Force Closed — closes even inside date window). Use override only in exceptional circumstances. When override is active, a warning badge is shown on the settings page. + +Changes to election settings require Admin role and are written to `activity_log` with `log_name = 'admin_settings'`, recording who changed what and when. + +Note: `is_registration_open` (the base toggle) is still set in the database during initial setup. The `is_registration_open_override` is the runtime control available via the Admin Settings UI. + +## KTM Vacancy Rule for Public KP/KPDP Selection + +For public KP or KPDP applications: + +- Candidate KTM options come from active `staff_assignments` with position `KTM`, same election, same Pusat Mengundi, and assigned Saluran Mengundi. +- Remaining vacancy is calculated from `position_quotas` for the KTM's Saluran Mengundi and requested role. +- Used vacancy counts active team assignments under that KTM plus reserved public applications in `submitted`, `under_ppm_review`, or `approved` status. +- If no vacancy remains, the KTM cannot be selected. + +## PPM Review Flow + +Phase 5 implementation behavior: + +- PPM application URLs use application public UUID. +- PPM list and detail views are scoped by active PPM `staff_assignments`. +- `ApplicationPolicy` blocks PPM access to applications outside assigned Pusat Mengundi. +- Approved applications are moved directly to `assigned` because Phase 5 approval also creates the active staff assignment. +- Private document download is available only through the authorized PPM review route. + +1. PPM opens own dashboard. +2. System lists applications only for PPM's assigned Pusat Mengundi. +3. PPM reviews applicant detail and documents. +4. PPM may change applicant role before approval. +5. System records any role change in audit trail. +6. PPM approves only if IC document and bank statement exist. +7. If approved as KTM, PPM assigns KTM to a Saluran Mengundi. +8. If approved as KP or KPDP, PPM assigns applicant to an approved KTM under the same Pusat Mengundi. +9. If approved as PAPM, system assigns at Pusat Mengundi level. +10. System creates or updates `staff_assignments`. +11. System records assignment history and activity log. + +## KTM Flow + +Phase 6 implementation behavior: + +- KTM routes are under `/ktm/applications`. +- KTM scope is based on active `staff_assignments` with position `KTM`, status `active`, and a Saluran Mengundi. +- KTM can create only KP applicant records. +- KTM-created records are stored as `source = created_by_ktm` and `status = submitted`. +- Creating a KP applicant queues a Markdown email to the applicant. +- KTM approval creates an active KP `staff_assignments` row under the KTM assignment and changes application status to `assigned`. +- KTM can delete only unassigned records they created through the KTM flow, changing status to `deleted_by_ktm` before soft delete. + +1. KTM logs in and opens own dashboard. +2. System identifies active KTM assignment and Saluran. +3. KTM views own KP list and remaining KP vacancy. +4. During registration period, KTM can register KP only. +5. KTM-created KP has `source = created_by_ktm`. +6. System sends email notification to applicant. +7. KTM can approve KP application only under own team while registration is open. +8. If applicant wants to self-register with the same IC, system blocks until KTM deletes the KTM-created record. +9. Deleting a KTM-created applicant changes status to `deleted_by_ktm`, records status history, and soft deletes the record. + +## Admin Manual Entry and Management Flow + +Phase 7 implementation behavior: + +- Admin management routes are under `/admin/management`. +- Admin can list all applications with filters for status, Pusat Mengundi, jawatan, and keyword. +- Admin can create manual application records with `source = admin_manual`. +- Manual Admin-created records create pending bank verification state but do not send KTM-created applicant email. +- Admin can edit application personal, role, bank, status, and placement-related fields. +- Admin can direct-assign an application to Pusat/Saluran/KTM team; this creates or updates `staff_assignments`, sets application status to `assigned`, and writes status/assignment history. +- Admin can edit existing `staff_assignments`; changes are mirrored back to the related application where applicable. +- Admin can key in and delete Police, KKM, and JKM representative records. +- If registration is closed, Admin changes require `catatan`; the system stores it in `system_notes`. + +1. Admin opens global application/staff list. +2. Admin can create or edit application/staff records. +3. Admin can directly assign staff to Pusat Mengundi and Saluran Mengundi. +4. Admin can key in Police, KKM, and JKM records. +5. If registration period has ended, Admin must provide `catatan` for any register/edit/assign action. +6. System saves note in `system_notes`. +7. System records activity log and assignment/application history. + +## Admin Kewangan Flow + +Phase 8 implementation behavior: + +- Finance routes are under `/kewangan/bank`. +- Finance list shows applications in `approved` or `assigned` status. +- Filters support Pusat Mengundi, role, bank verification status, missing bank statement, missing bank account number, and keyword search. +- Admin Kewangan can update only `bank_verifications.status` and `finance_note`. +- Updating verification records sets `verified_by_user_id` and `verified_at` and writes activity log. +- Admin Kewangan cannot access Admin assignment management routes. +- Finance export creates an XLSX file in private storage using OpenSpout and inserts an `export_logs` row. + +1. Admin Kewangan opens finance dashboard. +2. System lists approved staff and bank verification status. +3. Admin Kewangan filters by Pusat Mengundi, role, status, missing bank statement, or missing account number. +4. Admin Kewangan marks bank verification as pending, verified, rejected, or requires correction. +5. Admin Kewangan adds finance note. +6. System records bank verification activity log. +7. Admin Kewangan can export finance verification list. + +## Wheelchair Flow + +Phase 9 implementation behavior: + +- Wheelchair management routes are under `/admin/management/wheelchairs`. +- Admin can upsert one allocation record per election and Pusat Mengundi. +- Admin can record `taken` transactions with quantity, date/time, person taking, and notes. +- Admin can record `returned` transactions with quantity, date/time, return condition, and notes. +- System calculates total taken, total returned, outstanding quantity, and available quantity. +- Taken quantity is blocked when it exceeds available allocation. +- Returned quantity is blocked when it exceeds outstanding quantity. +- Allocation quantity is blocked when it is lower than outstanding quantity. +- Allocation changes and transactions are recorded in activity log. + +1. Admin enters allocated wheelchair quantity per Pusat Mengundi. +2. Admin records taken quantity, date/time, and person taking. +3. Admin records returned quantity, date/time, condition, and notes. +4. System stores each movement in `wheelchair_transactions`. +5. System records activity log. + +## Attendance Flow + +Phase 10 implementation behavior: + +- PPM attendance routes are under `/ppm/attendance`. +- PPM can only access Pusat Mengundi assigned through active PPM `staff_assignments`. +- PPM can record attendance only when `election_settings.is_attendance_active = true`. +- Attendance is recorded per active `staff_assignments` row and uses statuses `present`, `absent`, or `not_recorded`. +- Present records store check-in time; absent and not-recorded records clear check-in time. +- Attendance updates write activity log with actor, staff assignment, status, and check-in time. +- Admin attendance routes are under `/admin/attendance`. +- Admin first view lists Pusat Mengundi with total staff, present, absent, and not recorded. +- Admin detail view shows staff name, role, Saluran, status, check-in time, notes, and recorded by. +- Admin detail export generates XLSX with OpenSpout and writes `export_logs`. + +1. Attendance module becomes active based on election setting. +2. PPM opens attendance screen for own Pusat Mengundi. +3. System lists all assigned staff under that Pusat Mengundi. +4. PPM marks each staff as present or absent. +5. If present, check-in time is stored. +6. System stores recorded by and note. +7. Admin dashboard shows first-level list of Pusat Mengundi. +8. Admin clicks Pusat Mengundi to see staff-level attendance details. +9. Admin exports summary or detail attendance reports. + +Attendance statuses: + +```text +present +absent +not_recorded +``` + +## Bank Verification Status + +```text +pending +verified +rejected +requires_correction +``` + +Status meaning: + +- `pending`: Not yet checked by finance. +- `verified`: Bank details accepted. +- `rejected`: Bank details are invalid or unusable. +- `requires_correction`: Applicant/staff record needs correction or additional document. + +## Audit Events + +Must be audited: + +- Application submission. +- Application approval/rejection. +- Role change. +- Assignment creation/change/cancellation. +- Document upload/change. +- Bank verification update. +- Admin edit after registration closes. +- Attendance update. +- Wheelchair transaction. +- Excel export. + +Audit record should include: + +- actor +- action +- subject +- previous value +- new value +- timestamp +- note/catatan if available diff --git a/docs/06-ui-ux-plan.md b/docs/06-ui-ux-plan.md new file mode 100644 index 0000000..b6dda2b --- /dev/null +++ b/docs/06-ui-ux-plan.md @@ -0,0 +1,229 @@ +# UI/UX Plan + +## Design Direction + +The application should feel like a practical Malaysian government/PBT administration system: + +- Clean. +- Responsive. +- Data-first. +- Easy to scan. +- Conservative use of color. +- Clear status labels and actions. +- Works well on PC/laptop, tablet, and mobile phone. + +The UI should prioritize operational speed over decorative presentation. + +## Frontend Stack + +- Blade templates. +- Bootstrap 5. +- Bootstrap Icons. +- jQuery for progressive enhancement where suitable. +- Vite for compiled CSS and JavaScript. +- SweetAlert2 for confirmations. +- Select2 or Tom Select for searchable dropdowns. +- Flatpickr for date/time fields. +- DataTables only where it adds value; otherwise use Laravel server-side pagination with filters. + +## Layout + +### Authenticated Layout + +Common elements: + +- Top navigation bar. +- Collapsible sidebar or offcanvas menu on mobile. +- Role-aware navigation items. +- Breadcrumbs. +- Page title and primary action area. +- Flash messages. +- Main content area. + +### Public Application Layout + +Common elements: + +- Simple header. +- Pusat Mengundi context panel. +- Step-friendly form sections. +- Mobile-first form controls. +- Clear closed-registration and duplicate-registration messages. + +## Responsive Requirements + +Desktop: + +- Use tables with filters for large data. +- Summary cards at top of dashboard. +- Side navigation can remain visible. + +Tablet: + +- Use two-column card grids where appropriate. +- Keep filters collapsible if space is limited. + +Mobile: + +- Use offcanvas navigation. +- Convert dense tables into stacked card rows where practical. +- Keep primary actions sticky or clearly visible on long forms. +- Avoid horizontal scrolling except for exports or unavoidable wide data. + +## Dashboard Plans + +### Admin Dashboard + +Cards: + +- Total Pusat Mengundi. +- Total Saluran. +- Total applicants. +- Approved count. +- Pending review count. +- Missing IC/bank statement count. +- Bank verification pending count. +- Attendance summary on polling day. + +Primary views: + +- Election readiness summary. +- Registration period status. +- Pusat Mengundi list with setup completeness. +- Attendance summary when active. + +### PPM Dashboard + +Cards: + +- Assigned Pusat Mengundi. +- Pending applications. +- Approved staff by role. +- Saluran assignment status. +- Attendance action button on polling day. + +Primary views: + +- Applications requiring review. +- Saluran staffing completeness. +- Police/KKM/JKM information. + +### KTM Dashboard + +Cards: + +- Assigned Saluran. +- KP list count. +- Remaining KP vacancy. +- Registration period status. + +Primary actions: + +- Register KP if registration period is open. +- Review KP under own team. + +### Admin Kewangan Dashboard + +Cards: + +- Bank verification pending. +- Missing bank statement. +- Rejected bank records. +- Requires correction. + +Primary views: + +- Finance verification table. +- Filters by Pusat Mengundi, role, finance status, missing document, and missing account number. +- Export finance list. + +## UI Components + +Use reusable Blade components or partials for: + +- Status badges. +- Role badges. +- Breadcrumbs. +- Summary cards. +- Filter forms. +- Empty states. +- Confirmation modals. +- File/document rows. +- Sensitive data display with mask/reveal authorization. +- Pagination wrapper. + +## Status Display + +Application statuses should use badges: + +- `submitted`: secondary/info. +- `under_ppm_review`: warning. +- `approved`: success. +- `rejected`: danger. +- `assigned`: primary/success. +- `cancelled`: dark/secondary. +- `deleted_by_ktm`: secondary. + +Finance statuses: + +- `pending`: warning. +- `verified`: success. +- `rejected`: danger. +- `requires_correction`: info. + +Attendance statuses: + +- `present`: success. +- `absent`: danger. +- `not_recorded`: secondary. + +## Forms + +Guidelines: + +- Use server-side validation with clear inline errors. +- Use required markers for mandatory fields. +- Group long forms into sections. +- Keep upload guidance short and precise. +- Show current selected Pusat Mengundi on public QR form. +- For KP/KPDP role, dynamically show KTM dropdown only when required. +- Do not ask KTM applicants to select Saluran. + +## Tables and Lists + +Guidelines: + +- Use server-side pagination for large lists. +- Add filters before data tables. +- Use badges for status and role. +- Show masked IC/account by default. +- Keep actions in dropdown or grouped buttons. +- Use mobile stacked cards for high-use mobile screens. + +## Confirmation and Destructive Actions + +- Use SweetAlert2 or Bootstrap modal for delete/cancel/reject confirmation. +- Reject and post-closing changes must collect a reason/note. +- Destructive actions should be rare and auditable. + +## Accessibility + +- Use semantic headings. +- Use labels for all inputs. +- Maintain adequate contrast. +- Ensure keyboard focus is visible. +- Avoid relying on color alone for status. +- Provide meaningful button text or accessible labels. + +## Empty States + +Examples: + +- No applications pending review. +- No KP vacancy available. +- Registration is closed. +- No bank records require correction. +- No attendance records yet. + +Empty states should state the situation and provide the next valid action where applicable. + diff --git a/docs/07-testing-plan.md b/docs/07-testing-plan.md new file mode 100644 index 0000000..82fbcd3 --- /dev/null +++ b/docs/07-testing-plan.md @@ -0,0 +1,183 @@ +# Testing Plan + +## Testing Stack + +Initial plan: + +- Laravel built-in PHPUnit support as the baseline. +- Pest may be installed if compatible and preferred after Laravel setup. +- Factories and seeders for realistic election, hierarchy, user, application, and assignment data. +- Laravel feature tests for workflows and authorization. +- Unit tests for business-rule services. + +## Test Data Strategy + +Factories should cover: + +- Election and settings. +- Bahagian Pilihanraya. +- Daerah Mengundi. +- Pusat Mengundi. +- Saluran Mengundi. +- Users with roles. +- Positions. +- Position quotas. +- Applications with document states. +- Staff assignments. +- Bank verifications. +- Attendances. +- Wheelchair allocations and transactions. + +Seeders should create: + +- Default roles and permissions. +- Default positions. +- Sample election. +- Sample hierarchy. +- Sample Admin user for local development. + +## Feature Tests + +Required feature tests: + +- Applicant can register during registration period. Implemented in Phase 4. +- Applicant cannot register after registration period. Implemented in Phase 4. +- Applicant cannot register without IC and bank statement. Implemented in Phase 4. +- KP/KPDP can choose KTM only if vacancy exists. KP coverage implemented in Phase 4. +- KTM cannot register non-KP role. Implemented in Phase 6 by forcing KTM-created applications to KP only. +- KTM cannot register after registration period. Implemented in Phase 6. +- PPM can only view own Pusat Mengundi applications. Implemented in Phase 5. +- PPM cannot approve without required documents. Implemented in Phase 5. +- Admin can edit after closing only with catatan. Implemented in Phase 7. +- Admin Kewangan can verify bank account but cannot change assignment. Implemented in Phase 8. +- PPM can record attendance for own Pusat Mengundi only. Implemented in Phase 10. +- Excel export creates export log. Implemented in Phase 8 for finance verification export and Phase 10 for attendance detail export. + +Additional recommended feature tests: + +- Public QR link uses Pusat Mengundi UUID and does not expose internal ID. +- Duplicate IC is blocked within current election. Implemented in Phase 4. +- Self-registration is blocked when active KTM-created applicant exists for same IC. Implemented in Phase 4. +- KTM can delete KTM-created applicant to allow self-registration. Implemented in Phase 6. +- Admin Kewangan cannot access assignment update routes. +- Admin Kewangan cannot access Admin management routes. Implemented in Phase 7 for assignment listing. +- PPM role change before approval is audited. Core assignment/history path implemented in Phase 5. +- Admin post-closing assignment update creates system note and audit log. Implemented in Phase 7. +- Sensitive document download requires authorization. PPM-owned document download implemented in Phase 5. +- Sensitive document download requires authorization. Admin/Admin Kewangan document access implemented in Phase 11. +- Core domain seeders create sample election hierarchy and dual-role assignment. +- Admin can create manual application records. Implemented in Phase 7. +- Admin can create Police/KKM/JKM representative records. JKM create covered in Phase 7 tests. +- Finance filters for missing bank statement and missing account number. Implemented in Phase 8. +- Finance export downloads XLSX and creates `export_logs`. Implemented in Phase 8. +- Admin can create/update wheelchair allocation. Implemented in Phase 9. +- Wheelchair taken quantity cannot exceed available allocation. Implemented in Phase 9. +- Wheelchair return quantity cannot exceed outstanding quantity. Implemented in Phase 9. +- Wheelchair allocation cannot be reduced below outstanding quantity. Implemented in Phase 9. +- Admin Kewangan cannot access wheelchair management. Implemented in Phase 9. +- Attendance recording requires active attendance module flag. Implemented in Phase 10. +- Admin can view attendance dashboard and detail. Implemented in Phase 10. +- Admin Kewangan cannot access Admin attendance dashboard. Implemented in Phase 10. + +## Unit Tests + +Required unit tests: + +- Vacancy calculation. Implemented in Phase 4 for KTM vacancy service. +- Registration period checker. Implemented in Phase 4. +- Role assignment rules. +- Sensitive data masking. Implemented in Phase 11. +- Attendance summary calculation. Implemented in Phase 10. + +Additional recommended unit tests: + +- Public registration blocked/open state with override. +- KP/KPDP KTM selection eligibility. +- PPM plus KTM dual-role validation. +- Bank verification state transition. +- Export filter serialization. + +## Policy and Authorization Tests + +Policies should be tested for: + +- Admin access to all records. +- PPM access only to own Pusat Mengundi. +- KTM access only to own team. +- Admin Kewangan bank-only access. +- Public applicant access only to QR routes and own flow. + +## Upload Tests + +Tests should verify: + +- Required documents are enforced for public submission and PPM approval. +- Invalid MIME type is rejected. +- Oversized file is rejected. +- Uploaded IC and bank statement are stored on private disk. +- Unauthorized users cannot download sensitive documents. +- Admin can download private uploaded documents. Implemented in Phase 11. +- Admin Kewangan can download bank statement only. Implemented in Phase 11. + +## Export Tests + +Tests should verify: + +- Export classes return expected headings. +- Export services apply filters. +- Export logs are created with generated_by, generated_at, report_type, filters, and file name. +- Finance export log creation implemented in Phase 8. +- Admin Kewangan finance export does not include unauthorized placement edit capability. + +## Audit Tests + +Tests should verify audit entries for: + +- Approval/rejection. +- Role changes. +- Assignment changes. +- Document changes. +- Bank verification. +- Post-closing Admin edit. +- Attendance update. +- Attendance update implemented in Phase 10 through activity log. +- Wheelchair transaction. +- Wheelchair transaction audit path implemented in Phase 9 through activity log. +- Excel export. +- Document downloads. + +## Manual Testing Checklist + +Desktop: + +- Login and dashboard navigation. +- Admin CRUD forms. +- Application review. +- Exports. + +Tablet: + +- Navigation. +- Filters. +- Review forms. +- Attendance list. + +Mobile: + +- Public QR application form. +- KTM dashboard and KP registration. +- PPM attendance screen. +- Stacked list/card views. + +## Continuous Quality Checks + +Planned commands after implementation exists: + +```bash +php artisan test +vendor/bin/pint +vendor/bin/phpstan analyse +npm.cmd run build +``` + +Actual commands may change based on installed packages and Windows shell behavior. diff --git a/docs/08-deployment-notes.md b/docs/08-deployment-notes.md new file mode 100644 index 0000000..24c585e --- /dev/null +++ b/docs/08-deployment-notes.md @@ -0,0 +1,247 @@ +# Deployment Notes + +## Target Environment + +The application is intended for a standard Laravel production deployment using: + +- PHP compatible with selected Laravel version. +- Composer. +- MySQL. +- Web server such as Nginx or Apache. +- Node/npm only for building frontend assets, not necessarily required on production runtime if assets are built during deployment. +- Queue worker for mail and background jobs. +- Scheduler for Laravel scheduled tasks where needed. + +## Local Environment Findings + +Inspection date: 2026-05-28 + +- PHP CLI: 8.5.1. +- Composer: 2.9.4. +- Node: 25.1.0. +- npm works through `npm.cmd`; `npm` PowerShell shim is blocked by execution policy. +- MySQL CLI is not available in PATH and must be confirmed before database setup. +- `pdo_mysql` PHP extension is available. +- MySQL 8.4.8 connection was confirmed through Laravel/PDO during Phase 2. +- Local `.env` is configured for MySQL database `prn2026`. + +## Environment Configuration + +Required `.env` values: + +```text +APP_NAME="Sistem Pengurusan Pusat Mengundi" +APP_ENV=production +APP_KEY= +APP_DEBUG=false +APP_URL= +APP_TIMEZONE=Asia/Kuala_Lumpur + +DB_CONNECTION=mysql +DB_HOST= +DB_PORT=3306 +DB_DATABASE= +DB_USERNAME= +DB_PASSWORD= + +QUEUE_CONNECTION=database +MAIL_MAILER= +MAIL_HOST= +MAIL_PORT= +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_ENCRYPTION= +MAIL_FROM_ADDRESS= +MAIL_FROM_NAME= +FILESYSTEM_DISK=local + +SEED_ADMIN_NAME="Admin Sistem" +SEED_ADMIN_EMAIL=admin@prn.local +SEED_ADMIN_PASSWORD=password +``` + +Production must override the seeded Admin password before deployment or disable default seeding after the first secure Admin account is created. + +## Storage + +Sensitive documents: + +- IC document. +- Bank statement. + +Rules: + +- Store sensitive documents on private disk. +- Do not expose sensitive uploads through public storage symlink. +- Serve document downloads through authorized controller actions only. +- Use secure file validation for MIME type, extension, and size. + +## Deployment Steps + +Initial production deployment should follow this sequence: + +1. Pull or upload application source. +2. Install Composer dependencies without dev packages. +3. Install/build frontend assets. +4. Configure `.env`. +5. Generate app key if not already configured. +6. Run migrations. +7. Run seeders for roles, permissions, positions, and initial Admin user where appropriate. +8. Cache configuration, routes, and views. +9. Start queue worker. +10. Configure scheduler. +11. Validate login and critical workflows. + +Planned commands: + +```bash +composer install --no-dev --optimize-autoloader +npm.cmd ci +npm.cmd run build +php artisan key:generate +php artisan migrate --force +php artisan db:seed --force +php artisan config:cache +php artisan route:cache +php artisan view:cache +php artisan queue:restart +``` + +Commands may need adjustment for the actual hosting environment. + +## Queue Worker + +Queue should handle: + +- KTM-created applicant email notifications. +- Potentially larger exports. +- Future notification jobs. + +Recommended: + +- Use database queue initially for simplicity. +- Use Supervisor, Windows service wrapper, or hosting panel worker support to keep queue running. + +## Scheduler + +Active scheduled tasks: + +| Command | Schedule | Purpose | +|---|---|---| +| `exports:purge --days=30` | Daily at 00:00 | Purge export files older than 30 days | + +To change the retention period, edit `routes/console.php` and update `--days=30`. + +Scheduler entry (add to OS cron): + +```bash +* * * * * php /path/to/artisan schedule:run >> /dev/null 2>&1 +``` + +To run purge manually: + +```bash +php artisan exports:purge --days=30 +php artisan exports:purge --days=7 # shorter retention for testing +``` + +## Security Checklist + +- `APP_DEBUG=false` in production. +- Strong database credentials. +- HTTPS enabled. +- Private document storage. +- Correct file permissions for storage and cache. +- Document downloads must use authorized controller routes; do not expose `storage/app/private`. +- Admin Kewangan document access is limited to bank statements. +- Queue worker runs under least-privilege user. +- Audit log retention policy defined. +- Regular backups. +- Admin accounts reviewed before launch. +- Default development accounts removed or password-changed. + +## Index Verification After Migration + +After running migrations on a fresh database, verify the performance indexes exist: + +```sql +SHOW INDEX FROM applications WHERE Key_name IN ( + 'app_election_status_idx', + 'app_ktm_position_idx' +); + +SHOW INDEX FROM staff_assignments WHERE Key_name IN ( + 'sa_election_pusat_position_idx', + 'sa_election_saluran_position_idx', + 'sa_reports_to_pos_status_idx' +); +``` + +All of the above should return rows. If any are missing, re-run `php artisan migrate`. + +Use `EXPLAIN` on critical service queries to confirm index usage: + +```sql +-- KtmVacancyService: reserved application count +EXPLAIN SELECT * FROM applications + WHERE selected_ktm_assignment_id = ? AND requested_position_id = ? + AND status IN ('submitted', 'under_ppm_review', 'approved'); + +-- KtmVacancyService: active KP count under a KTM +EXPLAIN SELECT COUNT(*) FROM staff_assignments + WHERE reports_to_assignment_id = ? AND position_id = ? AND status = 'active'; +``` + +Both should show `key` as the respective composite index, not NULL. + +## Final Quality Gate + +Run before production release: + +```bash +php artisan route:list --except-vendor +php artisan test +vendor/bin/pint --test +vendor/bin/phpstan analyse --memory-limit=1G +npm.cmd run build +``` + +The Windows local environment uses `.bat` wrappers for vendor binaries: + +```bash +vendor\bin\pint.bat --test +vendor\bin\phpstan.bat analyse --memory-limit=1G +``` + +## Backup Checklist + +Back up: + +- MySQL database. +- Private storage documents. +- Export files if retained. +- `.env` securely through infrastructure secret management, not public repository. + +Recommended frequency: + +- Daily database backups during active registration and polling period. +- More frequent backup before and on polling day. +- Storage backup aligned with database backups. + +## Performance Notes + +- Use server-side pagination for large lists. +- Add indexes for election, Pusat Mengundi, status, role, and IC lookup. +- Avoid loading documents or large relationship graphs on index pages. +- Use queued exports if datasets become large. +- Cache role/permission checks through Spatie package defaults. +- Keep attendance, finance, Admin management, and setup index pages paginated. +- For very large datasets, move XLSX exports to queued jobs and notify users when complete. + +## Release Notes Process + +Every deployment should update: + +- `docs/changelog.md` for user-facing changes. +- `docs/progress-log.md` for implementation phase completion. +- `docs/decision-log.md` for architecture, database, security, and package decisions. diff --git a/docs/09-improvement-plan.md b/docs/09-improvement-plan.md new file mode 100644 index 0000000..e4c0e82 --- /dev/null +++ b/docs/09-improvement-plan.md @@ -0,0 +1,323 @@ +# Improvement Plan + +Dokumen ini mencatat pelan pembetulan dan penambahbaikan sistem PRN2026 selepas semakan kod menyeluruh selepas Phase 11 selesai. Setiap fasa mengikut susunan keutamaan: kritikal → prestasi → pengalaman pengguna → pengurusan data. + +Setiap pembetulan dan perubahan wajib kemaskini: + +- `docs/progress-log.md` +- `docs/changelog.md` +- `docs/decision-log.md` jika ada keputusan seni bina, pakej, pangkalan data atau keselamatan yang berubah + +--- + +## Fasa 12: Pembetulan Kritikal dan Keselamatan + +Status: Belum dimulakan. + +Skop fasa ini ialah menangani kelemahan yang boleh menyebabkan data tidak konsisten, kawalan akses bocor, atau keperluan edit database secara manual dalam persekitaran produksi. + +### 12-A: Admin Settings UI — Toggle Kehadiran dan Override Pendaftaran + +Masalah semasa: + +- `election_settings.is_attendance_active` hanya boleh diubah melalui edit database secara terus. +- `election_settings.is_registration_open_override` juga tiada UI khusus; bergantung pada pengaturan tarikh semata-mata. +- Ini tidak selamat dan tidak praktikal dalam persekitaran produksi semasa hari mengundi. + +Tugas: + +- Tambah halaman Admin Settings di bawah `/admin/settings` atau `/admin/setup/settings`. +- Paparkan toggle untuk `is_attendance_active` dengan label jelas dan amaran impak. +- Paparkan toggle atau dropdown untuk `is_registration_open_override` (null / buka paksa / tutup paksa). +- Paparkan tarikh buka dan tarikh tutup pendaftaran semasa sebagai konteks. +- Tambah `AdminSettingsController` dan `UpdateElectionSettingsRequest`. +- Tambah `ElectionSettingsService` untuk enkapsulasi logik kemaskini. +- Rekod perubahan dalam activity log: siapa, nilai lama, nilai baru, masa. +- Tambah ujian ciri untuk kemaskini setiap toggle, halaman diakses Admin sahaja. +- Tambah pautan ke Admin Settings dari Admin dashboard. + +Kriteria keluar: + +- Admin boleh toggle kehadiran dan override pendaftaran dari UI tanpa sentuh database. +- Perubahan direkod dalam activity log. +- Admin Kewangan, PPM, KTM tidak boleh akses halaman settings. + +Dokumentasi yang perlu dikemaskini: + +- `docs/01-development-plan.md` — tambah Fasa 12 dengan semua tugas dan kriteria keluar. +- `docs/04-rbac-permission-matrix.md` — tambah permission `manage election settings` untuk Admin. +- `docs/05-workflow-design.md` — tambah bahagian pengurusan tetapan pilihan raya. +- `docs/progress-log.md` — rekod selepas siap. +- `docs/changelog.md` — rekod selepas siap. +- `docs/decision-log.md` — rekod keputusan reka bentuk halaman settings. + +--- + +### 12-B: Pembetulan Semakan IC Duplikasi untuk Rekod Soft-Deleted + +Masalah semasa: + +- Semakan IC duplikasi dalam `StorePublicApplicationRequest` menapis rekod aktif sahaja, tetapi soft-deleted KTM-created records masih wujud dalam pangkalan data. +- Jika KTM delete rekod pemohon (`deleted_by_ktm`), rekod tersebut soft-deleted tetapi IC dalam `applications` masih boleh menyebabkan konflik bergantung kepada cara query dikodkan. +- Risiko: pemohon awam mungkin diblock padahal KTM sudah delete rekod mereka. + +Tugas: + +- Semak semula query semakan IC duplikasi dalam `StorePublicApplicationRequest` dan mana-mana service yang menjalankan semakan serupa. +- Pastikan rekod `deleted_at IS NOT NULL` (soft-deleted) tidak diambil kira sebagai "aktif" dalam semakan duplikasi. +- Pastikan `withTrashed()` atau scoped query digunakan secara eksplisit dan konsisten. +- Tambah kes ujian: KTM delete rekod → pemohon awam dengan IC sama boleh mohon semula. +- Semak `KtmVacancyService` dan `AssignmentVacancyService` juga untuk memastikan tiada pengiraan yang termasuk soft-deleted rows secara tidak sengaja. + +Kriteria keluar: + +- Ujian baru lulus: soft-deleted KTM record tidak menghalang permohonan awam baharu. +- Semua semakan IC dalam system menggunakan kaedah yang sama dan konsisten. + +Dokumentasi yang perlu dikemaskini: + +- `docs/05-workflow-design.md` — perjelas bahawa soft-deleted records dikecualikan dari semakan IC aktif. +- `docs/progress-log.md` dan `docs/changelog.md` — rekod selepas siap. + +--- + +### 12-C: Perkukuh Enforcement Nota Catatan Post-Pendaftaran + +Masalah semasa: + +- `AdminPostCloseNoteService` menguatkuasakan keperluan `catatan` pada peringkat service layer. +- Jika ada controller baru yang lupa panggil service ini, semakan tidak berlaku dan Admin boleh edit rekod tanpa nota selepas pendaftaran ditutup tanpa sebarang amaran. +- Enforcement bergantung sepenuhnya pada konvensyen pemanggilan, bukan pada mekanisme framework. + +Tugas: + +- Semak semua controller Admin yang boleh mengubah `Application`, `StaffAssignment`, `PoliceEscort`, `KkmRepresentative`, atau `JkmRepresentative` selepas pendaftaran ditutup. +- Tambah senarai semak di `AdminPostCloseNoteService` yang boleh dipanggil secara konsisten. +- Pertimbangkan untuk tambah `middleware` khusus pada route group Admin management yang secara automatik menjalankan semakan ini, dan reject request tanpa `catatan` apabila pendaftaran ditutup. +- Jika middleware digunakan, dokumentasikan dalam `docs/decision-log.md`. +- Tambah ujian integrasi untuk setiap route Admin management yang sepatutnya enforce semakan ini. + +Kriteria keluar: + +- Semua route management Admin yang mengubah rekod domain enforce catatan secara konsisten apabila pendaftaran ditutup. +- Ujian baru mengesahkan enforcement pada setiap route yang berkenaan. +- Tiada jalan pintas yang boleh bypass semakan ini tanpa sengaja. + +Dokumentasi yang perlu dikemaskini: + +- `docs/05-workflow-design.md` — dokumentasikan mekanisme enforcement yang dipilih. +- `docs/decision-log.md` — rekod keputusan sama ada guna middleware atau service pattern. +- `docs/progress-log.md` dan `docs/changelog.md` — rekod selepas siap. + +--- + +### 12-D: Pembetulan SampleElectionSeeder dan Konsistensi Nama Jawatan + +Masalah semasa: + +- Selepas migrasi Phase 11 menukar `CALON_SIMPANAN` kepada `CALON_TAMBAHAN`, ada kemungkinan `SampleElectionSeeder.php` masih mengandungi rujukan kepada nama atau slug lama. +- Jika seeder dijalankan semula (fresh seed untuk UAT atau persekitaran baharu), data yang dijana mungkin tidak konsisten dengan `PositionSeeder`. + +Tugas: + +- Buka dan audit `SampleElectionSeeder.php` secara menyeluruh. +- Cari semua rujukan kepada `CALON_SIMPANAN` atau string berkaitan dan gantikan dengan `CALON_TAMBAHAN`. +- Pastikan semua slug jawatan dalam seeder sepadan tepat dengan apa yang `PositionSeeder` jana. +- Jalankan `php artisan migrate:fresh --seed` dan pastikan tiada ralat. +- Jalankan `php artisan test` dan pastikan semua 80 ujian lulus. + +Kriteria keluar: + +- `php artisan migrate:fresh --seed` selesai tanpa ralat. +- `php artisan test` lulus sepenuhnya. +- Tiada rujukan kepada `CALON_SIMPANAN` dalam mana-mana seeder. + +Dokumentasi yang perlu dikemaskini: + +- `docs/progress-log.md` dan `docs/changelog.md` — rekod selepas siap. + +--- + +### 12-E: Konsistensikan Semakan Jenis Dokumen pada Semua Path Download + +Masalah semasa: + +- Admin Kewangan route download secara eksplisit mengehad kepada `bank_statement` sahaja. +- Route download PPM, Admin, dan Admin Kewangan masing-masing menggunakan implementasi yang berbeza. +- Jika ada ketidakkonsistenan dalam cara jenis dokumen disahkan, pengguna yang dibenarkan mungkin boleh download jenis dokumen yang sepatutnya terhad untuk mereka. + +Tugas: + +- Audit semua route download dokumen: PPM, Admin, dan Admin Kewangan. +- Cipta satu senarai jenis dokumen yang dibenarkan per peranan (`document_type` allowlist). +- Refactor semakan jenis dokumen ke dalam satu tempat — boleh dalam `DocumentDownloadController` atau policy `ApplicationPolicy`. +- Tambah ujian untuk setiap peranan mengesahkan mereka: + - Boleh download dokumen yang dibenarkan. + - Tidak boleh download dokumen di luar skop mereka walaupun ID dokumen diketahui. +- Pastikan 403 dikembalikan, bukan 404, untuk download yang diblock (supaya mesej ralat tidak mendedahkan kewujudan dokumen). + +Kriteria keluar: + +- Semua path download dokumen menggunakan semakan jenis yang konsisten. +- Ujian baru mengesahkan boundary setiap peranan. +- Tiada path yang boleh bypass semakan jenis dokumen. + +Dokumentasi yang perlu dikemaskini: + +- `docs/04-rbac-permission-matrix.md` — tambah baris untuk akses dokumen per peranan. +- `docs/decision-log.md` — rekod keputusan centralize semakan jenis dokumen. +- `docs/progress-log.md` dan `docs/changelog.md` — rekod selepas siap. + +--- + +## Fasa 13: Indeks Pangkalan Data dan Prestasi + +Status: Belum dimulakan. + +Skop fasa ini ialah menambah composite indexes yang diperlukan sebelum sistem mula menerima data sebenar dalam kuantiti besar. + +### 13-A: Tambah Composite Indexes untuk Query Kerap + +Masalah semasa: + +- Query senarai Admin global (`/admin/management/applications`) menyaring mengikut `election_id`, `status`, dan `pusat_mengundi_id` tanpa composite index. +- Query `AssignmentVacancyService` dan `KtmVacancyService` menyaring `staff_assignments` mengikut `election_id`, `pusat_mengundi_id`, `position_id`, dan `is_active` tanpa composite index. +- Untuk pilihan raya dengan 5,000–20,000 permohonan, query ini boleh menjadi lambat secara signifikan. + +Tugas: + +- Cipta migration baharu untuk menambah indexes (jangan ubah migration sedia ada). +- Tambah pada jadual `applications`: + - `(election_id, status)` + - `(election_id, pusat_mengundi_id, status)` + - `(election_id, ic_number)` — untuk semakan IC duplikasi +- Tambah pada jadual `staff_assignments`: + - `(election_id, pusat_mengundi_id, position_id, is_active)` + - `(election_id, saluran_mengundi_id, position_id, is_active)` + - `(reports_to_assignment_id, is_active)` — untuk KtmVacancyService +- Tambah pada jadual `bank_verifications`: + - `(election_id, status)` +- Gunakan nama index yang pendek dan eksplisit (ikut konvensyen sedia ada dalam sistem). +- Jalankan `php artisan migrate` dan sahkan indexes wujud. +- Jalankan `php artisan test` dan pastikan semua ujian lulus. + +Kriteria keluar: + +- Migration berjaya tanpa ralat. +- Semua ujian lulus. +- Query utama pada senarai admin menggunakan indexes (boleh disahkan dengan `EXPLAIN` dalam MySQL Workbench). + +Dokumentasi yang perlu dikemaskini: + +- `docs/03-database-design.md` — tambah bahagian indexes dan justifikasi. +- `docs/decision-log.md` — rekod keputusan indexes yang ditambah dan sebab. +- `docs/08-deployment-notes.md` — tambah arahan semak indexes semasa deployment. +- `docs/progress-log.md` dan `docs/changelog.md` — rekod selepas siap. + +--- + +## Fasa 14: Portal Status Permohonan Pemohon Awam + +Status: Belum dimulakan. + +Skop fasa ini ialah memberi pemohon awam cara untuk semak status permohonan mereka tanpa perlu login atau hubungi Admin. + +### 14-A: Halaman Status Permohonan via UUID Awam + +Masalah semasa: + +- Pemohon awam hanya menerima redirect ke `/permohonan/{uuid}/berjaya` selepas mohon. +- Tiada cara untuk semak status kemudian (diluluskan? ditolak? kenapa ditolak?). +- Ini menyebabkan beban sokongan kepada Admin dan PPM. + +Tugas: + +- Tambah route `GET /permohonan/{application:public_uuid}/status`. +- Halaman ini tidak memerlukan login — cukup UUID awam permohonan sebagai pengecam. +- Paparkan maklumat terhad: nama pemohon (sebahagian), jawatan dipohon, pusat mengundi, status semasa, tarikh kemaskini terakhir. +- Jika status `rejected`, paparkan sebab penolakan jika ada. +- Jika status `assigned`, paparkan pengesahan penempatan tanpa mendedahkan butiran dalaman (saluran ID, dsb.). +- Jangan paparkan nombor IC penuh, nombor akaun bank, atau data sensitif lain. +- Gunakan `SensitiveData` helper untuk mask nama sebahagian jika perlu. +- Tambah ujian untuk: permohonan berstatus submitted, approved, assigned, rejected, dan UUID tidak wujud. + +Kriteria keluar: + +- Pemohon boleh semak status dengan hanya URL yang mengandungi UUID awam. +- Data sensitif tidak didedahkan. +- Halaman tidak mendedahkan sama ada UUID wujud atau tidak kepada orang lain (status 200 dengan mesej umum untuk UUID tidak dikenali, bukan 404). + +Dokumentasi yang perlu dikemaskini: + +- `docs/05-workflow-design.md` — tambah bahagian portal status pemohon. +- `docs/06-ui-ux-plan.md` — tambah reka bentuk halaman status. +- `docs/progress-log.md` dan `docs/changelog.md` — rekod selepas siap. +- `docs/decision-log.md` — rekod keputusan reka bentuk privacy (status 200 vs 404 untuk UUID tidak dikenali). + +--- + +## Fasa 15: Pengurusan Eksport dan Data Sensitif + +Status: Belum dimulakan. + +Skop fasa ini ialah memastikan fail eksport yang mengandungi data sensitif tidak kekal dalam storage tanpa had masa. + +### 15-A: Purge Automatik Fail Eksport Lama + +Masalah semasa: + +- Fail XLSX eksport kewangan (disimpan di `exports/finance`) dan eksport kehadiran (disimpan di `exports/attendance`) disimpan dalam private storage tanpa had masa. +- Fail-fail ini mengandungi data bank, nombor IC, dan maklumat peribadi lain. +- Tiada mekanisme untuk padam fail lama secara automatik. + +Tugas: + +- Tambah `ExportPurgeCommand` (`php artisan exports:purge`) sebagai Artisan command. +- Command menerima parameter `--days=` (default: 30) untuk menentukan tempoh simpanan. +- Command akan: + - Query `export_logs` untuk rekod lebih lama daripada tempoh yang ditetapkan. + - Padam fail dari storage disk yang berkaitan. + - Kemaskini `export_logs` dengan `purged_at` dan `purged_by` (system). + - Log aktiviti purge. +- Daftarkan command dalam `bootstrap/app.php` atau `routes/console.php` sebagai scheduled task (contoh: harian pada tengah malam). +- Tambah kolum `purged_at` pada jadual `export_logs` melalui migration baharu. +- Tambah ujian untuk: command berjaya padam fail lama, fail baru tidak terpadam, `export_logs` dikemaskini. + +Kriteria keluar: + +- Command boleh dijalankan secara manual dan secara scheduled. +- Fail lebih lama dari tempoh yang ditetapkan dipadamkan dari storage. +- `export_logs` menunjukkan rekod purge. +- Fail yang masih dalam tempoh simpanan tidak terpadam. + +Dokumentasi yang perlu dikemaskini: + +- `docs/03-database-design.md` — tambah kolum `purged_at` dalam jadual `export_logs`. +- `docs/08-deployment-notes.md` — tambah arahan konfigurasi scheduler dan polisi simpanan eksport. +- `docs/decision-log.md` — rekod keputusan tempoh simpanan default dan strategi purge. +- `docs/progress-log.md` dan `docs/changelog.md` — rekod selepas siap. + +--- + +## Ringkasan Keutamaan + +| Fasa | Tajuk | Keutamaan | Status | +|------|-------|-----------|--------| +| 12-A | Admin Settings UI (toggle kehadiran/pendaftaran) | Kritikal | **Selesai 2026-06-02** | +| 12-B | Pembetulan semakan IC soft-deleted | Kritikal | **Selesai 2026-06-02** | +| 12-C | Perkukuh enforcement nota catatan | Kritikal | **Selesai 2026-06-02** | +| 12-D | Pembetulan SampleElectionSeeder | Kritikal | **Selesai 2026-06-02** | +| 12-E | Konsistensikan semakan jenis dokumen download | Kritikal | **Selesai 2026-06-02** | +| 13-A | Tambah composite indexes pangkalan data | Prestasi | **Selesai 2026-06-02** | +| 14-A | Portal status permohonan pemohon awam | UX | **Selesai 2026-06-02** | +| 15-A | Purge automatik fail eksport sensitif | Data | **Selesai 2026-06-02** | + +--- + +## Nota Pelaksanaan + +- Setiap item dalam Fasa 12 boleh dilaksanakan secara bebas dan tidak perlu tunggu item lain selesai. +- Fasa 13 patut dilaksanakan sebelum data sebenar mula dimasukkan ke dalam sistem. +- Fasa 14 dan 15 boleh dilaksanakan secara selari kerana tidak ada kebergantungan antara satu sama lain. +- Setiap pembetulan mesti menjalankan semula suite ujian penuh (`php artisan test`) sebelum dianggap selesai. +- Jalankan `vendor\bin\pint.bat --test` dan `vendor\bin\phpstan.bat analyse --memory-limit=1G` selepas setiap fasa. diff --git a/docs/N49-Kota Iskandar - senarai pusat mengundi.xlsx b/docs/N49-Kota Iskandar - senarai pusat mengundi.xlsx new file mode 100644 index 0000000..71383ab Binary files /dev/null and b/docs/N49-Kota Iskandar - senarai pusat mengundi.xlsx differ diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..dd21257 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,395 @@ +# Changelog + +All notable user-facing changes to this project will be documented in this file. + +## 2026-05-28 - Phase 0 Planning Records + +Added: + +- Initial project planning documentation. +- Proposed architecture. +- Proposed database design. +- RBAC matrix. +- Workflow design. +- UI/UX plan. +- Testing plan. +- Deployment notes. +- Decision and progress logs. + +Notes: + +- No application features have been implemented yet. +- No user-facing web interface exists yet. + +## 2026-05-28 - Phase 0 Documentation Review + +Changed: + +- Clarified registration-open setting in database and workflow planning. +- Clarified Phase 1 package baseline. +- Recorded local-only Telescope and later-only DOMPDF decisions. + +## 2026-05-28 - Phase 1 Laravel Base Application + +Added: + +- Laravel 13 application base. +- Login, logout, password reset, email verification, and profile routes from Breeze. +- Bootstrap 5 authenticated and guest layouts. +- Role-based dashboards for Admin, Admin Kewangan, PPM, KTM, and Pemohon. +- RBAC roles and permissions seeder. +- Default local Admin seed account. +- Spatie permission and activity log foundations. +- QR code package foundation. +- Spreadsheet export foundation using OpenSpout. + +Changed: + +- Disabled generic public account registration. +- Replaced generated Tailwind views with Bootstrap views. + +Notes: + +- `maatwebsite/excel` was not installed because Composer rejected it for the current PHP 8.5.1/Laravel 13 environment. + +## 2026-05-28 - Phase 2 Core Database + +Added: + +- Core election-domain database schema. +- Domain models and relationships. +- Default position seeder. +- Sample election hierarchy seed data. +- Position quota seed data. +- Sample role users for Admin Kewangan, PPM, and KTM. +- Sample dual-role PPM plus KTM assignment. +- Sample wheelchair allocation. +- Core domain seeder test. + +Changed: + +- Confirmed local MySQL usage for migrations and seeders. +- Shortened long database index names for MySQL compatibility. + +## 2026-05-29 - Phase 3 Admin Setup Module + +Added: + +- Admin setup overview page. +- CRUD screens for Bahagian Pilihanraya, Daerah Mengundi, Pusat Mengundi, Saluran Mengundi, and Jawatan. +- Position quota setup for pusat-level and saluran-level vacancies. +- PPM assignment from Pusat Mengundi detail screen. +- QR generation and refresh for each Pusat Mengundi. +- Public QR placeholder page at `/pohon/{pusat_mengundi_uuid}`. +- Admin dashboard link to Election setup. +- Feature tests for Admin setup access, hierarchy creation, QR generation, quota setup, PPM assignment, and public UUID QR route. + +Changed: + +- QR links now resolve using public UUID values instead of internal database IDs. +- Public QR pages use a public layout without authenticated navigation. + +Notes: + +- The full public applicant form remains scheduled for Phase 4. +- KKM, JKM, and Police operational management remains scheduled for the later Admin management phase. + +## 2026-05-29 - Phase 4 Public Application Module + +Added: + +- Public application form at `/pohon/{pusat_mengundi_uuid}`. +- Application roles KTM, KP, KPDP, and PAPM. +- Dynamic KTM selection for KP/KPDP based on remaining vacancy. +- Required IC document and bank statement uploads. +- Private document storage for uploaded public application files. +- Registration period open/closed enforcement. +- Duplicate IC blocking for current election. +- Specific blocking message for active KTM-created records with the same IC. +- Application success page with public UUID reference. +- Pending bank verification creation for submitted applications. +- Feature and unit tests for public application rules, registration period, and vacancy calculation. + +Changed: + +- QR links now open the real public application flow instead of the Phase 3 placeholder. + +Notes: + +- PPM review and approval of submitted applications remains scheduled for Phase 5. + +## 2026-05-29 - Phase 5 PPM Review and Assignment + +Added: + +- PPM application list scoped to assigned Pusat Mengundi. +- PPM application detail page with applicant, bank, document, selected KTM, agency representative, and status history sections. +- PPM approval flow with role change before approval. +- KTM assignment to available Saluran Mengundi. +- KP/KPDP assignment to active KTM with remaining vacancy. +- PAPM assignment at Pusat Mengundi level. +- PPM rejection flow with required reason. +- Private document download for PPM-owned applications. +- Record-level authorization policy for PPM application access. +- PPM dashboard metrics and link to review queue. +- Feature tests for PPM scoping, missing-document block, approval/assignment, role change, and rejection. + +Changed: + +- Application approval in PPM flow now creates active staff assignment and moves the application to `assigned`. +- Base controller now includes Laravel authorization support for policies. + +Notes: + +- Polling-day attendance remains scheduled for Phase 10. + +## 2026-05-29 - Phase 6 KTM Flow + +Added: + +- KTM team page showing assigned Saluran, KP team members, and KP vacancy. +- KTM KP registration form. +- KTM-created KP records with `source = created_by_ktm`. +- Queued Markdown email notification for KTM-created applicants. +- KTM approval flow for KP applications under own team. +- KTM deletion flow for own unassigned KTM-created applicant records. +- Registration-period enforcement for KTM registration and approval. +- KTM dashboard metrics and links to team/KP registration pages. +- Feature tests for KTM team scope, KP-only registration, email notification, registration closing, approval, non-KP block, other-team block, and deletion. + +Changed: + +- KTM dashboard now shows assigned Saluran, active KP count, and KP vacancy from live data. + +Notes: + +- KTM cannot upload documents for applicants in this phase; document collection remains with public/admin flows. + +## 2026-05-29 - Phase 7 Admin Management and Manual Entry + +Added: + +- Admin management dashboard at `/admin/management`. +- Global Admin application list with filters. +- Manual application entry by Admin. +- Admin application edit screen with direct assignment panel. +- Global staff assignment list and edit screen. +- Police, KKM, and JKM representative entry screen. +- Required post-registration `catatan` validation for Admin changes after registration closes. +- System note creation for post-closing Admin changes. +- Activity and history logging for Admin application and assignment changes. +- Feature tests for manual entry, post-closing notes, direct assignment, representative creation, and Admin Kewangan route blocking. + +Changed: + +- Admin dashboard now shows live counts for Pusat Mengundi, Saluran, applications, and pending review. +- Admin dashboard links to the new management module. + +Notes: + +- Admin manual records do not send applicant email requests. + +## 2026-05-29 - Phase 8 Admin Kewangan + +Added: + +- Admin Kewangan bank verification module at `/kewangan/bank`. +- Finance list for approved/assigned staff. +- Filters for Pusat Mengundi, role, verification status, missing bank statement, missing account number, and keyword. +- Bank verification detail page. +- Status update for pending, verified, rejected, and requires correction. +- Finance note capture. +- Finance XLSX export with export log recording. +- Sensitive data masking for IC, phone, and bank account display. +- Feature tests for finance list, verification update, filters, export logs, and assignment access blocking. + +Changed: + +- Admin Kewangan dashboard now shows live finance counts and links to the finance module. + +Notes: + +- Finance export uses OpenSpout due to the documented `maatwebsite/excel` compatibility exception. + +## 2026-05-29 - Phase 9 Wheelchair Management + +Added: + +- Admin wheelchair management module at `/admin/management/wheelchairs`. +- Wheelchair allocation setup per Pusat Mengundi. +- Taken transaction recording with quantity, time, person taking, and notes. +- Returned transaction recording with quantity, time, condition, and notes. +- Summary counts for allocated, taken, returned, outstanding, and available quantities. +- Transaction history table. +- Audit logging for allocation changes and wheelchair transactions. +- Feature tests for allocation, taken/returned transactions, quantity validation, and role access blocking. + +Changed: + +- Admin management dashboard and navigation now include Kerusi Roda. + +Notes: + +- Transaction rows are retained as movement history; current balance is calculated from transaction totals. + +## 2026-05-29 - Phase 10 Attendance + +Added: + +- PPM attendance module at `/ppm/attendance`. +- PPM-scoped Pusat Mengundi attendance list. +- Bulk attendance recording for active staff assignments. +- Attendance statuses: present, absent, and not recorded. +- Check-in time, recorded by, and note fields. +- Attendance activation guard using `election_settings.is_attendance_active`. +- Admin attendance dashboard at `/admin/attendance`. +- Admin attendance detail view by Pusat Mengundi. +- Role-level attendance summary. +- Attendance detail XLSX export with export log recording. +- Feature and unit tests for PPM scoping, activation flag, Admin dashboard, export log, and summary calculation. + +Changed: + +- Admin dashboard now links to attendance. +- PPM dashboard now links to attendance and shows present count. + +Notes: + +- Attendance export uses OpenSpout due to the documented `maatwebsite/excel` compatibility exception. + +## 2026-05-29 - Phase 11 Polish and Hardening + +Added: + +- Authorized Admin document download route for private application documents. +- Authorized Admin Kewangan bank statement download route. +- Activity logging for Admin and finance document downloads. +- Document access tests for Admin, Admin Kewangan, and unauthorized roles. +- Sensitive data masking unit test. +- Final deployment quality gate notes. + +Changed: + +- Admin application edit screen now shows uploaded documents with secure download links. +- Admin Kewangan bank detail screen now links to bank statement download when available. + +Notes: + +- Admin Kewangan cannot download IC documents through the finance document route. +## 2026-05-31 + +### Changed + +- Borang daftar Pusat Mengundi kini menerima bilangan Saluran, KTM, Polis Iring, KP, PPM, KPDP, PAPM, JKM, KKM dan Calon Tambahan. +- Saluran dijana secara automatik mengikut nombor dan tidak lagi memerlukan nama. +- Istilah Calon Simpanan telah ditukar kepada Calon Tambahan. + +## 2026-06-02 - Fasa 15-A: Purge Automatik Fail Eksport Sensitif + +### Added + +- Artisan command `exports:purge --days=30` — memadam fail XLSX eksport lama dari storage dan menandakan rekod `export_logs` dengan `purged_at`. +- Scheduled task harian pada `00:00` dalam `routes/console.php` untuk jalankan purge secara automatik (retention default: 30 hari). +- Kolum `purged_at` dan `purged_by_user_id` pada jadual `export_logs`. +- Activity log `export_file_purged` untuk setiap fail yang dipadamkan. +- 7 ujian baharu dalam `ExportPurgeCommandTest`. + +### Changed + +- `ExportLog` model kini mempunyai cast `purged_at` dan relationship `purgedBy()`. + +## 2026-06-02 - Fasa 14-A: Portal Status Permohonan Pemohon Awam + +### Added + +- Halaman status permohonan di `/permohonan/{uuid}/status` — boleh dicapai tanpa log masuk menggunakan UUID awam sahaja. +- Memaparkan: nama disamarkan (3 aksara pertama), jawatan, pusat mengundi, status dengan badge berwarna, sebab penolakan jika ditolak. +- UUID tidak dikenali, rekod soft-deleted, dan rekod `deleted_by_ktm` mengembalikan 200 dengan mesej generik — tidak mendedahkan maklumat dalaman. +- `SensitiveData::maskName()` — helper masking nama (3 aksara pertama + `***`). +- Panel "Simpan pautan status" pada halaman kejayaan permohonan dengan butang salin URL. +- 9 ujian baharu dalam `PublicApplicationStatusTest`. + +## 2026-06-02 - Fasa 12-E: Konsistensikan Semakan Jenis Dokumen Download + +### Added + +- `ApplicationDocument` model kini mempunyai type constants (`TYPE_IC_DOCUMENT`, `TYPE_BANK_STATEMENT`) dan per-role allowlists (`ALLOWED_FOR_ADMIN`, `ALLOWED_FOR_PPM`, `ALLOWED_FOR_FINANCE`) sebagai satu sumber kebenaran. +- Activity log untuk PPM document downloads: `document_downloaded_by_ppm`. +- 9 ujian baharu dalam `DocumentDownloadAuthorizationTest` meliputi semua tiga path dan kes sempadan. + +### Fixed + +- PPM path kini mempunyai semakan jenis dokumen eksplisit menggunakan `ALLOWED_FOR_PPM`. +- PPM path kini mengembalikan 403 (bukan 404) untuk ownership mismatch — tidak mendedahkan kewujudan ID dokumen. +- Admin path kini menggunakan `ALLOWED_FOR_ADMIN` secara eksplisit — jenis dokumen baharu perlu didaftarkan untuk Admin dapat akses. +- Finance path kini menggunakan `ALLOWED_FOR_FINANCE` constant (logik sama, kini konsisten). + +## 2026-06-02 - Fasa 13-A: Composite Indexes Pangkalan Data + +### Added + +- Migration `2026_06_02_000000_add_performance_indexes.php` menambah 3 index baharu: + - `applications.(election_id, status)` — dashboard count dan senarai admin bertapis election + - `applications.(selected_ktm_assignment_id, requested_position_id)` — `KtmVacancyService` reserved-application count (dipanggil setiap semak kekosongan KP/KPDP) + - `staff_assignments.(reports_to_assignment_id, position_id, status)` — `KtmVacancyService` active-assignment count di bawah KTM +- Dokumentasi lengkap semua index dalam `docs/03-database-design.md`. +- Arahan verifikasi index (`SHOW INDEX`, `EXPLAIN`) dalam `docs/08-deployment-notes.md`. + +### Notes + +- Dua index yang diancangkan dalam plan sudah wujud dalam migration Phase 2: `(election_id, ic_number)` dan `(election_id, pusat_mengundi_id, status)` pada `applications`. +- `bank_verifications` tiada kolum `election_id` — index berkaitan tidak ditambah. + +## 2026-06-02 - Fasa 12-C: Perkukuh Enforcement Nota Catatan Post-Pendaftaran + +### Added + +- Middleware `RequireAdminPostCloseNote` — automatically enforces that a `note` field is present in all write operations (`POST`, `PATCH`, `PUT`, `DELETE`) on Admin management routes when election registration is closed. Middleware alias: `admin.post_close_note`. +- 5 ujian baharu meliputi semua mutation routes yang sebelum ini tidak mempunyai catatan enforcement tests: application create, assignment update, representative create, representative delete, dan ujian bahawa middleware tidak menyekat apabila pendaftaran terbuka. + +### Changed + +- Route group `admin/management` kini menggunakan middleware `['auth', 'role:Admin', 'admin.post_close_note']` — catatan dikuatkuasakan secara automatik pada semua write routes tanpa bergantung pada konvensyen pemanggilan service sahaja. + +## 2026-06-02 - Fasa 12-B: Pembetulan Semakan IC Soft-Deleted + +### Fixed + +- Audit dan pengesahan bahawa semua query semakan IC duplikasi (`StorePublicApplicationRequest`, `KtmApplicationService`) menggunakan perlindungan dua lapis yang betul: SoftDeletes scope (mengecualikan `deleted_at IS NOT NULL`) dan `whereNotIn` (mengecualikan `cancelled` dan `deleted_by_ktm`). +- Tambah comment dalam kod untuk mendokumentasikan intent perlindungan ini secara eksplisit. + +### Added (Tests) + +- Ujian baharu `test_public_registration_is_unblocked_after_ktm_deletes_applicant`: mengesahkan pemohon awam boleh mendaftar semula selepas KTM delete rekod yang dicipta. +- Ujian baharu `test_cancelled_application_does_not_block_new_registration`: mengesahkan rekod `cancelled` tidak menyekat pendaftaran IC yang sama. + +## 2026-06-02 - Fasa 12-A: Admin Settings UI + +### Added + +- Halaman Admin Settings di `/admin/setup/settings/{election}` untuk mengawal mod kehadiran dan override tempoh pendaftaran tanpa edit database. +- Toggle radio untuk `is_attendance_active`: Tidak aktif / Aktif. +- Toggle radio tiga-keadaan untuk `is_registration_open_override`: Auto (ikut tarikh) / Buka Paksa / Tutup Paksa. +- Pautan "Tetapan" dalam nav setup dan setup index actions. +- Amaran badge apabila override sedang aktif. +- Activity log untuk setiap perubahan tetapan (nilai sebelum dan selepas, siapa yang ubah). +- 9 ujian baharu untuk akses, kemaskini, dan activity log. + +### Fixed + +- `SampleElectionSeeder` kini query kod jawatan `CALON_TAMBAHAN` (bukan `CALON_SIMPANAN` lama) — `php artisan migrate:fresh --seed` kini berjalan tanpa ralat. +- `AdminSetupTest` dikemaskini untuk menghantar medan operasi yang diperlukan semasa cipta Pusat, dan menggunakan nombor saluran '3' untuk elak konflik dengan saluran auto-jana. + +## 2026-06-02 - Perancangan Fasa 12–15 + +### Added + +- Dokumen pelan penambahbaikan `docs/09-improvement-plan.md` merangkumi lapan isu yang dikenal pasti daripada semakan kod menyeluruh selepas Phase 11. +- Fasa 12–15 ditambah dalam `docs/01-development-plan.md` sebagai fasa penambahbaikan berprioritikan. + +### Planned (belum dilaksanakan) + +- Fasa 12: Pembetulan kritikal — Admin Settings UI untuk toggle kehadiran dan pendaftaran, pembetulan semakan IC soft-deleted, perkukuhan enforcement nota catatan, pembetulan seeder, dan konsistensi semakan jenis dokumen download. +- Fasa 13: Composite indexes pangkalan data untuk prestasi query berskala besar. +- Fasa 14: Portal status permohonan pemohon awam yang boleh dicapai via UUID tanpa login. +- Fasa 15: Purge automatik fail eksport sensitif dengan polisi simpanan boleh dikonfigurasi. diff --git a/docs/decision-log.md b/docs/decision-log.md new file mode 100644 index 0000000..434c480 --- /dev/null +++ b/docs/decision-log.md @@ -0,0 +1,1198 @@ +# Decision Log + +This log records architecture decisions, package choices, database design trade-offs, security decisions, and assumptions. + +## 2026-05-28 - Phase 0 Workspace Baseline + +Decision: + +- Treat current workspace as a clean/new project because no Laravel files or git repository are present. + +Reason: + +- Inspection found an empty `C:\Users\User\Aplikasi\prn2026` workspace, no `.git`, and no project files. + +Impact: + +- Phase 1 should create a fresh Laravel application. +- No existing code removal is required. + +## 2026-05-28 - Laravel Version Selection Approach + +Decision: + +- Use the latest stable Laravel version supported by Composer and PHP during Phase 1. + +Reason: + +- Local PHP CLI is 8.5.1 and Composer is 2.9.4, which should support modern Laravel versions. +- Actual package compatibility should be verified at install time. + +Impact: + +- Do not lock a specific Laravel major version in Phase 0. +- Record final installed Laravel version in this log during Phase 1. + +## 2026-05-28 - Authentication Scaffolding Choice + +Decision: + +- Plan to use Laravel Breeze with Blade stack for authentication scaffolding. + +Reason: + +- It is simple, maintainable, first-party, and suitable for government-style internal systems. +- Blade aligns with the requested stack. + +Impact: + +- Phase 1 should install Breeze after Laravel scaffolding. +- Public Pemohon QR flow should be custom and should not rely on open public account registration unless later required. + +## 2026-05-28 - Frontend Stack Choice + +Decision: + +- Use Bootstrap 5, Bootstrap Icons, Blade, Vite, and jQuery where suitable. + +Reason: + +- Bootstrap 5 provides responsive admin UI quickly and maintainably. +- Bootstrap Icons covers common UI icon needs. +- jQuery remains useful for simple progressive enhancements with plugins such as Select2/DataTables. + +Impact: + +- Avoid heavy SPA architecture. +- Keep Blade as the primary rendering model. + +## 2026-05-28 - RBAC Package Choice + +Decision: + +- Use `spatie/laravel-permission`. + +Reason: + +- Mature Laravel package for roles and permissions. +- Supports middleware, Blade directives, and permission caching. + +Impact: + +- Role and permission seeders are required. +- Record-level rules still require Laravel policies and services. + +## 2026-05-28 - Audit Package Choice + +Decision: + +- Use `spatie/laravel-activitylog` plus domain-specific history tables. + +Reason: + +- Activity log is useful for generic before/after auditing. +- Domain history tables are better for reporting application status and assignment changes. + +Impact: + +- Implement structured histories for application and assignment workflows. +- Use activity log for broad audit trail requirements. + +## 2026-05-28 - Excel Package Choice + +Decision: + +- Use `maatwebsite/excel`. + +Reason: + +- It is the common Laravel package for XLSX exports/imports and supports export classes. + +Impact: + +- Exports will live in `app/Exports`. +- Export query/filter logic will live in services. +- Every export must create an `export_logs` row. + +## 2026-05-28 - QR Code Package Choice + +Decision: + +- Plan to use a Laravel-compatible QR code package, with `simplesoftwareio/simple-qrcode` as the first candidate if compatible. + +Reason: + +- The system requires QR codes for Pusat Mengundi public registration links. + +Impact: + +- Confirm package compatibility during Phase 1 before installation. +- If incompatible, choose another maintained Laravel-compatible QR generator and document the change. + +## 2026-05-28 - UI Helper Package Choices + +Decision: + +- Plan to use SweetAlert2, Select2 or Tom Select, Flatpickr, and either DataTables or Laravel server-side pagination depending on screen needs. + +Reason: + +- SweetAlert2 improves confirmations. +- Searchable select controls are useful for Pusat, Saluran, KTM, and role assignment forms. +- Flatpickr improves date/time input consistency. +- Server-side pagination is safer for large operational data; DataTables may be used selectively. + +Impact: + +- Final npm package list must be verified in Phase 1. +- Prefer server-rendered pages and pagination for maintainability. + +## 2026-05-28 - Optional Debug and PDF Packages + +Decision: + +- Do not install Laravel Telescope in the initial Phase 1 production baseline. +- Do not install DOMPDF in Phase 1. + +Reason: + +- Telescope is useful for local development but should not be part of the production default unless explicitly configured and protected. +- DOMPDF is only needed later if printable PDF reports become a confirmed requirement. + +Impact: + +- Reconsider Telescope only for local development after core modules exist. +- Reconsider DOMPDF only when a concrete PDF report format is provided. + +## 2026-05-28 - Static Analysis and Formatting + +Decision: + +- Use Laravel Pint. +- Use Larastan/PHPStan if compatible with the selected Laravel and PHP versions. + +Reason: + +- Pint provides consistent code style. +- Static analysis improves reliability, but compatibility with PHP 8.5 and current Laravel must be checked. + +Impact: + +- Phase 1 or Phase 11 should configure quality commands. + +## 2026-05-28 - Testing Framework + +Decision: + +- Use Laravel's built-in PHPUnit baseline. +- Pest may be installed if compatible and beneficial after Laravel setup. + +Reason: + +- PHPUnit is always available in standard Laravel projects. +- Pest improves readability but should not block delivery if compatibility issues arise. + +Impact: + +- Tests should be written in a way that can be implemented with PHPUnit even if Pest is skipped. + +## 2026-05-28 - Dual-Role Assignment Model + +Decision: + +- Model operational roles through `staff_assignments`, not a single role field on users or applications. + +Reason: + +- A PPM may also be KTM. +- Staff placement is tied to election, Pusat Mengundi, Saluran Mengundi, position, and team structure. + +Impact: + +- A person can have more than one active assignment when business rules allow it. +- Validation services must enforce invalid duplicates while allowing PPM + KTM. + +## 2026-05-28 - Public Identifier Security + +Decision: + +- Use UUID or ULID public identifiers for public QR links. + +Reason: + +- Public URLs must not expose internal auto-increment IDs. + +Impact: + +- Pusat Mengundi requires a public UUID/ULID column. +- Public route binding should resolve by public identifier. + +## 2026-05-28 - Sensitive Document Storage + +Decision: + +- Store IC documents and bank statements on private Laravel storage. + +Reason: + +- These are sensitive documents and must not be publicly accessible. + +Impact: + +- No public storage symlink for sensitive uploads. +- Downloads must go through authorized controller actions. + +## 2026-05-28 - Registration Period Assumption + +Assumption: + +- Registration period is event-specific and stored in `election_settings`. +- `election_settings.is_registration_open` is stored and used together with the configured registration dates. + +Reason: + +- The system may need to support future elections with different dates. +- The original requirement explicitly asks for a setting indicating whether registration is open. + +Impact: + +- Registration checks must use the active election setting. +- Admin settings UI must make both the date window and open/closed state clear. + +## 2026-05-28 - Generic Public Account Registration + +Assumption: + +- Generic public user registration should not be enabled by default. + +Reason: + +- Requirement says registration is only for public applicant flow if allowed. +- QR applicant flow can collect application records without creating login accounts. + +Impact: + +- Breeze registration may be disabled or restricted for internal user creation. +- Admin-created users handle internal roles. + +## 2026-05-28 - MySQL Availability + +Assumption: + +- MySQL server will be provided even though `mysql` CLI is not currently in PATH. + +Reason: + +- The requirement specifies MySQL, but local CLI check failed. + +Impact: + +- Phase 1 must confirm DB connection through Laravel `.env` and migrations. + +## 2026-05-28 - Phase 1 Laravel Version Installed + +Decision: + +- Installed `laravel/laravel` v13.8.0. +- Installed `laravel/framework` v13.12.0. + +Reason: + +- Composer resolved Laravel 13 as the latest stable skeleton/framework compatible with PHP 8.5.1. + +Impact: + +- Package compatibility was verified against Laravel 13 and PHP 8.5.1 during installation. + +## 2026-05-28 - Phase 1 Authentication and Registration + +Decision: + +- Installed Laravel Breeze v2.4.2 for simple Blade authentication scaffolding. +- Disabled generic public `/register` routes. +- Retained public applicant registration for a later QR-specific module. + +Reason: + +- Internal users should be Admin-created. +- The original requirement allows public registration only for the applicant flow if enabled. + +Impact: + +- `/login`, password reset, logout, profile, and email verification routes exist. +- `/register` returns 404. +- Public applicant registration will be implemented under `/pohon/{pusat_mengundi_uuid}` in Phase 4. + +## 2026-05-28 - Phase 1 Package Compatibility Results + +Decision: + +- Installed compatible Composer packages: + - `spatie/laravel-permission` v7.4.1 + - `spatie/laravel-activitylog` v5.0.0 + - `simplesoftwareio/simple-qrcode` v4.2.0 + - `laravel/breeze` v2.4.2 + - `larastan/larastan` v3.9.6 + - `openspout/openspout` v5.7.1 +- Laravel Pint was already installed by the Laravel 13 skeleton at v1.29.1. +- PHPUnit was installed by the Laravel 13 skeleton at v12.5.28. + +Reason: + +- Composer successfully resolved these versions with Laravel 13.12.0 and PHP 8.5.1. + +Impact: + +- RBAC, activity log, QR generation, auth scaffolding, static analysis, formatting, and spreadsheet export foundations are available. + +## 2026-05-28 - Maatwebsite Excel Compatibility Exception + +Decision: + +- Did not install `maatwebsite/excel` in Phase 1. +- Installed `openspout/openspout` v5.7.1 as the compatible spreadsheet export foundation. + +Reason: + +- Composer rejected `maatwebsite/excel` for this environment. +- Latest resolvable `maatwebsite/excel` versions either do not support Laravel 13 or depend on `phpoffice/phpspreadsheet` versions constrained to PHP `<8.5.0`. +- The local environment is PHP 8.5.1. +- Faking Composer platform PHP was rejected because it would weaken production-readiness. + +Impact: + +- Export service design remains unchanged. +- Export implementation should use OpenSpout unless `maatwebsite/excel` releases a PHP 8.5 and Laravel 13 compatible version or the runtime target is changed to a compatible PHP version. +- This is a documented deviation from the preferred export package in the original requirement due to compatibility. + +## 2026-05-28 - Phase 1 Frontend Package Results + +Decision: + +- Installed npm packages: + - `bootstrap` + - `@popperjs/core` + - `bootstrap-icons` + - `jquery` + - `sweetalert2` + - `tom-select` + - `flatpickr` + - `datatables.net-bs5` + - `datatables.net-responsive-bs5` + +Reason: + +- These packages support the requested Bootstrap 5, jQuery-assisted, responsive admin UI. + +Impact: + +- Vite production build is available. +- Tailwind dependencies remain from Breeze scaffolding but are not used by the application layout. + +## 2026-05-28 - Generated View Replacement + +Decision: + +- Replaced generated Laravel/Breeze Tailwind views with Bootstrap 5 Blade views. + +Reason: + +- The original requirement specifies Bootstrap 5. + +Impact: + +- Auth, guest, app, navigation, profile, welcome, and dashboard views now follow the Bootstrap-based government-style admin UI direction. + +## 2026-05-28 - Phase 1 Local Database Choice + +Decision: + +- `.env.example` is configured for MySQL. +- Local `.env` remains on SQLite for Phase 1 verification. + +Reason: + +- The PHP `pdo_mysql` extension is available, but MySQL CLI/server credentials are not available in the current workspace. +- SQLite allows auth, RBAC migrations, seeding, and tests to be verified immediately. + +Impact: + +- Production and shared environments must configure MySQL in `.env`. +- Local development can switch to MySQL once credentials are provided. + +Superseded by: + +- `2026-05-28 - Phase 2 MySQL Connection Confirmed`. + +## 2026-05-28 - Application Timezone + +Decision: + +- Set application timezone to `Asia/Kuala_Lumpur` through `APP_TIMEZONE`. + +Reason: + +- The system is for Malaysian election operations and polling-day attendance timestamps must use local time. + +Impact: + +- Date/time display, audit timestamps, and attendance check-in defaults will align with Malaysia time when using Laravel timezone helpers. + +## 2026-05-28 - Phase 2 MySQL Connection Confirmed + +Decision: + +- Use MySQL for local Phase 2 migration and seeding verification. + +Reason: + +- Laravel successfully connected to MySQL 8.4.8 through PDO using `.env` database `prn2026`. +- This better matches the production database requirement than SQLite. + +Impact: + +- Phase 2 schema was verified against MySQL, including identifier length constraints. +- MySQL CLI remains unavailable in PATH, but Laravel/PDO connectivity is sufficient for migrations and tests requiring the configured app database. + +## 2026-05-28 - Phase 2 Core Domain Migration Strategy + +Decision: + +- Implement Phase 2 domain schema in one ordered migration file. + +Reason: + +- The tables are tightly coupled by foreign keys and the phase is a single baseline domain cut. +- Keeping the initial domain schema in one migration makes fresh local setup easier at this stage. + +Impact: + +- Future schema changes should use separate additive migrations. +- Down migration disables foreign key checks to safely drop circular application/assignment references. + +## 2026-05-28 - Phase 2 Index Name Strategy + +Decision: + +- Use explicit short names for long composite indexes. + +Reason: + +- MySQL rejected Laravel's generated index name for `position_quotas` because it exceeded the 64-character identifier limit. + +Impact: + +- Composite indexes use concise names such as `pq_election_saluran_position_idx` and `sa_election_saluran_position_idx`. + +## 2026-05-28 - Phase 2 Seed Data Strategy + +Decision: + +- Seed a realistic but small sample election hierarchy and operational assignments. + +Reason: + +- Later UI phases need immediate data for dashboards, setup screens, and authorization checks. + +Impact: + +- Seed data includes one election, one Bahagian, one Daerah, one Pusat, three Saluran, quotas, sample users, and a PPM who also has a KTM assignment. + +## 2026-05-29 - Phase 3 Admin Setup Scope Boundary + +Decision: + +- Complete Phase 3 as Admin setup for hierarchy CRUD, Jawatan CRUD, quota setup, PPM assignment, and QR generation. +- Keep KKM, JKM, and Police operational CRUD for Phase 7 Admin management. + +Reason: + +- The original phase sequence defines Phase 3 around election structure, PPM assignment, jawatan/quota setup, and QR links. +- KKM, JKM, and Police records need the broader Admin management controls, post-registration notes, and audit behavior planned in Phase 7. + +Impact: + +- The Phase 2 schema already contains representative tables. +- Phase 3 UI does not yet manage representative records. + +## 2026-05-29 - Phase 3 QR Storage and Route Binding + +Decision: + +- Generate QR SVG files into private local storage under `qrcodes/pusat-mengundi/{public_uuid}.svg`. +- Serve the QR SVG through an Admin-protected controller route. +- Use `/pohon/{pusatMengundi:public_uuid}` as the public registration URL. + +Reason: + +- The public URL must not expose internal database IDs. +- Keeping generated QR assets outside the public web root avoids accidental direct exposure and keeps access paths explicit. + +Impact: + +- Admin can regenerate QR codes per Pusat Mengundi. +- Public QR routes currently show a placeholder until Phase 4 implements the full application form. + +## 2026-05-29 - Phase 3 PPM Assignment Service + +Decision: + +- Implement PPM assignment through `PpmAssignmentService`. +- Store the assignment in `staff_assignments`, mark previous active PPM assignment for the same Pusat Mengundi as `replaced`, create a `staff_assignment_histories` row, and assign the RBAC role `PPM` to the user. + +Reason: + +- Dual-role support requires assignments to remain separate from RBAC roles. +- RBAC controls application access, while `staff_assignments` controls operational placement. + +Impact: + +- A user can be PPM and also hold another operational assignment such as KTM when later assignment rules allow it. +- PPM changes are auditable from the assignment history and activity log. + +## 2026-05-29 - Phase 3 Quota Consistency Validation + +Decision: + +- Validate that a selected Saluran Mengundi belongs to the selected Pusat Mengundi when creating or updating a position quota. + +Reason: + +- Quotas must not accidentally attach a saluran from another Pusat Mengundi. + +Impact: + +- Position quotas can be set at pusat level by leaving saluran blank, or at saluran level with a matching pusat and saluran pair. + +## 2026-05-29 - Phase 3 Public Layout + +Decision: + +- Add a lightweight public Blade layout for QR-facing pages. + +Reason: + +- Public QR routes should not render the authenticated internal navigation when accessed by unauthenticated applicants. + +Impact: + +- The Phase 3 QR placeholder is mobile-friendly and independent of internal Admin navigation. + +## 2026-05-29 - Phase 4 Public Application Controller + +Decision: + +- Replace the Phase 3 QR placeholder route implementation with `PublicApplicationController`. +- Keep the public QR URL as `/pohon/{pusatMengundi:public_uuid}`. +- Use `/permohonan/{application:public_uuid}/berjaya` for the post-submission confirmation page. + +Reason: + +- Existing QR codes and registration URLs must remain valid. +- Public UUID route binding avoids exposing internal auto-increment IDs. + +Impact: + +- Public applicants can now submit real applications through the QR link. +- The old placeholder controller remains unused and can be removed in a later cleanup with a recorded reason. + +## 2026-05-29 - Phase 4 Registration Period Service + +Decision: + +- Add `RegistrationPeriodService` to centralize open/closed registration checks. +- Treat `is_registration_open_override` as an explicit override when it is not null. + +Reason: + +- Public, KTM, PPM, and Admin workflows will all need consistent registration-period behavior in later phases. + +Impact: + +- Public GET and POST routes block submissions when registration is closed. +- Admin settings UI in a later phase should clearly show whether an override is active. + +## 2026-05-29 - Phase 4 Public Upload Storage + +Decision: + +- Store public IC documents and bank statements on Laravel's private `local` disk under `applications/{application_public_uuid}`. +- Record metadata in `application_documents`. + +Reason: + +- IC documents and bank statements are sensitive and must not be served from public storage. + +Impact: + +- Later document download/view actions must go through authorized controllers. +- Public success pages do not expose document paths. + +## 2026-05-29 - Phase 4 KTM Vacancy Calculation + +Decision: + +- Add `KtmVacancyService`. +- For public KP/KPDP applications, remaining vacancy is quota minus active team assignments minus reserved submitted/under-review/approved applications for the same KTM assignment and role. + +Reason: + +- Applicants should only choose a KTM when vacancy exists, and pending submissions should reserve capacity to prevent overbooking. + +Impact: + +- The public form shows only KTM options with remaining vacancy for KP/KPDP. +- POST validation rechecks vacancy to prevent stale form submission. + +## 2026-05-29 - Phase 4 Duplicate IC Rule + +Decision: + +- Normalize IC numbers to digits and enforce one active application per election. +- If the duplicate active record has `source = created_by_ktm`, show a specific message instructing that KTM must delete that pre-created record first. + +Reason: + +- The original requirement explicitly distinguishes KTM-created records from public self-registration. + +Impact: + +- Public submissions are blocked for duplicate IC values. +- Cancelled or `deleted_by_ktm` records do not block future self-registration. + +## 2026-05-29 - Phase 4 Bank Verification Initialization + +Decision: + +- Create a pending `bank_verifications` row immediately after public application submission. + +Reason: + +- This prepares records for the Admin Kewangan workflow and avoids missing finance state later. + +Impact: + +- Admin Kewangan module can later query pending bank checks without needing to backfill submitted public applications. + +## 2026-05-29 - Phase 5 PPM Record-Level Authorization + +Decision: + +- Add `ApplicationPolicy` and `PpmScopeService`. +- Scope PPM access by active `staff_assignments` where position is `PPM`, status is `active`, and assignment is at Pusat Mengundi level. + +Reason: + +- Role middleware alone cannot enforce "PPM only manages own Pusat Mengundi". + +Impact: + +- PPM can list, view, approve, reject, and download documents only for applications under their assigned Pusat Mengundi. +- Admin remains able to pass policy checks for future shared application views. + +## 2026-05-29 - Phase 5 Approval Creates Assignment + +Decision: + +- PPM approval creates or updates an active `staff_assignments` row and sets application status to `assigned`. +- Assignment history and application status history are written in the same transaction. + +Reason: + +- In this module, approval is coupled with the required placement selection for KTM, KP, KPDP, or PAPM. +- A separate `approved` holding state can still be used later if a workflow needs approval without placement. + +Impact: + +- Approved Phase 5 applications immediately appear as active staff assignments. +- Role changes before approval are recorded through application status metadata, assignment history, and activity log. + +## 2026-05-29 - Phase 5 Vacancy Validation + +Decision: + +- Add `AssignmentVacancyService` for pusat-level and saluran-level placement checks. +- Reuse `KtmVacancyService` for KP/KPDP assignment to KTM, with an option to ignore the application being approved when counting reserved vacancy. + +Reason: + +- PPM assignment must prevent overfilling KTM, KP, KPDP, and PAPM quotas. +- Pending KP/KPDP public applications reserve vacancy, but the current application should not block its own approval. + +Impact: + +- PPM can assign KTM only to an available Saluran. +- PPM can assign KP/KPDP only to an active KTM under the same Pusat Mengundi with remaining vacancy. + +## 2026-05-29 - Phase 5 Authorized Document Download + +Decision: + +- Add PPM document download route under authenticated PPM review routes. +- Verify the document belongs to the application before streaming it from private storage. + +Reason: + +- PPM needs to review uploaded IC and bank statement documents, but files must remain private. + +Impact: + +- Uploaded files are not exposed through public storage URLs. +- Document viewing for Admin/Admin Kewangan can be added later using the same private-storage pattern. + +## 2026-05-29 - Phase 5 Attendance Boundary + +Decision: + +- Do not implement polling-day attendance in Phase 5. +- Keep attendance screens, summaries, and exports for Phase 10. + +Reason: + +- The phase sequence separates PPM review/assignment from attendance reporting/export design. +- Attendance has separate dashboard and export requirements that should be implemented together. + +Impact: + +- PPM dashboard still shows attendance as a future module. +- Phase 10 remains responsible for polling-day attendance. + +## 2026-05-29 - Phase 6 KTM Scope Service + +Decision: + +- Add `KtmScopeService` to define KTM ownership from active `staff_assignments` with position `KTM`, a Saluran Mengundi, and status `active`. + +Reason: + +- A KTM may only manage KP records under their own assigned Saluran/team. + +Impact: + +- KTM listing, registration, approval, and deletion are constrained to the current user's active KTM assignments. +- KTM cannot manage another KTM's team. + +## 2026-05-29 - Phase 6 KTM-Created Applicant Records + +Decision: + +- KTM registration creates `applications` rows with `source = created_by_ktm`, `status = submitted`, and requested position `KP`. +- The KTM form does not accept a selectable role; malicious extra role fields are ignored by the service. + +Reason: + +- KTM is only allowed to register KP, not KTM, KPDP, PAPM, PPM, Police, KKM, or JKM. +- The existing public duplicate-IC rule already blocks public self-registration while an active KTM-created record exists. + +Impact: + +- KTM-created applicants can be reviewed/approved by KTM under their own team. +- Public self-registration remains blocked until the KTM-created record is deleted or cancelled. + +## 2026-05-29 - Phase 6 Email Notification + +Decision: + +- Add queued Markdown Mailable `KtmCreatedApplicantMail`. +- Queue the email when KTM creates a KP applicant. + +Reason: + +- The requirement asks that KTM-created applicants receive email notification with name, role, Pusat Mengundi, KTM name, and correction instructions. + +Impact: + +- Queue/mail configuration must be valid in production. +- Tests use Laravel mail fake to verify the mailable is queued. + +## 2026-05-29 - Phase 6 KTM Approval and Deletion + +Decision: + +- KTM approval of KP creates active `staff_assignments` with `reports_to_assignment_id` pointing to the KTM assignment and status `assigned` on the application. +- KTM can delete only unassigned `created_by_ktm` records under their own assignment. + +Reason: + +- Approval is a placement action, and after registration closes only Admin should be allowed to register/edit/assign. +- Deletion is needed to unblock public self-registration for applicants pre-created by KTM. + +Impact: + +- KTM approval is blocked after registration closes. +- Deletion writes `deleted_by_ktm` status history and soft deletes the application. + +## 2026-05-29 - Phase 7 Admin Management Route Boundary + +Decision: + +- Add Admin management routes under `/admin/management`, separate from `/admin/setup`. + +Reason: + +- Setup screens manage election structure and QR preparation, while Phase 7 manages operational application/staff corrections and representative records. + +Impact: + +- Admin dashboard now links to both setup and management. +- Admin Kewangan is blocked from management routes because placement changes are Admin-only. + +## 2026-05-29 - Phase 7 Manual Entry Source + +Decision: + +- Admin manual applications use `source = admin_manual`. +- Direct Admin assignments use `source = admin_direct`, while edited assignments use `source = admin_update`. + +Reason: + +- Source flags distinguish public, KTM-created, PPM-approved, and Admin-maintained records for audit and later reporting. +- Admin-keyed records must not trigger KTM-created applicant bank/detail request email behavior. + +Impact: + +- Manual records create pending bank verification state but no email notification. +- Reports can filter records by operational source. + +## 2026-05-29 - Phase 7 Post-Closing Catatan Enforcement + +Decision: + +- Centralize post-closing note enforcement in `AdminPostCloseNoteService`. +- Require `catatan` when the election registration period is closed for Admin application edit, manual entry, direct assignment, assignment edit, and representative changes. + +Reason: + +- The original requirement makes post-registration Admin changes allowable only with a note. + +Impact: + +- Missing catatan returns validation errors. +- Accepted post-closing changes create `system_notes` linked to the changed record. + +## 2026-05-29 - Phase 7 Representative Management + +Decision: + +- Implement Police, KKM, and JKM manual entry in one representative management screen while keeping their existing separate database tables. + +Reason: + +- Operationally these records are maintained together by Admin, but the schema separates fields because Police can be saluran-specific and has rank/station fields. + +Impact: + +- PPM representative display can continue reading the existing separate tables. +- Future edit forms can extend the same service without schema changes. + +## 2026-05-29 - Phase 8 Finance Scope + +Decision: + +- Implement finance verification under `/kewangan/bank`. +- Limit Admin Kewangan write access to `bank_verifications.status` and `finance_note`. + +Reason: + +- The finance role must verify bank details without changing placement or assignments. + +Impact: + +- Admin Kewangan can filter and update finance records. +- Admin Kewangan remains blocked from Admin management assignment routes. + +## 2026-05-29 - Phase 8 Finance Export Implementation + +Decision: + +- Implement finance XLSX export with OpenSpout in `app/Exports/FinanceVerificationExport`. +- Keep export orchestration in `FinanceExportService` and query/filter logic in `FinanceVerificationQuery`. + +Reason: + +- The original preferred package `maatwebsite/excel` remains incompatible with this PHP 8.5/Laravel 13 environment. +- The project already documented OpenSpout as the compatible export foundation. + +Impact: + +- Export files are stored in private local storage under `exports/finance`. +- Every finance export writes an `export_logs` row with user, timestamp, report type, filters, file name, disk, and path. + +## 2026-05-29 - Phase 8 Sensitive Finance Display + +Decision: + +- Add `App\Support\SensitiveData` masking helper and use it on finance screens for IC, phone, and bank account values. + +Reason: + +- Finance users need enough information to identify records, but full sensitive values should not be displayed unless necessary. + +Impact: + +- Finance list/detail screens mask IC, phone, and account numbers. +- Future modules can reuse the same helper for consistent sensitive data handling. + +## 2026-05-29 - Phase 9 Wheelchair Management Placement + +Decision: + +- Implement wheelchair management under Admin Management at `/admin/management/wheelchairs`. + +Reason: + +- Wheelchair allocation and movement are operational Admin functions tied to Pusat Mengundi, not initial election hierarchy setup. + +Impact: + +- Admin can access wheelchair management from the Admin management module. +- Admin Kewangan and other roles cannot access wheelchair management routes. + +## 2026-05-29 - Phase 9 Wheelchair Quantity Rules + +Decision: + +- Treat `wheelchair_allocations.allocated_quantity` as the maximum quantity available for a Pusat Mengundi. +- Treat `wheelchair_transactions` rows as immutable movement records. +- Calculate outstanding quantity as total taken minus total returned. + +Reason: + +- Separate movement records are more auditable than overwriting current state fields. + +Impact: + +- Taken transactions are blocked when quantity exceeds available allocation. +- Returned transactions are blocked when quantity exceeds outstanding quantity. +- Allocation cannot be reduced below outstanding quantity. +- Every allocation change and movement is written to activity log. + +## 2026-05-29 - Phase 10 Attendance Activation + +Decision: + +- Block PPM attendance recording unless `election_settings.is_attendance_active` is true. + +Reason: + +- Attendance should only be used on polling day or when explicitly activated by Admin settings. + +Impact: + +- PPM can see attendance screens but cannot submit records while inactive. +- Tests explicitly activate the flag before successful recording. + +## 2026-05-29 - Phase 10 Attendance Scope and Storage + +Decision: + +- Store attendance per active `staff_assignments` row. +- Scope PPM recording by active PPM assignment to Pusat Mengundi. + +Reason: + +- Staff assignments already represent final placement and support dual roles. +- Attendance belongs to a concrete operational assignment, not only a user. + +Impact: + +- A user with dual role can have separate attendance records per assignment. +- Admin summaries can group attendance by Pusat, role, and Saluran. + +## 2026-05-29 - Phase 10 Attendance Export + +Decision: + +- Implement attendance detail export using OpenSpout and `AttendanceExportService`. + +Reason: + +- Report format may change later, so export formatting is isolated from controllers. +- OpenSpout remains the compatible XLSX writer for this environment. + +Impact: + +- Attendance detail exports are stored in private local storage under `exports/attendance`. +- Every export writes an `export_logs` row with report type and Pusat filter parameters. + +## 2026-05-29 - Phase 11 Document Access Hardening + +Decision: + +- Add authorized document download routes for Admin and Admin Kewangan. +- Admin can download all application documents. +- Admin Kewangan can download only `bank_statement` documents. + +Reason: + +- PPM document download existed, but Admin and Finance document access also needs an explicit private-storage controller path. +- Finance users should not download IC documents because the finance workflow only requires bank evidence. + +Impact: + +- Sensitive files remain on private storage and are served only through authenticated, role-protected routes. +- Document download actions are recorded in activity log. + +## 2026-05-29 - Phase 11 Final Hardening Scope + +Decision: + +- Treat Phase 11 as final quality gate, security review, targeted test-gap closure, and documentation hardening. + +Reason: + +- The main feature phases are complete; remaining work should reduce release risk without changing broad behavior. + +Impact: + +- Added document authorization tests and sensitive-data masking unit test. +- Deployment notes now include final quality gate commands and document-storage warnings. +## 2026-06-02 - Fasa 15-A: Export File Purge Strategy + +Decision: + +- Retention period default: 30 days. Configurable via `--days` option to allow shorter retention for audit events (e.g., polling day exports) or longer retention for post-event review. +- Purge is triggered by `created_at < now - days` on the `export_logs` table, not `generated_at`, because `created_at` is always set by Eloquent and cannot be null. +- Records with files already missing from disk are still marked `purged_at` to prevent repeated processing attempts. +- `purged_by_user_id` is nullable and not set by the scheduled command (system-initiated purge). It is reserved for future manual purge UI where an Admin might trigger purge on demand and should be attributed. +- The scheduled task uses `withoutOverlapping()` and `runInBackground()` so large purge runs don't block the scheduler. + +Reason: + +- Finance verification lists and attendance exports contain bank account numbers and personal data. Indefinite storage of these files increases breach risk. +- A 30-day default balances post-export review window (staff may need to re-download for a few weeks) against data minimisation. + +Impact: + +- `routes/console.php` now contains a `Schedule::command(...)` entry. +- `php artisan schedule:run` must be configured in the operating system cron (per existing deployment notes). +- `export_logs` table has `purged_at` and `purged_by_user_id` columns via migration `2026_06_02_000001_add_purged_at_to_export_logs.php`. + +## 2026-06-02 - Fasa 14-A: Applicant Status Portal + +Decision: + +- Route uses a plain string parameter `{publicUuid}` instead of route model binding `{application:public_uuid}` to prevent Laravel from returning 404 for unknown UUIDs. The controller handles all lookup and returns 200 with a generic message for invalid or internal-only UUIDs. +- `deleted_by_ktm` applications and soft-deleted records return the same generic message as "not found", so the portal cannot be used to infer whether a UUID once existed. +- Name masking shows only the first 3 characters to confirm identity to the applicant without revealing the full name to observers. +- IC number, bank account, and phone are never displayed. +- Rejection reason is displayed when status is `rejected` — this is intentional as the applicant has a right to know why their application was rejected. + +Reason: + +- Without this feature, applicants could only find out their status by contacting the Admin or PPM, creating unnecessary support burden. +- UUID-as-identifier (not requiring login) balances accessibility with security — only the person who received the QR-generated UUID link can access the status. + +Impact: + +- Route: `GET /permohonan/{publicUuid}/status` → `public.applications.status`. +- No authentication middleware applied to this route. +- `SensitiveData::maskName()` added. + +## 2026-06-02 - Fasa 12-E: Document Type Allowlists + +Decision: + +- Define document type allowlists as constants on the `ApplicationDocument` model (`ALLOWED_FOR_ADMIN`, `ALLOWED_FOR_PPM`, `ALLOWED_FOR_FINANCE`), making the single source of truth co-located with the model they describe. +- All download paths reference these constants; adding a new document type requires a deliberate update to the relevant allowlist. + +Reason: + +- Admin and PPM paths had no explicit type checks — silently allowed any document type. If a new sensitive type were added, those roles would automatically get access without a conscious decision. +- PPM path used 404 for ownership mismatch, which reveals document ID existence to unauthorised callers. Changed to 403. +- PPM path had no activity log — inconsistent with Admin and Finance paths. + +Impact: + +- `ApplicationDocument::ALLOWED_FOR_ADMIN = ['ic_document', 'bank_statement']` — Admin can download both types. +- `ApplicationDocument::ALLOWED_FOR_PPM = ['ic_document', 'bank_statement']` — PPM can download both types (needed to review before approval). +- `ApplicationDocument::ALLOWED_FOR_FINANCE = ['bank_statement']` — Finance can only download bank statements. +- Any new document type added to the system requires explicit inclusion in each allowlist before the relevant role can download it. + +## 2026-06-02 - Fasa 13-A: Composite Database Indexes + +Decision: + +- Audit existing indexes before adding new ones. Verified that Phase 2 migration already contains `(election_id, ic_number)` and `(election_id, pusat_mengundi_id, status)` on `applications`, and `(election_id, pusat_mengundi_id, position_id)` and `(election_id, saluran_mengundi_id, position_id)` on `staff_assignments`. +- Added only genuinely missing indexes via a separate migration to avoid modifying the baseline Phase 2 migration. +- Did not add `(election_id, status)` on `bank_verifications` because that table does not have an `election_id` column. + +Reason: + +- `KtmVacancyService` is called on every public KP/KPDP application and on the form for KTM option display. Two queries inside it (`$assignedCount` and `$reservedCount`) had no composite coverage. +- The admin application list query does not filter by `election_id`, so `(election_id, status)` on `applications` primarily helps dashboard count queries and future per-election reporting. + +Impact: + +- Migration: `2026_06_02_000000_add_performance_indexes.php`. +- Index verification steps documented in `docs/08-deployment-notes.md`. +- No model or service code changes required. + +## 2026-06-02 - Fasa 12-C: Middleware RequireAdminPostCloseNote + +Decision: + +- Add `RequireAdminPostCloseNote` middleware and apply it to the entire `admin/management` route group as a safety net on top of the existing service-layer enforcement. +- Keep all existing `$this->postCloseNoteService->requireIfClosed(...)` calls in services (they provide the correct election context for each record). +- Middleware resolves the election from: `election_id` in the request body (create routes), or the first active election (edit/delete routes where the election comes from the model, not the request). + +Reason: + +- The existing service-layer enforcement is correct but convention-based — a new controller that forgets to call `requireIfClosed()` would silently bypass the gate. +- Middleware provides an automatic belt-and-suspenders check that applies to ALL write operations in the route group regardless of which service is called. +- The middleware approach is safe because: (a) single active election is the normal operating state, and (b) the service still does the precise check with the correct election. + +Impact: + +- Alias: `admin.post_close_note` registered in `bootstrap/app.php`. +- Applied to `['auth', 'role:Admin', 'admin.post_close_note']` in `routes/web.php` for the management group. +- Read operations (GET) pass through without note check. +- Missing note returns redirect-back with validation errors on `note` field. + +## 2026-06-02 - Fasa 12-A: Admin Settings UI + +Decision: + +- Add Admin Settings UI at `GET/PATCH /admin/setup/settings/{election}` for toggling `is_attendance_active` and `is_registration_open_override`. +- Place the Settings screen within the existing `admin/setup` route group and include it in the `_nav.blade.php` as a warning-toned button. +- Log all settings changes via `activity('admin_settings')` with before and after values. + +Reason: + +- Both `is_attendance_active` and `is_registration_open_override` were previously only editable via direct database access, which is unsafe in production. +- The risk of accidental misconfiguration (e.g., forgetting to activate attendance on polling day) is high without a UI. + +Impact: + +- `ElectionSettingsController`, `ElectionSettingsService`, and `UpdateElectionSettingsRequest` added. +- Route names: `admin.setup.settings.edit`, `admin.setup.settings.update`. +- Accessibility: Admin role only; all other roles are forbidden. +- `is_registration_open_override` exposes three logical states: null (auto), true (force open), false (force closed), mapped via HTML radio buttons with empty string for null. + +## 2026-06-02 - Fasa 12-D: Pembetulan SampleElectionSeeder (CALON_TAMBAHAN) + +Decision: + +- Fixed `SampleElectionSeeder::seedQuotas()` to query position code `CALON_TAMBAHAN` instead of the old `CALON_SIMPANAN`. +- Fixed `tests/Feature/AdminSetupTest.php` to include required operational counts (`saluran_count`, etc.) when posting to `admin.setup.pusat.store`, and to use saluran number '3' (not '1') to avoid conflict with auto-generated salurans from `PusatOperationalSetupService`. + +Reason: + +- The 2026-05-31 phase renamed `CALON_SIMPANAN` → `CALON_TAMBAHAN` via migration. The seeder was not updated, causing `KeyError` when accessing `$positions['CALON_TAMBAHAN']`. +- The 2026-05-31 phase also made `saluran_count` required on POST for Pusat creation. The test was not updated to match, causing a conflict when it tried to manually create saluran number 1 that already existed. + +Impact: + +- `php artisan migrate:fresh --seed` now runs cleanly. +- All 90 tests pass. +- Pint and PHPStan pass. + +## 2026-05-31 - Pendaftaran operasi semasa daftar Pusat Mengundi + +- Nama Saluran tidak lagi diperlukan pada UI. Saluran dikenal pasti menggunakan nombor di bawah Pusat Mengundi. +- Borang daftar Pusat Mengundi menerima bilangan Saluran, KTM, Polis Iring, KP, PPM, KPDP, PAPM, JKM, KKM dan Calon Tambahan. +- Saluran bernombor `1..N` dijana secara automatik semasa Pusat Mengundi dicipta. +- Kuota `PPM`, `PAPM`, `JKM` dan `KKM` disimpan pada skop Pusat Mengundi. +- Kuota `KTM`, `POLIS`, `KP`, `KPDP` dan `CALON_TAMBAHAN` disimpan pada setiap Saluran. Ini mengekalkan model operasi satu pasukan bagi setiap Saluran. +- Istilah `CALON_SIMPANAN` ditukar kepada `CALON_TAMBAHAN` melalui migrasi data agar rekod sedia ada kekal boleh digunakan. +- Penciptaan Pusat, Saluran dan kuota dilaksanakan dalam transaksi pangkalan data supaya konfigurasi tidak tertinggal separuh jika setup gagal. diff --git a/docs/progress-log.md b/docs/progress-log.md new file mode 100644 index 0000000..35ae88f --- /dev/null +++ b/docs/progress-log.md @@ -0,0 +1,1256 @@ +# Progress Log + +## 2026-05-28 - Phase 0: Project Inspection and Planning + +Status: Completed + +Completed: + +- Inspected workspace. +- Confirmed workspace is currently empty. +- Confirmed no git repository is initialized. +- Confirmed no existing Laravel application is present. +- Confirmed no `AGENTS.md` project instruction file is present. +- Checked local tooling: + - PHP 8.5.1 available. + - Composer 2.9.4 available. + - Node 25.1.0 available. + - `npm.cmd` 11.14.1 available. + - `npm` PowerShell shim blocked by execution policy. + - MySQL CLI not available in PATH. +- Created Phase 0 planning documentation. + +Changed files: + +- `docs/00-project-brief.md` +- `docs/01-development-plan.md` +- `docs/02-architecture.md` +- `docs/03-database-design.md` +- `docs/04-rbac-permission-matrix.md` +- `docs/05-workflow-design.md` +- `docs/06-ui-ux-plan.md` +- `docs/07-testing-plan.md` +- `docs/08-deployment-notes.md` +- `docs/decision-log.md` +- `docs/progress-log.md` +- `docs/changelog.md` + +Pending items: + +- Initialize or create Laravel project in Phase 1. +- Confirm exact Laravel version during install. +- Confirm MySQL server credentials and connectivity. +- Verify package compatibility during Composer/npm installation. +- Initialize git repository if desired. + +Next step: + +- Proceed to Phase 1 only after approval: Laravel setup, authentication scaffolding, Bootstrap 5 base layout, roles, permissions, and base dashboards. + +Manual testing for this phase: + +- Review documentation files for completeness. +- Confirm assumptions in `docs/decision-log.md`. + +## 2026-05-28 - Phase 0 Review Before Implementation + +Status: Completed + +Completed: + +- Reviewed all Phase 0 documentation files against the original requirement. +- Confirmed the required documentation set is present. +- Clarified Phase 1 package baseline in the development plan. +- Clarified `election_settings.is_registration_open` in database and workflow design. +- Recorded optional Telescope and DOMPDF decisions. + +Changed files: + +- `docs/01-development-plan.md` +- `docs/03-database-design.md` +- `docs/05-workflow-design.md` +- `docs/decision-log.md` +- `docs/progress-log.md` + +Pending items: + +- Proceed with Phase 1 implementation. + +Next step: + +- Scaffold Laravel, install packages, configure authentication, Bootstrap layout, roles/permissions, and base dashboards. + +## 2026-05-28 - Phase 1: Laravel Setup and Authentication + +Status: Completed + +Completed: + +- Scaffolded Laravel application. +- Installed Laravel framework 13.12.0. +- Installed Laravel Breeze authentication scaffolding. +- Disabled generic public account registration route. +- Installed and configured Bootstrap 5 layout through Vite. +- Installed Bootstrap Icons, jQuery, SweetAlert2, Tom Select, Flatpickr, and DataTables Bootstrap packages. +- Installed `spatie/laravel-permission` and published migrations/config. +- Installed `spatie/laravel-activitylog` and published migrations/config. +- Installed `simplesoftwareio/simple-qrcode`. +- Attempted `maatwebsite/excel`; Composer rejected it for PHP 8.5.1/Laravel 13 compatibility. +- Installed `openspout/openspout` as compatible spreadsheet export foundation. +- Installed Larastan/PHPStan. +- Added role middleware aliases. +- Added User `HasRoles` support. +- Added role and permission seeder. +- Added default Admin seeding through `config/prn.php`. +- Added role-based dashboard routes and base dashboard UI. +- Updated auth/profile/welcome views to Bootstrap. +- Updated `.env.example` for MySQL. +- Set application timezone to `Asia/Kuala_Lumpur`. +- Kept local `.env` on SQLite because MySQL server credentials are not yet available. +- Added tests for disabled registration and dashboard role protection. + +Changed files: + +- Laravel application scaffold files. +- `app/Http/Controllers/DashboardController.php` +- `app/Models/User.php` +- `bootstrap/app.php` +- `config/activitylog.php` +- `config/permission.php` +- `config/prn.php` +- `config/app.php` +- `database/migrations/*permission*` +- `database/migrations/*activity_log*` +- `database/seeders/DatabaseSeeder.php` +- `database/seeders/RolePermissionSeeder.php` +- `resources/css/app.css` +- `resources/js/app.js` +- `resources/views/auth/*.blade.php` +- `resources/views/dashboards/index.blade.php` +- `resources/views/layouts/*.blade.php` +- `resources/views/profile/edit.blade.php` +- `resources/views/welcome.blade.php` +- `routes/auth.php` +- `routes/web.php` +- `tests/Feature/Auth/RegistrationTest.php` +- `tests/Feature/DashboardAccessTest.php` +- `.env.example` +- `.gitignore` +- `composer.json` +- `composer.lock` +- `package.json` +- `package-lock.json` +- `phpstan.neon` +- `docs/*` + +Verification: + +- `php artisan migrate --seed` completed after seeder correction. +- `php artisan db:seed` completed. +- `npm.cmd run build` passed. +- `php artisan route:list` passed. +- `php artisan test` passed: 27 tests, 64 assertions. +- `vendor\bin\pint.bat --test` passed. +- `vendor\bin\phpstan.bat analyse --memory-limit=1G` passed. + +Pending items: + +- Confirm MySQL database credentials and switch local `.env` to MySQL when available. +- Implement Phase 2 core domain migrations/models/seeders. +- Replace OpenSpout with `maatwebsite/excel` only if a compatible release becomes available or runtime PHP target changes. + +Manual testing: + +- Visit `/`. +- Log in as seeded Admin: + - email: `admin@prn.local` + - password: `password` +- Confirm `/dashboard` redirects Admin to `/admin/dashboard`. +- Confirm `/register` is not available. +- Resize browser to confirm mobile navbar behavior. + +Next step: + +- Phase 2: Core database and seeders for election hierarchy, positions, quotas, applications, assignments, and operational records. + +## 2026-05-28 - Phase 2: Core Database and Seeders + +Status: Completed + +Completed: + +- Added core election-domain migration. +- Added `user_profiles`, election hierarchy, position/quota, application/document/history, assignment/history, representative, wheelchair, attendance, finance, export, and note tables. +- Added domain models and Eloquent relationships. +- Added baseline factories for election hierarchy, positions, applications, and assignments. +- Added `PositionSeeder`. +- Added `SampleElectionSeeder`. +- Updated `DatabaseSeeder` to seed roles, positions, Admin user, and sample election data. +- Seeded sample election `PRN2026`. +- Seeded one Bahagian Pilihanraya, one Daerah Mengundi, one Pusat Mengundi, and three Saluran Mengundi. +- Seeded 9 default positions and 17 sample quotas. +- Seeded sample Admin Kewangan, PPM, and KTM users. +- Seeded sample dual-role assignment where one user is both PPM and KTM. +- Seeded sample wheelchair allocation. +- Added feature test for core domain seeder output and dual-role assignment. +- Confirmed MySQL connection through Laravel/PDO. +- Shortened long composite index names for MySQL compatibility. + +Changed files: + +- `database/migrations/2026_05_28_030000_create_phase2_core_domain_tables.php` +- `app/Models/*.php` +- `database/factories/*.php` +- `database/seeders/DatabaseSeeder.php` +- `database/seeders/PositionSeeder.php` +- `database/seeders/SampleElectionSeeder.php` +- `tests/Feature/CoreDomainSeederTest.php` +- `docs/00-project-brief.md` +- `docs/01-development-plan.md` +- `docs/03-database-design.md` +- `docs/07-testing-plan.md` +- `docs/08-deployment-notes.md` +- `docs/decision-log.md` +- `docs/progress-log.md` +- `docs/changelog.md` + +Verification: + +- `php artisan migrate:fresh --seed` passed on MySQL database `prn2026`. +- `php artisan test` passed: 28 tests, 80 assertions. +- `vendor\bin\pint.bat --test` passed. +- `vendor\bin\phpstan.bat analyse --memory-limit=1G` passed. + +Manual testing: + +- Log in as `admin@prn.local` / `password`. +- Confirm base dashboard loads after fresh seed. +- Optional sample accounts: + - `kewangan@prn.local` / `password` + - `ppm@prn.local` / `password` + - `ktm@prn.local` / `password` +- Confirm `/dashboard` redirects based on each user's role. + +Pending items: + +- Phase 3 Admin setup module UI and CRUD. +- Implement policy-level ownership checks when domain UI routes are added. +- Add business-rule services for vacancy and registration period calculations in later phases. + +Next step: + +- Phase 3: Admin setup module for CRUD Bahagian, Daerah, Pusat, Saluran, Jawatan/quota setup, PPM assignment, and QR generation. + +## 2026-05-29 - Phase 3: Admin Setup Module + +Status: Completed + +Completed: + +- Added Admin setup routes under `/admin/setup`. +- Added setup overview page. +- Added CRUD controllers, Form Requests, and Blade views for Bahagian Pilihanraya, Daerah Mengundi, Pusat Mengundi, Saluran Mengundi, Jawatan, and Kekosongan Jawatan. +- Added Pusat Mengundi detail page with saluran list, QR registration link, QR preview, and PPM assignment form. +- Added QR generation service using public UUID registration URLs. +- Added Admin-protected QR SVG serving route. +- Added public QR placeholder route `/pohon/{pusatMengundi:public_uuid}`. +- Added PPM assignment service using `staff_assignments`, assignment history, RBAC role assignment, and activity logging. +- Added quota validation to prevent assigning a Saluran Mengundi from a different Pusat Mengundi. +- Added public Blade layout for QR-facing pages. +- Linked Admin dashboard action to Election setup. +- Added feature tests for Admin setup module behavior. + +Changed files: + +- `app/Http/Controllers/Admin/Setup/AdminSetupController.php` +- `app/Http/Controllers/Admin/Setup/BahagianPilihanrayaController.php` +- `app/Http/Controllers/Admin/Setup/DaerahMengundiController.php` +- `app/Http/Controllers/Admin/Setup/PositionController.php` +- `app/Http/Controllers/Admin/Setup/PositionQuotaController.php` +- `app/Http/Controllers/Admin/Setup/PusatMengundiController.php` +- `app/Http/Controllers/Admin/Setup/SaluranMengundiController.php` +- `app/Http/Controllers/DashboardController.php` +- `app/Http/Controllers/PublicApplicationPlaceholderController.php` +- `app/Http/Requests/Admin/Setup/*.php` +- `app/Services/Admin/PpmAssignmentService.php` +- `app/Services/Admin/PusatQrCodeService.php` +- `resources/views/admin/setup/**/*.blade.php` +- `resources/views/dashboards/index.blade.php` +- `resources/views/layouts/public.blade.php` +- `resources/views/public/application-placeholder.blade.php` +- `routes/web.php` +- `tests/Feature/AdminSetupTest.php` +- `docs/01-development-plan.md` +- `docs/05-workflow-design.md` +- `docs/decision-log.md` +- `docs/progress-log.md` +- `docs/changelog.md` + +Verification: + +- `php artisan route:list --except-vendor` passed and shows 65 routes. +- `php artisan test --filter=AdminSetupTest` passed: 4 tests, 24 assertions. +- `php artisan test` passed: 32 tests, 104 assertions. +- `vendor\bin\pint.bat --test` passed. +- `vendor\bin\phpstan.bat analyse --memory-limit=1G` passed. +- `npm.cmd run build` passed. + +Manual testing: + +- Log in as `admin@prn.local` / `password`. +- Open `/admin/setup`. +- Create or edit Bahagian, Daerah, Pusat, Saluran, Jawatan, and Kuota records. +- Open a Pusat Mengundi detail page, generate QR, and assign PPM. +- Open the generated `/pohon/{uuid}` public link and confirm it shows the intended Pusat Mengundi. +- Log in as `ktm@prn.local` / `password` and confirm `/admin/setup` is forbidden. + +Pending items: + +- Full public application form and document upload in Phase 4. +- Registration-period enforcement for public and KTM-created applications in Phase 4 and Phase 6. +- Representative management for Police, KKM, and JKM in the later Admin management phase. +- Rich filters for very large setup lists if operational data volume requires them. + +Next step: + +- Phase 4: Public application module using QR link, registration-period rules, document uploads, KTM selection for KP/KPDP, and duplicate IC blocking. + +## 2026-05-29 - Phase 4: Public Application Module + +Status: Completed + +Completed: + +- Replaced QR placeholder flow with public application form on `/pohon/{pusatMengundi:public_uuid}`. +- Added public success page using application public UUID. +- Added `RegistrationPeriodService` for registration open/closed checks. +- Added `KtmVacancyService` for KP/KPDP KTM selection and vacancy calculation. +- Added `PublicApplicationSubmissionService` for transactional application creation, private document storage, status history, bank verification initialization, and activity logging. +- Added `StorePublicApplicationRequest` with validation for applicant fields, IC normalization, required documents, public roles, duplicate IC, KTM-created blocking rule, and KTM vacancy recheck. +- Added responsive public Blade form for applicant details, role selection, KTM selection, bank data, and document upload. +- Added closed-registration page. +- Stored IC document and bank statement metadata in `application_documents`. +- Created pending `bank_verifications` row for public submissions. +- Added Phase 4 feature tests and unit tests. + +Changed files: + +- `app/Http/Controllers/PublicApplicationController.php` +- `app/Http/Requests/Public/StorePublicApplicationRequest.php` +- `app/Services/RegistrationPeriodService.php` +- `app/Services/Public/KtmVacancyService.php` +- `app/Services/Public/PublicApplicationSubmissionService.php` +- `resources/views/layouts/public.blade.php` +- `resources/views/public/applications/create.blade.php` +- `resources/views/public/applications/closed.blade.php` +- `resources/views/public/applications/success.blade.php` +- `routes/web.php` +- `tests/Feature/PublicApplicationTest.php` +- `tests/Unit/KtmVacancyServiceTest.php` +- `tests/Unit/RegistrationPeriodServiceTest.php` +- `docs/01-development-plan.md` +- `docs/03-database-design.md` +- `docs/05-workflow-design.md` +- `docs/07-testing-plan.md` +- `docs/decision-log.md` +- `docs/progress-log.md` +- `docs/changelog.md` + +Verification: + +- `php artisan route:list --except-vendor` passed and shows 67 routes. +- `php artisan test --filter=PublicApplicationTest` passed: 6 tests, 27 assertions. +- `php artisan test` passed: 41 tests, 137 assertions. +- `vendor\bin\pint.bat --test` passed. +- `vendor\bin\phpstan.bat analyse --memory-limit=1G` passed. +- `npm.cmd run build` passed. + +Manual testing: + +- Open a generated `/pohon/{uuid}` QR link. +- Submit a KTM or PAPM application with IC document and bank statement. +- Submit KP with an available KTM selection. +- Try submitting without IC document or bank statement and confirm validation blocks it. +- Set registration end date to a past date and confirm public form shows closed-registration message. +- Create a `created_by_ktm` application for an IC and confirm public self-registration with the same IC is blocked. + +Pending items: + +- Phase 5 PPM review, approval/rejection, role change, and assignment of submitted public applications. +- Phase 6 KTM-created application deletion flow and email notification. +- Authorized document viewing/downloading for PPM/Admin/Admin Kewangan. +- Public application status portal if later required. + +Next step: + +- Phase 5: PPM review and assignment module. + +## 2026-05-29 - Phase 5: PPM Review and Assignment Module + +Status: Completed + +Completed: + +- Added PPM application review routes under `/ppm/applications`. +- Added PPM-scoped application list and detail screens. +- Added `ApplicationPolicy` and `PpmScopeService` for record-level PPM access control. +- Added `PpmApplicationReviewService` for transactional approve/reject behavior. +- Added `AssignmentVacancyService` for Pusat/Saluran quota checks. +- Extended `KtmVacancyService` to ignore the current application during PPM approval vacancy checks. +- Added PPM approval form allowing role change before approval. +- Implemented KTM assignment to available Saluran Mengundi. +- Implemented KP/KPDP assignment to active KTM with remaining vacancy. +- Implemented PAPM assignment at Pusat Mengundi level. +- Implemented rejection with required reason. +- Added authorized private document download for PPM-owned applications. +- Added PPM dashboard metrics and link to the review queue. +- Added representative display sections for Police, KKM, and JKM records. +- Added `AuthorizesRequests` to the base controller for policy authorization. +- Added Phase 5 feature tests. + +Changed files: + +- `app/Http/Controllers/Controller.php` +- `app/Http/Controllers/DashboardController.php` +- `app/Http/Controllers/Ppm/ApplicationReviewController.php` +- `app/Http/Requests/Ppm/ApproveApplicationRequest.php` +- `app/Http/Requests/Ppm/RejectApplicationRequest.php` +- `app/Models/PusatMengundi.php` +- `app/Policies/ApplicationPolicy.php` +- `app/Services/Ppm/AssignmentVacancyService.php` +- `app/Services/Ppm/PpmApplicationReviewService.php` +- `app/Services/Ppm/PpmScopeService.php` +- `app/Services/Public/KtmVacancyService.php` +- `resources/views/ppm/applications/index.blade.php` +- `resources/views/ppm/applications/show.blade.php` +- `routes/web.php` +- `tests/Feature/PpmApplicationReviewTest.php` +- `docs/01-development-plan.md` +- `docs/04-rbac-permission-matrix.md` +- `docs/05-workflow-design.md` +- `docs/07-testing-plan.md` +- `docs/decision-log.md` +- `docs/progress-log.md` +- `docs/changelog.md` + +Verification: + +- `php artisan route:list --except-vendor` passed and shows 72 routes. +- `php artisan test --filter=PpmApplicationReviewTest` passed: 6 tests, 24 assertions. +- `php artisan test` passed: 47 tests, 161 assertions. +- `vendor\bin\pint.bat --test` passed. +- `vendor\bin\phpstan.bat analyse --memory-limit=1G` passed. +- `npm.cmd run build` passed. + +Manual testing: + +- Log in as `ppm@prn.local` / `password`. +- Open `/ppm/applications`. +- Submit a public application through QR first, then confirm it appears in the PPM queue. +- Open the application detail page and download uploaded documents. +- Approve a KTM application into an available Saluran. +- Approve a KP/KPDP application under an active KTM. +- Change an applicant role before approval and confirm assignment is created. +- Reject an application with a reason. +- Confirm a PPM cannot access an application outside their assigned Pusat Mengundi. + +Pending items: + +- Phase 6 KTM flow for KP registration/approval, deletion of KTM-created records, and email notification. +- Admin/Admin Kewangan document viewing routes. +- Admin global management and post-registration notes in Phase 7. +- Attendance module in Phase 10. + +Next step: + +- Phase 6: KTM flow for assigned Saluran, KP registration, KP approval, KTM-created deletion, and email notification. + +## 2026-05-29 - Phase 6: KTM Flow + +Status: Completed + +Completed: + +- Added KTM application routes under `/ktm/applications`. +- Added `KtmScopeService` for active KTM assignment ownership checks. +- Added `KtmApplicationService` for KTM KP registration, KP approval, and KTM-created deletion. +- Added queued Markdown Mailable `KtmCreatedApplicantMail`. +- Added KTM team list showing assigned Saluran, active KP team members, and KP vacancy. +- Added KTM KP registration form. +- Added KTM application detail page with approve/delete actions. +- Enforced KTM registration period on KP registration and approval. +- Enforced KP-only creation and approval for KTM. +- Enforced own-team-only approval/deletion. +- Implemented `source = created_by_ktm` for KTM-created records. +- Implemented delete flow that sets `deleted_by_ktm`, records status history, and soft deletes the KTM-created application. +- Updated KTM dashboard with live metrics and navigation links. +- Added Phase 6 feature tests. + +Changed files: + +- `app/Http/Controllers/DashboardController.php` +- `app/Http/Controllers/Ktm/TeamController.php` +- `app/Http/Requests/Ktm/RegisterKpApplicantRequest.php` +- `app/Mail/KtmCreatedApplicantMail.php` +- `app/Services/Ktm/KtmApplicationService.php` +- `app/Services/Ktm/KtmScopeService.php` +- `app/Services/Public/KtmVacancyService.php` +- `resources/views/emails/ktm/created-applicant.blade.php` +- `resources/views/ktm/applications/create.blade.php` +- `resources/views/ktm/applications/index.blade.php` +- `resources/views/ktm/applications/show.blade.php` +- `routes/web.php` +- `tests/Feature/KtmFlowTest.php` +- `docs/01-development-plan.md` +- `docs/04-rbac-permission-matrix.md` +- `docs/05-workflow-design.md` +- `docs/07-testing-plan.md` +- `docs/decision-log.md` +- `docs/progress-log.md` +- `docs/changelog.md` + +Verification: + +- `php artisan route:list --except-vendor` passed and shows 78 routes. +- `php artisan test --filter=KtmFlowTest` passed: 6 tests, 25 assertions. +- `php artisan test` passed: 53 tests, 186 assertions. +- `vendor\bin\pint.bat --test` passed. +- `vendor\bin\phpstan.bat analyse --memory-limit=1G` passed. +- `npm.cmd run build` passed. + +Manual testing: + +- Log in as `ktm@prn.local` / `password`. +- Open `/ktm/applications`. +- Confirm assigned Saluran and KP vacancy are shown. +- Register a KP and confirm email is queued/sent according to local mail configuration. +- Approve a KP under own team and confirm active assignment is created. +- Try approving KP from another KTM team and confirm it is blocked. +- Try approving a non-KP application and confirm it is blocked. +- Set registration end date to the past and confirm KTM registration/approval is blocked. +- Delete an unassigned KTM-created applicant and confirm public self-registration with the same IC is no longer blocked. + +Pending items: + +- Phase 7 Admin global management/manual entry and post-registration notes. +- Admin/Admin Kewangan document viewing routes. +- Finance verification module in Phase 8. +- Attendance module in Phase 10. + +Next step: + +- Phase 7: Admin management and manual entry. + +## 2026-05-29 - Phase 7: Admin Management and Manual Entry + +Status: Completed + +Completed: + +- Added Admin management routes under `/admin/management`. +- Added Admin management overview dashboard. +- Added global application list with filters. +- Added Admin manual application entry with `source = admin_manual`. +- Added Admin application edit screen. +- Added direct assignment from Admin application edit screen. +- Added global staff assignment list and assignment edit screen. +- Added post-registration catatan enforcement through `AdminPostCloseNoteService`. +- Added `system_notes` records for accepted post-closing Admin changes. +- Added assignment/application history and activity log entries for Admin changes. +- Added Police, KKM, and JKM representative entry/deletion screen. +- Updated Admin dashboard with live counts and management module links. +- Added Phase 7 feature tests. + +Changed files: + +- `app/Http/Controllers/Admin/Management/AdminManagementController.php` +- `app/Http/Controllers/Admin/Management/ManagedApplicationController.php` +- `app/Http/Controllers/Admin/Management/RepresentativeController.php` +- `app/Http/Controllers/Admin/Management/StaffAssignmentController.php` +- `app/Http/Controllers/DashboardController.php` +- `app/Http/Requests/Admin/Management/*.php` +- `app/Services/Admin/Management/*.php` +- `resources/views/admin/management/**/*.blade.php` +- `routes/web.php` +- `tests/Feature/AdminManagementTest.php` +- `docs/01-development-plan.md` +- `docs/04-rbac-permission-matrix.md` +- `docs/05-workflow-design.md` +- `docs/07-testing-plan.md` +- `docs/decision-log.md` +- `docs/progress-log.md` +- `docs/changelog.md` + +Verification: + +- `php artisan route:list --except-vendor` passed and shows 91 routes. +- `php artisan test --filter=AdminManagementTest` passed: 5 tests, 18 assertions. +- `php artisan test` passed: 58 tests, 204 assertions. +- `vendor\bin\phpstan.bat analyse --memory-limit=1G` passed. +- `vendor\bin\pint.bat --test` passed. +- `npm.cmd run build` passed. + +Manual testing: + +- Log in as `admin@prn.local` / `password`. +- Open `/admin/management`. +- Create a manual application. +- Edit a manual or public application. +- Direct assign an application to a Pusat, Saluran, or KTM team. +- Edit an existing staff assignment. +- Create Police, KKM, and JKM representative records. +- Close the registration period for the election and confirm Admin changes require catatan. +- Log in as `kewangan@prn.local` / `password` and confirm `/admin/management/assignments` is forbidden. + +Pending items: + +- Admin document viewing route for IC/bank statement remains for the finance/document module. +- Excel export implementation remains in later finance/export phases. +- More granular representative edit forms can be added if operational users need inline correction instead of delete/recreate. + +Next step: + +- Phase 8: Admin Kewangan module for bank verification, finance filters, and finance export. + +## 2026-05-29 - Phase 8: Admin Kewangan + +Status: Completed + +Completed: + +- Added finance bank verification routes under `/kewangan/bank`. +- Added Admin Kewangan list of approved/assigned staff bank records. +- Added filters for status, Pusat Mengundi, role, missing bank statement, missing account number, and keyword. +- Added bank verification detail screen. +- Added finance status update with finance note, verified by, and verified at. +- Added activity log for finance verification changes. +- Added sensitive display masking helper. +- Added finance XLSX export using OpenSpout. +- Added export log creation for finance exports. +- Updated Admin Kewangan dashboard with live counts and module links. +- Added Phase 8 feature tests. + +Changed files: + +- `app/Exports/FinanceVerificationExport.php` +- `app/Http/Controllers/DashboardController.php` +- `app/Http/Controllers/Kewangan/BankVerificationController.php` +- `app/Http/Requests/Kewangan/UpdateBankVerificationRequest.php` +- `app/Services/Finance/BankVerificationService.php` +- `app/Services/Finance/FinanceExportService.php` +- `app/Services/Finance/FinanceVerificationQuery.php` +- `app/Support/SensitiveData.php` +- `resources/views/kewangan/bank/index.blade.php` +- `resources/views/kewangan/bank/show.blade.php` +- `routes/web.php` +- `tests/Feature/FinanceModuleTest.php` +- `docs/01-development-plan.md` +- `docs/04-rbac-permission-matrix.md` +- `docs/05-workflow-design.md` +- `docs/07-testing-plan.md` +- `docs/decision-log.md` +- `docs/progress-log.md` +- `docs/changelog.md` + +Verification: + +- `php artisan route:list --except-vendor` passed and shows 95 routes. +- `php artisan test --filter=FinanceModuleTest` passed: 5 tests, 18 assertions. +- `php artisan test` passed: 63 tests, 222 assertions. +- `vendor\bin\phpstan.bat analyse --memory-limit=1G` passed. +- `vendor\bin\pint.bat --test` passed. +- `npm.cmd run build` passed. + +Manual testing: + +- Log in as `kewangan@prn.local` / `password`. +- Open `/kewangan/bank`. +- Filter records by Pusat, role, status, missing bank statement, and missing account number. +- Open a bank verification record. +- Mark it verified, rejected, or requires correction with a finance note. +- Export the finance list and confirm a file downloads. +- Confirm `/admin/management/assignments` is forbidden for Admin Kewangan. + +Pending items: + +- Admin/Admin Kewangan authorized document download for bank statement remains to be added when document review UX is finalized. +- Additional finance export formats can be added if official template is provided. + +Next step: + +- Phase 9: Wheelchair management. + +## 2026-05-29 - Phase 9: Wheelchair Management + +Status: Completed + +Completed: + +- Added Admin wheelchair management routes under `/admin/management/wheelchairs`. +- Added allocation upsert per election and Pusat Mengundi. +- Added wheelchair management service for allocation and transaction rules. +- Added taken transaction recording with quantity, time, person taking, and notes. +- Added return transaction recording with quantity, time, condition, and notes. +- Added quantity guards for allocation, available balance, and outstanding return balance. +- Added activity logging for allocation and transaction changes. +- Added wheelchair list and detail screens. +- Updated Admin management dashboard and navigation. +- Added Phase 9 feature tests. + +Changed files: + +- `app/Http/Controllers/Admin/Management/AdminManagementController.php` +- `app/Http/Controllers/Admin/Management/WheelchairController.php` +- `app/Http/Requests/Admin/Management/StoreWheelchairTransactionRequest.php` +- `app/Http/Requests/Admin/Management/UpsertWheelchairAllocationRequest.php` +- `app/Services/Admin/Management/WheelchairManagementService.php` +- `resources/views/admin/management/_nav.blade.php` +- `resources/views/admin/management/index.blade.php` +- `resources/views/admin/management/wheelchairs/index.blade.php` +- `resources/views/admin/management/wheelchairs/show.blade.php` +- `routes/web.php` +- `tests/Feature/WheelchairManagementTest.php` +- `docs/01-development-plan.md` +- `docs/04-rbac-permission-matrix.md` +- `docs/05-workflow-design.md` +- `docs/07-testing-plan.md` +- `docs/decision-log.md` +- `docs/progress-log.md` +- `docs/changelog.md` + +Verification: + +- `php artisan route:list --except-vendor` passed and shows 100 routes. +- `php artisan test --filter=WheelchairManagementTest` passed: 6 tests, 16 assertions. +- `php artisan test` passed: 69 tests, 238 assertions. +- `vendor\bin\phpstan.bat analyse --memory-limit=1G` passed. +- `vendor\bin\pint.bat --test` passed. +- `npm.cmd run build` passed. + +Manual testing: + +- Log in as `admin@prn.local` / `password`. +- Open `/admin/management/wheelchairs`. +- Create or update a wheelchair allocation for a Pusat Mengundi. +- Open the allocation detail screen. +- Record wheelchair taken transaction. +- Record wheelchair returned transaction with condition `baik` or `rosak`. +- Try taking more than available and confirm validation blocks it. +- Try returning more than outstanding and confirm validation blocks it. +- Try lowering allocation below outstanding and confirm validation blocks it. +- Log in as `kewangan@prn.local` / `password` and confirm wheelchair management is forbidden. + +Pending items: + +- Phase 10 attendance module. +- Optional wheelchair export/report if later required. + +Next step: + +- Phase 10: Attendance module. + +## 2026-05-29 - Phase 10: Attendance Module + +Status: Completed + +Completed: + +- Added PPM attendance routes under `/ppm/attendance`. +- Added PPM attendance list for assigned Pusat Mengundi. +- Added PPM attendance recording screen for active staff under own Pusat Mengundi. +- Added attendance activation guard using `election_settings.is_attendance_active`. +- Added bulk attendance recording service. +- Added activity log entries for attendance updates. +- Added Admin attendance routes under `/admin/attendance`. +- Added Admin attendance dashboard by Pusat Mengundi. +- Added Admin attendance detail view with staff, role, Saluran, status, check-in time, notes, and recorded by. +- Added attendance summary service with totals and role-level summary. +- Added attendance detail XLSX export using OpenSpout. +- Added export log creation for attendance exports. +- Updated Admin and PPM dashboard links/metrics. +- Added Phase 10 feature and unit tests. + +Changed files: + +- `app/Exports/AttendanceDetailExport.php` +- `app/Http/Controllers/Admin/AttendanceController.php` +- `app/Http/Controllers/DashboardController.php` +- `app/Http/Controllers/Ppm/AttendanceController.php` +- `app/Http/Requests/Ppm/RecordAttendanceRequest.php` +- `app/Services/Attendance/AttendanceExportService.php` +- `app/Services/Attendance/AttendanceRecordingService.php` +- `app/Services/Attendance/AttendanceSummaryService.php` +- `resources/views/admin/attendance/index.blade.php` +- `resources/views/admin/attendance/show.blade.php` +- `resources/views/ppm/attendance/index.blade.php` +- `resources/views/ppm/attendance/show.blade.php` +- `routes/web.php` +- `tests/Feature/AttendanceModuleTest.php` +- `tests/Unit/AttendanceSummaryServiceTest.php` +- `docs/01-development-plan.md` +- `docs/04-rbac-permission-matrix.md` +- `docs/05-workflow-design.md` +- `docs/07-testing-plan.md` +- `docs/decision-log.md` +- `docs/progress-log.md` +- `docs/changelog.md` + +Verification: + +- `php artisan route:list --except-vendor` passed and shows 106 routes. +- `php artisan test --filter=Attendance` passed: 7 tests, 21 assertions. +- `php artisan test` passed: 76 tests, 259 assertions. +- `vendor\bin\phpstan.bat analyse --memory-limit=1G` passed. +- `vendor\bin\pint.bat --test` passed. +- `npm.cmd run build` passed. + +Manual testing: + +- Activate attendance for the election in database/settings. +- Log in as `ppm@prn.local` / `password`. +- Open `/ppm/attendance`. +- Record present/absent/not recorded statuses for staff under own Pusat Mengundi. +- Confirm PPM cannot access or record another Pusat Mengundi. +- Log in as `admin@prn.local` / `password`. +- Open `/admin/attendance`. +- View Pusat Mengundi summary and detail. +- Export attendance detail and confirm file downloads. +- Log in as `kewangan@prn.local` / `password` and confirm Admin attendance dashboard is forbidden. + +Pending items: + +- Admin settings UI to toggle `is_attendance_active` if operational users need UI control. +- Official attendance report template can replace the current export layout when provided. + +Next step: + +- Phase 11: Testing, polish, security review, performance review, and final hardening. + +## 2026-05-29 - Phase 11: Testing, Polish, and Hardening + +Status: Completed + +Completed: + +- Reviewed authorization, private document access, sensitive data masking, exports, and quality gates. +- Added Admin document download route for private application documents. +- Added Admin Kewangan bank statement download route. +- Restricted finance document downloads to `bank_statement` only. +- Added activity logging for Admin and finance document downloads. +- Added secure document links to Admin application edit and finance bank detail screens. +- Added feature tests for Admin/Admin Kewangan/unauthorized document access. +- Added unit test for sensitive data masking. +- Updated deployment notes with final quality gate commands and document storage hardening notes. +- Updated Phase 11 documentation and release notes. + +Changed files: + +- `app/Http/Controllers/DocumentDownloadController.php` +- `app/Http/Controllers/Admin/Management/ManagedApplicationController.php` +- `resources/views/admin/management/applications/edit.blade.php` +- `resources/views/kewangan/bank/show.blade.php` +- `routes/web.php` +- `tests/Feature/DocumentDownloadAuthorizationTest.php` +- `tests/Unit/SensitiveDataTest.php` +- `docs/01-development-plan.md` +- `docs/07-testing-plan.md` +- `docs/08-deployment-notes.md` +- `docs/decision-log.md` +- `docs/progress-log.md` +- `docs/changelog.md` + +Verification: + +- `php artisan test --filter=DocumentDownloadAuthorizationTest` passed: 3 tests, 4 assertions. +- `php artisan test --filter=SensitiveDataTest` passed: 1 test, 4 assertions. +- `php artisan route:list --except-vendor` passed and shows 108 routes. +- `php artisan test` passed: 80 tests, 267 assertions. +- `vendor\bin\phpstan.bat analyse --memory-limit=1G` passed. +- `vendor\bin\pint.bat --test` passed. +- `npm.cmd run build` passed. + +Manual testing: + +- Log in as `admin@prn.local` / `password`. +- Open an application with uploaded documents and download IC/bank documents. +- Log in as `kewangan@prn.local` / `password`. +- Open bank verification detail and download bank statement. +- Confirm Admin Kewangan cannot download IC document through finance document route. +- Run end-to-end smoke test from QR application to attendance export. + +Pending items: + +- Replace current exports with official report templates if/when provided. +- Add Admin UI toggle for attendance activation if operational users need it outside database/settings maintenance. + +Next step: + +- Production readiness review with real environment credentials, mail transport, queue worker, HTTPS, backup plan, and user acceptance testing. +## 2026-05-31 - Penambahbaikan daftar Pusat Mengundi + +### Selesai + +- Membuang keperluan nama Saluran daripada borang dan validasi setup Saluran. +- Menambah sepuluh input bilangan operasi pada borang daftar Pusat Mengundi. +- Menjana Saluran bernombor serta kuota Pusat dan Saluran secara automatik semasa daftar Pusat. +- Menukar istilah Calon Simpanan kepada Calon Tambahan dengan migrasi data. +- Menambah ujian ciri untuk penciptaan Pusat, Saluran tanpa nama dan kuota operasi. + +### Fail berubah + +- `app/Services/Admin/PusatOperationalSetupService.php` +- `app/Http/Controllers/Admin/Setup/PusatMengundiController.php` +- `app/Http/Requests/Admin/Setup/UpsertPusatMengundiRequest.php` +- `app/Http/Requests/Admin/Setup/UpsertSaluranMengundiRequest.php` +- `resources/views/admin/setup/pusat/_form.blade.php` +- `resources/views/admin/setup/saluran/_form.blade.php` +- `database/migrations/2026_05_31_000000_rename_calon_simpanan_position_to_calon_tambahan.php` +- `database/seeders/PositionSeeder.php` +- `database/seeders/SampleElectionSeeder.php` +- `tests/Feature/Admin/PusatOperationalSetupTest.php` + +### Semakan manual + +- Buka `/admin/setup/pusat/create`, pastikan sepuluh medan bilangan operasi dipaparkan. +- Daftar Pusat baharu dan pastikan Saluran bernombor dijana tanpa meminta nama Saluran. + +### Seterusnya + +- Jalankan migrasi, ujian automatik dan semakan statik selepas shell runner tersedia. + +## 2026-06-02 - Perancangan Fasa 12–15: Pembetulan dan Penambahbaikan + +Status: Selesai (perancangan) + +Selesai: + +- Menjalankan semakan kod menyeluruh ke atas keseluruhan sistem selepas Phase 11 dan penambahbaikan 2026-05-31. +- Mengenal pasti lapan isu meliputi keselamatan, prestasi, pengalaman pengguna, dan pengurusan data. +- Menyusun isu-isu tersebut kepada empat fasa penambahbaikan berprioritikan (Fasa 12–15). +- Mendokumentasikan plan penuh dalam `docs/09-improvement-plan.md`. +- Mengemas kini `docs/01-development-plan.md` dengan ringkasan Fasa 12–15. + +Fail berubah: + +- `docs/09-improvement-plan.md` (baharu) +- `docs/01-development-plan.md` +- `docs/progress-log.md` +- `docs/changelog.md` + +Isu yang dikenal pasti: + +- 12-A: Tiada UI toggle untuk `is_attendance_active` dan `is_registration_open_override` — memerlukan edit database terus dalam produksi. +- 12-B: Semakan IC duplikasi mungkin tidak mengecualikan soft-deleted records secara eksplisit dan konsisten. +- 12-C: `AdminPostCloseNoteService` boleh dipintas jika controller baharu lupa memanggilnya. +- 12-D: `SampleElectionSeeder` mungkin masih mengandungi rujukan kepada `CALON_SIMPANAN` selepas migration rename Phase 11. +- 12-E: Semakan jenis dokumen pada path download tidak sepenuhnya konsisten antara peranan. +- 13-A: Composite indexes belum wujud pada `applications`, `staff_assignments`, `bank_verifications` — boleh perlahan apabila data besar. +- 14-A: Tiada halaman status permohonan untuk pemohon awam — mereka perlu hubungi Admin untuk semak status. +- 15-A: Fail XLSX eksport sensitif disimpan tanpa had masa — polisi purge automatik diperlukan. + +Seterusnya: + +- Mulakan Fasa 12 dengan 12-A (Admin Settings UI) kerana ia paling mendesak untuk kebolehgunaan dalam persekitaran produksi. + +## 2026-06-02 - Fasa 12-A: Admin Settings UI dan 12-D: Pembetulan Seeder + +Status: Selesai + +Selesai: + +- Tambah halaman Admin Settings di `/admin/setup/settings/{election}` untuk toggle `is_attendance_active` dan `is_registration_open_override` tanpa perlu edit database. +- Tambah `ElectionSettingsController`, `ElectionSettingsService`, `UpdateElectionSettingsRequest`. +- Tambah view `admin/setup/settings/edit.blade.php` dengan toggle radio untuk kedua-dua tetapan, badge status semasa, dan amaran override aktif. +- Tambah pautan "Tetapan" (kuning) ke `admin/setup/_nav.blade.php` dan setup index actions. +- Perubahan tetapan direkod dalam `activity_log` dengan nilai sebelum dan selepas. +- Betulkan bug 12-D: `SampleElectionSeeder::seedQuotas()` masih query `CALON_SIMPANAN` — ditukar kepada `CALON_TAMBAHAN`. +- Betulkan `AdminSetupTest`: tambah medan operasi yang diperlukan untuk pusat.store, dan tukar nombor saluran dari '1' ke '3' untuk elak konflik dengan saluran auto-jana. +- Betulkan anotasi PHPDoc pada `UpdateElectionSettingsRequest::prepareForValidation()`. +- Tambah anotasi `@var ElectionSetting` pada `ElectionSettingsService` untuk PHPStan. +- Jalankan `pint` (auto-fix 2 fail) dan `phpstan` (0 error). + +Fail berubah: + +- `app/Http/Controllers/Admin/Setup/ElectionSettingsController.php` (baharu) +- `app/Http/Requests/Admin/Setup/UpdateElectionSettingsRequest.php` (baharu) +- `app/Services/Admin/ElectionSettingsService.php` (baharu) +- `resources/views/admin/setup/settings/edit.blade.php` (baharu) +- `resources/views/admin/setup/_nav.blade.php` +- `resources/views/admin/setup/index.blade.php` +- `routes/web.php` +- `database/seeders/SampleElectionSeeder.php` +- `tests/Feature/Admin/ElectionSettingsTest.php` (baharu) +- `tests/Feature/AdminSetupTest.php` +- `docs/04-rbac-permission-matrix.md` +- `docs/05-workflow-design.md` +- `docs/09-improvement-plan.md` +- `docs/decision-log.md` +- `docs/progress-log.md` +- `docs/changelog.md` + +Semakan: + +- `php artisan route:list --except-vendor` — 110 routes (tambah 2). +- `php artisan test` — 90 tests, 310 assertions (**selesai, semua lulus**). +- `vendor\bin\pint.bat --test` — lulus. +- `vendor\bin\phpstan.bat analyse --memory-limit=1G` — lulus, 0 error. + +Seterusnya: + +- 12-B: Semak dan betulkan query IC duplikasi untuk pastikan soft-deleted records dikecualikan. +- 12-C: Perkukuh enforcement nota catatan post-pendaftaran. +- 12-E: Konsistensikan semakan jenis dokumen download. + +## 2026-06-02 - Fasa 12-B: Pembetulan Semakan IC Soft-Deleted + +Status: Selesai + +Selesai: + +- Audit menyeluruh semua lokasi semakan IC duplikasi: `StorePublicApplicationRequest::validateDuplicateIc()`, `KtmApplicationService::registerKp()`, `KtmVacancyService::remainingVacancy()`, dan `AssignmentVacancyService`. +- Disahkan bahawa kedua-dua query IC menggunakan SoftDeletes scope (mengecualikan `deleted_at IS NOT NULL`) DAN `whereNotIn('status', ['cancelled', 'deleted_by_ktm'])` — perlindungan dua lapis yang betul. +- `cancelled` records: dikecualikan oleh `whereNotIn` sahaja (bukan soft-deleted). +- `deleted_by_ktm` records: dikecualikan oleh kedua-dua SoftDeletes scope DAN `whereNotIn` (redundant safety layer). +- Tambah comment eksplisit pada kedua-dua query IC untuk mendokumentasikan intent perlindungan dua lapis ini. +- Tambah 2 ujian baharu dalam `PublicApplicationTest`: + - `test_public_registration_is_unblocked_after_ktm_deletes_applicant`: KTM cipta rekod, KTM delete (soft-delete + status deleted_by_ktm), pemohon awam dengan IC yang sama boleh daftar semula. + - `test_cancelled_application_does_not_block_new_registration`: Rekod `cancelled` (bukan soft-deleted) tidak menyekat pendaftaran IC yang sama. +- `KtmVacancyService` dan `AssignmentVacancyService` disahkan betul — menggunakan whitelist status dan `StaffAssignment` queries tidak melibatkan soft-deleted `Application` records secara salah. + +Fail berubah: + +- `app/Http/Requests/Public/StorePublicApplicationRequest.php` — tambah comment +- `app/Services/Ktm/KtmApplicationService.php` — tambah comment +- `tests/Feature/PublicApplicationTest.php` — tambah 2 ujian +- `docs/09-improvement-plan.md` +- `docs/progress-log.md` +- `docs/changelog.md` + +Semakan: + +- `php artisan test` — 92 tests, 316 assertions (**semua lulus**). +- `vendor\bin\pint.bat --test` — lulus. +- `vendor\bin\phpstan.bat analyse --memory-limit=1G` — lulus, 0 error. + +Seterusnya: + +- 12-C: Perkukuh enforcement nota catatan post-pendaftaran. +- 12-E: Konsistensikan semakan jenis dokumen download. + +## 2026-06-02 - Fasa 12-C: Perkukuh Enforcement Nota Catatan Post-Pendaftaran + +Status: Selesai + +Selesai: + +- Audit semua 6 service methods yang mengubah rekod domain selepas pendaftaran tutup — kesemua sudah memanggil `AdminPostCloseNoteService::requireIfClosed()` dengan betul. +- Masalah: enforcement bergantung pada konvensyen — controller baharu boleh lupa panggil service tersebut. +- Tambah middleware `RequireAdminPostCloseNote` sebagai safety net automatik pada route group `admin/management`. +- Middleware menyemak `election_id` dari request body (untuk create routes) atau active election (untuk edit/delete routes). Jika pendaftaran ditutup dan `note` kosong, redirect balik dengan ralat validasi. +- Daftarkan alias `admin.post_close_note` dalam `bootstrap/app.php`. +- Apply middleware ke `['auth', 'role:Admin', 'admin.post_close_note']` dalam `routes/web.php`. +- Tambah 5 ujian baharu dalam `AdminManagementTest`: + - `test_admin_create_application_after_registration_closed_requires_catatan` + - `test_admin_update_assignment_after_registration_closed_requires_catatan` + - `test_admin_create_representative_after_registration_closed_requires_catatan` + - `test_admin_delete_representative_after_registration_closed_requires_catatan` + - `test_middleware_does_not_block_when_registration_is_open` + +Fail berubah: + +- `app/Http/Middleware/RequireAdminPostCloseNote.php` (baharu) +- `bootstrap/app.php` — alias middleware baharu +- `routes/web.php` — middleware pada route group management +- `tests/Feature/AdminManagementTest.php` — tambah 5 ujian +- `docs/decision-log.md` +- `docs/09-improvement-plan.md` +- `docs/progress-log.md` +- `docs/changelog.md` + +Semakan: + +- `php artisan test` — 97 tests, 337 assertions (**semua lulus**). +- `vendor\bin\pint.bat --test` — lulus. +- `vendor\bin\phpstan.bat analyse --memory-limit=1G` — lulus, 0 error. + +Seterusnya: + +- 12-E: Konsistensikan semakan jenis dokumen download. + +## 2026-06-02 - Fasa 13-A: Composite Indexes Pangkalan Data + +Status: Selesai + +Selesai: + +- Audit index sedia ada berbanding query kritikal dalam `KtmVacancyService`, `AssignmentVacancyService`, `ManagedApplicationController`, dan `DashboardController`. +- Dapatan: dua index yang disenaraikan dalam plan sudah wujud — `(election_id, ic_number)` dan `(election_id, pusat_mengundi_id, status)` pada `applications`, serta dua index komposit pada `staff_assignments`. +- `bank_verifications` tiada kolum `election_id` — index `(election_id, status)` yang diancangkan dalam plan tidak relevan dan tidak ditambah. +- Tiga index baharu yang benar-benar diperlukan ditambah melalui migration `2026_06_02_000000_add_performance_indexes.php`: + - `applications.app_election_status_idx` — `(election_id, status)` untuk dashboard count queries + - `applications.app_ktm_position_idx` — `(selected_ktm_assignment_id, requested_position_id)` untuk `KtmVacancyService` reserved count + - `staff_assignments.sa_reports_to_pos_status_idx` — `(reports_to_assignment_id, position_id, status)` untuk `KtmVacancyService` assigned count +- Dokumentasi index lengkap ditambah dalam `docs/03-database-design.md`. +- Arahan verifikasi index dengan `SHOW INDEX` dan `EXPLAIN` ditambah dalam `docs/08-deployment-notes.md`. + +Fail berubah: + +- `database/migrations/2026_06_02_000000_add_performance_indexes.php` (baharu) +- `docs/03-database-design.md` +- `docs/08-deployment-notes.md` +- `docs/decision-log.md` +- `docs/09-improvement-plan.md` +- `docs/progress-log.md` +- `docs/changelog.md` + +Semakan: + +- `php artisan migrate` — lulus, 2 migration dijalankan. +- `php artisan test` — 97 tests, 337 assertions (**semua lulus**). +- `vendor\bin\pint.bat --test` — lulus. +- `vendor\bin\phpstan.bat analyse --memory-limit=1G` — lulus, 0 error. + +Seterusnya: + +- 12-E: Konsistensikan semakan jenis dokumen download. +- Fasa 14: Portal status permohonan pemohon awam. +- Fasa 15: Purge automatik fail eksport sensitif. + +## 2026-06-02 - Fasa 12-E: Konsistensikan Semakan Jenis Dokumen Download + +Status: Selesai + +Selesai: + +- Audit tiga path download dokumen — PPM, Admin, Finance (Admin Kewangan). +- Dapatan: + - PPM: tiada type check eksplisit, tiada activity log, ownership failure pakai 404 (patut 403). + - Admin: tiada type check eksplisit (senyap allow semua jenis). + - Finance: sudah betul — explicit `bank_statement` check, log activity, pakai 403. +- Tambah `ApplicationDocument::TYPE_IC_DOCUMENT`, `TYPE_BANK_STATEMENT`, `ALLOWED_FOR_ADMIN`, `ALLOWED_FOR_PPM`, `ALLOWED_FOR_FINANCE` sebagai constants dalam model — satu tempat yang autoriti untuk semua rules. +- Kemaskini `DocumentDownloadController::admin()` guna `ALLOWED_FOR_ADMIN` — jenis baharu perlu didaftarkan secara eksplisit untuk Admin dapat akses. +- Kemaskini `DocumentDownloadController::finance()` guna `ALLOWED_FOR_FINANCE` — logik sama, kini guna constant. +- Kemaskini `ApplicationReviewController::downloadDocument()`: + - Tambah `ALLOWED_FOR_PPM` type check → 403 jika jenis tidak dibenarkan. + - Tukar ownership check dari 404 → 403 (tidak mendedahkan kewujudan ID dokumen). + - Tambah activity log `document_downloaded_by_ppm`. +- Tambah 9 ujian baharu dalam `DocumentDownloadAuthorizationTest` meliputi semua tiga path dan kes utama: + - Admin: ic_document ✓, bank_statement ✓, peranan tidak dibenarkan ✗. + - Finance: bank_statement ✓, ic_document ✗. + - PPM: ic_document (pusat sendiri) ✓, bank_statement (pusat sendiri) ✓, dokumen pusat lain ✗, activity log disimpan. + +Fail berubah: + +- `app/Models/ApplicationDocument.php` — tambah type constants dan allowlist +- `app/Http/Controllers/DocumentDownloadController.php` — explicit type checks via constants +- `app/Http/Controllers/Ppm/ApplicationReviewController.php` — type check, 403, activity log +- `tests/Feature/DocumentDownloadAuthorizationTest.php` — tulis semula dengan 9 ujian komprehensif +- `docs/04-rbac-permission-matrix.md` +- `docs/decision-log.md` +- `docs/09-improvement-plan.md` +- `docs/progress-log.md` +- `docs/changelog.md` + +Semakan: + +- `php artisan test` — 103 tests, 342 assertions (**semua lulus**). +- `vendor\bin\pint.bat --test` — lulus. +- `vendor\bin\phpstan.bat analyse --memory-limit=1G` — lulus, 0 error. + +Seterusnya: + +- Fasa 14: Portal status permohonan pemohon awam. +- Fasa 15: Purge automatik fail eksport sensitif. + +## 2026-06-02 - Fasa 14-A: Portal Status Permohonan Pemohon Awam + +Status: Selesai + +Selesai: + +- Tambah route `GET /permohonan/{publicUuid}/status` tanpa route model binding untuk elak 404 mendedahkan kewujudan UUID. +- Tambah `PublicApplicationController::status()` yang mengendalikan semua kes dengan selamat: + - UUID tidak wujud → 200 dengan mesej generik. + - Rekod soft-deleted → 200 dengan mesej generik. + - Status `deleted_by_ktm` → 200 dengan mesej generik (tidak dedahkan status dalaman). + - Rekod aktif → 200 dengan data terhad. +- Tambah `SensitiveData::maskName()` — tunjuk 3 aksara pertama sahaja. +- Cipta view `public/applications/status.blade.php` memaparkan: nama disamarkan, jawatan dipohon, jawatan diluluskan (jika berbeza), pusat mengundi, badge status dengan warna, sebab penolakan (jika ditolak), nombor rujukan UUID. +- Kemaskini `success.blade.php` dengan panel simpan pautan status dan butang salin URL ke clipboard. +- Tambah 9 ujian baharu dalam `PublicApplicationStatusTest`: + - submitted, assigned, rejected (dengan sebab), UUID tidak dikenali, deleted_by_ktm, soft-deleted — semuanya. + - Nama penuh tidak dipaparkan, IC tidak dipaparkan. + - Halaman boleh dicapai tanpa log masuk. + - Success page paparkan pautan status. + +Fail berubah: + +- `app/Http/Controllers/PublicApplicationController.php` — tambah `status()` +- `app/Support/SensitiveData.php` — tambah `maskName()` +- `resources/views/public/applications/status.blade.php` (baharu) +- `resources/views/public/applications/success.blade.php` — tambah panel pautan status +- `routes/web.php` — route baharu +- `tests/Feature/PublicApplicationStatusTest.php` (baharu) +- `docs/05-workflow-design.md` +- `docs/06-ui-ux-plan.md` +- `docs/decision-log.md` +- `docs/09-improvement-plan.md` +- `docs/progress-log.md` +- `docs/changelog.md` + +Semakan: + +- `php artisan test` — 112 tests, 364 assertions (**semua lulus**). +- `vendor\bin\pint.bat --test` — lulus. +- `vendor\bin\phpstan.bat analyse --memory-limit=1G` — lulus, 0 error. + +Seterusnya: + +- Fasa 15: Purge automatik fail eksport sensitif. + +## 2026-06-02 - Fasa 15-A: Purge Automatik Fail Eksport Sensitif + +Status: Selesai + +Selesai: + +- Tambah kolum `purged_at` (nullable timestamp) dan `purged_by_user_id` (nullable FK) pada `export_logs` melalui migration `2026_06_02_000001_add_purged_at_to_export_logs.php`. +- Kemaskini model `ExportLog` dengan cast `purged_at` dan relationship `purgedBy()`. +- Cipta Artisan command `ExportPurgeCommand` (`php artisan exports:purge --days=30`): + - Query `export_logs` di mana `created_at < now - days` DAN `purged_at IS NULL`. + - Padam fail dari storage disk yang berkaitan. + - Rekod yang filenya sudah tiada di disk tetap ditandakan `purged_at` supaya tidak diproses semula. + - Log aktiviti `export_file_purged` untuk setiap rekod. + - Lapor bilangan fail dipadamkan dan rekod tiada fail. +- Daftarkan command sebagai scheduled task harian pada `00:00` dalam `routes/console.php` (`--days=30`, withoutOverlapping, runInBackground). +- Tambah 7 ujian baharu dalam `ExportPurgeCommandTest`: + - Fail lama dipadamkan, `purged_at` ditetapkan. + - Fail dalam tempoh simpanan tidak disentuh. + - Rekod tanpa fail (`missing`) tetap ditandakan `purged_at`. + - Rekod yang sudah `purged_at` tidak diproses semula. + - `--days` custom berfungsi. + - Activity log direkod. + - `--days=0` gagal dengan exit code bukan 0. + +Fail berubah: + +- `database/migrations/2026_06_02_000001_add_purged_at_to_export_logs.php` (baharu) +- `app/Models/ExportLog.php` — tambah cast + relationship +- `app/Console/Commands/ExportPurgeCommand.php` (baharu) +- `routes/console.php` — scheduled task +- `tests/Feature/ExportPurgeCommandTest.php` (baharu) +- `docs/03-database-design.md` +- `docs/08-deployment-notes.md` +- `docs/decision-log.md` +- `docs/09-improvement-plan.md` +- `docs/progress-log.md` +- `docs/changelog.md` + +Semakan: + +- `php artisan migrate` — lulus. +- `php artisan test` — 119 tests, 388 assertions (**semua lulus**). +- `vendor\bin\pint.bat --test` — lulus. +- `vendor\bin\phpstan.bat analyse --memory-limit=1G` — lulus, 0 error. + +Semua fasa selesai (12-A hingga 15-A). Sistem sedia untuk UAT. diff --git a/docs/~$N49-Kota Iskandar - senarai pusat mengundi.xlsx b/docs/~$N49-Kota Iskandar - senarai pusat mengundi.xlsx new file mode 100644 index 0000000..aca6c43 Binary files /dev/null and b/docs/~$N49-Kota Iskandar - senarai pusat mengundi.xlsx differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2bb33d0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2880 @@ +{ + "name": "prn2026", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@popperjs/core": "^2.11.8", + "bootstrap": "^5.3.8", + "bootstrap-icons": "^1.13.1", + "datatables.net-bs5": "^2.3.8", + "datatables.net-responsive-bs5": "^3.0.8", + "flatpickr": "^4.6.13", + "jquery": "^4.0.0", + "sweetalert2": "^11.26.25", + "tom-select": "^2.6.1" + }, + "devDependencies": { + "@tailwindcss/forms": "^0.5.2", + "@tailwindcss/vite": "^4.0.0", + "alpinejs": "^3.4.2", + "autoprefixer": "^10.4.2", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^3.1", + "postcss": "^8.4.31", + "tailwindcss": "^3.1.0", + "vite": "^8.0.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@orchidjs/sifter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@orchidjs/sifter/-/sifter-1.1.0.tgz", + "integrity": "sha512-mYwHCfr736cIWWdhhSZvDbf90AKt2xyrJspKFC3qyIJG1LtrJeJunYEqCGG4Aq2ijENbc4WkOjszcvNaIAS/pQ==", + "license": "Apache-2.0", + "dependencies": { + "@orchidjs/unicode-variants": "^1.1.2" + } + }, + "node_modules/@orchidjs/unicode-variants": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@orchidjs/unicode-variants/-/unicode-variants-1.1.2.tgz", + "integrity": "sha512-5DobW1CHgnBROOEpFlEXytED5OosEWESFvg/VYmH0143oXcijYTprRYJTs+55HzGM4IqxiLFSuqEzu9mPNwVsA==", + "license": "Apache-2.0" + }, + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", + "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tailwindcss/vite/node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", + "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.1.5" + } + }, + "node_modules/@vue/shared": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", + "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/alpinejs": { + "version": "3.15.12", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz", + "integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.1.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/bootstrap-icons": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz", + "integrity": "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/datatables.net": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/datatables.net/-/datatables.net-2.3.8.tgz", + "integrity": "sha512-uhViowhlDlheAuo5a8TrkQqADsjrtGeOyvrigvr4t0+K3MyAWqClORXWAYIcN9VLX6iIX0C8O9gwJNd01hITRg==", + "license": "MIT", + "dependencies": { + "jquery": ">=1.7" + } + }, + "node_modules/datatables.net-bs5": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/datatables.net-bs5/-/datatables.net-bs5-2.3.8.tgz", + "integrity": "sha512-TbFH99QSWm93Kn3teHLFKeyOqYbaiddlHvRFdXUwAvh/fjTMhACWmHG+I43ss8d23OEFHV0WIbN4lpPusZm5zw==", + "license": "MIT", + "dependencies": { + "datatables.net": "2.3.8", + "jquery": ">=1.7" + } + }, + "node_modules/datatables.net-responsive": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/datatables.net-responsive/-/datatables.net-responsive-3.0.8.tgz", + "integrity": "sha512-htslaX9g/9HFrJeyFQKEe/XJWpawPxpvy+M6vc/NkKQIrKhbxSoPc3phPqmlnZth6b9hgawqWDT0e0lwf5p+KA==", + "license": "MIT", + "dependencies": { + "datatables.net": "^2", + "jquery": ">=1.7" + } + }, + "node_modules/datatables.net-responsive-bs5": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/datatables.net-responsive-bs5/-/datatables.net-responsive-bs5-3.0.8.tgz", + "integrity": "sha512-f0YTxv/HKWKXkOdutwDe3MmRM3AWf4Lxw7FjrgVc3H5+62emUnHep6cA9VwUcAAMywNqMYVndaKPyhAoeKUCyQ==", + "license": "MIT", + "dependencies": { + "datatables.net-bs5": "^2", + "datatables.net-responsive": "3.0.8", + "jquery": ">=1.7" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.363", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.363.tgz", + "integrity": "sha512-VjUKPyWzGnT1fujlkEGC/BvN70Hh70KXtAqcmniXviYlJC/ivcT+BWGPyxWVbJZLfvtKR6dqg1L7T7pgAMBtWA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", + "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flatpickr": { + "version": "4.6.13", + "resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz", + "integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==", + "license": "MIT" + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jquery": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz", + "integrity": "sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==", + "license": "MIT" + }, + "node_modules/laravel-vite-plugin": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.0.tgz", + "integrity": "sha512-Fzocl+X4eQ9jOi0RwdphYRGkUbPJ3ky1pTAST5Ot18cS2gw6d2vldK2eCrlKDVjtibCjCx5qptYDlA0373n7qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "tinyglobby": "^0.2.12", + "vite-plugin-full-reload": "^1.1.0" + }, + "bin": { + "clean-orphaned-assets": "bin/clean.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "fontaine": "^0.5.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "fontaine": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sweetalert2": { + "version": "11.26.25", + "resolved": "https://registry.npmjs.org/sweetalert2/-/sweetalert2-11.26.25.tgz", + "integrity": "sha512-+hunCOJdJ6FLj04T9YSLvvZXRjsvIkTeTKP2e4VF8CaBias961BTnWiSFAy7F/CM5eq3QK2Rraoc5Gzftslvkg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/limonte" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tom-select": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tom-select/-/tom-select-2.6.1.tgz", + "integrity": "sha512-d/1kngVOQTGcI/2pVDfDLYjtjUgSSd3fSgkYUpi0y+yRtQQu2kzljj3aUdqMfqc45cjPvDEpfDt/hSX4awDFTg==", + "license": "Apache-2.0", + "dependencies": { + "@orchidjs/sifter": "^1.1.0", + "@orchidjs/unicode-variants": "^1.1.2" + }, + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tom-select" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-full-reload": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", + "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "picomatch": "^2.3.1" + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..705670f --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@tailwindcss/forms": "^0.5.2", + "@tailwindcss/vite": "^4.0.0", + "alpinejs": "^3.4.2", + "autoprefixer": "^10.4.2", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^3.1", + "postcss": "^8.4.31", + "tailwindcss": "^3.1.0", + "vite": "^8.0.0" + }, + "dependencies": { + "@popperjs/core": "^2.11.8", + "bootstrap": "^5.3.8", + "bootstrap-icons": "^1.13.1", + "datatables.net-bs5": "^2.3.8", + "datatables.net-responsive-bs5": "^3.0.8", + "flatpickr": "^4.6.13", + "jquery": "^4.0.0", + "sweetalert2": "^11.26.25", + "tom-select": "^2.6.1" + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..3f084a5 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,9 @@ +includes: + - vendor/larastan/larastan/extension.neon + +parameters: + paths: + - app + - routes + - database/seeders + level: 5 diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..e7f0a48 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,36 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + + diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..49c0612 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..b574a59 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/resources/css/app.css b/resources/css/app.css new file mode 100644 index 0000000..a67e4af --- /dev/null +++ b/resources/css/app.css @@ -0,0 +1,114 @@ +@import 'bootstrap/dist/css/bootstrap.min.css'; +@import 'bootstrap-icons/font/bootstrap-icons.css'; +@import 'sweetalert2/dist/sweetalert2.min.css'; +@import 'tom-select/dist/css/tom-select.bootstrap5.css'; +@import 'flatpickr/dist/flatpickr.min.css'; +@import 'datatables.net-bs5/css/dataTables.bootstrap5.min.css'; +@import 'datatables.net-responsive-bs5/css/responsive.bootstrap5.min.css'; + +:root { + --prn-primary: #123c69; + --prn-primary-dark: #0b2949; + --prn-accent: #2d6a4f; + --prn-border: #d9e2ec; + --prn-surface: #f5f7fa; + --prn-muted: #62748a; +} + +body { + background: var(--prn-surface); + color: #1f2933; +} + +.app-navbar { + background: var(--prn-primary-dark); +} + +.brand-mark { + align-items: center; + background: #fff; + border-radius: 8px; + color: var(--prn-primary-dark); + display: inline-flex; + height: 36px; + justify-content: center; + width: 36px; +} + +.page-shell { + padding-bottom: 3rem; + padding-top: 1.5rem; +} + +.page-heading { + border-bottom: 1px solid var(--prn-border); + margin-bottom: 1.5rem; + padding-bottom: 1rem; +} + +.eyebrow { + color: var(--prn-muted); + font-size: .8125rem; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.stat-card, +.content-panel, +.auth-card { + background: #fff; + border: 1px solid var(--prn-border); + border-radius: 8px; + box-shadow: 0 8px 20px rgba(15, 23, 42, .04); +} + +.stat-card { + min-height: 132px; + padding: 1rem; +} + +.stat-icon { + align-items: center; + border-radius: 8px; + display: inline-flex; + height: 42px; + justify-content: center; + width: 42px; +} + +.auth-body { + background: + linear-gradient(180deg, rgba(18, 60, 105, .94), rgba(18, 60, 105, .82)), + #123c69; + min-height: 100vh; +} + +.auth-shell { + margin: 0 auto; + max-width: 460px; + padding: 2rem 1rem; +} + +.auth-card { + padding: 1.5rem; +} + +.nav-link.active { + font-weight: 700; +} + +.table-mobile-card { + border: 1px solid var(--prn-border); + border-radius: 8px; +} + +@media (max-width: 575.98px) { + .page-shell { + padding-top: 1rem; + } + + .auth-shell { + padding-top: 1rem; + } +} diff --git a/resources/js/app.js b/resources/js/app.js new file mode 100644 index 0000000..3947896 --- /dev/null +++ b/resources/js/app.js @@ -0,0 +1,52 @@ +import * as bootstrap from 'bootstrap'; +import DataTable from 'datatables.net-bs5'; +import 'datatables.net-responsive-bs5'; +import flatpickr from 'flatpickr'; +import $ from 'jquery'; +import Swal from 'sweetalert2'; +import TomSelect from 'tom-select'; + +window.$ = window.jQuery = $; +window.bootstrap = bootstrap; +window.DataTable = DataTable; +window.flatpickr = flatpickr; +window.Swal = Swal; +window.TomSelect = TomSelect; + +document.addEventListener('DOMContentLoaded', () => { + + + document.querySelectorAll('[data-confirm]').forEach((form) => { + form.addEventListener('submit', (event) => { + event.preventDefault(); + + Swal.fire({ + title: form.dataset.confirmTitle || 'Sahkan tindakan', + text: form.dataset.confirmText || 'Tindakan ini akan direkodkan.', + icon: 'warning', + showCancelButton: true, + confirmButtonText: form.dataset.confirmButton || 'Ya, teruskan', + cancelButtonText: 'Batal', + confirmButtonColor: '#123c69', + }).then((result) => { + if (result.isConfirmed) { + form.submit(); + } + }); + }); + }); + + document.querySelectorAll('[data-flatpickr]').forEach((input) => { + flatpickr(input, { + enableTime: input.dataset.enableTime === 'true', + dateFormat: input.dataset.dateFormat || 'Y-m-d', + }); + }); + + document.querySelectorAll('[data-tom-select]').forEach((select) => { + new TomSelect(select, { + create: false, + plugins: ['dropdown_input'], + }); + }); +}); diff --git a/resources/views/admin/attendance/index.blade.php b/resources/views/admin/attendance/index.blade.php new file mode 100644 index 0000000..59044ad --- /dev/null +++ b/resources/views/admin/attendance/index.blade.php @@ -0,0 +1,52 @@ +@extends('layouts.app') + +@section('title', 'Dashboard Kehadiran') + +@section('content') +
+
Admin
+

Dashboard Kehadiran

+

Ringkasan kehadiran mengikut Pusat Mengundi.

+
+ +
+
+ + + + + + + + + + + + + + @forelse ($pusats as $pusat) + @php($summary = $summaryService->totalsForPusat($pusat)) + + + + + + + + + + @empty + + @endforelse + +
StafHadirTidak HadirBelum RekodStatus ModulTindakan
+
{{ $pusat->code }} - {{ $pusat->name }}
+
{{ $pusat->election?->name }}
+
{{ $summary['total_staff'] }}{{ $summary['present'] }}{{ $summary['absent'] }}{{ $summary['not_recorded'] }}{{ $pusat->election?->settings?->is_attendance_active ? 'Aktif' : 'Belum aktif' }} + Detail +
Tiada Pusat Mengundi.
+
+
+ +
{{ $pusats->links() }}
+@endsection diff --git a/resources/views/admin/attendance/show.blade.php b/resources/views/admin/attendance/show.blade.php new file mode 100644 index 0000000..b2c4679 --- /dev/null +++ b/resources/views/admin/attendance/show.blade.php @@ -0,0 +1,86 @@ +@extends('layouts.app') + +@section('title', 'Detail Kehadiran') + +@section('content') +
+
Admin
+
+
+

Detail Kehadiran

+

{{ $pusat->code }} - {{ $pusat->name }}

+
+
+ Kembali + Export +
+
+
+ +
+ @foreach ([ + ['label' => 'Staf', 'value' => $summary['total_staff'], 'tone' => 'primary'], + ['label' => 'Hadir', 'value' => $summary['present'], 'tone' => 'success'], + ['label' => 'Tidak hadir', 'value' => $summary['absent'], 'tone' => 'danger'], + ['label' => 'Belum rekod', 'value' => $summary['not_recorded'], 'tone' => 'warning'], + ] as $card) +
+
+
{{ $card['label'] }}
+
{{ $card['value'] }}
+
+
+ @endforeach +
+ +
+

Ringkasan Mengikut Jawatan

+
+ @foreach ($roleSummaries as $roleSummary) +
+
+
{{ $roleSummary['role'] }}
+
Staf {{ $roleSummary['total_staff'] }} · Hadir {{ $roleSummary['present'] }} · Tidak hadir {{ $roleSummary['absent'] }} · Belum {{ $roleSummary['not_recorded'] }}
+
+
+ @endforeach +
+
+ +
+
+ + + + + + + + + + + + + + @forelse ($assignments as $assignment) + @php($attendance = $assignment->attendance) + + + + + + + + + + @empty + + @endforelse + +
StafJawatanSaluranStatusMasa Check-inCatatanDirekod Oleh
+
{{ $assignment->application?->name ?: $assignment->user?->name ?: 'Tanpa nama' }}
+
{{ $assignment->application?->ic_number ?: $assignment->user?->email ?: '-' }}
+
{{ $assignment->position?->code }}{{ $assignment->saluranMengundi?->number ?: '-' }}{{ str_replace('_', ' ', $attendance?->status ?? 'not_recorded') }}{{ $attendance?->check_in_time?->format('d/m/Y H:i') ?: '-' }}{{ $attendance?->note ?: '-' }}{{ $attendance?->recordedBy?->name ?: '-' }}
Tiada staf aktif.
+
+
+@endsection diff --git a/resources/views/admin/management/_nav.blade.php b/resources/views/admin/management/_nav.blade.php new file mode 100644 index 0000000..73ffc31 --- /dev/null +++ b/resources/views/admin/management/_nav.blade.php @@ -0,0 +1,22 @@ + diff --git a/resources/views/admin/management/applications/_assignment_form.blade.php b/resources/views/admin/management/applications/_assignment_form.blade.php new file mode 100644 index 0000000..3f285c6 --- /dev/null +++ b/resources/views/admin/management/applications/_assignment_form.blade.php @@ -0,0 +1,48 @@ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/resources/views/admin/management/applications/_form.blade.php b/resources/views/admin/management/applications/_form.blade.php new file mode 100644 index 0000000..bc34bb5 --- /dev/null +++ b/resources/views/admin/management/applications/_form.blade.php @@ -0,0 +1,86 @@ +@php($isEdit = $application->exists) + +
+ @unless($isEdit) +
+ + +
+ @endunless + +
+ + +
+ +
+ + +
+ + @if($isEdit) +
+ + +
+ +
+ + +
+ @endif + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/resources/views/admin/management/applications/create.blade.php b/resources/views/admin/management/applications/create.blade.php new file mode 100644 index 0000000..03d5134 --- /dev/null +++ b/resources/views/admin/management/applications/create.blade.php @@ -0,0 +1,24 @@ +@extends('layouts.app') + +@section('title', 'Manual Entry') + +@section('content') +
+
Admin
+

Manual Entry Permohonan

+

Rekod yang dimasukkan Admin tidak menghantar email permintaan butiran bank.

+
+ + @include('admin.management._nav') + +
+
+ @csrf + @include('admin.management.applications._form') +
+ Batal + +
+
+
+@endsection diff --git a/resources/views/admin/management/applications/edit.blade.php b/resources/views/admin/management/applications/edit.blade.php new file mode 100644 index 0000000..030bf87 --- /dev/null +++ b/resources/views/admin/management/applications/edit.blade.php @@ -0,0 +1,77 @@ +@extends('layouts.app') + +@section('title', 'Edit Permohonan') + +@section('content') +
+
Admin
+
+
+

Edit Permohonan

+

{{ $application->name }} · {{ $application->public_uuid }}

+
+
+
+ + @include('admin.management._nav') + +
+
+
+

Maklumat Pemohon

+
+ @csrf + @method('PATCH') + @include('admin.management.applications._form') +
+ Kembali + +
+
+
+
+
+
+

Direct Assignment

+
+ @csrf + @include('admin.management.applications._assignment_form') +
+ +
+
+
+ +
+

Sejarah Status

+
+ @forelse ($application->statusHistories->sortByDesc('created_at') as $history) +
+
{{ $history->from_status ?: '-' }} → {{ $history->to_status }}
+
{{ $history->created_at?->format('d/m/Y H:i') }} · {{ $history->note ?: '-' }}
+
+ @empty +
Tiada sejarah.
+ @endforelse +
+
+ +
+

Dokumen

+ @forelse ($application->documents as $document) +
+
+
{{ str_replace('_', ' ', $document->document_type) }}
+
{{ $document->original_name }}
+
+ + Muat turun + +
+ @empty +
Tiada dokumen dimuat naik.
+ @endforelse +
+
+
+@endsection diff --git a/resources/views/admin/management/applications/index.blade.php b/resources/views/admin/management/applications/index.blade.php new file mode 100644 index 0000000..e4130d4 --- /dev/null +++ b/resources/views/admin/management/applications/index.blade.php @@ -0,0 +1,87 @@ +@extends('layouts.app') + +@section('title', 'Senarai Permohonan') + +@section('content') +
+
Admin
+
+
+

Senarai Permohonan

+

Paparan global untuk semakan, edit, dan direct assignment.

+
+ +
+
+ + @include('admin.management._nav') + +
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ + + + + + + + + + + + + + @forelse ($applications as $application) + + + + + + + + + + @empty + + @endforelse + +
PusatJawatanSumberTindakan
+
{{ $application->name }}
+
{{ $application->ic_number }} · {{ $application->email ?: '-' }}
+
{{ $application->pusatMengundi?->code }}
{{ $application->pusatMengundi?->name }}
{{ $application->approvedPosition?->code ?: $application->requestedPosition?->code }}{{ str_replace('_', ' ', $application->status) }}{{ $application->source }}{{ $application->created_at?->format('d/m/Y') }}Edit
Tiada permohonan dijumpai.
+
+
+ +
{{ $applications->links() }}
+@endsection diff --git a/resources/views/admin/management/assignments/edit.blade.php b/resources/views/admin/management/assignments/edit.blade.php new file mode 100644 index 0000000..7a51c04 --- /dev/null +++ b/resources/views/admin/management/assignments/edit.blade.php @@ -0,0 +1,89 @@ +@extends('layouts.app') + +@section('title', 'Edit Tugasan') + +@section('content') +
+
Admin
+

Edit Tugasan

+

{{ $assignment->application?->name ?: $assignment->user?->name ?: 'Tugasan staf' }}

+
+ + @include('admin.management._nav') + +
+
+
+
+ @csrf + @method('PATCH') +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ Kembali + +
+
+
+
+
+
+

Sejarah Tugasan

+ @forelse ($assignment->histories->sortByDesc('created_at') as $history) +
+
{{ $history->created_at?->format('d/m/Y H:i') }}
+
{{ $history->note ?: '-' }}
+
+ @empty +
Tiada sejarah.
+ @endforelse +
+
+
+@endsection diff --git a/resources/views/admin/management/assignments/index.blade.php b/resources/views/admin/management/assignments/index.blade.php new file mode 100644 index 0000000..36f7bbe --- /dev/null +++ b/resources/views/admin/management/assignments/index.blade.php @@ -0,0 +1,79 @@ +@extends('layouts.app') + +@section('title', 'Senarai Tugasan') + +@section('content') +
+
Admin
+

Senarai Tugasan Staf

+

Semak dan ubah penempatan staf secara langsung.

+
+ + @include('admin.management._nav') + +
+
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ + + + + + + + + + + + + @forelse ($assignments as $assignment) + + + + + + + + + @empty + + @endforelse + +
StafJawatanPusat / SaluranKTM KetuaTindakan
+
{{ $assignment->application?->name ?: $assignment->user?->name ?: 'Tanpa nama' }}
+
{{ $assignment->application?->ic_number ?: $assignment->user?->email ?: '-' }}
+
{{ $assignment->position?->code }}{{ $assignment->pusatMengundi?->code }}
{{ $assignment->saluranMengundi ? 'Saluran '.$assignment->saluranMengundi->number : 'Pusat level' }}
{{ $assignment->reportsTo?->user?->name ?: '-' }}{{ $assignment->status }}Edit
Tiada tugasan dijumpai.
+
+
+ +
{{ $assignments->links() }}
+@endsection diff --git a/resources/views/admin/management/index.blade.php b/resources/views/admin/management/index.blade.php new file mode 100644 index 0000000..c4865e3 --- /dev/null +++ b/resources/views/admin/management/index.blade.php @@ -0,0 +1,53 @@ +@extends('layouts.app') + +@section('title', 'Pengurusan Admin') + +@section('content') +
+
Admin
+
+
+

Pengurusan Admin

+

Senarai global, manual entry, direct assignment, wakil agensi, dan catatan perubahan selepas pendaftaran tutup.

+
+
+
+ + @include('admin.management._nav') + +
+ @foreach ([ + ['label' => 'Permohonan', 'value' => $counts['applications'], 'icon' => 'bi-file-earmark-person', 'tone' => 'primary'], + ['label' => 'Assigned', 'value' => $counts['assigned'], 'icon' => 'bi-person-check', 'tone' => 'success'], + ['label' => 'Tugasan aktif', 'value' => $counts['staffAssignments'], 'icon' => 'bi-person-lines-fill', 'tone' => 'info'], + ['label' => 'Wakil agensi', 'value' => $counts['representatives'], 'icon' => 'bi-building-check', 'tone' => 'secondary'], + ['label' => 'Dokumen tidak lengkap', 'value' => $counts['missingDocuments'], 'icon' => 'bi-file-earmark-x', 'tone' => 'danger'], + ['label' => 'Bank tidak lengkap', 'value' => $counts['incompleteBank'], 'icon' => 'bi-bank', 'tone' => 'warning'], + ['label' => 'Kerusi roda', 'value' => $counts['wheelchairAllocated'], 'icon' => 'bi-universal-access', 'tone' => 'primary'], + ['label' => 'Belum dipulang', 'value' => $counts['wheelchairOutstanding'], 'icon' => 'bi-arrow-return-left', 'tone' => 'warning'], + ] as $card) +
+
+
+
+
{{ $card['label'] }}
+
{{ $card['value'] }}
+
+ +
+
+
+ @endforeach +
+ + +@endsection diff --git a/resources/views/admin/management/penempatan/pindah.blade.php b/resources/views/admin/management/penempatan/pindah.blade.php new file mode 100644 index 0000000..9acf337 --- /dev/null +++ b/resources/views/admin/management/penempatan/pindah.blade.php @@ -0,0 +1,78 @@ +@extends('layouts.app') + +@section('title', 'Pindah Petugas') + +@section('content') +
+ +

Pindah Petugas

+
+ + @include('admin.management._nav') + +
+
+ {{-- Maklumat petugas semasa --}} +
+

Maklumat Petugas Semasa

+
+
Nama
+
{{ $assignment->application?->name ?? $assignment->user?->name ?? '-' }}
+
No. KP
+
{{ $assignment->application?->ic_number ?? '-' }}
+
Jawatan
+
{{ $assignment->position?->code }}
+
Pusat Semasa
+
{{ $pusat->code }} — {{ $pusat->name }}
+
Saluran Semasa
+
{{ $assignment->saluranMengundi ? 'Saluran '.$assignment->saluranMengundi->number : 'Peringkat Pusat' }}
+
+
+ + {{-- Form pindah --}} +
+

Pindah ke Lokasi Baru

+
+ @csrf +
+ + + @error('pusat_mengundi_id')
{{ $message }}
@enderror +
+ +
+ + +
Senarai saluran akan dikemaskini apabila pusat baru dipilih.
+ @error('saluran_mengundi_id')
{{ $message }}
@enderror +
+ +
+ Batal + +
+
+
+
+
+@endsection diff --git a/resources/views/admin/management/penempatan/show.blade.php b/resources/views/admin/management/penempatan/show.blade.php new file mode 100644 index 0000000..c8a6fb7 --- /dev/null +++ b/resources/views/admin/management/penempatan/show.blade.php @@ -0,0 +1,146 @@ +@extends('layouts.app') + +@section('title', 'Penempatan — ' . $pusat->name) + +@section('content') +
+
Admin · Dashboard
+
+
+

Penempatan Petugas

+

+ {{ $pusat->code }} — {{ $pusat->name }} + @if ($pusat->dun) · DUN {{ $pusat->dun->code }} @endif + @if ($ppm) · PPM: {{ $ppm->application?->name }} @endif +

+
+ +
+
+ + @include('admin.management._nav') + + @forelse ($sections as $section) +
+ {{-- Section header --}} +
+

+ @if ($section['saluran']) + {{ $section['label'] }} + {{ $section['saluran']->voter_count ?? 0 }} pengundi + @else + {{ $section['label'] }} + @endif +

+ @php + $sectionFilled = $section['entries']->sum(fn($e) => $e['assignments']->count()); + $sectionQuota = $section['entries']->sum(fn($e) => $e['quota']->quota); + $sectionVacant = $section['entries']->sum(fn($e) => $e['vacant']); + @endphp + + {{ $sectionFilled }} / {{ $sectionQuota }} diisi + +
+ +
+ + + + + + + + + + + + + + @forelse ($section['entries'] as $entry) + {{-- Filled slots --}} + @foreach ($entry['assignments'] as $assignment) + + @if ($loop->first) + + @endif + @php $app = $assignment->application; @endphp + + + + + + + + @endforeach + + {{-- Vacant slots --}} + @for ($i = 0; $i < $entry['vacant']; $i++) + + @if ($entry['assignments']->isEmpty() && $i === 0) + + @endif + + + + @endfor + + @empty + + + + @endforelse + +
JawatanNama PetugasNo. KPTelefonBankNo. AkaunTindakan
+ {{ $entry['position']?->code }} +
{{ $entry['position']?->name }}
+
Kuota: {{ $entry['quota']->quota }}
+
+
{{ $app?->name ?? $assignment->user?->name ?? 'Tanpa nama' }}
+ Aktif +
{{ $app?->ic_number ?? '-' }}{{ $app?->phone_number ?? '-' }}{{ $app?->bank_name ?? '-' }} + {{ $app?->bank_account_number ? \App\Support\SensitiveData::maskAccount($app->bank_account_number) : '-' }} + + + Pindah + +
+ @csrf + +
+
+ {{ $entry['position']?->code }} +
{{ $entry['position']?->name }}
+
Kuota: {{ $entry['quota']->quota }}
+
— Slot kosong — + + Assign + +
Tiada kuota ditetapkan untuk bahagian ini.
+
+
+ @empty +
+ + Tiada kuota ditetapkan untuk pusat ini. Tetapkan kuota. +
+ @endforelse +@endsection diff --git a/resources/views/admin/management/representatives/index.blade.php b/resources/views/admin/management/representatives/index.blade.php new file mode 100644 index 0000000..bd56182 --- /dev/null +++ b/resources/views/admin/management/representatives/index.blade.php @@ -0,0 +1,96 @@ +@extends('layouts.app') + +@section('title', 'Wakil Agensi') + +@section('content') +
+
Admin
+

Wakil Polis, KKM dan JKM

+

Kemasukan manual wakil agensi untuk Pusat Mengundi dan Saluran.

+
+ + @include('admin.management._nav') + +
+

Tambah Wakil

+
+ @csrf +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+ + @foreach ([['title' => 'Polis Pengiring', 'items' => $policeEscorts, 'type' => 'police'], ['title' => 'Wakil KKM', 'items' => $kkmRepresentatives, 'type' => 'kkm'], ['title' => 'Wakil JKM', 'items' => $jkmRepresentatives, 'type' => 'jkm']] as $section) +
+
{{ $section['title'] }}
+
+ + + + @forelse ($section['items'] as $item) + + + + + + + + @empty + + @endforelse + +
NamaPusatTelefonNotaTindakan
{{ $item->name }}
{{ $item->ic_number ?: '-' }}
{{ $item->pusatMengundi?->code }} @if($section['type'] === 'police' && $item->saluranMengundi) · Saluran {{ $item->saluranMengundi->number }} @endif{{ $item->phone_number ?: '-' }}{{ $item->notes ?: '-' }} +
+ @csrf + @method('DELETE') + + +
+
Tiada rekod.
+
+
{{ $section['items']->links() }}
+
+ @endforeach +@endsection diff --git a/resources/views/admin/management/wheelchairs/index.blade.php b/resources/views/admin/management/wheelchairs/index.blade.php new file mode 100644 index 0000000..0581871 --- /dev/null +++ b/resources/views/admin/management/wheelchairs/index.blade.php @@ -0,0 +1,91 @@ +@extends('layouts.app') + +@section('title', 'Kerusi Roda') + +@section('content') +
+
Admin
+
+
+

Pengurusan Kerusi Roda

+

Peruntukan, rekod ambil, rekod pulang, keadaan, dan catatan kerusi roda.

+
+
+
+ + @include('admin.management._nav') + +
+

Tetapkan Peruntukan

+
+ @csrf +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
+
+ +
+
+ + + + + + + + + + + + + + @forelse ($allocations as $allocation) + + + + + + + + + + @empty + + @endforelse + +
Pusat MengundiPeruntukanDiambilDipulangBelum DipulangBaki Boleh DiambilTindakan
+
{{ $allocation->pusatMengundi?->code }} - {{ $allocation->pusatMengundi?->name }}
+
{{ $allocation->election?->name }}
+
{{ $allocation->allocated_quantity }}{{ $service->takenQuantity($allocation) }}{{ $service->returnedQuantity($allocation) }}{{ $service->outstandingQuantity($allocation) }}{{ $service->availableQuantity($allocation) }}Urus
Tiada peruntukan kerusi roda.
+
+
+ +
{{ $allocations->links() }}
+@endsection diff --git a/resources/views/admin/management/wheelchairs/show.blade.php b/resources/views/admin/management/wheelchairs/show.blade.php new file mode 100644 index 0000000..5531374 --- /dev/null +++ b/resources/views/admin/management/wheelchairs/show.blade.php @@ -0,0 +1,232 @@ +@extends('layouts.app') + +@section('title', 'Rekod Kerusi Roda') + +@section('content') +
+
Admin
+
+
+

Rekod Kerusi Roda

+

{{ $allocation->pusatMengundi?->code }} — {{ $allocation->pusatMengundi?->name }}

+
+ +
+
+ + @include('admin.management._nav') + + @php + $taken = $service->takenQuantity($allocation); + $returned = $service->returnedQuantity($allocation); + $outstanding = $service->outstandingQuantity($allocation); + $available = $service->availableQuantity($allocation); + @endphp + +
+ @foreach ([ + ['label' => 'Peruntukan', 'value' => $allocation->allocated_quantity, 'tone' => 'primary'], + ['label' => 'Diambil', 'value' => $taken, 'tone' => 'info'], + ['label' => 'Dipulang', 'value' => $returned, 'tone' => 'success'], + ['label' => 'Belum dipulang', 'value' => $outstanding, 'tone' => 'warning'], + ['label' => 'Baki boleh diambil','value' => $available, 'tone' => 'secondary'], + ] as $card) +
+
+
{{ $card['label'] }}
+
{{ $card['value'] }}
+
+
+ @endforeach +
+ +
+ {{-- Kiri: 3 panel borang --}} +
+ + {{-- Kemaskini Peruntukan --}} +
+

Kemaskini Peruntukan

+
+ @csrf @method('PATCH') + + +
+ + +
Minimum {{ $outstanding }} (kerusi belum dipulang).
+ @error('allocated_quantity')
{{ $message }}
@enderror +
+
+ + +
+
+ +
+
+
+ + {{-- Rekod Ambil --}} +
+

Rekod Ambil

+

Baki boleh diambil: {{ $available }}

+ + @if ($available === 0) +
Tiada baki kerusi roda untuk diambil.
+ @else +
+ @csrf +
+
+ + +
Maks: {{ $available }}
+ @error('quantity')
{{ $message }}
@enderror +
+
+ + + @error('taken_at')
{{ $message }}
@enderror +
+
+ + + @error('taken_by_name')
{{ $message }}
@enderror +
+
+ + +
+
+
+ +
+
+ @endif +
+ + {{-- Rekod Pulang --}} +
+

Rekod Pulang

+

Belum dipulang: {{ $outstanding }}

+ + @if ($outstanding === 0) +
Tiada kerusi roda yang perlu dipulangkan.
+ @else +
+ @csrf +
+
+ + +
Maks: {{ $outstanding }}
+ @error('quantity')
{{ $message }}
@enderror +
+
+ + + @error('returned_at')
{{ $message }}
@enderror +
+
+ + + @error('returned_by_name')
{{ $message }}
@enderror +
+
+ + + @error('return_condition')
{{ $message }}
@enderror +
+
+ + +
+
+
+ +
+
+ @endif +
+
+ + {{-- Kanan: Sejarah transaksi --}} +
+
+
Sejarah Transaksi
+
+ + + + + + + + + + + + + + @forelse ($allocation->transactions->sortByDesc('created_at') as $tx) + + + + + + + + + + @empty + + @endforelse + +
JenisJmlMasaNamaKeadaanDirekod OlehNota
+ @if ($tx->transaction_type === 'taken') + Ambil + @else + Pulang + @endif + {{ $tx->quantity }} + {{ ($tx->taken_at ?? $tx->returned_at)?->format('d/m/Y H:i') ?? '-' }} + + @if ($tx->transaction_type === 'taken') + {{ $tx->taken_by_name ?: '-' }} + @else + {{ $tx->returned_by_name ?: '-' }} + @endif + + @if ($tx->transaction_type === 'returned' && $tx->return_condition) + + {{ ucfirst($tx->return_condition) }} + + @else + + @endif + {{ $tx->recordedBy?->name ?: '-' }}{{ $tx->notes ?: '-' }}
Tiada transaksi direkodkan.
+
+
+
+
+@endsection diff --git a/resources/views/admin/setup/_nav.blade.php b/resources/views/admin/setup/_nav.blade.php new file mode 100644 index 0000000..4581e23 --- /dev/null +++ b/resources/views/admin/setup/_nav.blade.php @@ -0,0 +1,18 @@ +
+
+ + Overview + + Jawatan + Pusat + + Import + + @php $activeElection = \App\Models\Election::query()->where('status', 'active')->first(); @endphp + @if ($activeElection) + + Tetapan + + @endif +
+
diff --git a/resources/views/admin/setup/bahagian/_form.blade.php b/resources/views/admin/setup/bahagian/_form.blade.php new file mode 100644 index 0000000..2556568 --- /dev/null +++ b/resources/views/admin/setup/bahagian/_form.blade.php @@ -0,0 +1,25 @@ +@csrf + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ Batal + +
diff --git a/resources/views/admin/setup/bahagian/create.blade.php b/resources/views/admin/setup/bahagian/create.blade.php new file mode 100644 index 0000000..a0cf7e0 --- /dev/null +++ b/resources/views/admin/setup/bahagian/create.blade.php @@ -0,0 +1,13 @@ +@extends('layouts.app') + +@section('title', 'Tambah Bahagian') + +@section('content') +
Setup

Tambah Bahagian Pilihanraya

+ @include('admin.setup._nav') +
+
+ @include('admin.setup.bahagian._form') +
+
+@endsection diff --git a/resources/views/admin/setup/bahagian/edit.blade.php b/resources/views/admin/setup/bahagian/edit.blade.php new file mode 100644 index 0000000..4c64cea --- /dev/null +++ b/resources/views/admin/setup/bahagian/edit.blade.php @@ -0,0 +1,14 @@ +@extends('layouts.app') + +@section('title', 'Edit Bahagian') + +@section('content') +
Setup

Edit Bahagian Pilihanraya

+ @include('admin.setup._nav') +
+
+ @method('PUT') + @include('admin.setup.bahagian._form') +
+
+@endsection diff --git a/resources/views/admin/setup/bahagian/index.blade.php b/resources/views/admin/setup/bahagian/index.blade.php new file mode 100644 index 0000000..541fccd --- /dev/null +++ b/resources/views/admin/setup/bahagian/index.blade.php @@ -0,0 +1,45 @@ +@extends('layouts.app') + +@section('title', 'Bahagian Pilihanraya') + +@section('content') +
+
Setup
+
+
+

Bahagian Pilihanraya

+

Senarai bahagian pilihanraya untuk election aktif.

+
+ Tambah +
+
+ + @include('admin.setup._nav') + +
+
+ + + + @forelse ($bahagians as $bahagian) + + + + + + + @empty + + @endforelse + +
ElectionTindakan
{{ $bahagian->code }}{{ $bahagian->name }}{{ $bahagian->election?->name }} + Edit +
+ @csrf @method('DELETE') + +
+
Tiada rekod.
+
+ {{ $bahagians->links() }} +
+@endsection diff --git a/resources/views/admin/setup/daerah/_form.blade.php b/resources/views/admin/setup/daerah/_form.blade.php new file mode 100644 index 0000000..78c509a --- /dev/null +++ b/resources/views/admin/setup/daerah/_form.blade.php @@ -0,0 +1,33 @@ +@csrf + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ Batal + +
diff --git a/resources/views/admin/setup/daerah/create.blade.php b/resources/views/admin/setup/daerah/create.blade.php new file mode 100644 index 0000000..f394ad3 --- /dev/null +++ b/resources/views/admin/setup/daerah/create.blade.php @@ -0,0 +1,7 @@ +@extends('layouts.app') +@section('title', 'Tambah Daerah') +@section('content') +
Setup

Tambah Daerah Mengundi

+ @include('admin.setup._nav') +
@include('admin.setup.daerah._form')
+@endsection diff --git a/resources/views/admin/setup/daerah/edit.blade.php b/resources/views/admin/setup/daerah/edit.blade.php new file mode 100644 index 0000000..abe415d --- /dev/null +++ b/resources/views/admin/setup/daerah/edit.blade.php @@ -0,0 +1,7 @@ +@extends('layouts.app') +@section('title', 'Edit Daerah') +@section('content') +
Setup

Edit Daerah Mengundi

+ @include('admin.setup._nav') +
@method('PUT') @include('admin.setup.daerah._form')
+@endsection diff --git a/resources/views/admin/setup/daerah/index.blade.php b/resources/views/admin/setup/daerah/index.blade.php new file mode 100644 index 0000000..39f4c06 --- /dev/null +++ b/resources/views/admin/setup/daerah/index.blade.php @@ -0,0 +1,41 @@ +@extends('layouts.app') + +@section('title', 'Daerah Mengundi') + +@section('content') +
+
Setup
+
+

Daerah Mengundi

Daerah mengundi di bawah bahagian pilihanraya.

+ Tambah +
+
+ @include('admin.setup._nav') +
+
+ + + + @forelse ($daerahs as $daerah) + + + + + + + + @empty + + @endforelse + +
BahagianElectionTindakan
{{ $daerah->code }}{{ $daerah->name }}{{ $daerah->bahagianPilihanraya?->name }}{{ $daerah->election?->name }} + Edit +
+ @csrf @method('DELETE') + +
+
Tiada rekod.
+
+ {{ $daerahs->links() }} +
+@endsection diff --git a/resources/views/admin/setup/import/create.blade.php b/resources/views/admin/setup/import/create.blade.php new file mode 100644 index 0000000..10f297f --- /dev/null +++ b/resources/views/admin/setup/import/create.blade.php @@ -0,0 +1,128 @@ +@extends('layouts.app') + +@section('title', 'Import Pusat Mengundi') + +@section('content') +
+
Admin · Setup
+

Import Pusat Mengundi

+

Upload fail XLSX untuk mencipta DUN, Pusat Mengundi, Saluran, dan Kuota secara automatik.

+
+ + @include('admin.setup._nav') + + @if (session('import_errors')) + + @endif + +
+
+
+

Borang Import

+ +
+ @csrf + +
+ + + @error('election_id') +
{{ $message }}
+ @enderror +
+ +
+
+ + + @error('dun_code') +
{{ $message }}
+ @enderror +
+
+ + + @error('dun_name') +
{{ $message }}
+ @enderror +
+
+ +
+ + +
Format: .xlsx sahaja, saiz maksimum 10MB.
+ @error('file') +
{{ $message }}
+ @enderror +
+ + + +
+ Batal + +
+
+
+
+ +
+
+

Format Fail XLSX

+

Fail mesti mempunyai lajur mengikut susunan berikut (baris pertama = tajuk):

+
+ + + + + + + + + + + + + + + + + + + + + +
LajurNamaKeterangan
ASEKOLAHNama Pusat Mengundi
BNAMA PPMNama PPM (simpan sebagai metadata)
CSALURANBilangan saluran (nombor)
D–FKTM, POLIS, KPDikira automatik dari bilangan saluran
GPPMKuota PPM per pusat (0 atau 1)
HKPDPDikira automatik
IPAPMKuota PAPM per pusat (nombor)
JJKMKuota JKM per pusat
KKKMKuota KKM per pusat
LTAMBAHANDikira automatik
MNO TELEFONTelefon PPM (simpan sebagai metadata)
+
+

+ Lajur dengan formula Excel (KTM, POLIS, KP, KPDP, TAMBAHAN) dikira semula secara automatik berdasarkan bilangan saluran — nilai asal dalam fail diabaikan. +

+
+
+
+@endsection diff --git a/resources/views/admin/setup/index.blade.php b/resources/views/admin/setup/index.blade.php new file mode 100644 index 0000000..9f62913 --- /dev/null +++ b/resources/views/admin/setup/index.blade.php @@ -0,0 +1,55 @@ +@extends('layouts.app') + +@section('title', 'Setup Pilihanraya') + +@section('content') +
+
Admin
+
+
+

Setup Pilihanraya

+

Konfigurasi hierarchy, jawatan, kuota, PPM, dan QR Pusat Mengundi.

+
+
+
+ + @include('admin.setup._nav') + +
+ @foreach ([ + ['label' => 'Election', 'value' => $counts['elections'], 'icon' => 'bi-calendar-event', 'tone' => 'primary'], + ['label' => 'DUN', 'value' => $counts['duns'], 'icon' => 'bi-map', 'tone' => 'info'], + ['label' => 'Pusat', 'value' => $counts['pusat'], 'icon' => 'bi-building', 'tone' => 'success'], + ['label' => 'Saluran', 'value' => $counts['saluran'], 'icon' => 'bi-diagram-3', 'tone' => 'success'], + ['label' => 'Jawatan', 'value' => $counts['positions'], 'icon' => 'bi-person-vcard', 'tone' => 'secondary'], + ['label' => 'Kuota', 'value' => $counts['quotas'], 'icon' => 'bi-list-ol', 'tone' => 'warning'], + ['label' => 'PPM aktif', 'value' => $counts['ppmAssignments'], 'icon' => 'bi-person-check', 'tone' => 'primary'], + ] as $card) +
+
+
+
+
{{ $card['label'] }}
+
{{ $card['value'] }}
+
+ +
+
+
+ @endforeach +
+ +
+

Tindakan Setup

+
+ + + + + @php $activeElection = \App\Models\Election::query()->where('status', 'active')->first(); @endphp + @if ($activeElection) + + @endif +
+
+@endsection diff --git a/resources/views/admin/setup/positions/_form.blade.php b/resources/views/admin/setup/positions/_form.blade.php new file mode 100644 index 0000000..9162b4e --- /dev/null +++ b/resources/views/admin/setup/positions/_form.blade.php @@ -0,0 +1,45 @@ +@csrf + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ is_public_applyable))> + +
+
+ exists ? $position->is_assignable : true))> + +
+
+ allows_dual_role))> + +
+
+
+
+ +
+ Batal + +
diff --git a/resources/views/admin/setup/positions/create.blade.php b/resources/views/admin/setup/positions/create.blade.php new file mode 100644 index 0000000..99c3902 --- /dev/null +++ b/resources/views/admin/setup/positions/create.blade.php @@ -0,0 +1,7 @@ +@extends('layouts.app') +@section('title', 'Tambah Jawatan') +@section('content') +
Setup

Tambah Jawatan

+ @include('admin.setup._nav') +
@include('admin.setup.positions._form')
+@endsection diff --git a/resources/views/admin/setup/positions/edit.blade.php b/resources/views/admin/setup/positions/edit.blade.php new file mode 100644 index 0000000..aa24bc3 --- /dev/null +++ b/resources/views/admin/setup/positions/edit.blade.php @@ -0,0 +1,7 @@ +@extends('layouts.app') +@section('title', 'Edit Jawatan') +@section('content') +
Setup

Edit Jawatan

+ @include('admin.setup._nav') +
@method('PUT') @include('admin.setup.positions._form')
+@endsection diff --git a/resources/views/admin/setup/positions/index.blade.php b/resources/views/admin/setup/positions/index.blade.php new file mode 100644 index 0000000..981f9cd --- /dev/null +++ b/resources/views/admin/setup/positions/index.blade.php @@ -0,0 +1,42 @@ +@extends('layouts.app') + +@section('title', 'Jawatan') + +@section('content') +
+
Setup
+
+

Jawatan

Tetapan jawatan dan ciri assignment.

+ Tambah +
+
+ @include('admin.setup._nav') +
+
+ + + + @forelse ($positions as $position) + + + + + + + + + @empty + + @endforelse + +
AwamDual-roleTindakan
{{ $position->code }}{{ $position->name }}{{ $position->scope }}{{ $position->is_public_applyable ? 'Ya' : 'Tidak' }}{{ $position->allows_dual_role ? 'Ya' : 'Tidak' }} + Edit +
+ @csrf @method('DELETE') + +
+
Tiada rekod.
+
+ {{ $positions->links() }} +
+@endsection diff --git a/resources/views/admin/setup/pusat/_form.blade.php b/resources/views/admin/setup/pusat/_form.blade.php new file mode 100644 index 0000000..0b2c356 --- /dev/null +++ b/resources/views/admin/setup/pusat/_form.blade.php @@ -0,0 +1,105 @@ +@csrf + +
+
+ + + @error('election_id') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('dun_id') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('code') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('name') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('address') +
{{ $message }}
+ @enderror +
+ + @unless ($pusat->exists) + @php + $operationalFields = [ + 'saluran_count' => ['label' => 'Bilangan Saluran', 'default' => 1, 'min' => 1], + 'ktm_count' => ['label' => 'KTM setiap Saluran', 'default' => 1], + 'polis_iring_count' => ['label' => 'Polis Iring setiap Saluran', 'default' => 1], + 'kp_count' => ['label' => 'KP setiap Saluran', 'default' => 4], + 'ppm_count' => ['label' => 'PPM setiap Pusat', 'default' => 1], + 'kpdp_count' => ['label' => 'KPDP setiap Saluran', 'default' => 1], + 'papm_count' => ['label' => 'PAPM setiap Pusat', 'default' => 0], + 'jkm_count' => ['label' => 'JKM setiap Pusat', 'default' => 1], + 'kkm_count' => ['label' => 'KKM setiap Pusat', 'default' => 1], + 'calon_tambahan_count' => ['label' => 'Calon Tambahan setiap Saluran', 'default' => 1], + ]; + @endphp + +
+

Bilangan Operasi

+

Saluran akan dijana mengikut nombor secara automatik.

+
+ + @foreach ($operationalFields as $field => $configuration) +
+ + + @error($field) +
{{ $message }}
+ @enderror +
+ @endforeach + @endunless +
+ +
+ Batal + +
diff --git a/resources/views/admin/setup/pusat/create.blade.php b/resources/views/admin/setup/pusat/create.blade.php new file mode 100644 index 0000000..0adbd60 --- /dev/null +++ b/resources/views/admin/setup/pusat/create.blade.php @@ -0,0 +1,18 @@ +@extends('layouts.app') + +@section('title', 'Tambah Pusat') + +@section('content') +
+
Setup
+

Tambah Pusat Mengundi

+
+ + @include('admin.setup._nav') + +
+
+ @include('admin.setup.pusat._form') +
+
+@endsection diff --git a/resources/views/admin/setup/pusat/edit.blade.php b/resources/views/admin/setup/pusat/edit.blade.php new file mode 100644 index 0000000..143f5c0 --- /dev/null +++ b/resources/views/admin/setup/pusat/edit.blade.php @@ -0,0 +1,19 @@ +@extends('layouts.app') + +@section('title', 'Edit Pusat') + +@section('content') +
+
Setup
+

Edit Pusat Mengundi

+
+ + @include('admin.setup._nav') + +
+
+ @method('PUT') + @include('admin.setup.pusat._form') +
+
+@endsection diff --git a/resources/views/admin/setup/pusat/index.blade.php b/resources/views/admin/setup/pusat/index.blade.php new file mode 100644 index 0000000..a10c19f --- /dev/null +++ b/resources/views/admin/setup/pusat/index.blade.php @@ -0,0 +1,47 @@ +@extends('layouts.app') + +@section('title', 'Pusat Mengundi') + +@section('content') +
+
Setup
+
+

Pusat Mengundi

Pusat Mengundi, QR pendaftaran, dan assignment PPM.

+ Tambah +
+
+ @include('admin.setup._nav') +
+
+ + + + @forelse ($pusats as $pusat) + + + + + + + + + + @empty + + @endforelse + +
DUNPPMSaluranQRTindakan
{{ $pusat->code }}{{ $pusat->name }}{{ $pusat->dun?->code }}{{ $pusat->ppm_name ?: '-' }}{{ $pusat->saluran_mengundis_count ?? $pusat->saluranMengundis->count() }}{{ $pusat->qr_code_path ? 'Ada' : 'Belum' }} + Buka + Saluran + Edit +
+ @csrf + @method('DELETE') + +
+
Tiada rekod. Import data pusat.
+
+ {{ $pusats->links() }} +
+@endsection diff --git a/resources/views/admin/setup/pusat/show.blade.php b/resources/views/admin/setup/pusat/show.blade.php new file mode 100644 index 0000000..15902ec --- /dev/null +++ b/resources/views/admin/setup/pusat/show.blade.php @@ -0,0 +1,87 @@ +@extends('layouts.app') + +@section('title', $pusat->name) + +@section('content') +
+
Pusat Mengundi
+
+
+

{{ $pusat->name }}

+

{{ $pusat->code }} - {{ $pusat->daerahMengundi?->name }}

+
+
+ Edit +
+ @csrf + +
+
+
+
+ @include('admin.setup._nav') + +
+
+
+

Maklumat Pusat

+
+
Election
{{ $pusat->election?->name }}
+
Alamat
{{ $pusat->address ?: '-' }}
+
Public UUID
{{ $pusat->public_uuid }}
+
Link QR
+
+ @if ($pusat->registration_url) + {{ $pusat->registration_url }} + @else + Belum dijana. + @endif +
+
+
+ +
+

Saluran

+
+ + + + @forelse ($pusat->saluranMengundis as $saluran) + + @empty + + @endforelse + +
No.NamaPengundi
{{ $saluran->number }}{{ $saluran->name }}{{ $saluran->voter_count ?? '-' }}
Tiada saluran.
+
+
+
+ +
+
+

QR Pendaftaran

+ @if ($pusat->qr_code_path) + QR Pusat Mengundi + @else +

QR belum dijana.

+ @endif +
QR menunjuk ke pautan awam Pusat Mengundi ini.
+
+ +
+

Tetapan PPM

+

PPM semasa: {{ $ppmAssignment?->user?->name ?? 'Belum ditetapkan' }}

+
+ @csrf + + + +
+
+
+
+@endsection diff --git a/resources/views/admin/setup/quotas/_form.blade.php b/resources/views/admin/setup/quotas/_form.blade.php new file mode 100644 index 0000000..1234b98 --- /dev/null +++ b/resources/views/admin/setup/quotas/_form.blade.php @@ -0,0 +1,35 @@ +@csrf + +
+
+ + + @error('position_id') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('quota') +
{{ $message }}
+ @enderror +
+
+ +
+ Batal + +
diff --git a/resources/views/admin/setup/quotas/_pusat-form.blade.php b/resources/views/admin/setup/quotas/_pusat-form.blade.php new file mode 100644 index 0000000..ec4f36d --- /dev/null +++ b/resources/views/admin/setup/quotas/_pusat-form.blade.php @@ -0,0 +1,41 @@ +@csrf + +
+
+ + @if ($quota->exists) + {{-- Edit: papar nama sahaja, tidak boleh tukar jawatan (guna updateOrCreate) --}} + + + @else + + @error('position_id') +
{{ $message }}
+ @enderror + @endif +
+ +
+ + + @error('quota') +
{{ $message }}
+ @enderror +
+
+ +
+ Batal + +
diff --git a/resources/views/admin/setup/quotas/create.blade.php b/resources/views/admin/setup/quotas/create.blade.php new file mode 100644 index 0000000..35f4475 --- /dev/null +++ b/resources/views/admin/setup/quotas/create.blade.php @@ -0,0 +1,23 @@ +@extends('layouts.app') + +@section('title', 'Tambah Kuota — Saluran ' . $saluran->number) + +@section('content') +
+ +

Tambah Kuota Jawatan

+
+ + @include('admin.setup._nav') + +
+
+ @include('admin.setup.quotas._form') +
+
+@endsection diff --git a/resources/views/admin/setup/quotas/edit.blade.php b/resources/views/admin/setup/quotas/edit.blade.php new file mode 100644 index 0000000..53cf51c --- /dev/null +++ b/resources/views/admin/setup/quotas/edit.blade.php @@ -0,0 +1,24 @@ +@extends('layouts.app') + +@section('title', 'Edit Kuota — ' . $quota->position?->code) + +@section('content') +
+ +

Edit Kuota Jawatan

+
+ + @include('admin.setup._nav') + +
+
+ @method('PUT') + @include('admin.setup.quotas._form') +
+
+@endsection diff --git a/resources/views/admin/setup/quotas/index.blade.php b/resources/views/admin/setup/quotas/index.blade.php new file mode 100644 index 0000000..6ff5980 --- /dev/null +++ b/resources/views/admin/setup/quotas/index.blade.php @@ -0,0 +1,70 @@ +@extends('layouts.app') + +@section('title', 'Kuota — Saluran ' . $saluran->number) + +@section('content') +
+
+ Setup · + Pusat Mengundi · + {{ $pusat->name }} +
+
+
+

Kuota Jawatan

+

{{ $pusat->code }} — {{ $pusat->name }} · Saluran {{ $saluran->number }}

+
+ + Tambah Kuota + +
+
+ + @include('admin.setup._nav') + +
+
+ + + + + + + + + + @forelse ($quotas as $quota) + + + + + + @empty + + + + @endforelse + +
JawatanTindakan
+ {{ $quota->position?->code }} +
{{ $quota->position?->name }}
+
{{ $quota->quota }} + Edit +
+ @csrf @method('DELETE') + +
+
+ Tiada kuota. Tambah kuota. +
+
+ {{ $quotas->links() }} +
+ + +@endsection diff --git a/resources/views/admin/setup/quotas/pusat-create.blade.php b/resources/views/admin/setup/quotas/pusat-create.blade.php new file mode 100644 index 0000000..4b92b3f --- /dev/null +++ b/resources/views/admin/setup/quotas/pusat-create.blade.php @@ -0,0 +1,23 @@ +@extends('layouts.app') + +@section('title', 'Tambah Kuota Pusat — ' . $pusat->name) + +@section('content') +
+ +

Tambah Kuota Peringkat Pusat

+
+ + @include('admin.setup._nav') + +
+
+ @include('admin.setup.quotas._pusat-form') +
+
+@endsection diff --git a/resources/views/admin/setup/quotas/pusat-edit.blade.php b/resources/views/admin/setup/quotas/pusat-edit.blade.php new file mode 100644 index 0000000..f05af51 --- /dev/null +++ b/resources/views/admin/setup/quotas/pusat-edit.blade.php @@ -0,0 +1,24 @@ +@extends('layouts.app') + +@section('title', 'Edit Kuota Pusat — ' . $quota->position?->code) + +@section('content') +
+ +

Edit Kuota Peringkat Pusat

+
+ + @include('admin.setup._nav') + +
+
+ @method('PUT') + @include('admin.setup.quotas._pusat-form') +
+
+@endsection diff --git a/resources/views/admin/setup/quotas/pusat-index.blade.php b/resources/views/admin/setup/quotas/pusat-index.blade.php new file mode 100644 index 0000000..8bc3614 --- /dev/null +++ b/resources/views/admin/setup/quotas/pusat-index.blade.php @@ -0,0 +1,70 @@ +@extends('layouts.app') + +@section('title', 'Kuota Pusat — ' . $pusat->name) + +@section('content') +
+
+ Setup · + Pusat Mengundi · + {{ $pusat->name }} +
+
+
+

Kuota Jawatan — Peringkat Pusat

+

{{ $pusat->code }} — {{ $pusat->name }} · Saluran 0 (Pusat)

+
+ + Tambah Kuota + +
+
+ + @include('admin.setup._nav') + +
+
+ + + + + + + + + + @forelse ($quotas as $quota) + + + + + + @empty + + + + @endforelse + +
JawatanTindakan
+ {{ $quota->position?->code }} +
{{ $quota->position?->name }}
+
{{ $quota->quota }} + Edit +
+ @csrf @method('DELETE') + +
+
+ Tiada kuota peringkat pusat. Tambah kuota. +
+
+ {{ $quotas->links() }} +
+ + +@endsection diff --git a/resources/views/admin/setup/saluran/_form.blade.php b/resources/views/admin/setup/saluran/_form.blade.php new file mode 100644 index 0000000..197e324 --- /dev/null +++ b/resources/views/admin/setup/saluran/_form.blade.php @@ -0,0 +1,28 @@ +@csrf + +
+
+ + + @error('number') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('voter_count') +
{{ $message }}
+ @enderror +
+
+ +
+ Batal + +
diff --git a/resources/views/admin/setup/saluran/create.blade.php b/resources/views/admin/setup/saluran/create.blade.php new file mode 100644 index 0000000..5c28664 --- /dev/null +++ b/resources/views/admin/setup/saluran/create.blade.php @@ -0,0 +1,18 @@ +@extends('layouts.app') + +@section('title', 'Tambah Saluran — ' . $pusat->name) + +@section('content') +
+ +

Tambah Saluran Mengundi

+
+ + @include('admin.setup._nav') + +
+
+ @include('admin.setup.saluran._form') +
+
+@endsection diff --git a/resources/views/admin/setup/saluran/edit.blade.php b/resources/views/admin/setup/saluran/edit.blade.php new file mode 100644 index 0000000..01c1e6b --- /dev/null +++ b/resources/views/admin/setup/saluran/edit.blade.php @@ -0,0 +1,19 @@ +@extends('layouts.app') + +@section('title', 'Edit Saluran — ' . $pusat->name) + +@section('content') +
+ +

Edit Saluran Mengundi

+
+ + @include('admin.setup._nav') + +
+
+ @method('PUT') + @include('admin.setup.saluran._form') +
+
+@endsection diff --git a/resources/views/admin/setup/saluran/index.blade.php b/resources/views/admin/setup/saluran/index.blade.php new file mode 100644 index 0000000..23ef64b --- /dev/null +++ b/resources/views/admin/setup/saluran/index.blade.php @@ -0,0 +1,69 @@ +@extends('layouts.app') + +@section('title', 'Saluran — ' . $pusat->name) + +@section('content') +
+ +
+
+

Saluran Mengundi

+

{{ $pusat->code }} — {{ $pusat->name }}

+
+ + Tambah Saluran + +
+
+ + @include('admin.setup._nav') + +
+
+ + + + + + {{-- Row tetap: Saluran 0 = Kuota peringkat Pusat --}} + + + + + + + @forelse ($salurans as $saluran) + + + + + + + @empty + + @endforelse + +
NamaTindakan
0 + Pusat +
Kuota jawatan peringkat pusat (PPM, PAPM, JKM, KKM, dll.)
+
+ Kuota +
{{ $saluran->number }}{{ $saluran->name ?? '-' }}{{ $saluran->voter_count ?? '-' }} + Kuota + Edit +
+ @csrf @method('DELETE') + +
+
Tiada saluran. Tambah saluran.
+
+ {{ $salurans->links() }} +
+ + +@endsection diff --git a/resources/views/admin/setup/settings/edit.blade.php b/resources/views/admin/setup/settings/edit.blade.php new file mode 100644 index 0000000..ec95162 --- /dev/null +++ b/resources/views/admin/setup/settings/edit.blade.php @@ -0,0 +1,213 @@ +@extends('layouts.app') + +@section('title', 'Tetapan Pilihanraya') + +@section('content') +
+
Admin · Setup
+
+
+

Tetapan Pilihanraya

+

{{ $election->name }} · Kawalan tarikh, mod kehadiran, dan pendaftaran.

+
+
+
+ + @include('admin.setup._nav') + +
+ {{-- Status semasa --}} +
+
+

Status Semasa

+ + @if ($election->settings) +
+
Pilihanraya
+
{{ $election->name }}
+ +
Tarikh Daftar Buka
+
{{ $election->settings->registration_start_date?->format('d/m/Y') ?? '-' }}
+ +
Tarikh Daftar Tutup
+
{{ $election->settings->registration_end_date?->format('d/m/Y') ?? '-' }}
+ +
Tarikh Mengundi
+
{{ $election->settings->polling_date?->format('d/m/Y') ?? '-' }}
+ +
Status Pendaftaran
+
+ @if ($isRegistrationOpen) + Dibuka + @else + Ditutup + @endif + @if ($election->settings->is_registration_open_override !== null) + Override aktif + @endif +
+ +
Mod Kehadiran
+
+ @if ($election->settings->is_attendance_active) + Aktif + @else + Tidak aktif + @endif +
+
+ @else +

Tiada tetapan lagi. Isikan borang untuk mencipta tetapan baru.

+ @endif +
+
+ + {{-- Borang tetapan --}} +
+
+

{{ $election->settings ? 'Kemaskini Tetapan' : 'Tetapan Baru' }}

+ + @php + $isNew = $election->settings === null; + $formAction = $isNew + ? route('admin.setup.settings.store', $election) + : route('admin.setup.settings.update', $election); + @endphp + +
+ @csrf + @unless ($isNew) @method('PATCH') @endunless + + {{-- Tarikh --}} +
+
+ + + @error('registration_start_date') +
{{ $message }}
+ @enderror +
+
+ + + @error('registration_end_date') +
{{ $message }}
+ @enderror +
+
+ + + @error('polling_date') +
{{ $message }}
+ @enderror +
+
+ +
+ + {{-- Mod Kehadiran --}} +
+ +

+ Apabila diaktifkan, PPM boleh merekod kehadiran staf pada skrin kehadiran. + Aktifkan hanya pada hari mengundi. +

+
+
+ settings?->is_attendance_active ? '1' : '0') === '0' ? 'checked' : '' }}> + +
+
+ settings?->is_attendance_active ? '1' : '0') === '1' ? 'checked' : '' }}> + +
+
+ @error('is_attendance_active') +
{{ $message }}
+ @enderror +
+ +
+ + {{-- Override Pendaftaran --}} +
+ +

+ Secara lalai, sistem mengikut tarikh buka dan tutup pendaftaran yang ditetapkan. + Gunakan override hanya apabila perlu buka atau tutup pendaftaran di luar tarikh tersebut. +

+ + @php + $currentOverride = old('is_registration_open_override', + $election->settings?->is_registration_open_override === null + ? '' + : ($election->settings->is_registration_open_override ? '1' : '0') + ); + @endphp + +
+
+ + +
+
+ + +
+
+ + +
+
+ @error('is_registration_open_override') +
{{ $message }}
+ @enderror +
+ + @if ($election->settings?->is_registration_open_override !== null) + + @endif + +
+ Kembali + +
+
+
+
+
+@endsection diff --git a/resources/views/auth/confirm-password.blade.php b/resources/views/auth/confirm-password.blade.php new file mode 100644 index 0000000..540ab43 --- /dev/null +++ b/resources/views/auth/confirm-password.blade.php @@ -0,0 +1,25 @@ +@extends('layouts.guest') + +@section('title', 'Sahkan Kata Laluan') + +@section('content') +
+
Pengesahan
+

Sahkan kata laluan

+

Masukkan kata laluan semasa untuk meneruskan tindakan ini.

+
+ +
+ @csrf + +
+ + + @error('password') +
{{ $message }}
+ @enderror +
+ + +
+@endsection diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php new file mode 100644 index 0000000..18c77f5 --- /dev/null +++ b/resources/views/auth/forgot-password.blade.php @@ -0,0 +1,28 @@ +@extends('layouts.guest') + +@section('title', 'Lupa Kata Laluan') + +@section('content') +
+
Reset akses
+

Lupa kata laluan

+

Masukkan emel akaun untuk menerima pautan reset.

+
+ +
+ @csrf + +
+ + + @error('email') +
{{ $message }}
+ @enderror +
+ +
+ Kembali ke log masuk + +
+
+@endsection diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100644 index 0000000..1d77056 --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,46 @@ +@extends('layouts.guest') + +@section('title', 'Log Masuk') + +@section('content') +
+
Akses Pengguna
+

Log masuk

+

Masukkan emel dan kata laluan akaun sistem.

+
+ +
+ @csrf + +
+ + + @error('email') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('password') +
{{ $message }}
+ @enderror +
+ +
+ + +
+ +
+ @if (Route::has('password.request')) + Lupa kata laluan? + @endif + + +
+
+@endsection diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php new file mode 100644 index 0000000..9d5a8d7 --- /dev/null +++ b/resources/views/auth/register.blade.php @@ -0,0 +1,12 @@ +@extends('layouts.guest') + +@section('title', 'Pendaftaran Akaun') + +@section('content') +
+
Pendaftaran akaun
+

Pendaftaran umum ditutup

+

Akaun dalaman diwujudkan oleh Admin. Permohonan awam akan menggunakan pautan QR Pusat Mengundi dalam fasa permohonan.

+ Log masuk +
+@endsection diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php new file mode 100644 index 0000000..ab7414d --- /dev/null +++ b/resources/views/auth/reset-password.blade.php @@ -0,0 +1,42 @@ +@extends('layouts.guest') + +@section('title', 'Reset Kata Laluan') + +@section('content') +
+
Reset akses
+

Tetapkan kata laluan baru

+
+ +
+ @csrf + + + +
+ + + @error('email') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('password') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('password_confirmation') +
{{ $message }}
+ @enderror +
+ + +
+@endsection diff --git a/resources/views/auth/verify-email.blade.php b/resources/views/auth/verify-email.blade.php new file mode 100644 index 0000000..8baeaaa --- /dev/null +++ b/resources/views/auth/verify-email.blade.php @@ -0,0 +1,29 @@ +@extends('layouts.guest') + +@section('title', 'Sahkan Emel') + +@section('content') +
+
Pengesahan emel
+

Sahkan emel

+

Pautan pengesahan telah dihantar ke emel akaun anda.

+
+ + @if (session('status') === 'verification-link-sent') + + @endif + +
+
+ @csrf + +
+ +
+ @csrf + +
+
+@endsection diff --git a/resources/views/components/application-logo.blade.php b/resources/views/components/application-logo.blade.php new file mode 100644 index 0000000..46579cf --- /dev/null +++ b/resources/views/components/application-logo.blade.php @@ -0,0 +1,3 @@ + + + diff --git a/resources/views/components/auth-session-status.blade.php b/resources/views/components/auth-session-status.blade.php new file mode 100644 index 0000000..c4bd6e2 --- /dev/null +++ b/resources/views/components/auth-session-status.blade.php @@ -0,0 +1,7 @@ +@props(['status']) + +@if ($status) +
merge(['class' => 'font-medium text-sm text-green-600']) }}> + {{ $status }} +
+@endif diff --git a/resources/views/components/danger-button.blade.php b/resources/views/components/danger-button.blade.php new file mode 100644 index 0000000..d17d288 --- /dev/null +++ b/resources/views/components/danger-button.blade.php @@ -0,0 +1,3 @@ + diff --git a/resources/views/components/dropdown-link.blade.php b/resources/views/components/dropdown-link.blade.php new file mode 100644 index 0000000..e0f8ce1 --- /dev/null +++ b/resources/views/components/dropdown-link.blade.php @@ -0,0 +1 @@ +merge(['class' => 'block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 transition duration-150 ease-in-out']) }}>{{ $slot }} diff --git a/resources/views/components/dropdown.blade.php b/resources/views/components/dropdown.blade.php new file mode 100644 index 0000000..a46f7c8 --- /dev/null +++ b/resources/views/components/dropdown.blade.php @@ -0,0 +1,35 @@ +@props(['align' => 'right', 'width' => '48', 'contentClasses' => 'py-1 bg-white']) + +@php +$alignmentClasses = match ($align) { + 'left' => 'ltr:origin-top-left rtl:origin-top-right start-0', + 'top' => 'origin-top', + default => 'ltr:origin-top-right rtl:origin-top-left end-0', +}; + +$width = match ($width) { + '48' => 'w-48', + default => $width, +}; +@endphp + +
+
+ {{ $trigger }} +
+ + +
diff --git a/resources/views/components/input-error.blade.php b/resources/views/components/input-error.blade.php new file mode 100644 index 0000000..9e6da21 --- /dev/null +++ b/resources/views/components/input-error.blade.php @@ -0,0 +1,9 @@ +@props(['messages']) + +@if ($messages) +
    merge(['class' => 'text-sm text-red-600 space-y-1']) }}> + @foreach ((array) $messages as $message) +
  • {{ $message }}
  • + @endforeach +
+@endif diff --git a/resources/views/components/input-label.blade.php b/resources/views/components/input-label.blade.php new file mode 100644 index 0000000..1cc65e2 --- /dev/null +++ b/resources/views/components/input-label.blade.php @@ -0,0 +1,5 @@ +@props(['value']) + + diff --git a/resources/views/components/modal.blade.php b/resources/views/components/modal.blade.php new file mode 100644 index 0000000..70704c1 --- /dev/null +++ b/resources/views/components/modal.blade.php @@ -0,0 +1,78 @@ +@props([ + 'name', + 'show' => false, + 'maxWidth' => '2xl' +]) + +@php +$maxWidth = [ + 'sm' => 'sm:max-w-sm', + 'md' => 'sm:max-w-md', + 'lg' => 'sm:max-w-lg', + 'xl' => 'sm:max-w-xl', + '2xl' => 'sm:max-w-2xl', +][$maxWidth]; +@endphp + +
+
+
+
+ +
+ {{ $slot }} +
+
diff --git a/resources/views/components/nav-link.blade.php b/resources/views/components/nav-link.blade.php new file mode 100644 index 0000000..5c101a2 --- /dev/null +++ b/resources/views/components/nav-link.blade.php @@ -0,0 +1,11 @@ +@props(['active']) + +@php +$classes = ($active ?? false) + ? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 text-sm font-medium leading-5 text-gray-900 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out' + : 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none focus:text-gray-700 focus:border-gray-300 transition duration-150 ease-in-out'; +@endphp + +merge(['class' => $classes]) }}> + {{ $slot }} + diff --git a/resources/views/components/primary-button.blade.php b/resources/views/components/primary-button.blade.php new file mode 100644 index 0000000..d71f0b6 --- /dev/null +++ b/resources/views/components/primary-button.blade.php @@ -0,0 +1,3 @@ + diff --git a/resources/views/components/responsive-nav-link.blade.php b/resources/views/components/responsive-nav-link.blade.php new file mode 100644 index 0000000..43b91e7 --- /dev/null +++ b/resources/views/components/responsive-nav-link.blade.php @@ -0,0 +1,11 @@ +@props(['active']) + +@php +$classes = ($active ?? false) + ? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 text-start text-base font-medium text-indigo-700 bg-indigo-50 focus:outline-none focus:text-indigo-800 focus:bg-indigo-100 focus:border-indigo-700 transition duration-150 ease-in-out' + : 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out'; +@endphp + +merge(['class' => $classes]) }}> + {{ $slot }} + diff --git a/resources/views/components/secondary-button.blade.php b/resources/views/components/secondary-button.blade.php new file mode 100644 index 0000000..b32b69f --- /dev/null +++ b/resources/views/components/secondary-button.blade.php @@ -0,0 +1,3 @@ + diff --git a/resources/views/components/sort-th.blade.php b/resources/views/components/sort-th.blade.php new file mode 100644 index 0000000..2d64203 --- /dev/null +++ b/resources/views/components/sort-th.blade.php @@ -0,0 +1,16 @@ +@props(['column', 'label']) +@php + $currentSort = request('sort'); + $currentDir = request('direction', 'asc'); + $active = $currentSort === $column; + $nextDir = ($active && $currentDir === 'asc') ? 'desc' : 'asc'; + $icon = $active + ? ($currentDir === 'asc' ? 'bi-sort-down' : 'bi-sort-up') + : 'bi-arrow-down-up'; +@endphp + + + {{ $label }} + + diff --git a/resources/views/components/text-input.blade.php b/resources/views/components/text-input.blade.php new file mode 100644 index 0000000..da1b12d --- /dev/null +++ b/resources/views/components/text-input.blade.php @@ -0,0 +1,3 @@ +@props(['disabled' => false]) + +merge(['class' => 'border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm']) }}> diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php new file mode 100644 index 0000000..7312db8 --- /dev/null +++ b/resources/views/dashboard.blade.php @@ -0,0 +1,10 @@ +@include('dashboards.index', [ + 'title' => 'Dashboard', + 'eyebrow' => 'Akaun pengguna', + 'description' => 'Paparan asas sistem.', + 'cards' => [ + ['label' => 'Peranan aktif', 'value' => '0', 'icon' => 'bi-shield-lock', 'tone' => 'secondary'], + ['label' => 'Modul aktif', 'value' => '0', 'icon' => 'bi-grid', 'tone' => 'secondary'], + ], + 'actions' => [], +]) diff --git a/resources/views/dashboards/admin.blade.php b/resources/views/dashboards/admin.blade.php new file mode 100644 index 0000000..210c906 --- /dev/null +++ b/resources/views/dashboards/admin.blade.php @@ -0,0 +1,137 @@ +@extends('layouts.app') + +@section('title', 'Dashboard Admin') + +@section('content') +
+
Kawalan operasi pilihanraya
+
+
+

Dashboard Admin

+

Ringkasan setup, kekosongan petugas, dan modul pengurusan.

+
+ +
+
+ + {{-- Stats --}} +
+ @foreach ($cards as $card) +
+
+
+
+
{{ $card['label'] }}
+
{{ $card['value'] }}
+
+ + + +
+
+
+ @endforeach +
+ + {{-- Vacancy table --}} +
+
+
+

Pusat dengan Kekosongan Petugas

+

Berdasarkan kuota jawatan berbanding tugasan aktif.

+
+ @if ($pusatsWithVacancy->isEmpty()) + Semua penuh + @else + {{ $pusatsWithVacancy->count() }} pusat ada kekosongan + @endif +
+ + @if ($pusatsWithVacancy->isEmpty()) +
+ + Semua pusat mengundi telah penuh petugas mengikut kuota. +
+ @else +
+ + + + + + + + + + + + + + @foreach ($pusatsWithVacancy as $pusat) + + + + + + + + + + @endforeach + +
Pusat MengundiDUNPPMSaluranDiisi / KuotaKosongTindakan
+
{{ $pusat->code }}
+
{{ $pusat->name }}
+
{{ $pusat->dun?->code ?? '-' }}{{ $pusat->ppm_name }}{{ $pusat->saluran_mengundis_count }} + {{ $pusat->filled }} + / {{ $pusat->total_quota }} + @if ($pusat->total_quota > 0) +
+
+
+ @endif +
+ {{ $pusat->vacant }} + + + Urus Penempatan + +
+
+ @endif +
+ + {{-- Quick links --}} + +@endsection diff --git a/resources/views/dashboards/index.blade.php b/resources/views/dashboards/index.blade.php new file mode 100644 index 0000000..85a2540 --- /dev/null +++ b/resources/views/dashboards/index.blade.php @@ -0,0 +1,62 @@ +@extends('layouts.app') + +@section('title', $title) + +@section('content') +
+
{{ $eyebrow }}
+
+
+

{{ $title }}

+

{{ $description }}

+
+
+ + Phase 1 + +
+
+
+ +
+ @foreach ($cards as $card) +
+
+
+
+
{{ $card['label'] }}
+
{{ $card['value'] }}
+
+ + + +
+
+
+ @endforeach +
+ + @if (! empty($actions)) +
+
+

Modul Utama

+ Base dashboard +
+
+ @foreach ($actions as $action) +
+ @if (is_array($action)) + + {{ $action['label'] }} + + @else + + @endif +
+ @endforeach +
+
+ @endif +@endsection diff --git a/resources/views/emails/ktm/created-applicant.blade.php b/resources/views/emails/ktm/created-applicant.blade.php new file mode 100644 index 0000000..d78116a --- /dev/null +++ b/resources/views/emails/ktm/created-applicant.blade.php @@ -0,0 +1,15 @@ + +# Makluman Pendaftaran KP + +Nama: {{ $application->name }} + +Jawatan: KP - Kerani Pengundian + +Pusat Mengundi: {{ $application->pusatMengundi?->name ?? '-' }} + +KTM: {{ $ktmAssignment->user?->name ?? '-' }} + +Rekod anda telah didaftarkan oleh KTM untuk semakan lanjut. Sila hubungi KTM atau Admin jika terdapat maklumat yang tidak tepat. + +Terima kasih. + diff --git a/resources/views/emails/staff/operational-account-created.blade.php b/resources/views/emails/staff/operational-account-created.blade.php new file mode 100644 index 0000000..323e28f --- /dev/null +++ b/resources/views/emails/staff/operational-account-created.blade.php @@ -0,0 +1,34 @@ + +# Akaun Login Telah Dicipta + +Assalamualaikum / Salam Sejahtera **{{ $user->name }}**, + +Anda telah ditetapkan sebagai **{{ $roleCode }}** dalam Sistem Pengurusan Kakitangan Pilihanraya. + +Akaun login telah disediakan untuk anda. Sila gunakan maklumat berikut untuk log masuk: + + +**URL Login:** {{ url('/login') }} + +**E-mel:** {{ $user->email }} + +**Kata Laluan Sementara:** `{{ $tempPassword }}` + + +> **Penting:** Sila tukar kata laluan anda selepas log masuk pertama melalui menu **Profil**. + +Setelah log masuk, anda boleh mengakses fungsi berikut mengikut peranan anda: + +@if ($roleCode === 'PPM') +- Semak dan luluskan permohonan kakitangan +- Rekod kehadiran staf pada hari mengundi +@elseif ($roleCode === 'KTM') +- Daftar dan urus ahli pasukan KP +- Semak permohonan KP +@endif + +Jika anda mempunyai sebarang masalah log masuk, sila hubungi pentadbir sistem. + +Terima kasih,
+{{ config('app.name') }} +
diff --git a/resources/views/kewangan/bank/index.blade.php b/resources/views/kewangan/bank/index.blade.php new file mode 100644 index 0000000..6c5c7d4 --- /dev/null +++ b/resources/views/kewangan/bank/index.blade.php @@ -0,0 +1,111 @@ +@extends('layouts.app') + +@section('title', 'Semakan Bank') + +@section('content') +
+
Admin Kewangan
+
+
+

Semakan Akaun Bank

+

Semak maklumat bank staf yang telah diluluskan tanpa mengubah penempatan.

+
+ +
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+
+
+ +
+
+ + + + + + + + + + + + + @forelse ($verifications as $verification) + @php($application = $verification->application) + @php($position = $application?->approvedPosition ?: $application?->requestedPosition) + @php($hasBankStatement = $application?->documents->contains('document_type', 'bank_statement') === true) + + + + + + + + + @empty + + @endforelse + +
StafPusat / JawatanBankDokumenTindakan
+
{{ $application?->name }}
+
{{ \App\Support\SensitiveData::maskIc($application?->ic_number) }} · {{ \App\Support\SensitiveData::maskPhone($application?->phone_number) }}
+
{{ $application?->pusatMengundi?->code }}
{{ $position?->code ?: '-' }}
+
{{ $application?->bank_name ?: '-' }}
+
{{ \App\Support\SensitiveData::maskAccount($application?->bank_account_number) }}
+
+ {{ $hasBankStatement ? 'Penyata ada' : 'Penyata tiada' }} + {{ str_replace('_', ' ', $verification->status) }}Semak
Tiada rekod kewangan dijumpai.
+
+
+ +
{{ $verifications->links() }}
+@endsection diff --git a/resources/views/kewangan/bank/show.blade.php b/resources/views/kewangan/bank/show.blade.php new file mode 100644 index 0000000..388ec2e --- /dev/null +++ b/resources/views/kewangan/bank/show.blade.php @@ -0,0 +1,104 @@ +@extends('layouts.app') + +@section('title', 'Semakan Rekod Bank') + +@section('content') + @php($application = $verification->application) + @php($position = $application?->approvedPosition ?: $application?->requestedPosition) + @php($hasBankStatement = $application?->documents->contains('document_type', 'bank_statement') === true) + +
+
Admin Kewangan
+
+
+

Semakan Rekod Bank

+

{{ $application?->name }} · {{ $application?->pusatMengundi?->name }}

+
+ +
+
+ +
+
+
+

Maklumat Bank

+
+
+
Nama
+
{{ $application?->name }}
+
+
+
No. IC
+
{{ \App\Support\SensitiveData::maskIc($application?->ic_number) }}
+
+
+
Pusat Mengundi
+
{{ $application?->pusatMengundi?->code }} - {{ $application?->pusatMengundi?->name }}
+
+
+
Jawatan
+
{{ $position?->code }} - {{ $position?->name }}
+
+
+
Bank
+
{{ $application?->bank_name ?: '-' }}
+
+
+
No. Akaun
+
{{ \App\Support\SensitiveData::maskAccount($application?->bank_account_number) }}
+
+
+
Penyata Bank
+ {{ $hasBankStatement ? 'Ada' : 'Tiada' }} + @if ($hasBankStatement) + @php($bankStatement = $application?->documents->firstWhere('document_type', 'bank_statement')) + @if ($bankStatement) + + @endif + @endif +
+
+
Status Semasa
+ {{ str_replace('_', ' ', $verification->status) }} +
+
+
+
+
+
+

Keputusan Semakan

+
+ @csrf + @method('PATCH') +
+ + +
+
+ + +
+
+ +
+
+
+ +
+

Maklumat Audit Ringkas

+
Disemak oleh
+
{{ $verification->verifiedBy?->name ?: '-' }}
+
Masa semakan
+
{{ $verification->verified_at?->format('d/m/Y H:i') ?: '-' }}
+
+
+
+@endsection diff --git a/resources/views/ktm/applications/create.blade.php b/resources/views/ktm/applications/create.blade.php new file mode 100644 index 0000000..a82dd72 --- /dev/null +++ b/resources/views/ktm/applications/create.blade.php @@ -0,0 +1,65 @@ +@extends('layouts.app') + +@section('title', 'Daftar KP') + +@section('content') +
+
KTM
+
+
+

Daftar KP

+

KTM hanya boleh mendaftarkan Kerani Pengundian untuk saluran sendiri.

+
+ + Kembali + +
+
+ +
+
+ @csrf +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+@endsection diff --git a/resources/views/ktm/applications/index.blade.php b/resources/views/ktm/applications/index.blade.php new file mode 100644 index 0000000..6c45d67 --- /dev/null +++ b/resources/views/ktm/applications/index.blade.php @@ -0,0 +1,88 @@ +@extends('layouts.app') + +@section('title', 'Team KP') + +@section('content') +
+
KTM
+
+
+

Team KP

+

Saluran dan permohonan KP di bawah tugasan KTM anda.

+
+ + Daftar KP + +
+
+ +
+ @forelse ($assignments as $assignment) +
+
+
+
+
{{ $assignment->pusatMengundi?->name }}
+

Saluran {{ $assignment->saluranMengundi?->number }}

+
+ +
+
Kekosongan KP
+
{{ $vacancyService->remainingForAssignment($assignment, 'KP') }}
+
+
KP Aktif
+ @forelse ($assignment->teamMembers->where('status', 'active') as $member) +
{{ $member->application?->name ?? 'KP tanpa nama' }}
+ @empty +
Tiada KP aktif.
+ @endforelse +
+
+ @empty +
+
Tiada tugasan KTM aktif.
+
+ @endforelse +
+ +
+

Permohonan KP

+
+ + + + + + + + + + + + @forelse ($applications as $application) + + + + + + + + @empty + + + + @endforelse + +
Pusat / SaluranSumberTindakan
+
{{ $application->name }}
+
{{ Illuminate\Support\Str::mask($application->ic_number, '*', 6, 4) }}
+
+ {{ $application->pusatMengundi?->name }} +
Saluran {{ $application->selectedKtmAssignment?->saluranMengundi?->number ?? '-' }}
+
{{ $application->source }}{{ $application->status }} + Buka +
Tiada permohonan KP.
+
+ {{ $applications->links() }} +
+@endsection diff --git a/resources/views/ktm/applications/show.blade.php b/resources/views/ktm/applications/show.blade.php new file mode 100644 index 0000000..f09f058 --- /dev/null +++ b/resources/views/ktm/applications/show.blade.php @@ -0,0 +1,81 @@ +@extends('layouts.app') + +@section('title', 'Permohonan KP') + +@section('content') +
+
KTM
+
+
+

{{ $application->name }}

+

{{ $application->pusatMengundi?->name }} - Saluran {{ $application->selectedKtmAssignment?->saluranMengundi?->number ?? '-' }}

+
+ + Kembali + +
+
+ +
+
+
+
+

Maklumat KP

+ {{ $application->status }} +
+
+
No. Kad Pengenalan
+
{{ $application->ic_number }}
+
Telefon
+
{{ $application->phone_number }}
+
Emel
+
{{ $application->email ?: '-' }}
+
Alamat
+
{{ $application->address ?: '-' }}
+
Sumber
+
{{ $application->source }}
+
Jawatan
+
{{ $application->requestedPosition?->code }} - {{ $application->requestedPosition?->name }}
+
+
+
+
+
+

Tindakan

+ @if (in_array($application->status, ['submitted', 'under_ppm_review', 'approved'], true)) +
+ @csrf + +
+ @endif + + @if ($application->source === 'created_by_ktm' && $application->status !== 'assigned') +
+ @csrf + @method('DELETE') + +
+ @endif +
+ +
+

Sejarah Status

+ @forelse ($application->statusHistories->sortByDesc('created_at') as $history) +
+
{{ $history->from_status ?: '-' }} ke {{ $history->to_status }}
+
{{ $history->created_at?->format('d/m/Y H:i') }} oleh {{ $history->changedBy?->name ?? 'Sistem' }}
+ @if ($history->note) +
{{ $history->note }}
+ @endif +
+ @empty +

Tiada sejarah status.

+ @endforelse +
+
+
+@endsection diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php new file mode 100644 index 0000000..3671e68 --- /dev/null +++ b/resources/views/layouts/app.blade.php @@ -0,0 +1,45 @@ + + + + + + + + @yield('title', $title ?? 'Dashboard') - {{ config('app.name') }} + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + @include('layouts.navigation') + +
+
+ @if (session('status')) + + @endif + + @if ($errors->any()) + + @endif + + @isset($slot) + {{ $slot }} + @else + @yield('content') + @endisset +
+
+ + @stack('scripts') + + diff --git a/resources/views/layouts/guest.blade.php b/resources/views/layouts/guest.blade.php new file mode 100644 index 0000000..953470c --- /dev/null +++ b/resources/views/layouts/guest.blade.php @@ -0,0 +1,34 @@ + + + + + + + + @yield('title', $title ?? 'Log Masuk') - {{ config('app.name') }} + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+ + +
+ @if (session('status')) + + @endif + + @isset($slot) + {{ $slot }} + @else + @yield('content') + @endisset +
+
+ + diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php new file mode 100644 index 0000000..e07715c --- /dev/null +++ b/resources/views/layouts/navigation.blade.php @@ -0,0 +1,72 @@ +@php($user = auth()->user()) + + diff --git a/resources/views/layouts/public.blade.php b/resources/views/layouts/public.blade.php new file mode 100644 index 0000000..382b377 --- /dev/null +++ b/resources/views/layouts/public.blade.php @@ -0,0 +1,49 @@ + + + + + + + + @yield('title', $title ?? 'Pendaftaran') - {{ config('app.name') }} + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + + +
+
+ @if (session('status')) + + @endif + + @if ($errors->any()) + + @endif + + @yield('content') +
+
+ + @stack('scripts') + + diff --git a/resources/views/ppm/applications/index.blade.php b/resources/views/ppm/applications/index.blade.php new file mode 100644 index 0000000..cb20f55 --- /dev/null +++ b/resources/views/ppm/applications/index.blade.php @@ -0,0 +1,74 @@ +@extends('layouts.app') + +@section('title', 'Semakan Permohonan PPM') + +@section('content') +
+
PPM
+
+
+

Semakan Permohonan

+

Permohonan untuk Pusat Mengundi di bawah jagaan anda sahaja.

+
+
+
+ +
+
+
+ + +
+
+ +
+
+
+ +
+
+ + + + + + + + + + + + + @forelse ($applications as $application) + + + + + + + + + @empty + + + + @endforelse + +
PusatJawatanTindakan
+
{{ $application->name }}
+
{{ Illuminate\Support\Str::mask($application->ic_number, '*', 6, 4) }}
+
{{ $application->pusatMengundi?->name }} + {{ $application->approvedPosition?->code ?? $application->requestedPosition?->code }} +
{{ $application->requestedPosition?->name }}
+
{{ $application->status }}{{ $application->created_at?->format('d/m/Y H:i') }} + Semak +
Tiada permohonan untuk semakan.
+
+ {{ $applications->links() }} +
+@endsection diff --git a/resources/views/ppm/applications/show.blade.php b/resources/views/ppm/applications/show.blade.php new file mode 100644 index 0000000..2ef29fb --- /dev/null +++ b/resources/views/ppm/applications/show.blade.php @@ -0,0 +1,265 @@ +@extends('layouts.app') + +@section('title', 'Semak Permohonan') + +@section('content') +
+
PPM
+
+
+

{{ $application->name }}

+

{{ $application->pusatMengundi?->code }} - {{ $application->pusatMengundi?->name }}

+
+ + Kembali + +
+
+ +
+
+
+
+

Maklumat Pemohon

+ {{ $application->status }} +
+
+
No. Kad Pengenalan
+
{{ $application->ic_number }}
+
Telefon
+
{{ $application->phone_number }}
+
Emel
+
{{ $application->email ?: '-' }}
+
Alamat
+
{{ $application->address ?: '-' }}
+
Jawatan Dipohon
+
{{ $application->requestedPosition?->code }} - {{ $application->requestedPosition?->name }}
+
Jawatan Diluluskan
+
+ @if ($application->approvedPosition) + {{ $application->approvedPosition->code }} - {{ $application->approvedPosition->name }} + @else + Belum diluluskan + @endif +
+
Bank
+
{{ $application->bank_name }} / {{ Illuminate\Support\Str::mask((string) $application->bank_account_number, '*', 4, 4) }}
+
KTM Pilihan Pemohon
+
+ @if ($application->selectedKtmAssignment) + {{ $application->selectedKtmAssignment->user?->name ?? '-' }} + Saluran {{ $application->selectedKtmAssignment->saluranMengundi?->number ?? '-' }} + @else + - + @endif +
+
+
+ +
+

Dokumen

+
+ + + + + + + + + + @forelse ($application->documents as $document) + + + + + + @empty + + + + @endforelse + +
JenisFailTindakan
{{ $document->document_type }}{{ $document->original_name }} + + Muat turun + +
Tiada dokumen.
+
+
+ +
+

Wakil Agensi

+
+
+
+
Polis Pengiring
+ @forelse ($application->pusatMengundi?->policeEscorts ?? [] as $police) +
{{ $police->name }} {{ $police->saluranMengundi?->number ? 'Saluran '.$police->saluranMengundi->number : '' }}
+ @empty +
Tiada rekod.
+ @endforelse +
+
+
+
+
Wakil KKM
+ @forelse ($application->pusatMengundi?->kkmRepresentatives ?? [] as $kkm) +
{{ $kkm->name }}
+ @empty +
Tiada rekod.
+ @endforelse +
+
+
+
+
Wakil JKM
+ @forelse ($application->pusatMengundi?->jkmRepresentatives ?? [] as $jkm) +
{{ $jkm->name }}
+ @empty +
Tiada rekod.
+ @endforelse +
+
+
+
+
+ +
+
+

Lulus dan Assign

+ @if (in_array($application->status, ['submitted', 'under_ppm_review', 'approved'], true)) +
+ @csrf + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ @else +

Permohonan ini tidak lagi boleh diluluskan.

+ @endif +
+ +
+

Tolak Permohonan

+ @if (in_array($application->status, ['submitted', 'under_ppm_review', 'approved'], true)) +
+ @csrf +
+ + +
+ +
+ @else +

Permohonan ini tidak lagi boleh ditolak.

+ @endif +
+ +
+

Sejarah Status

+ @forelse ($application->statusHistories->sortByDesc('created_at') as $history) +
+
{{ $history->from_status ?: '-' }} ke {{ $history->to_status }}
+
{{ $history->created_at?->format('d/m/Y H:i') }} oleh {{ $history->changedBy?->name ?? 'Sistem' }}
+ @if ($history->note) +
{{ $history->note }}
+ @endif +
+ @empty +

Tiada sejarah status.

+ @endforelse +
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/ppm/attendance/index.blade.php b/resources/views/ppm/attendance/index.blade.php new file mode 100644 index 0000000..e4ea9b6 --- /dev/null +++ b/resources/views/ppm/attendance/index.blade.php @@ -0,0 +1,45 @@ +@extends('layouts.app') + +@section('title', 'Kehadiran PPM') + +@section('content') +
+
PPM
+

Rekod Kehadiran

+

Pilih Pusat Mengundi jagaan untuk merekod kehadiran hari mengundi.

+
+ +
+ @forelse ($pusats as $pusat) + @php($summary = $summaryService->totalsForPusat($pusat)) +
+
+
+
+

{{ $pusat->code }} - {{ $pusat->name }}

+
{{ $pusat->election?->name }}
+
+ + {{ $pusat->election?->settings?->is_attendance_active ? 'Aktif' : 'Belum aktif' }} + +
+
+
Staf
{{ $summary['total_staff'] }}
+
Hadir
{{ $summary['present'] }}
+
Tidak hadir
{{ $summary['absent'] }}
+
Belum rekod
{{ $summary['not_recorded'] }}
+
+ +
+
+ @empty +
+
Tiada Pusat Mengundi ditetapkan kepada akaun PPM ini.
+
+ @endforelse +
+@endsection diff --git a/resources/views/ppm/attendance/show.blade.php b/resources/views/ppm/attendance/show.blade.php new file mode 100644 index 0000000..46589dc --- /dev/null +++ b/resources/views/ppm/attendance/show.blade.php @@ -0,0 +1,92 @@ +@extends('layouts.app') + +@section('title', 'Rekod Kehadiran') + +@section('content') +
+
PPM
+
+
+

Rekod Kehadiran

+

{{ $pusat->code }} - {{ $pusat->name }}

+
+ +
+
+ +
+ @foreach ([ + ['label' => 'Staf', 'value' => $summary['total_staff'], 'tone' => 'primary'], + ['label' => 'Hadir', 'value' => $summary['present'], 'tone' => 'success'], + ['label' => 'Tidak hadir', 'value' => $summary['absent'], 'tone' => 'danger'], + ['label' => 'Belum rekod', 'value' => $summary['not_recorded'], 'tone' => 'warning'], + ] as $card) +
+
+
{{ $card['label'] }}
+
{{ $card['value'] }}
+
+
+ @endforeach +
+ + @if (! $pusat->election?->settings?->is_attendance_active) +
+ Modul kehadiran belum diaktifkan untuk pilihanraya ini. +
+ @endif + +
+ @csrf +
+
+ + + + + + + + + + + + + @forelse ($assignments as $assignment) + @php($attendance = $assignment->attendance) + + + + + + + + + @empty + + @endforelse + +
StafJawatanSaluranStatusMasa Check-inCatatan
+
{{ $assignment->application?->name ?: $assignment->user?->name ?: 'Tanpa nama' }}
+
{{ $assignment->application?->ic_number ?: $assignment->user?->email ?: '-' }}
+
{{ $assignment->position?->code }}{{ $assignment->saluranMengundi?->number ?: '-' }} + + + id}.check_in_time", $attendance?->check_in_time?->format('Y-m-d\TH:i')) }}"> + + id}.note", $attendance?->note) }}"> +
Tiada staf aktif untuk Pusat Mengundi ini.
+
+
+ +
+ +
+
+@endsection diff --git a/resources/views/profile/edit.blade.php b/resources/views/profile/edit.blade.php new file mode 100644 index 0000000..fee11d0 --- /dev/null +++ b/resources/views/profile/edit.blade.php @@ -0,0 +1,81 @@ +@extends('layouts.app') + +@section('title', 'Profil') + +@section('content') +
+
Akaun Pengguna
+

Profil

+

Maklumat asas akaun sistem.

+
+ +
+
+
+

Maklumat profil

+
+ @csrf + @method('PATCH') + +
+ + + @error('name') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('email') +
{{ $message }}
+ @enderror +
+ + +
+
+
+ +
+
+

Kata laluan

+
+ @csrf + @method('PUT') + +
+ + + @error('current_password', 'updatePassword') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('password', 'updatePassword') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('password_confirmation', 'updatePassword') +
{{ $message }}
+ @enderror +
+ + +
+
+
+
+@endsection diff --git a/resources/views/profile/partials/delete-user-form.blade.php b/resources/views/profile/partials/delete-user-form.blade.php new file mode 100644 index 0000000..edeeb4a --- /dev/null +++ b/resources/views/profile/partials/delete-user-form.blade.php @@ -0,0 +1,55 @@ +
+
+

+ {{ __('Delete Account') }} +

+ +

+ {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.') }} +

+
+ + {{ __('Delete Account') }} + + +
+ @csrf + @method('delete') + +

+ {{ __('Are you sure you want to delete your account?') }} +

+ +

+ {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }} +

+ +
+ + + + + +
+ +
+ + {{ __('Cancel') }} + + + + {{ __('Delete Account') }} + +
+
+
+
diff --git a/resources/views/profile/partials/update-password-form.blade.php b/resources/views/profile/partials/update-password-form.blade.php new file mode 100644 index 0000000..eaca1ac --- /dev/null +++ b/resources/views/profile/partials/update-password-form.blade.php @@ -0,0 +1,48 @@ +
+
+

+ {{ __('Update Password') }} +

+ +

+ {{ __('Ensure your account is using a long, random password to stay secure.') }} +

+
+ +
+ @csrf + @method('put') + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ {{ __('Save') }} + + @if (session('status') === 'password-updated') +

{{ __('Saved.') }}

+ @endif +
+
+
diff --git a/resources/views/profile/partials/update-profile-information-form.blade.php b/resources/views/profile/partials/update-profile-information-form.blade.php new file mode 100644 index 0000000..5ae3d35 --- /dev/null +++ b/resources/views/profile/partials/update-profile-information-form.blade.php @@ -0,0 +1,64 @@ +
+
+

+ {{ __('Profile Information') }} +

+ +

+ {{ __("Update your account's profile information and email address.") }} +

+
+ +
+ @csrf +
+ +
+ @csrf + @method('patch') + +
+ + + +
+ +
+ + + + + @if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail()) +
+

+ {{ __('Your email address is unverified.') }} + + +

+ + @if (session('status') === 'verification-link-sent') +

+ {{ __('A new verification link has been sent to your email address.') }} +

+ @endif +
+ @endif +
+ +
+ {{ __('Save') }} + + @if (session('status') === 'profile-updated') +

{{ __('Saved.') }}

+ @endif +
+
+
diff --git a/resources/views/public/application-placeholder.blade.php b/resources/views/public/application-placeholder.blade.php new file mode 100644 index 0000000..11206d3 --- /dev/null +++ b/resources/views/public/application-placeholder.blade.php @@ -0,0 +1,34 @@ +@extends('layouts.public') + +@section('title', 'Permohonan Petugas') + +@section('content') +
+
Pendaftaran Awam
+

Permohonan Petugas Pilihanraya

+

{{ $pusatMengundi->name }}

+
+ +
+
+
+ +
+
+

Borang permohonan akan dibina dalam Phase 4

+

+ Pautan QR ini telah disediakan untuk Pusat Mengundi yang betul. Modul permohonan awam seterusnya + akan menggunakan UUID ini tanpa mendedahkan ID dalaman sistem. +

+
+
Kod Pusat
+
{{ $pusatMengundi->code }}
+
Daerah
+
{{ $pusatMengundi->daerahMengundi?->name ?? '-' }}
+
Alamat
+
{{ $pusatMengundi->address ?: '-' }}
+
+
+
+
+@endsection diff --git a/resources/views/public/applications/closed.blade.php b/resources/views/public/applications/closed.blade.php new file mode 100644 index 0000000..ed4b5e1 --- /dev/null +++ b/resources/views/public/applications/closed.blade.php @@ -0,0 +1,23 @@ +@extends('layouts.public') + +@section('title', 'Pendaftaran Ditutup') + +@section('content') +
+
Pendaftaran Awam
+

Pendaftaran Ditutup

+

{{ $pusatMengundi->code }} - {{ $pusatMengundi->name }}

+
+ +
+
+
+ +
+
+

Permohonan tidak boleh dihantar

+

{{ $message }}

+
+
+
+@endsection diff --git a/resources/views/public/applications/create.blade.php b/resources/views/public/applications/create.blade.php new file mode 100644 index 0000000..eaa6b68 --- /dev/null +++ b/resources/views/public/applications/create.blade.php @@ -0,0 +1,164 @@ +@extends('layouts.public') + +@section('title', 'Permohonan Petugas') + +@section('content') +
+
Pendaftaran Awam
+

Permohonan Petugas Pilihanraya

+

{{ $pusatMengundi->code }} - {{ $pusatMengundi->name }}

+
+ +
+
+
+

Pusat Mengundi

+
+
Daerah
+
{{ $pusatMengundi->daerahMengundi?->name ?? '-' }}
+
Alamat
+
{{ $pusatMengundi->address ?: '-' }}
+
+
+
+ +
+
+
+ @csrf + +
+
+

Maklumat Pemohon

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+

Jawatan dan Bank

+
+ +
+ + +
+ +
+ + +
KTM hanya dipaparkan jika kekosongan masih ada.
+
+ +
+ + +
+ +
+ + +
+ +
+
+

Dokumen

+
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/public/applications/status.blade.php b/resources/views/public/applications/status.blade.php new file mode 100644 index 0000000..e912c28 --- /dev/null +++ b/resources/views/public/applications/status.blade.php @@ -0,0 +1,91 @@ +@extends('layouts.public') + +@section('title', 'Status Permohonan') + +@section('content') +
+
Semak Status Permohonan
+

Status Permohonan

+

Paparan status terhad. Dokumen dan maklumat sensitif tidak dipaparkan.

+
+ + @if ($application === null) + {{-- Generic response for unknown / internal-only UUIDs --}} +
+
+
+ +
+
+

Status tidak dapat dipaparkan

+

+ Tiada rekod permohonan yang aktif ditemui untuk pautan ini. + Sila pastikan URL yang digunakan adalah betul. +

+
+
+
+ @else + @php + $statusConfig = match ($application->status) { + 'submitted' => ['label' => 'Menunggu Semakan', 'tone' => 'info', 'icon' => 'bi-hourglass-split'], + 'under_ppm_review' => ['label' => 'Sedang Disemak', 'tone' => 'warning', 'icon' => 'bi-eye'], + 'approved' => ['label' => 'Diluluskan', 'tone' => 'success', 'icon' => 'bi-check-circle'], + 'assigned' => ['label' => 'Diluluskan & Ditempatkan', 'tone' => 'success', 'icon' => 'bi-person-check'], + 'rejected' => ['label' => 'Ditolak', 'tone' => 'danger', 'icon' => 'bi-x-circle'], + 'cancelled' => ['label' => 'Dibatalkan', 'tone' => 'secondary', 'icon' => 'bi-slash-circle'], + default => ['label' => ucfirst($application->status), 'tone' => 'secondary', 'icon' => 'bi-circle'], + }; + @endphp + +
+
+
+ +
+
+

{{ $statusConfig['label'] }}

+

+ Dikemaskini: {{ $application->updated_at?->format('d/m/Y H:i') }} +

+
+
+ +
+
Nama (disamarkan)
+
{{ $maskedName }}
+ +
Jawatan Dipohon
+
+ {{ $application->requestedPosition?->name ?? '-' }} +
+ + @if ($application->approvedPosition && $application->approvedPosition->id !== $application->requestedPosition?->id) +
Jawatan Diluluskan
+
{{ $application->approvedPosition->name }}
+ @endif + +
Pusat Mengundi
+
{{ $application->pusatMengundi?->name ?? '-' }}
+ +
Status
+
+ + {{ $statusConfig['label'] }} + +
+ + @if ($application->status === 'rejected' && $application->rejection_reason) +
Sebab Penolakan
+
{{ $application->rejection_reason }}
+ @endif +
+
+ +

+ + Untuk pertanyaan lanjut, hubungi pentadbir pilihanraya dengan menyertakan nombor rujukan anda: + {{ $application->public_uuid }} +

+ @endif +@endsection diff --git a/resources/views/public/applications/success.blade.php b/resources/views/public/applications/success.blade.php new file mode 100644 index 0000000..8187d73 --- /dev/null +++ b/resources/views/public/applications/success.blade.php @@ -0,0 +1,49 @@ +@extends('layouts.public') + +@section('title', 'Permohonan Diterima') + +@section('content') +
+
Pendaftaran Awam
+

Permohonan Diterima

+

Rujukan: {{ $application->public_uuid }}

+
+ +
+
+
+ +
+
+

Rekod permohonan telah disimpan

+
+
Pusat Mengundi
+
{{ $application->pusatMengundi?->name }}
+
Jawatan Dipohon
+
{{ $application->requestedPosition?->code }} - {{ $application->requestedPosition?->name }}
+
Status
+
Menunggu Semakan
+
+
+
+
+ +
+

Simpan pautan status anda

+

+ Gunakan pautan berikut untuk semak status permohonan anda pada bila-bila masa tanpa perlu log masuk. +

+
+ + +
+

+ Nombor rujukan: {{ $application->public_uuid }} +

+
+@endsection diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php new file mode 100644 index 0000000..0cdfcfd --- /dev/null +++ b/resources/views/welcome.blade.php @@ -0,0 +1,23 @@ +@extends('layouts.guest') + +@section('title', 'Akses Sistem') + +@section('content') +
+
Pilihanraya Negeri
+

Sistem Pengurusan Pusat Mengundi

+

Akses dalaman untuk Admin, Admin Kewangan, PPM, dan KTM.

+
+ +
+ @auth + + Dashboard + + @else + + Log masuk + + @endauth +
+@endsection diff --git a/routes/auth.php b/routes/auth.php new file mode 100644 index 0000000..efc3763 --- /dev/null +++ b/routes/auth.php @@ -0,0 +1,53 @@ +group(function () { + Route::get('login', [AuthenticatedSessionController::class, 'create']) + ->name('login'); + + Route::post('login', [AuthenticatedSessionController::class, 'store']); + + Route::get('forgot-password', [PasswordResetLinkController::class, 'create']) + ->name('password.request'); + + Route::post('forgot-password', [PasswordResetLinkController::class, 'store']) + ->name('password.email'); + + Route::get('reset-password/{token}', [NewPasswordController::class, 'create']) + ->name('password.reset'); + + Route::post('reset-password', [NewPasswordController::class, 'store']) + ->name('password.store'); +}); + +Route::middleware('auth')->group(function () { + Route::get('verify-email', EmailVerificationPromptController::class) + ->name('verification.notice'); + + Route::get('verify-email/{id}/{hash}', VerifyEmailController::class) + ->middleware(['signed', 'throttle:6,1']) + ->name('verification.verify'); + + Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store']) + ->middleware('throttle:6,1') + ->name('verification.send'); + + Route::get('confirm-password', [ConfirmablePasswordController::class, 'show']) + ->name('password.confirm'); + + Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']); + + Route::put('password', [PasswordController::class, 'update'])->name('password.update'); + + Route::post('logout', [AuthenticatedSessionController::class, 'destroy']) + ->name('logout'); +}); diff --git a/routes/console.php b/routes/console.php new file mode 100644 index 0000000..ceeba12 --- /dev/null +++ b/routes/console.php @@ -0,0 +1,16 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); + +// Purge export files older than 30 days daily at midnight. +Schedule::command(ExportPurgeCommand::class, ['--days=30']) + ->dailyAt('00:00') + ->withoutOverlapping() + ->runInBackground(); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..1c0cbb3 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,215 @@ +name('home'); + +Route::get('/permohonan/{application:public_uuid}/berjaya', [PublicApplicationController::class, 'success']) + ->name('public.applications.success'); + +Route::get('/permohonan/{publicUuid}/status', [PublicApplicationController::class, 'status']) + ->name('public.applications.status'); + +Route::get('/pohon/{pusatMengundi:public_uuid}', [PublicApplicationController::class, 'create']) + ->name('public.applications.create'); +Route::post('/pohon/{pusatMengundi:public_uuid}', [PublicApplicationController::class, 'store']) + ->name('public.applications.store'); + +Route::middleware('auth')->group(function () { + Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard'); + + Route::get('/admin/dashboard', [DashboardController::class, 'admin']) + ->middleware('role:Admin') + ->name('admin.dashboard'); + + Route::get('/kewangan/dashboard', [DashboardController::class, 'kewangan']) + ->middleware('role:Admin Kewangan') + ->name('kewangan.dashboard'); + + Route::get('/ppm/dashboard', [DashboardController::class, 'ppm']) + ->middleware('role:PPM') + ->name('ppm.dashboard'); + + Route::get('/ktm/dashboard', [DashboardController::class, 'ktm']) + ->middleware('role:KTM') + ->name('ktm.dashboard'); + + Route::get('/pemohon/dashboard', [DashboardController::class, 'pemohon']) + ->middleware('role:Pemohon') + ->name('pemohon.dashboard'); + + Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); + Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); + Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); +}); + +Route::middleware(['auth', 'role:Admin']) + ->prefix('admin/setup') + ->name('admin.setup.') + ->group(function (): void { + Route::get('/', [AdminSetupController::class, 'index'])->name('index'); + + Route::get('import', [PusatImportController::class, 'create'])->name('import.create'); + Route::post('import', [PusatImportController::class, 'store'])->name('import.store'); + + Route::resource('pusat', PusatMengundiController::class) + ->parameters(['pusat' => 'pusatMengundi']); + + Route::post('pusat/{pusatMengundi}/generate-qr', [PusatMengundiController::class, 'generateQr']) + ->name('pusat.generate-qr'); + Route::get('pusat/{pusatMengundi}/qr.svg', [PusatMengundiController::class, 'qrSvg']) + ->name('pusat.qr-svg'); + Route::post('pusat/{pusatMengundi}/assign-ppm', [PusatMengundiController::class, 'assignPpm']) + ->name('pusat.assign-ppm'); + + Route::resource('pusat.saluran', SaluranMengundiController::class) + ->parameters(['pusat' => 'pusatMengundi', 'saluran' => 'saluranMengundi']) + ->except(['show']); + + Route::resource('positions', PositionController::class) + ->except(['show']); + + Route::resource('pusat.kuota', PusatLevelQuotaController::class) + ->parameters(['pusat' => 'pusatMengundi', 'kuota' => 'quota']) + ->except(['show']); + + Route::resource('pusat.saluran.kuota', PositionQuotaController::class) + ->parameters(['pusat' => 'pusatMengundi', 'saluran' => 'saluranMengundi', 'kuota' => 'quota']) + ->except(['show']); + + Route::get('settings/{election}', [ElectionSettingsController::class, 'edit']) + ->name('settings.edit'); + Route::post('settings/{election}', [ElectionSettingsController::class, 'store']) + ->name('settings.store'); + Route::patch('settings/{election}', [ElectionSettingsController::class, 'update']) + ->name('settings.update'); + }); + +Route::middleware(['auth', 'role:Admin', 'admin.post_close_note']) + ->prefix('admin/management') + ->name('admin.management.') + ->group(function (): void { + Route::get('/', [AdminManagementController::class, 'index'])->name('index'); + + Route::get('/applications', [ManagedApplicationController::class, 'index'])->name('applications.index'); + Route::get('/applications/create', [ManagedApplicationController::class, 'create'])->name('applications.create'); + Route::post('/applications', [ManagedApplicationController::class, 'store'])->name('applications.store'); + Route::get('/applications/{application:public_uuid}/edit', [ManagedApplicationController::class, 'edit'])->name('applications.edit'); + Route::patch('/applications/{application:public_uuid}', [ManagedApplicationController::class, 'update'])->name('applications.update'); + Route::post('/applications/{application:public_uuid}/assign', [ManagedApplicationController::class, 'assign'])->name('applications.assign'); + + Route::get('/assignments', [StaffAssignmentController::class, 'index'])->name('assignments.index'); + Route::get('/assignments/{assignment}/edit', [StaffAssignmentController::class, 'edit'])->name('assignments.edit'); + Route::patch('/assignments/{assignment}', [StaffAssignmentController::class, 'update'])->name('assignments.update'); + + Route::get('/representatives', [RepresentativeController::class, 'index'])->name('representatives.index'); + Route::post('/representatives', [RepresentativeController::class, 'store'])->name('representatives.store'); + Route::delete('/representatives/{type}/{id}', [RepresentativeController::class, 'destroy'])->name('representatives.destroy'); + + Route::get('/penempatan/{pusatMengundi}', [PennempatanController::class, 'show'])->name('penempatan.show'); + Route::post('/penempatan/{pusatMengundi}/lepas/{assignment}', [PennempatanController::class, 'lepas'])->name('penempatan.lepas'); + Route::get('/penempatan/{pusatMengundi}/pindah/{assignment}', [PennempatanController::class, 'pindahForm'])->name('penempatan.pindah.form'); + Route::post('/penempatan/{pusatMengundi}/pindah/{assignment}', [PennempatanController::class, 'pindah'])->name('penempatan.pindah'); + + Route::get('/wheelchairs', [WheelchairController::class, 'index'])->name('wheelchairs.index'); + Route::post('/wheelchairs', [WheelchairController::class, 'store'])->name('wheelchairs.store'); + Route::get('/wheelchairs/{allocation}', [WheelchairController::class, 'show'])->name('wheelchairs.show'); + Route::patch('/wheelchairs/{allocation}', [WheelchairController::class, 'update'])->name('wheelchairs.update'); + Route::post('/wheelchairs/{allocation}/ambil', [WheelchairController::class, 'recordTaken'])->name('wheelchairs.ambil'); + Route::post('/wheelchairs/{allocation}/pulang', [WheelchairController::class, 'recordReturn'])->name('wheelchairs.pulang'); + }); + +Route::middleware(['auth', 'role:Admin']) + ->prefix('admin/attendance') + ->name('admin.attendance.') + ->group(function (): void { + Route::get('/', [AdminAttendanceController::class, 'index'])->name('index'); + Route::get('/{pusatMengundi}', [AdminAttendanceController::class, 'show'])->name('show'); + Route::get('/{pusatMengundi}/export', [AdminAttendanceController::class, 'export'])->name('export'); + }); + +Route::middleware(['auth', 'role:Admin']) + ->prefix('admin/documents') + ->name('admin.documents.') + ->group(function (): void { + Route::get('/{document}', [DocumentDownloadController::class, 'admin'])->name('download'); + }); + +Route::middleware(['auth', 'role:Admin Kewangan|Admin']) + ->prefix('kewangan') + ->name('kewangan.') + ->group(function (): void { + Route::get('/bank', [BankVerificationController::class, 'index'])->name('bank.index'); + Route::get('/bank/export', [BankVerificationController::class, 'export'])->name('bank.export'); + Route::get('/bank/{bankVerification}', [BankVerificationController::class, 'show'])->name('bank.show'); + Route::patch('/bank/{bankVerification}', [BankVerificationController::class, 'update'])->name('bank.update'); + Route::get('/documents/{document}', [DocumentDownloadController::class, 'finance'])->name('documents.download'); + }); + +Route::middleware(['auth', 'role:PPM']) + ->prefix('ppm') + ->name('ppm.') + ->group(function (): void { + Route::get('/applications', [PpmApplicationReviewController::class, 'index']) + ->name('applications.index'); + Route::get('/applications/{application:public_uuid}', [PpmApplicationReviewController::class, 'show']) + ->name('applications.show'); + Route::post('/applications/{application:public_uuid}/approve', [PpmApplicationReviewController::class, 'approve']) + ->name('applications.approve'); + Route::post('/applications/{application:public_uuid}/reject', [PpmApplicationReviewController::class, 'reject']) + ->name('applications.reject'); + Route::get('/applications/{application:public_uuid}/documents/{document}', [PpmApplicationReviewController::class, 'downloadDocument']) + ->name('applications.documents.download'); + + Route::get('/attendance', [PpmAttendanceController::class, 'index']) + ->name('attendance.index'); + Route::get('/attendance/{pusatMengundi}', [PpmAttendanceController::class, 'show']) + ->name('attendance.show'); + Route::post('/attendance/{pusatMengundi}', [PpmAttendanceController::class, 'store']) + ->name('attendance.store'); + }); + +Route::middleware(['auth', 'role:KTM']) + ->prefix('ktm') + ->name('ktm.') + ->group(function (): void { + Route::get('/applications', [KtmTeamController::class, 'index']) + ->name('applications.index'); + Route::get('/applications/create', [KtmTeamController::class, 'create']) + ->name('applications.create'); + Route::post('/applications', [KtmTeamController::class, 'store']) + ->name('applications.store'); + Route::get('/applications/{application:public_uuid}', [KtmTeamController::class, 'show']) + ->name('applications.show'); + Route::post('/applications/{application:public_uuid}/approve', [KtmTeamController::class, 'approve']) + ->name('applications.approve'); + Route::delete('/applications/{application:public_uuid}', [KtmTeamController::class, 'destroy']) + ->name('applications.destroy'); + }); + +require __DIR__.'/auth.php'; diff --git a/storage/app/.gitignore b/storage/app/.gitignore new file mode 100644 index 0000000..fedb287 --- /dev/null +++ b/storage/app/.gitignore @@ -0,0 +1,4 @@ +* +!private/ +!public/ +!.gitignore diff --git a/storage/app/private/.gitignore b/storage/app/private/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/private/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/public/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore new file mode 100644 index 0000000..05c4471 --- /dev/null +++ b/storage/framework/.gitignore @@ -0,0 +1,9 @@ +compiled.php +config.php +down +events.scanned.php +maintenance.php +routes.php +routes.scanned.php +schedule-* +services.json diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore new file mode 100644 index 0000000..01e4a6c --- /dev/null +++ b/storage/framework/cache/.gitignore @@ -0,0 +1,3 @@ +* +!data/ +!.gitignore diff --git a/storage/framework/cache/data/.gitignore b/storage/framework/cache/data/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/cache/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/sessions/.gitignore b/storage/framework/sessions/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/sessions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/testing/.gitignore b/storage/framework/testing/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/testing/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/logs/.gitignore b/storage/logs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..c29eb1a --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,21 @@ +import defaultTheme from 'tailwindcss/defaultTheme'; +import forms from '@tailwindcss/forms'; + +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php', + './storage/framework/views/*.php', + './resources/views/**/*.blade.php', + ], + + theme: { + extend: { + fontFamily: { + sans: ['Figtree', ...defaultTheme.fontFamily.sans], + }, + }, + }, + + plugins: [forms], +}; diff --git a/test_output.txt b/test_output.txt new file mode 100644 index 0000000..8b4b451 --- /dev/null +++ b/test_output.txt @@ -0,0 +1 @@ +{"tool":"phpunit","result":"failed","tests":4,"passed":3,"assertions":17,"duration_ms":577,"errors":1,"error_details":[{"test":"Tests\\Feature\\AdminSetupTest::test_admin_can_create_election_hierarchy_records_and_generate_pusat_qr","file":"C:\\Users\\User\\Aplikasi\\prn2026\\tests\\Feature\\AdminSetupTest.php","line":23,"message":"Call to a member function all() on array"}]} diff --git a/tests/Feature/Admin/ElectionSettingsTest.php b/tests/Feature/Admin/ElectionSettingsTest.php new file mode 100644 index 0000000..53df8ed --- /dev/null +++ b/tests/Feature/Admin/ElectionSettingsTest.php @@ -0,0 +1,189 @@ +seed(); + + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + $election = Election::query()->firstOrFail(); + + $this->actingAs($admin) + ->get(route('admin.setup.settings.edit', $election)) + ->assertOk() + ->assertSee('Tetapan Pilihanraya') + ->assertSee('Mod Kehadiran') + ->assertSee('Override Tempoh Pendaftaran'); + } + + public function test_admin_can_activate_attendance(): void + { + $this->seed(); + + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + $election = Election::query()->firstOrFail(); + $election->settings()->update(['is_attendance_active' => false]); + + $this->actingAs($admin) + ->patch(route('admin.setup.settings.update', $election), [ + 'is_attendance_active' => '1', + 'is_registration_open_override' => '', + ]) + ->assertRedirect(route('admin.setup.settings.edit', $election)); + + $this->assertTrue( + ElectionSetting::query()->where('election_id', $election->id)->value('is_attendance_active') + ); + } + + public function test_admin_can_deactivate_attendance(): void + { + $this->seed(); + + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + $election = Election::query()->firstOrFail(); + $election->settings()->update(['is_attendance_active' => true]); + + $this->actingAs($admin) + ->patch(route('admin.setup.settings.update', $election), [ + 'is_attendance_active' => '0', + 'is_registration_open_override' => '', + ]) + ->assertRedirect(route('admin.setup.settings.edit', $election)); + + $this->assertFalse( + ElectionSetting::query()->where('election_id', $election->id)->value('is_attendance_active') + ); + } + + public function test_admin_can_force_open_registration(): void + { + $this->seed(); + + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + $election = Election::query()->firstOrFail(); + $election->settings()->update(['is_registration_open_override' => null]); + + $this->actingAs($admin) + ->patch(route('admin.setup.settings.update', $election), [ + 'is_attendance_active' => '0', + 'is_registration_open_override' => '1', + ]) + ->assertRedirect(route('admin.setup.settings.edit', $election)); + + $this->assertTrue( + ElectionSetting::query()->where('election_id', $election->id)->value('is_registration_open_override') + ); + } + + public function test_admin_can_force_close_registration(): void + { + $this->seed(); + + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + $election = Election::query()->firstOrFail(); + $election->settings()->update(['is_registration_open_override' => null]); + + $this->actingAs($admin) + ->patch(route('admin.setup.settings.update', $election), [ + 'is_attendance_active' => '0', + 'is_registration_open_override' => '0', + ]) + ->assertRedirect(route('admin.setup.settings.edit', $election)); + + $this->assertFalse( + ElectionSetting::query()->where('election_id', $election->id)->value('is_registration_open_override') + ); + } + + public function test_admin_can_reset_registration_override_to_auto(): void + { + $this->seed(); + + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + $election = Election::query()->firstOrFail(); + $election->settings()->update(['is_registration_open_override' => true]); + + $this->actingAs($admin) + ->patch(route('admin.setup.settings.update', $election), [ + 'is_attendance_active' => '0', + 'is_registration_open_override' => '', + ]) + ->assertRedirect(route('admin.setup.settings.edit', $election)); + + $this->assertNull( + ElectionSetting::query()->where('election_id', $election->id)->value('is_registration_open_override') + ); + } + + public function test_non_admin_cannot_access_settings_page(): void + { + $this->seed(); + + $election = Election::query()->firstOrFail(); + + foreach (['Admin Kewangan', 'PPM', 'KTM'] as $role) { + $user = User::factory()->create(); + $user->assignRole($role); + + $this->actingAs($user) + ->get(route('admin.setup.settings.edit', $election)) + ->assertForbidden(); + } + } + + public function test_non_admin_cannot_update_settings(): void + { + $this->seed(); + + $election = Election::query()->firstOrFail(); + $user = User::factory()->create(); + $user->assignRole('Admin Kewangan'); + + $this->actingAs($user) + ->patch(route('admin.setup.settings.update', $election), [ + 'is_attendance_active' => '1', + 'is_registration_open_override' => '', + ]) + ->assertForbidden(); + } + + public function test_settings_change_is_recorded_in_activity_log(): void + { + $this->seed(); + + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + $election = Election::query()->firstOrFail(); + $election->settings()->update(['is_attendance_active' => false]); + + $this->actingAs($admin) + ->patch(route('admin.setup.settings.update', $election), [ + 'is_attendance_active' => '1', + 'is_registration_open_override' => '', + ]); + + $this->assertDatabaseHas('activity_log', [ + 'log_name' => 'admin_settings', + 'description' => 'election_settings_updated', + 'causer_id' => $admin->id, + ]); + } +} diff --git a/tests/Feature/Admin/PusatOperationalSetupTest.php b/tests/Feature/Admin/PusatOperationalSetupTest.php new file mode 100644 index 0000000..703bf43 --- /dev/null +++ b/tests/Feature/Admin/PusatOperationalSetupTest.php @@ -0,0 +1,83 @@ +seed(); + + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + $election = Election::query()->firstOrFail(); + $daerah = DaerahMengundi::query()->where('election_id', $election->id)->firstOrFail(); + + $response = $this + ->actingAs($admin) + ->post(route('admin.setup.pusat.store'), [ + 'election_id' => $election->id, + 'daerah_mengundi_id' => $daerah->id, + 'code' => 'PMAUTO', + 'name' => 'Pusat Mengundi Automatik', + 'address' => 'Alamat ujian', + 'saluran_count' => 2, + 'ktm_count' => 1, + 'polis_iring_count' => 1, + 'kp_count' => 4, + 'ppm_count' => 1, + 'kpdp_count' => 1, + 'papm_count' => 3, + 'jkm_count' => 1, + 'kkm_count' => 1, + 'calon_tambahan_count' => 2, + ]); + + $response->assertRedirect(route('admin.setup.pusat.index')); + + $pusat = PusatMengundi::query()->where('code', 'PMAUTO')->firstOrFail(); + $salurans = SaluranMengundi::query()->where('pusat_mengundi_id', $pusat->id)->get(); + $this->assertCount(2, $salurans); + + $salurans->each(function (SaluranMengundi $saluran): void { + $this->assertNull($saluran->name); + + $this->assertQuota($saluran->pusat_mengundi_id, $saluran->id, 'KTM', 1); + $this->assertQuota($saluran->pusat_mengundi_id, $saluran->id, 'POLIS', 1); + $this->assertQuota($saluran->pusat_mengundi_id, $saluran->id, 'KP', 4); + $this->assertQuota($saluran->pusat_mengundi_id, $saluran->id, 'KPDP', 1); + $this->assertQuota($saluran->pusat_mengundi_id, $saluran->id, 'CALON_TAMBAHAN', 2); + }); + + $this->assertQuota($pusat->id, null, 'PPM', 1); + $this->assertQuota($pusat->id, null, 'PAPM', 3); + $this->assertQuota($pusat->id, null, 'JKM', 1); + $this->assertQuota($pusat->id, null, 'KKM', 1); + } + + private function assertQuota(int $pusatId, ?int $saluranId, string $positionCode, int $quota): void + { + $positionId = Position::query()->where('code', $positionCode)->value('id'); + + $this->assertSame( + $quota, + PositionQuota::query() + ->where('pusat_mengundi_id', $pusatId) + ->where('saluran_mengundi_id', $saluranId) + ->where('position_id', $positionId) + ->value('quota'), + ); + } +} diff --git a/tests/Feature/AdminManagementTest.php b/tests/Feature/AdminManagementTest.php new file mode 100644 index 0000000..7f7b007 --- /dev/null +++ b/tests/Feature/AdminManagementTest.php @@ -0,0 +1,347 @@ +seed(DatabaseSeeder::class); + + $admin = User::query()->where('email', 'admin@prn.local')->firstOrFail(); + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $position = Position::query()->where('code', 'PAPM')->firstOrFail(); + + $this->actingAs($admin) + ->post(route('admin.management.applications.store'), [ + 'election_id' => $pusat->election_id, + 'pusat_mengundi_id' => $pusat->id, + 'requested_position_id' => $position->id, + 'name' => 'Manual Admin', + 'ic_number' => '900101105555', + 'phone_number' => '0123456789', + 'email' => 'manual@example.test', + 'bank_name' => 'Maybank', + 'bank_account_number' => '1234567890', + ]) + ->assertRedirect(); + + $application = Application::query()->where('ic_number', '900101105555')->firstOrFail(); + + $this->assertSame('admin_manual', $application->source); + $this->assertSame('submitted', $application->status); + $this->assertTrue($application->bankVerification()->exists()); + } + + public function test_admin_edit_after_registration_closed_requires_catatan(): void + { + $this->seed(DatabaseSeeder::class); + + $admin = User::query()->where('email', 'admin@prn.local')->firstOrFail(); + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $position = Position::query()->where('code', 'PAPM')->firstOrFail(); + $application = Application::factory()->create([ + 'election_id' => $pusat->election_id, + 'pusat_mengundi_id' => $pusat->id, + 'requested_position_id' => $position->id, + 'ic_number' => '900101105556', + ]); + $this->closeRegistration($pusat->election); + + $payload = [ + 'pusat_mengundi_id' => $pusat->id, + 'requested_position_id' => $position->id, + 'status' => 'submitted', + 'name' => 'Manual Edit', + 'ic_number' => '900101105556', + 'phone_number' => '0123456789', + 'email' => 'manual-edit@example.test', + ]; + + $this->actingAs($admin) + ->patch(route('admin.management.applications.update', $application->public_uuid), $payload) + ->assertSessionHasErrors('note'); + + $this->actingAs($admin) + ->patch(route('admin.management.applications.update', $application->public_uuid), [ + ...$payload, + 'note' => 'Pembetulan selepas tutup pendaftaran.', + ]) + ->assertRedirect(); + + $this->assertTrue(SystemNote::query() + ->where('noteable_type', Application::class) + ->where('noteable_id', $application->id) + ->where('note_type', 'admin_application_edit_after_close') + ->exists()); + } + + public function test_admin_can_direct_assign_application_after_close_with_catatan(): void + { + $this->seed(DatabaseSeeder::class); + + $admin = User::query()->where('email', 'admin@prn.local')->firstOrFail(); + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $position = Position::query()->where('code', 'PAPM')->firstOrFail(); + $application = Application::factory()->create([ + 'election_id' => $pusat->election_id, + 'pusat_mengundi_id' => $pusat->id, + 'requested_position_id' => $position->id, + 'ic_number' => '900101105557', + ]); + $this->closeRegistration($pusat->election); + + $this->actingAs($admin) + ->post(route('admin.management.applications.assign', $application->public_uuid), [ + 'position_id' => $position->id, + 'pusat_mengundi_id' => $pusat->id, + 'status' => 'active', + ]) + ->assertSessionHasErrors('note'); + + $this->actingAs($admin) + ->post(route('admin.management.applications.assign', $application->public_uuid), [ + 'position_id' => $position->id, + 'pusat_mengundi_id' => $pusat->id, + 'status' => 'active', + 'note' => 'Lantikan manual selepas tutup.', + ]) + ->assertRedirect(); + + $application->refresh(); + + $this->assertSame('assigned', $application->status); + $this->assertTrue(StaffAssignment::query() + ->where('application_id', $application->id) + ->where('position_id', $position->id) + ->where('source', 'admin_direct') + ->exists()); + $this->assertTrue(SystemNote::query()->where('note_type', 'admin_assignment_after_close')->exists()); + } + + public function test_admin_can_create_representative_record(): void + { + $this->seed(DatabaseSeeder::class); + + $admin = User::query()->where('email', 'admin@prn.local')->firstOrFail(); + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + + $this->actingAs($admin) + ->post(route('admin.management.representatives.store'), [ + 'type' => 'jkm', + 'election_id' => $pusat->election_id, + 'pusat_mengundi_id' => $pusat->id, + 'name' => 'Wakil JKM', + 'ic_number' => '900101105558', + 'phone_number' => '0123456789', + 'agency' => 'JKM', + ]) + ->assertRedirect(route('admin.management.representatives.index')); + + $this->assertTrue(JkmRepresentative::query()->where('name', 'Wakil JKM')->exists()); + } + + public function test_admin_kewangan_cannot_access_admin_management_routes(): void + { + $this->seed(DatabaseSeeder::class); + + $finance = User::query()->where('email', 'kewangan@prn.local')->firstOrFail(); + + $this->actingAs($finance) + ->get(route('admin.management.assignments.index')) + ->assertForbidden(); + } + + public function test_admin_create_application_after_registration_closed_requires_catatan(): void + { + $this->seed(DatabaseSeeder::class); + + $admin = User::query()->where('email', 'admin@prn.local')->firstOrFail(); + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $position = Position::query()->where('code', 'PAPM')->firstOrFail(); + $this->closeRegistration($pusat->election); + + $payload = [ + 'election_id' => $pusat->election_id, + 'pusat_mengundi_id' => $pusat->id, + 'requested_position_id' => $position->id, + 'name' => 'Manual Post-Close', + 'ic_number' => '900101105560', + 'phone_number' => '0123456789', + 'email' => 'post-close@example.test', + 'bank_name' => 'Maybank', + 'bank_account_number' => '1234567890', + ]; + + // Without note — both middleware and service should reject + $this->actingAs($admin) + ->post(route('admin.management.applications.store'), $payload) + ->assertSessionHasErrors('note'); + + $this->assertDatabaseMissing('applications', ['ic_number' => '900101105560']); + + // With note — must succeed + $this->actingAs($admin) + ->post(route('admin.management.applications.store'), [ + ...$payload, + 'note' => 'Manual entry selepas tutup pendaftaran.', + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('applications', ['ic_number' => '900101105560']); + } + + public function test_admin_update_assignment_after_registration_closed_requires_catatan(): void + { + $this->seed(DatabaseSeeder::class); + + $admin = User::query()->where('email', 'admin@prn.local')->firstOrFail(); + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $position = Position::query()->where('code', 'PAPM')->firstOrFail(); + + $assignment = StaffAssignment::query() + ->where('election_id', $pusat->election_id) + ->where('pusat_mengundi_id', $pusat->id) + ->where('status', 'active') + ->firstOrFail(); + + $this->closeRegistration($pusat->election); + + $payload = [ + 'position_id' => $assignment->position_id, + 'pusat_mengundi_id' => $pusat->id, + 'status' => 'active', + ]; + + // Without note + $this->actingAs($admin) + ->patch(route('admin.management.assignments.update', $assignment), $payload) + ->assertSessionHasErrors('note'); + + // With note + $this->actingAs($admin) + ->patch(route('admin.management.assignments.update', $assignment), [ + ...$payload, + 'note' => 'Kemaskini tugasan selepas tutup.', + ]) + ->assertRedirect(); + + $this->assertTrue(SystemNote::query() + ->where('note_type', 'admin_assignment_edit_after_close') + ->exists()); + } + + public function test_admin_create_representative_after_registration_closed_requires_catatan(): void + { + $this->seed(DatabaseSeeder::class); + + $admin = User::query()->where('email', 'admin@prn.local')->firstOrFail(); + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $this->closeRegistration($pusat->election); + + $payload = [ + 'type' => 'kkm', + 'election_id' => $pusat->election_id, + 'pusat_mengundi_id' => $pusat->id, + 'name' => 'Wakil KKM Post-Close', + ]; + + // Without note + $this->actingAs($admin) + ->post(route('admin.management.representatives.store'), $payload) + ->assertSessionHasErrors('note'); + + // With note + $this->actingAs($admin) + ->post(route('admin.management.representatives.store'), [ + ...$payload, + 'note' => 'Tambah wakil selepas tutup.', + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('kkm_representatives', ['name' => 'Wakil KKM Post-Close']); + } + + public function test_admin_delete_representative_after_registration_closed_requires_catatan(): void + { + $this->seed(DatabaseSeeder::class); + + $admin = User::query()->where('email', 'admin@prn.local')->firstOrFail(); + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + + $jkm = JkmRepresentative::query()->create([ + 'election_id' => $pusat->election_id, + 'pusat_mengundi_id' => $pusat->id, + 'name' => 'JKM Untuk Padam', + ]); + + $this->closeRegistration($pusat->election); + + // Without note — middleware blocks before service runs + $this->actingAs($admin) + ->delete(route('admin.management.representatives.destroy', ['type' => 'jkm', 'id' => $jkm->id])) + ->assertSessionHasErrors('note'); + + $this->assertDatabaseHas('jkm_representatives', ['id' => $jkm->id]); + + // With note — service enforces its own check too + $this->actingAs($admin) + ->delete(route('admin.management.representatives.destroy', ['type' => 'jkm', 'id' => $jkm->id]), [ + 'note' => 'Padam wakil selepas tutup.', + ]) + ->assertRedirect(); + + $this->assertSoftDeleted('jkm_representatives', ['id' => $jkm->id]); + } + + public function test_middleware_does_not_block_when_registration_is_open(): void + { + $this->seed(DatabaseSeeder::class); + + $admin = User::query()->where('email', 'admin@prn.local')->firstOrFail(); + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $position = Position::query()->where('code', 'PAPM')->firstOrFail(); + + // Registration is open by default in seed; no note required + $this->actingAs($admin) + ->post(route('admin.management.applications.store'), [ + 'election_id' => $pusat->election_id, + 'pusat_mengundi_id' => $pusat->id, + 'requested_position_id' => $position->id, + 'name' => 'Admin Open', + 'ic_number' => '900101105561', + 'phone_number' => '0123456789', + 'email' => 'admin-open@example.test', + 'bank_name' => 'Maybank', + 'bank_account_number' => '1234567890', + ]) + ->assertRedirect() + ->assertSessionMissing('errors'); + + $this->assertDatabaseHas('applications', ['ic_number' => '900101105561']); + } + + private function closeRegistration(Election $election): void + { + $election->settings()->update([ + 'registration_start_date' => now()->subWeeks(4)->toDateString(), + 'registration_end_date' => now()->subDay()->toDateString(), + 'is_registration_open' => true, + 'is_registration_open_override' => null, + ]); + } +} diff --git a/tests/Feature/AdminSetupTest.php b/tests/Feature/AdminSetupTest.php new file mode 100644 index 0000000..e4b355e --- /dev/null +++ b/tests/Feature/AdminSetupTest.php @@ -0,0 +1,168 @@ +seed(DatabaseSeeder::class); + + $admin = User::query()->where('email', 'admin@prn.local')->firstOrFail(); + $election = Election::query()->where('code', 'PRN2026')->firstOrFail(); + + $this->actingAs($admin) + ->post(route('admin.setup.bahagian.store'), [ + 'election_id' => $election->id, + 'code' => 'B999', + 'name' => 'Bahagian Ujian', + ]) + ->assertRedirect(route('admin.setup.bahagian.index')); + + $bahagian = BahagianPilihanraya::query()->where('code', 'B999')->firstOrFail(); + + $this->actingAs($admin) + ->post(route('admin.setup.daerah.store'), [ + 'election_id' => $election->id, + 'bahagian_pilihanraya_id' => $bahagian->id, + 'code' => 'D999', + 'name' => 'Daerah Ujian', + ]) + ->assertRedirect(route('admin.setup.daerah.index')); + + $daerah = DaerahMengundi::query()->where('code', 'D999')->firstOrFail(); + + $this->actingAs($admin) + ->post(route('admin.setup.pusat.store'), [ + 'election_id' => $election->id, + 'daerah_mengundi_id' => $daerah->id, + 'code' => 'PM999', + 'name' => 'SK Ujian', + 'address' => 'Jalan Ujian', + 'saluran_count' => 2, + 'ktm_count' => 1, + 'polis_iring_count' => 1, + 'kp_count' => 4, + 'ppm_count' => 1, + 'kpdp_count' => 1, + 'papm_count' => 3, + 'jkm_count' => 1, + 'kkm_count' => 1, + 'calon_tambahan_count' => 1, + ]) + ->assertRedirect(route('admin.setup.pusat.index')); + + $pusat = PusatMengundi::query()->where('code', 'PM999')->firstOrFail(); + + $this->assertNotNull($pusat->public_uuid); + $this->assertStringContainsString('/pohon/'.$pusat->public_uuid, (string) $pusat->registration_url); + $this->assertNotNull($pusat->qr_code_path); + Storage::disk('local')->assertExists($pusat->qr_code_path); + + $this->actingAs($admin) + ->post(route('admin.setup.saluran.store'), [ + 'election_id' => $election->id, + 'pusat_mengundi_id' => $pusat->id, + 'number' => '3', + 'voter_count' => 500, + ]) + ->assertRedirect(route('admin.setup.saluran.index')); + + $this->assertDatabaseHas('saluran_mengundis', [ + 'pusat_mengundi_id' => $pusat->id, + 'number' => '1', + ]); + } + + public function test_admin_can_create_position_quota_and_assign_ppm(): void + { + $this->seed(DatabaseSeeder::class); + + $admin = User::query()->where('email', 'admin@prn.local')->firstOrFail(); + $election = Election::query()->where('code', 'PRN2026')->firstOrFail(); + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $saluran = SaluranMengundi::query()->where('pusat_mengundi_id', $pusat->id)->firstOrFail(); + $position = Position::query()->where('code', 'KP')->firstOrFail(); + $newPpm = User::factory()->create(); + + $this->actingAs($admin) + ->post(route('admin.setup.quotas.store'), [ + 'election_id' => $election->id, + 'pusat_mengundi_id' => $pusat->id, + 'saluran_mengundi_id' => $saluran->id, + 'position_id' => $position->id, + 'quota' => 6, + ]) + ->assertRedirect(route('admin.setup.quotas.index')); + + $quota = PositionQuota::query() + ->where('election_id', $election->id) + ->where('pusat_mengundi_id', $pusat->id) + ->where('saluran_mengundi_id', $saluran->id) + ->where('position_id', $position->id) + ->firstOrFail(); + + $this->assertSame(6, $quota->quota); + + $this->actingAs($admin) + ->post(route('admin.setup.pusat.assign-ppm', $pusat), [ + 'user_id' => $newPpm->id, + ]) + ->assertRedirect(route('admin.setup.pusat.show', $pusat)); + + $newPpm->refresh(); + $this->assertTrue($newPpm->hasRole('PPM')); + + $ppmPosition = Position::query()->where('code', 'PPM')->firstOrFail(); + + $this->assertTrue(StaffAssignment::query() + ->where('election_id', $election->id) + ->where('pusat_mengundi_id', $pusat->id) + ->where('position_id', $ppmPosition->id) + ->where('user_id', $newPpm->id) + ->where('status', 'active') + ->exists()); + } + + public function test_non_admin_cannot_access_admin_setup_routes(): void + { + $this->seed(DatabaseSeeder::class); + + $ktm = User::query()->where('email', 'ktm@prn.local')->firstOrFail(); + + $this->actingAs($ktm) + ->get(route('admin.setup.index')) + ->assertForbidden(); + } + + public function test_public_qr_placeholder_uses_public_uuid_without_authentication(): void + { + $this->seed(DatabaseSeeder::class); + + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + + $this->get(route('public.applications.create', [ + 'pusatMengundi' => $pusat->public_uuid, + ])) + ->assertOk() + ->assertSee($pusat->name) + ->assertSee($pusat->code); + } +} diff --git a/tests/Feature/AttendanceModuleTest.php b/tests/Feature/AttendanceModuleTest.php new file mode 100644 index 0000000..d209459 --- /dev/null +++ b/tests/Feature/AttendanceModuleTest.php @@ -0,0 +1,175 @@ +seed(DatabaseSeeder::class); + + $ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail(); + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $this->activateAttendance($pusat->election); + $assignment = StaffAssignment::query() + ->where('pusat_mengundi_id', $pusat->id) + ->where('status', 'active') + ->firstOrFail(); + + $this->actingAs($ppm) + ->post(route('ppm.attendance.store', $pusat), [ + 'attendance' => [ + $assignment->id => [ + 'status' => 'present', + 'check_in_time' => now()->format('Y-m-d H:i:s'), + 'note' => 'Hadir awal.', + ], + ], + ]) + ->assertRedirect(route('ppm.attendance.show', $pusat)); + + $this->assertDatabaseHas('attendances', [ + 'staff_assignment_id' => $assignment->id, + 'pusat_mengundi_id' => $pusat->id, + 'status' => 'present', + 'recorded_by_user_id' => $ppm->id, + 'note' => 'Hadir awal.', + ]); + } + + public function test_ppm_cannot_record_attendance_for_other_pusat(): void + { + $this->seed(DatabaseSeeder::class); + + $ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail(); + $otherPusat = $this->otherPusat(); + $this->activateAttendance($otherPusat->election); + $position = Position::query()->where('code', 'PAPM')->firstOrFail(); + $assignment = StaffAssignment::query()->create([ + 'election_id' => $otherPusat->election_id, + 'position_id' => $position->id, + 'pusat_mengundi_id' => $otherPusat->id, + 'status' => 'active', + 'assigned_at' => now(), + 'source' => 'test', + ]); + + $this->actingAs($ppm) + ->post(route('ppm.attendance.store', $otherPusat), [ + 'attendance' => [ + $assignment->id => ['status' => 'present'], + ], + ]) + ->assertSessionHasErrors('pusat_mengundi_id'); + + $this->assertDatabaseMissing('attendances', [ + 'staff_assignment_id' => $assignment->id, + ]); + } + + public function test_attendance_recording_requires_active_attendance_module(): void + { + $this->seed(DatabaseSeeder::class); + + $ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail(); + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $assignment = StaffAssignment::query() + ->where('pusat_mengundi_id', $pusat->id) + ->where('status', 'active') + ->firstOrFail(); + + $this->actingAs($ppm) + ->post(route('ppm.attendance.store', $pusat), [ + 'attendance' => [ + $assignment->id => ['status' => 'present'], + ], + ]) + ->assertSessionHasErrors('attendance'); + } + + public function test_admin_can_view_attendance_dashboard_and_detail(): void + { + $this->seed(DatabaseSeeder::class); + + $admin = User::query()->where('email', 'admin@prn.local')->firstOrFail(); + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $this->activateAttendance($pusat->election); + + $this->actingAs($admin) + ->get(route('admin.attendance.index')) + ->assertOk() + ->assertSee($pusat->name); + + $this->actingAs($admin) + ->get(route('admin.attendance.show', $pusat)) + ->assertOk() + ->assertSee('Ringkasan Mengikut Jawatan'); + } + + public function test_attendance_export_creates_export_log(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $admin = User::query()->where('email', 'admin@prn.local')->firstOrFail(); + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + + $this->actingAs($admin) + ->get(route('admin.attendance.export', $pusat)) + ->assertOk(); + + $exportLog = ExportLog::query()->where('report_type', 'attendance_detail_by_pusat')->firstOrFail(); + + $this->assertSame($admin->id, $exportLog->generated_by_user_id); + $this->assertSame($pusat->election_id, $exportLog->election_id); + Storage::disk('local')->assertExists($exportLog->path); + } + + public function test_admin_kewangan_cannot_access_attendance_dashboard(): void + { + $this->seed(DatabaseSeeder::class); + + $finance = User::query()->where('email', 'kewangan@prn.local')->firstOrFail(); + + $this->actingAs($finance) + ->get(route('admin.attendance.index')) + ->assertForbidden(); + } + + private function activateAttendance(Election $election): void + { + $election->settings()->update([ + 'is_attendance_active' => true, + ]); + } + + private function otherPusat(): PusatMengundi + { + $election = Election::query()->where('code', 'PRN2026')->firstOrFail(); + $daerah = DaerahMengundi::factory()->forElection($election)->create([ + 'code' => 'D888', + 'name' => 'Daerah Attendance Lain', + ]); + + return PusatMengundi::factory()->create([ + 'election_id' => $election->id, + 'daerah_mengundi_id' => $daerah->id, + 'code' => 'PM888', + 'name' => 'Pusat Attendance Lain', + ]); + } +} diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php new file mode 100644 index 0000000..13dcb7c --- /dev/null +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -0,0 +1,54 @@ +get('/login'); + + $response->assertStatus(200); + } + + public function test_users_can_authenticate_using_the_login_screen(): void + { + $user = User::factory()->create(); + + $response = $this->post('/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $this->assertAuthenticated(); + $response->assertRedirect(route('dashboard', absolute: false)); + } + + public function test_users_can_not_authenticate_with_invalid_password(): void + { + $user = User::factory()->create(); + + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ]); + + $this->assertGuest(); + } + + public function test_users_can_logout(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/logout'); + + $this->assertGuest(); + $response->assertRedirect('/'); + } +} diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php new file mode 100644 index 0000000..705570b --- /dev/null +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -0,0 +1,58 @@ +unverified()->create(); + + $response = $this->actingAs($user)->get('/verify-email'); + + $response->assertStatus(200); + } + + public function test_email_can_be_verified(): void + { + $user = User::factory()->unverified()->create(); + + Event::fake(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $response = $this->actingAs($user)->get($verificationUrl); + + Event::assertDispatched(Verified::class); + $this->assertTrue($user->fresh()->hasVerifiedEmail()); + $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); + } + + public function test_email_is_not_verified_with_invalid_hash(): void + { + $user = User::factory()->unverified()->create(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1('wrong-email')] + ); + + $this->actingAs($user)->get($verificationUrl); + + $this->assertFalse($user->fresh()->hasVerifiedEmail()); + } +} diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php new file mode 100644 index 0000000..ff85721 --- /dev/null +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -0,0 +1,44 @@ +create(); + + $response = $this->actingAs($user)->get('/confirm-password'); + + $response->assertStatus(200); + } + + public function test_password_can_be_confirmed(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/confirm-password', [ + 'password' => 'password', + ]); + + $response->assertRedirect(); + $response->assertSessionHasNoErrors(); + } + + public function test_password_is_not_confirmed_with_invalid_password(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/confirm-password', [ + 'password' => 'wrong-password', + ]); + + $response->assertSessionHasErrors(); + } +} diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php new file mode 100644 index 0000000..aa50350 --- /dev/null +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -0,0 +1,73 @@ +get('/forgot-password'); + + $response->assertStatus(200); + } + + public function test_reset_password_link_can_be_requested(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class); + } + + public function test_reset_password_screen_can_be_rendered(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) { + $response = $this->get('/reset-password/'.$notification->token); + + $response->assertStatus(200); + + return true; + }); + } + + public function test_password_can_be_reset_with_valid_token(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { + $response = $this->post('/reset-password', [ + 'token' => $notification->token, + 'email' => $user->email, + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('login')); + + return true; + }); + } +} diff --git a/tests/Feature/Auth/PasswordUpdateTest.php b/tests/Feature/Auth/PasswordUpdateTest.php new file mode 100644 index 0000000..ca28c6c --- /dev/null +++ b/tests/Feature/Auth/PasswordUpdateTest.php @@ -0,0 +1,51 @@ +create(); + + $response = $this + ->actingAs($user) + ->from('/profile') + ->put('/password', [ + 'current_password' => 'password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $this->assertTrue(Hash::check('new-password', $user->refresh()->password)); + } + + public function test_correct_password_must_be_provided_to_update_password(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->from('/profile') + ->put('/password', [ + 'current_password' => 'wrong-password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]); + + $response + ->assertSessionHasErrorsIn('updatePassword', 'current_password') + ->assertRedirect('/profile'); + } +} diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php new file mode 100644 index 0000000..1a23f13 --- /dev/null +++ b/tests/Feature/Auth/RegistrationTest.php @@ -0,0 +1,31 @@ +get('/register'); + + $response->assertNotFound(); + } + + public function test_public_account_registration_post_is_disabled(): void + { + $response = $this->post('/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $this->assertGuest(); + $response->assertNotFound(); + } +} diff --git a/tests/Feature/CoreDomainSeederTest.php b/tests/Feature/CoreDomainSeederTest.php new file mode 100644 index 0000000..32df548 --- /dev/null +++ b/tests/Feature/CoreDomainSeederTest.php @@ -0,0 +1,66 @@ +seed(DatabaseSeeder::class); + + $this->assertDatabaseCount('positions', 9); + $this->assertDatabaseCount('elections', 1); + $this->assertDatabaseCount('bahagian_pilihanrayas', 1); + $this->assertDatabaseCount('daerah_mengundis', 1); + $this->assertDatabaseCount('pusat_mengundis', 1); + $this->assertDatabaseCount('saluran_mengundis', 3); + $this->assertDatabaseCount('position_quotas', 17); + $this->assertDatabaseCount('staff_assignments', 3); + + $election = Election::query()->where('code', 'PRN2026')->firstOrFail(); + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail(); + + $this->assertTrue($ppm->hasRole('PPM')); + $this->assertTrue($ppm->hasRole('KTM')); + $this->assertSame($election->id, $pusat->election_id); + $this->assertSame(3, $pusat->saluranMengundis()->count()); + $this->assertNotNull($pusat->wheelchairAllocation); + + $ppmPosition = Position::query()->where('code', 'PPM')->firstOrFail(); + $ktmPosition = Position::query()->where('code', 'KTM')->firstOrFail(); + + $this->assertTrue(PositionQuota::query() + ->where('election_id', $election->id) + ->where('pusat_mengundi_id', $pusat->id) + ->where('position_id', $ppmPosition->id) + ->whereNull('saluran_mengundi_id') + ->exists()); + + $this->assertTrue(StaffAssignment::query() + ->where('election_id', $election->id) + ->where('user_id', $ppm->id) + ->where('position_id', $ppmPosition->id) + ->whereNull('saluran_mengundi_id') + ->exists()); + + $this->assertTrue(StaffAssignment::query() + ->where('election_id', $election->id) + ->where('user_id', $ppm->id) + ->where('position_id', $ktmPosition->id) + ->whereNotNull('saluran_mengundi_id') + ->exists()); + } +} diff --git a/tests/Feature/DashboardAccessTest.php b/tests/Feature/DashboardAccessTest.php new file mode 100644 index 0000000..d459592 --- /dev/null +++ b/tests/Feature/DashboardAccessTest.php @@ -0,0 +1,38 @@ +seed(RolePermissionSeeder::class); + + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + + $response = $this->actingAs($admin)->get('/dashboard'); + + $response->assertRedirect(route('admin.dashboard', absolute: false)); + } + + public function test_role_dashboard_is_protected_by_role_middleware(): void + { + $this->seed(RolePermissionSeeder::class); + + $admin = User::factory()->create(); + $admin->assignRole('Admin'); + + $user = User::factory()->create(); + + $this->actingAs($admin)->get('/admin/dashboard')->assertOk(); + $this->actingAs($user)->get('/admin/dashboard')->assertForbidden(); + } +} diff --git a/tests/Feature/DocumentDownloadAuthorizationTest.php b/tests/Feature/DocumentDownloadAuthorizationTest.php new file mode 100644 index 0000000..d13d70f --- /dev/null +++ b/tests/Feature/DocumentDownloadAuthorizationTest.php @@ -0,0 +1,211 @@ +seed(DatabaseSeeder::class); + + $admin = User::query()->where('email', 'admin@prn.local')->firstOrFail(); + + $this->actingAs($admin) + ->get(route('admin.documents.download', $this->document('ic_document'))) + ->assertOk(); + } + + public function test_admin_can_download_bank_statement(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $admin = User::query()->where('email', 'admin@prn.local')->firstOrFail(); + + $this->actingAs($admin) + ->get(route('admin.documents.download', $this->document('bank_statement'))) + ->assertOk(); + } + + public function test_unauthorized_role_cannot_access_admin_download_route(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $ktm = User::query()->where('email', 'ktm@prn.local')->firstOrFail(); + + $this->actingAs($ktm) + ->get(route('admin.documents.download', $this->document('bank_statement'))) + ->assertForbidden(); + } + + // ── Finance path ───────────────────────────────────────────────────── + + public function test_finance_can_download_bank_statement(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $finance = User::query()->where('email', 'kewangan@prn.local')->firstOrFail(); + + $this->actingAs($finance) + ->get(route('kewangan.documents.download', $this->document('bank_statement'))) + ->assertOk(); + } + + public function test_finance_cannot_download_ic_document_via_finance_route(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $finance = User::query()->where('email', 'kewangan@prn.local')->firstOrFail(); + + $this->actingAs($finance) + ->get(route('kewangan.documents.download', $this->document('ic_document'))) + ->assertForbidden(); + } + + // ── PPM path ────────────────────────────────────────────────────────── + + public function test_ppm_can_download_ic_document_from_own_pusat(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail(); + $pusat = $this->ppmPusat($ppm); + $document = $this->documentForPusat($pusat, 'ic_document'); + + $this->actingAs($ppm) + ->get(route('ppm.applications.documents.download', [ + 'application' => $document->application->public_uuid, + 'document' => $document, + ])) + ->assertOk(); + } + + public function test_ppm_can_download_bank_statement_from_own_pusat(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail(); + $pusat = $this->ppmPusat($ppm); + $document = $this->documentForPusat($pusat, 'bank_statement'); + + $this->actingAs($ppm) + ->get(route('ppm.applications.documents.download', [ + 'application' => $document->application->public_uuid, + 'document' => $document, + ])) + ->assertOk(); + } + + public function test_ppm_cannot_download_document_from_another_pusat(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail(); + + // Create a document belonging to a DIFFERENT pusat than PPM's assignment + $otherPusat = PusatMengundi::factory()->create([ + 'election_id' => PusatMengundi::query()->where('code', 'PM001')->value('election_id'), + 'daerah_mengundi_id' => PusatMengundi::query()->where('code', 'PM001')->value('daerah_mengundi_id'), + ]); + $document = $this->documentForPusat($otherPusat, 'ic_document'); + + $this->actingAs($ppm) + ->get(route('ppm.applications.documents.download', [ + 'application' => $document->application->public_uuid, + 'document' => $document, + ])) + ->assertForbidden(); + } + + public function test_ppm_download_records_activity_log(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail(); + $pusat = $this->ppmPusat($ppm); + $document = $this->documentForPusat($pusat, 'ic_document'); + + $this->actingAs($ppm) + ->get(route('ppm.applications.documents.download', [ + 'application' => $document->application->public_uuid, + 'document' => $document, + ])); + + $this->assertDatabaseHas('activity_log', [ + 'log_name' => 'documents', + 'description' => 'document_downloaded_by_ppm', + 'causer_id' => $ppm->id, + ]); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private function document(string $type, string $fileName = 'file.pdf'): ApplicationDocument + { + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + + return $this->documentForPusat($pusat, $type, $fileName); + } + + private function documentForPusat(PusatMengundi $pusat, string $type, string $fileName = 'file.pdf'): ApplicationDocument + { + $position = Position::query()->where('code', 'PAPM')->firstOrFail(); + $application = Application::factory()->create([ + 'election_id' => $pusat->election_id, + 'pusat_mengundi_id' => $pusat->id, + 'requested_position_id' => $position->id, + ]); + $path = 'applications/'.$application->public_uuid.'/'.$fileName; + Storage::disk('local')->put($path, 'test content'); + + $document = ApplicationDocument::query()->create([ + 'application_id' => $application->id, + 'document_type' => $type, + 'disk' => 'local', + 'path' => $path, + 'original_name' => $fileName, + 'mime_type' => 'application/pdf', + 'size' => 12, + ]); + + $document->setRelation('application', $application); + + return $document; + } + + private function ppmPusat(User $ppm): PusatMengundi + { + $ppmPosition = Position::query()->where('code', 'PPM')->firstOrFail(); + + $assignment = StaffAssignment::query() + ->where('user_id', $ppm->id) + ->where('position_id', $ppmPosition->id) + ->where('status', 'active') + ->firstOrFail(); + + return PusatMengundi::query()->findOrFail($assignment->pusat_mengundi_id); + } +} diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php new file mode 100644 index 0000000..8364a84 --- /dev/null +++ b/tests/Feature/ExampleTest.php @@ -0,0 +1,19 @@ +get('/'); + + $response->assertStatus(200); + } +} diff --git a/tests/Feature/ExportPurgeCommandTest.php b/tests/Feature/ExportPurgeCommandTest.php new file mode 100644 index 0000000..7fb7617 --- /dev/null +++ b/tests/Feature/ExportPurgeCommandTest.php @@ -0,0 +1,180 @@ +seed(DatabaseSeeder::class); + + $path = 'exports/finance/old-export.xlsx'; + Storage::disk('local')->put($path, 'export content'); + + $log = ExportLog::query()->create([ + 'generated_at' => now()->subDays(31), + 'report_type' => 'finance_verification_list', + 'file_name' => 'old-export.xlsx', + 'disk' => 'local', + 'path' => $path, + 'created_at' => now()->subDays(31), + 'updated_at' => now()->subDays(31), + ]); + + $this->artisan('exports:purge', ['--days' => 30]) + ->assertSuccessful() + ->expectsOutputToContain('Purged 1 export file(s).'); + + Storage::disk('local')->assertMissing($path); + $this->assertNotNull($log->fresh()->purged_at); + } + + public function test_command_does_not_purge_files_within_retention_period(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $path = 'exports/finance/recent-export.xlsx'; + Storage::disk('local')->put($path, 'export content'); + + $log = ExportLog::query()->create([ + 'generated_at' => now()->subDays(10), + 'report_type' => 'finance_verification_list', + 'file_name' => 'recent-export.xlsx', + 'disk' => 'local', + 'path' => $path, + ]); + + $this->artisan('exports:purge', ['--days' => 30]) + ->assertSuccessful() + ->expectsOutputToContain('No export files eligible'); + + Storage::disk('local')->assertExists($path); + $this->assertNull($log->fresh()->purged_at); + } + + public function test_command_marks_log_as_purged_even_when_file_missing(): void + { + $this->seed(DatabaseSeeder::class); + + $log = ExportLog::query()->create([ + 'generated_at' => now()->subDays(40), + 'report_type' => 'attendance_detail_by_pusat', + 'file_name' => 'missing-file.xlsx', + 'disk' => 'local', + 'path' => 'exports/attendance/missing-file.xlsx', + 'created_at' => now()->subDays(40), + 'updated_at' => now()->subDays(40), + ]); + + $this->artisan('exports:purge', ['--days' => 30]) + ->assertSuccessful() + ->expectsOutputToContain('1 record(s) had no file on disk'); + + $this->assertNotNull($log->fresh()->purged_at); + } + + public function test_command_does_not_re_purge_already_purged_records(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $path = 'exports/finance/already-purged.xlsx'; + + $log = ExportLog::query()->create([ + 'generated_at' => now()->subDays(60), + 'report_type' => 'finance_verification_list', + 'file_name' => 'already-purged.xlsx', + 'disk' => 'local', + 'path' => $path, + 'purged_at' => now()->subDays(5), + 'created_at' => now()->subDays(60), + 'updated_at' => now()->subDays(60), + ]); + + $this->artisan('exports:purge', ['--days' => 30]) + ->assertSuccessful() + ->expectsOutputToContain('No export files eligible'); + + // purged_at should not change + $this->assertEquals( + $log->fresh()->purged_at->toDateString(), + now()->subDays(5)->toDateString() + ); + } + + public function test_command_accepts_custom_retention_days(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $path = 'exports/finance/week-old.xlsx'; + Storage::disk('local')->put($path, 'export content'); + + $log = ExportLog::query()->create([ + 'generated_at' => now()->subDays(8), + 'report_type' => 'finance_verification_list', + 'file_name' => 'week-old.xlsx', + 'disk' => 'local', + 'path' => $path, + 'created_at' => now()->subDays(8), + 'updated_at' => now()->subDays(8), + ]); + + // With default 30 days — should NOT purge + $this->artisan('exports:purge', ['--days' => 30]) + ->assertSuccessful() + ->expectsOutputToContain('No export files eligible'); + + $this->assertNull($log->fresh()->purged_at); + + // With 7 days — should purge + $this->artisan('exports:purge', ['--days' => 7]) + ->assertSuccessful() + ->expectsOutputToContain('Purged 1 export file(s).'); + + $this->assertNotNull($log->fresh()->purged_at); + Storage::disk('local')->assertMissing($path); + } + + public function test_command_records_activity_log_on_purge(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $path = 'exports/finance/log-test.xlsx'; + Storage::disk('local')->put($path, 'export content'); + + ExportLog::query()->create([ + 'generated_at' => now()->subDays(31), + 'report_type' => 'finance_verification_list', + 'file_name' => 'log-test.xlsx', + 'disk' => 'local', + 'path' => $path, + 'created_at' => now()->subDays(31), + 'updated_at' => now()->subDays(31), + ]); + + $this->artisan('exports:purge', ['--days' => 30])->assertSuccessful(); + + $this->assertDatabaseHas('activity_log', [ + 'log_name' => 'exports', + 'description' => 'export_file_purged', + ]); + } + + public function test_command_rejects_invalid_days_option(): void + { + $this->artisan('exports:purge', ['--days' => 0]) + ->assertFailed(); + } +} diff --git a/tests/Feature/FinanceModuleTest.php b/tests/Feature/FinanceModuleTest.php new file mode 100644 index 0000000..7ecc15d --- /dev/null +++ b/tests/Feature/FinanceModuleTest.php @@ -0,0 +1,155 @@ +seed(DatabaseSeeder::class); + + $finance = User::query()->where('email', 'kewangan@prn.local')->firstOrFail(); + $application = $this->assignedApplication('Finance View', '910101105551'); + + $this->actingAs($finance) + ->get(route('kewangan.bank.index')) + ->assertOk() + ->assertSee($application->name) + ->assertSee('Penyata ada'); + } + + public function test_admin_kewangan_can_verify_bank_account(): void + { + $this->seed(DatabaseSeeder::class); + + $finance = User::query()->where('email', 'kewangan@prn.local')->firstOrFail(); + $application = $this->assignedApplication('Finance Verify', '910101105552'); + $verification = $application->bankVerification()->firstOrFail(); + + $this->actingAs($finance) + ->patch(route('kewangan.bank.update', $verification), [ + 'status' => 'verified', + 'finance_note' => 'Akaun disahkan.', + ]) + ->assertRedirect(route('kewangan.bank.show', $verification)); + + $verification->refresh(); + + $this->assertSame('verified', $verification->status); + $this->assertSame('Akaun disahkan.', $verification->finance_note); + $this->assertSame($finance->id, $verification->verified_by_user_id); + $this->assertNotNull($verification->verified_at); + } + + public function test_finance_filters_missing_bank_statement_and_account_number(): void + { + $this->seed(DatabaseSeeder::class); + + $finance = User::query()->where('email', 'kewangan@prn.local')->firstOrFail(); + $complete = $this->assignedApplication('Finance Complete', '910101105553'); + $missing = $this->assignedApplication('Finance Missing', '910101105554', false, false); + + $this->actingAs($finance) + ->get(route('kewangan.bank.index', [ + 'missing_bank_statement' => 1, + 'missing_account_number' => 1, + ])) + ->assertOk() + ->assertSee($missing->name) + ->assertDontSee($complete->name); + } + + public function test_admin_kewangan_cannot_change_assignment(): void + { + $this->seed(DatabaseSeeder::class); + + $finance = User::query()->where('email', 'kewangan@prn.local')->firstOrFail(); + $assignment = StaffAssignment::query()->firstOrFail(); + + $this->actingAs($finance) + ->get(route('admin.management.assignments.edit', $assignment)) + ->assertForbidden(); + } + + public function test_finance_export_creates_export_log(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $finance = User::query()->where('email', 'kewangan@prn.local')->firstOrFail(); + $this->assignedApplication('Finance Export', '910101105555'); + + $this->actingAs($finance) + ->get(route('kewangan.bank.export')) + ->assertOk(); + + $exportLog = ExportLog::query()->where('report_type', 'finance_verification_list')->firstOrFail(); + + $this->assertSame($finance->id, $exportLog->generated_by_user_id); + $this->assertNotNull($exportLog->generated_at); + $this->assertSame('local', $exportLog->disk); + Storage::disk('local')->assertExists($exportLog->path); + } + + private function assignedApplication(string $name, string $icNumber, bool $withBankStatement = true, bool $withAccountNumber = true): Application + { + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $position = Position::query()->where('code', 'PAPM')->firstOrFail(); + + $application = Application::factory()->create([ + 'election_id' => $pusat->election_id, + 'pusat_mengundi_id' => $pusat->id, + 'requested_position_id' => $position->id, + 'approved_position_id' => $position->id, + 'status' => 'assigned', + 'name' => $name, + 'ic_number' => $icNumber, + 'bank_name' => 'Maybank', + 'bank_account_number' => $withAccountNumber ? '1234567890' : null, + ]); + + BankVerification::query()->create([ + 'application_id' => $application->id, + 'status' => 'pending', + ]); + + StaffAssignment::query()->create([ + 'election_id' => $pusat->election_id, + 'application_id' => $application->id, + 'position_id' => $position->id, + 'pusat_mengundi_id' => $pusat->id, + 'status' => 'active', + 'assigned_at' => now(), + 'source' => 'test', + ]); + + if ($withBankStatement) { + ApplicationDocument::query()->create([ + 'application_id' => $application->id, + 'document_type' => 'bank_statement', + 'disk' => 'local', + 'path' => 'applications/'.$application->public_uuid.'/bank.pdf', + 'original_name' => 'bank.pdf', + 'mime_type' => 'application/pdf', + 'size' => 12, + ]); + } + + return $application; + } +} diff --git a/tests/Feature/KtmFlowTest.php b/tests/Feature/KtmFlowTest.php new file mode 100644 index 0000000..d6808a6 --- /dev/null +++ b/tests/Feature/KtmFlowTest.php @@ -0,0 +1,204 @@ +seed(DatabaseSeeder::class); + + $ktm = User::query()->where('email', 'ktm@prn.local')->firstOrFail(); + $assignment = $this->ktmAssignmentFor($ktm); + + $this->actingAs($ktm) + ->get(route('ktm.applications.index')) + ->assertOk() + ->assertSee('Saluran '.$assignment->saluranMengundi?->number) + ->assertSee('Team KP'); + } + + public function test_ktm_can_register_kp_only_and_email_applicant(): void + { + Mail::fake(); + $this->seed(DatabaseSeeder::class); + + $ktm = User::query()->where('email', 'ktm@prn.local')->firstOrFail(); + $assignment = $this->ktmAssignmentFor($ktm); + $kpPosition = Position::query()->where('code', 'KP')->firstOrFail(); + + $this->actingAs($ktm) + ->post(route('ktm.applications.store'), [ + 'ktm_assignment_id' => $assignment->id, + 'name' => 'KP Didaftar KTM', + 'ic_number' => '880101101111', + 'phone_number' => '0123456789', + 'email' => 'kp-created@example.test', + 'address' => 'Alamat KP', + 'requested_position_code' => 'KTM', + ]) + ->assertRedirect(); + + $application = Application::query()->where('ic_number', '880101101111')->firstOrFail(); + + $this->assertSame('created_by_ktm', $application->source); + $this->assertSame('submitted', $application->status); + $this->assertSame($kpPosition->id, $application->requested_position_id); + $this->assertSame($assignment->id, $application->selected_ktm_assignment_id); + + Mail::assertQueued(KtmCreatedApplicantMail::class, fn (KtmCreatedApplicantMail $mail): bool => $mail->application->is($application)); + } + + public function test_ktm_cannot_register_kp_after_registration_period(): void + { + Mail::fake(); + $this->seed(DatabaseSeeder::class); + + $ktm = User::query()->where('email', 'ktm@prn.local')->firstOrFail(); + $assignment = $this->ktmAssignmentFor($ktm); + $assignment->election->settings()->update([ + 'registration_start_date' => now()->subWeeks(4)->toDateString(), + 'registration_end_date' => now()->subDay()->toDateString(), + 'is_registration_open' => true, + 'is_registration_open_override' => null, + ]); + + $this->actingAs($ktm) + ->post(route('ktm.applications.store'), [ + 'ktm_assignment_id' => $assignment->id, + 'name' => 'KP Lewat', + 'ic_number' => '880101101112', + 'phone_number' => '0123456789', + 'email' => 'kp-late@example.test', + ]) + ->assertSessionHasErrors('registration'); + + $this->assertDatabaseMissing('applications', [ + 'ic_number' => '880101101112', + ]); + Mail::assertNothingQueued(); + } + + public function test_ktm_can_approve_kp_application_under_own_team(): void + { + $this->seed(DatabaseSeeder::class); + + $ktm = User::query()->where('email', 'ktm@prn.local')->firstOrFail(); + $assignment = $this->ktmAssignmentFor($ktm); + $application = $this->kpApplicationFor($assignment, 'KP Untuk Approve', '880101101113'); + + $this->actingAs($ktm) + ->post(route('ktm.applications.approve', $application->public_uuid)) + ->assertRedirect(route('ktm.applications.show', $application->public_uuid)); + + $application->refresh(); + $kpPosition = Position::query()->where('code', 'KP')->firstOrFail(); + + $this->assertSame('assigned', $application->status); + $this->assertSame($kpPosition->id, $application->approved_position_id); + $this->assertTrue(StaffAssignment::query() + ->where('application_id', $application->id) + ->where('reports_to_assignment_id', $assignment->id) + ->where('position_id', $kpPosition->id) + ->where('status', 'active') + ->exists()); + } + + public function test_ktm_cannot_approve_non_kp_or_other_team_application(): void + { + $this->seed(DatabaseSeeder::class); + + $ktm = User::query()->where('email', 'ktm@prn.local')->firstOrFail(); + $ownAssignment = $this->ktmAssignmentFor($ktm); + $otherKtm = User::query()->where('email', 'ppm@prn.local')->firstOrFail(); + $otherAssignment = $this->ktmAssignmentFor($otherKtm); + $papmPosition = Position::query()->where('code', 'PAPM')->firstOrFail(); + + $otherTeamApplication = $this->kpApplicationFor($otherAssignment, 'KP Team Lain', '880101101114'); + $nonKpApplication = Application::query()->create([ + 'election_id' => $ownAssignment->election_id, + 'pusat_mengundi_id' => $ownAssignment->pusat_mengundi_id, + 'selected_ktm_assignment_id' => $ownAssignment->id, + 'public_uuid' => (string) Str::uuid(), + 'source' => 'public', + 'status' => 'submitted', + 'name' => 'Bukan KP', + 'ic_number' => '880101101115', + 'phone_number' => '0123456789', + 'email' => 'not-kp@example.test', + 'requested_position_id' => $papmPosition->id, + ]); + + $this->actingAs($ktm) + ->post(route('ktm.applications.approve', $otherTeamApplication->public_uuid)) + ->assertSessionHasErrors('application'); + + $this->actingAs($ktm) + ->post(route('ktm.applications.approve', $nonKpApplication->public_uuid)) + ->assertSessionHasErrors('requested_position_id'); + } + + public function test_ktm_can_delete_own_created_applicant_to_allow_public_registration(): void + { + Mail::fake(); + $this->seed(DatabaseSeeder::class); + + $ktm = User::query()->where('email', 'ktm@prn.local')->firstOrFail(); + $assignment = $this->ktmAssignmentFor($ktm); + $application = $this->kpApplicationFor($assignment, 'KP Padam', '880101101116', 'created_by_ktm'); + + $this->actingAs($ktm) + ->delete(route('ktm.applications.destroy', $application->public_uuid)) + ->assertRedirect(route('ktm.applications.index')); + + $this->assertSoftDeleted('applications', [ + 'id' => $application->id, + 'status' => 'deleted_by_ktm', + ]); + } + + private function ktmAssignmentFor(User $user): StaffAssignment + { + $ktmPosition = Position::query()->where('code', 'KTM')->firstOrFail(); + + return StaffAssignment::query() + ->with(['election.settings', 'pusatMengundi', 'saluranMengundi']) + ->where('user_id', $user->id) + ->where('position_id', $ktmPosition->id) + ->where('status', 'active') + ->whereNotNull('saluran_mengundi_id') + ->firstOrFail(); + } + + private function kpApplicationFor(StaffAssignment $assignment, string $name, string $icNumber, string $source = 'public'): Application + { + $kpPosition = Position::query()->where('code', 'KP')->firstOrFail(); + + return Application::query()->create([ + 'election_id' => $assignment->election_id, + 'pusat_mengundi_id' => $assignment->pusat_mengundi_id, + 'selected_ktm_assignment_id' => $assignment->id, + 'public_uuid' => (string) Str::uuid(), + 'source' => $source, + 'status' => 'submitted', + 'name' => $name, + 'ic_number' => $icNumber, + 'phone_number' => '0123456789', + 'email' => Str::slug($name).'@example.test', + 'requested_position_id' => $kpPosition->id, + ]); + } +} diff --git a/tests/Feature/PpmApplicationReviewTest.php b/tests/Feature/PpmApplicationReviewTest.php new file mode 100644 index 0000000..6ad059a --- /dev/null +++ b/tests/Feature/PpmApplicationReviewTest.php @@ -0,0 +1,246 @@ +seed(DatabaseSeeder::class); + + $ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail(); + $ownPusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $otherPusat = $this->otherPusat(); + $position = Position::query()->where('code', 'PAPM')->firstOrFail(); + + $ownApplication = Application::factory()->create([ + 'election_id' => $ownPusat->election_id, + 'pusat_mengundi_id' => $ownPusat->id, + 'requested_position_id' => $position->id, + 'name' => 'Pemohon Pusat Sendiri', + ]); + + Application::factory()->create([ + 'election_id' => $otherPusat->election_id, + 'pusat_mengundi_id' => $otherPusat->id, + 'requested_position_id' => $position->id, + 'name' => 'Pemohon Pusat Lain', + ]); + + $this->actingAs($ppm) + ->get(route('ppm.applications.index')) + ->assertOk() + ->assertSee($ownApplication->name) + ->assertDontSee('Pemohon Pusat Lain'); + } + + public function test_ppm_cannot_view_application_outside_own_pusat(): void + { + $this->seed(DatabaseSeeder::class); + + $ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail(); + $position = Position::query()->where('code', 'PAPM')->firstOrFail(); + $otherPusat = $this->otherPusat(); + + $application = Application::factory()->create([ + 'election_id' => $otherPusat->election_id, + 'pusat_mengundi_id' => $otherPusat->id, + 'requested_position_id' => $position->id, + ]); + + $this->actingAs($ppm) + ->get(route('ppm.applications.show', $application->public_uuid)) + ->assertForbidden(); + } + + public function test_ppm_cannot_approve_application_without_required_documents(): void + { + $this->seed(DatabaseSeeder::class); + + $ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail(); + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $position = Position::query()->where('code', 'PAPM')->firstOrFail(); + + $application = Application::factory()->create([ + 'election_id' => $pusat->election_id, + 'pusat_mengundi_id' => $pusat->id, + 'requested_position_id' => $position->id, + ]); + + $this->actingAs($ppm) + ->post(route('ppm.applications.approve', $application->public_uuid), [ + 'approved_position_code' => 'PAPM', + ]) + ->assertSessionHasErrors('documents'); + + $this->assertDatabaseMissing('staff_assignments', [ + 'application_id' => $application->id, + ]); + } + + public function test_ppm_can_approve_ktm_and_assign_saluran(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail(); + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $position = Position::query()->where('code', 'KTM')->firstOrFail(); + $saluran = SaluranMengundi::query() + ->where('pusat_mengundi_id', $pusat->id) + ->where('number', '3') + ->firstOrFail(); + $application = $this->applicationWithDocuments($pusat, $position, 'Pemohon KTM'); + + $this->actingAs($ppm) + ->post(route('ppm.applications.approve', $application->public_uuid), [ + 'approved_position_code' => 'KTM', + 'saluran_mengundi_id' => $saluran->id, + 'note' => 'Diluluskan untuk Saluran 3.', + ]) + ->assertRedirect(route('ppm.applications.show', $application->public_uuid)); + + $application->refresh(); + + $this->assertSame('assigned', $application->status); + $this->assertSame($position->id, $application->approved_position_id); + $this->assertTrue(StaffAssignment::query() + ->where('application_id', $application->id) + ->where('position_id', $position->id) + ->where('saluran_mengundi_id', $saluran->id) + ->where('status', 'active') + ->exists()); + $this->assertTrue($application->statusHistories()->where('to_status', 'assigned')->exists()); + } + + public function test_ppm_can_change_role_and_assign_kp_to_approved_ktm(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail(); + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $requestedPosition = Position::query()->where('code', 'PAPM')->firstOrFail(); + $approvedPosition = Position::query()->where('code', 'KP')->firstOrFail(); + $ktmAssignment = $this->ktmAssignment($pusat); + $application = $this->applicationWithDocuments($pusat, $requestedPosition, 'Pemohon Ditukar KP'); + + $this->actingAs($ppm) + ->post(route('ppm.applications.approve', $application->public_uuid), [ + 'approved_position_code' => 'KP', + 'ktm_assignment_id' => $ktmAssignment->id, + 'note' => 'Tukar dari PAPM ke KP.', + ]) + ->assertRedirect(route('ppm.applications.show', $application->public_uuid)); + + $application->refresh(); + + $this->assertSame('assigned', $application->status); + $this->assertSame($approvedPosition->id, $application->approved_position_id); + $this->assertSame($ktmAssignment->id, $application->selected_ktm_assignment_id); + $this->assertTrue(StaffAssignment::query() + ->where('application_id', $application->id) + ->where('position_id', $approvedPosition->id) + ->where('reports_to_assignment_id', $ktmAssignment->id) + ->where('status', 'active') + ->exists()); + } + + public function test_ppm_can_reject_own_pusat_application(): void + { + $this->seed(DatabaseSeeder::class); + + $ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail(); + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $position = Position::query()->where('code', 'PAPM')->firstOrFail(); + $application = Application::factory()->create([ + 'election_id' => $pusat->election_id, + 'pusat_mengundi_id' => $pusat->id, + 'requested_position_id' => $position->id, + ]); + + $this->actingAs($ppm) + ->post(route('ppm.applications.reject', $application->public_uuid), [ + 'reason' => 'Tidak memenuhi syarat.', + ]) + ->assertRedirect(route('ppm.applications.show', $application->public_uuid)); + + $application->refresh(); + + $this->assertSame('rejected', $application->status); + $this->assertSame('Tidak memenuhi syarat.', $application->rejection_reason); + $this->assertTrue($application->statusHistories()->where('to_status', 'rejected')->exists()); + } + + private function otherPusat(): PusatMengundi + { + $election = Election::query()->where('code', 'PRN2026')->firstOrFail(); + $daerah = DaerahMengundi::factory()->forElection($election)->create([ + 'code' => 'D999', + 'name' => 'Daerah Lain', + ]); + + return PusatMengundi::factory()->create([ + 'election_id' => $election->id, + 'daerah_mengundi_id' => $daerah->id, + 'code' => 'PM999', + 'name' => 'Pusat Lain', + ]); + } + + private function applicationWithDocuments(PusatMengundi $pusatMengundi, Position $position, string $name): Application + { + $application = Application::factory()->create([ + 'election_id' => $pusatMengundi->election_id, + 'pusat_mengundi_id' => $pusatMengundi->id, + 'requested_position_id' => $position->id, + 'name' => $name, + ]); + + foreach (['ic_document', 'bank_statement'] as $type) { + $path = 'applications/'.$application->public_uuid.'/'.$type.'.pdf'; + Storage::disk('local')->put($path, 'test'); + + ApplicationDocument::query()->create([ + 'application_id' => $application->id, + 'document_type' => $type, + 'disk' => 'local', + 'path' => $path, + 'original_name' => $type.'.pdf', + 'mime_type' => 'application/pdf', + 'size' => 4, + ]); + } + + return $application; + } + + private function ktmAssignment(PusatMengundi $pusatMengundi): StaffAssignment + { + $ktmPosition = Position::query()->where('code', 'KTM')->firstOrFail(); + + return StaffAssignment::query() + ->where('election_id', $pusatMengundi->election_id) + ->where('pusat_mengundi_id', $pusatMengundi->id) + ->where('position_id', $ktmPosition->id) + ->where('status', 'active') + ->whereNotNull('saluran_mengundi_id') + ->firstOrFail(); + } +} diff --git a/tests/Feature/ProfileTest.php b/tests/Feature/ProfileTest.php new file mode 100644 index 0000000..252fdcc --- /dev/null +++ b/tests/Feature/ProfileTest.php @@ -0,0 +1,99 @@ +create(); + + $response = $this + ->actingAs($user) + ->get('/profile'); + + $response->assertOk(); + } + + public function test_profile_information_can_be_updated(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->patch('/profile', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $user->refresh(); + + $this->assertSame('Test User', $user->name); + $this->assertSame('test@example.com', $user->email); + $this->assertNull($user->email_verified_at); + } + + public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->patch('/profile', [ + 'name' => 'Test User', + 'email' => $user->email, + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $this->assertNotNull($user->refresh()->email_verified_at); + } + + public function test_user_can_delete_their_account(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->delete('/profile', [ + 'password' => 'password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/'); + + $this->assertGuest(); + $this->assertNull($user->fresh()); + } + + public function test_correct_password_must_be_provided_to_delete_account(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->from('/profile') + ->delete('/profile', [ + 'password' => 'wrong-password', + ]); + + $response + ->assertSessionHasErrorsIn('userDeletion', 'password') + ->assertRedirect('/profile'); + + $this->assertNotNull($user->fresh()); + } +} diff --git a/tests/Feature/PublicApplicationStatusTest.php b/tests/Feature/PublicApplicationStatusTest.php new file mode 100644 index 0000000..18777cd --- /dev/null +++ b/tests/Feature/PublicApplicationStatusTest.php @@ -0,0 +1,144 @@ +seed(DatabaseSeeder::class); + + $application = $this->application('submitted'); + + $this->get(route('public.applications.status', $application->public_uuid)) + ->assertOk() + ->assertSee('Menunggu Semakan') + ->assertDontSee($application->name) // full name must not appear + ->assertDontSee($application->ic_number); // IC must not appear + } + + public function test_assigned_application_shows_placed_status(): void + { + $this->seed(DatabaseSeeder::class); + + $application = $this->application('assigned'); + + $this->get(route('public.applications.status', $application->public_uuid)) + ->assertOk() + ->assertSee('Diluluskan'); + } + + public function test_rejected_application_shows_rejection_reason(): void + { + $this->seed(DatabaseSeeder::class); + + $application = $this->application('rejected', [ + 'rejection_reason' => 'Dokumen IC tidak jelas.', + ]); + + $this->get(route('public.applications.status', $application->public_uuid)) + ->assertOk() + ->assertSee('Ditolak') + ->assertSee('Dokumen IC tidak jelas.'); + } + + public function test_unknown_uuid_returns_200_with_generic_message(): void + { + $this->seed(DatabaseSeeder::class); + + $this->get(route('public.applications.status', (string) Str::uuid())) + ->assertOk() + ->assertSee('Status tidak dapat dipaparkan'); + } + + public function test_deleted_by_ktm_application_returns_generic_message(): void + { + $this->seed(DatabaseSeeder::class); + + $application = $this->application('deleted_by_ktm'); + $application->delete(); // soft-delete + + $this->get(route('public.applications.status', $application->public_uuid)) + ->assertOk() + ->assertSee('Status tidak dapat dipaparkan') + ->assertDontSee($application->name); + } + + public function test_soft_deleted_application_returns_generic_message(): void + { + $this->seed(DatabaseSeeder::class); + + $application = $this->application('cancelled'); + $application->delete(); + + $this->get(route('public.applications.status', $application->public_uuid)) + ->assertOk() + ->assertSee('Status tidak dapat dipaparkan'); + } + + public function test_name_is_masked_in_status_view(): void + { + $this->seed(DatabaseSeeder::class); + + $application = $this->application('submitted', ['name' => 'Ahmad bin Ali']); + + $response = $this->get(route('public.applications.status', $application->public_uuid)) + ->assertOk(); + + $response->assertSee('Ahm'); // first 3 chars shown + $response->assertDontSee('Ahmad bin Ali'); // full name not shown + } + + public function test_status_page_does_not_require_authentication(): void + { + $this->seed(DatabaseSeeder::class); + + $application = $this->application('submitted'); + + // Access without actingAs — must succeed + $this->get(route('public.applications.status', $application->public_uuid)) + ->assertOk(); + } + + public function test_status_page_shows_link_to_check_status_on_success_page(): void + { + $this->seed(DatabaseSeeder::class); + + $application = $this->application('submitted'); + $expectedUrl = route('public.applications.status', $application->public_uuid); + + $this->get(route('public.applications.success', $application->public_uuid)) + ->assertOk() + ->assertSee($expectedUrl); + } + + private function application(string $status, array $extra = []): Application + { + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $position = Position::query()->where('code', 'PAPM')->firstOrFail(); + + return Application::query()->create(array_merge([ + 'election_id' => $pusat->election_id, + 'pusat_mengundi_id' => $pusat->id, + 'requested_position_id' => $position->id, + 'public_uuid' => (string) Str::uuid(), + 'source' => 'public', + 'status' => $status, + 'name' => 'Pemohon Ujian', + 'ic_number' => '900101109999', + 'phone_number' => '0123456789', + 'email' => 'ujian@example.test', + 'address' => 'Jalan Ujian', + ], $extra)); + } +} diff --git a/tests/Feature/PublicApplicationTest.php b/tests/Feature/PublicApplicationTest.php new file mode 100644 index 0000000..88e33fd --- /dev/null +++ b/tests/Feature/PublicApplicationTest.php @@ -0,0 +1,285 @@ +seed(DatabaseSeeder::class); + + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + + $response = $this->post(route('public.applications.store', $pusat->public_uuid), $this->validPayload([ + 'requested_position_code' => 'PAPM', + 'ic_number' => '900101101111', + ])); + + $application = Application::query()->where('ic_number', '900101101111')->firstOrFail(); + + $response->assertRedirect(route('public.applications.success', $application->public_uuid)); + $this->assertSame('submitted', $application->status); + $this->assertSame('public', $application->source); + $this->assertSame($pusat->id, $application->pusat_mengundi_id); + $this->assertCount(2, $application->documents); + $this->assertTrue($application->bankVerification()->where('status', 'pending')->exists()); + $this->assertTrue($application->statusHistories()->where('to_status', 'submitted')->exists()); + + foreach ($application->documents as $document) { + Storage::disk('local')->assertExists($document->path); + } + } + + public function test_applicant_cannot_register_after_registration_period(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $pusat->election->settings()->update([ + 'registration_start_date' => now()->subWeeks(4)->toDateString(), + 'registration_end_date' => now()->subDay()->toDateString(), + 'is_registration_open' => true, + 'is_registration_open_override' => null, + ]); + + $this->get(route('public.applications.create', $pusat->public_uuid)) + ->assertOk() + ->assertSee('Pendaftaran Ditutup'); + + $this->post(route('public.applications.store', $pusat->public_uuid), $this->validPayload([ + 'ic_number' => '900101101112', + ])) + ->assertSessionHasErrors('registration'); + + $this->assertDatabaseMissing('applications', [ + 'ic_number' => '900101101112', + ]); + } + + public function test_applicant_cannot_register_without_required_documents(): void + { + $this->seed(DatabaseSeeder::class); + + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $payload = $this->validPayload([ + 'ic_number' => '900101101113', + ]); + unset($payload['ic_document'], $payload['bank_statement']); + + $this->post(route('public.applications.store', $pusat->public_uuid), $payload) + ->assertSessionHasErrors(['ic_document', 'bank_statement']); + + $this->assertDatabaseMissing('applications', [ + 'ic_number' => '900101101113', + ]); + } + + public function test_kp_applicant_can_choose_ktm_when_vacancy_exists(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $ktmAssignment = $this->ktmAssignment($pusat); + + $this->post(route('public.applications.store', $pusat->public_uuid), $this->validPayload([ + 'requested_position_code' => 'KP', + 'selected_ktm_assignment_id' => $ktmAssignment->id, + 'ic_number' => '900101101114', + ])) + ->assertRedirect(); + + $this->assertDatabaseHas('applications', [ + 'ic_number' => '900101101114', + 'selected_ktm_assignment_id' => $ktmAssignment->id, + ]); + } + + public function test_kp_applicant_cannot_choose_ktm_when_vacancy_is_full(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $ktmAssignment = $this->ktmAssignment($pusat); + $kpPosition = Position::query()->where('code', 'KP')->firstOrFail(); + + foreach (range(1, 4) as $sequence) { + StaffAssignment::query()->create([ + 'election_id' => $pusat->election_id, + 'user_id' => User::factory()->create()->id, + 'position_id' => $kpPosition->id, + 'pusat_mengundi_id' => $pusat->id, + 'saluran_mengundi_id' => $ktmAssignment->saluran_mengundi_id, + 'reports_to_assignment_id' => $ktmAssignment->id, + 'status' => 'active', + 'assigned_at' => now()->subMinutes($sequence), + 'source' => 'test', + ]); + } + + $this->post(route('public.applications.store', $pusat->public_uuid), $this->validPayload([ + 'requested_position_code' => 'KP', + 'selected_ktm_assignment_id' => $ktmAssignment->id, + 'ic_number' => '900101101115', + ])) + ->assertSessionHasErrors('selected_ktm_assignment_id'); + + $this->assertDatabaseMissing('applications', [ + 'ic_number' => '900101101115', + ]); + } + + public function test_public_registration_is_blocked_when_same_ic_was_created_by_ktm(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $position = Position::query()->where('code', 'KP')->firstOrFail(); + + Application::query()->create([ + 'election_id' => $pusat->election_id, + 'pusat_mengundi_id' => $pusat->id, + 'public_uuid' => (string) Str::uuid(), + 'source' => 'created_by_ktm', + 'status' => 'submitted', + 'name' => 'Pemohon Dicipta KTM', + 'ic_number' => '900101101116', + 'phone_number' => '0123456789', + 'email' => 'ktm-created@example.test', + 'address' => 'Alamat ujian', + 'requested_position_id' => $position->id, + ]); + + $this->post(route('public.applications.store', $pusat->public_uuid), $this->validPayload([ + 'requested_position_code' => 'PAPM', + 'ic_number' => '900101101116', + ])) + ->assertSessionHasErrors('ic_number'); + + $this->assertSame(1, Application::query()->where('ic_number', '900101101116')->count()); + } + + public function test_public_registration_is_unblocked_after_ktm_deletes_applicant(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $position = Position::query()->where('code', 'KP')->firstOrFail(); + + $ktmCreated = Application::query()->create([ + 'election_id' => $pusat->election_id, + 'pusat_mengundi_id' => $pusat->id, + 'public_uuid' => (string) Str::uuid(), + 'source' => 'created_by_ktm', + 'status' => 'submitted', + 'name' => 'Pemohon Dicipta KTM', + 'ic_number' => '900101101117', + 'phone_number' => '0123456789', + 'email' => 'ktm-to-delete@example.test', + 'address' => 'Alamat ujian', + 'requested_position_id' => $position->id, + ]); + + // Simulate KTM delete: set status and soft-delete + $ktmCreated->forceFill(['status' => 'deleted_by_ktm'])->save(); + $ktmCreated->delete(); + + // Public applicant with same IC must now be allowed to register + $this->post(route('public.applications.store', $pusat->public_uuid), $this->validPayload([ + 'requested_position_code' => 'PAPM', + 'ic_number' => '900101101117', + ])) + ->assertRedirect(); + + // Exactly 1 active application exists (the new one); deleted one is soft-deleted + $this->assertSame(1, Application::query()->where('ic_number', '900101101117')->count()); + $this->assertSame(2, Application::withTrashed()->where('ic_number', '900101101117')->count()); + } + + public function test_cancelled_application_does_not_block_new_registration(): void + { + Storage::fake('local'); + $this->seed(DatabaseSeeder::class); + + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $position = Position::query()->where('code', 'PAPM')->firstOrFail(); + + // Create an application and cancel it (status = cancelled, NOT soft-deleted) + Application::query()->create([ + 'election_id' => $pusat->election_id, + 'pusat_mengundi_id' => $pusat->id, + 'public_uuid' => (string) Str::uuid(), + 'source' => 'public', + 'status' => 'cancelled', + 'name' => 'Pemohon Dibatalkan', + 'ic_number' => '900101101118', + 'phone_number' => '0123456789', + 'email' => 'cancelled@example.test', + 'address' => 'Alamat ujian', + 'requested_position_id' => $position->id, + ]); + + // Same IC must be allowed to register fresh + $this->post(route('public.applications.store', $pusat->public_uuid), $this->validPayload([ + 'requested_position_code' => 'PAPM', + 'ic_number' => '900101101118', + ])) + ->assertRedirect(); + + $this->assertSame(2, Application::query()->where('ic_number', '900101101118')->count()); + $this->assertSame(1, Application::query()->where('ic_number', '900101101118')->where('status', 'submitted')->count()); + } + + /** + * @param array $overrides + * @return array + */ + private function validPayload(array $overrides = []): array + { + return array_replace([ + 'name' => 'Pemohon Ujian', + 'ic_number' => '900101101110', + 'phone_number' => '0123456789', + 'email' => 'pemohon@example.test', + 'address' => 'No. 1, Jalan Ujian', + 'requested_position_code' => 'KTM', + 'selected_ktm_assignment_id' => null, + 'bank_name' => 'Maybank', + 'bank_account_number' => '1234567890', + 'ic_document' => UploadedFile::fake()->create('ic.pdf', 120, 'application/pdf'), + 'bank_statement' => UploadedFile::fake()->create('bank.pdf', 120, 'application/pdf'), + ], $overrides); + } + + private function ktmAssignment(PusatMengundi $pusatMengundi): StaffAssignment + { + $ktmPosition = Position::query()->where('code', 'KTM')->firstOrFail(); + + return StaffAssignment::query() + ->where('election_id', $pusatMengundi->election_id) + ->where('pusat_mengundi_id', $pusatMengundi->id) + ->where('position_id', $ktmPosition->id) + ->where('status', 'active') + ->whereNotNull('saluran_mengundi_id') + ->firstOrFail(); + } +} diff --git a/tests/Feature/WheelchairManagementTest.php b/tests/Feature/WheelchairManagementTest.php new file mode 100644 index 0000000..d71bee5 --- /dev/null +++ b/tests/Feature/WheelchairManagementTest.php @@ -0,0 +1,166 @@ +seed(DatabaseSeeder::class); + + $admin = User::query()->where('email', 'admin@prn.local')->firstOrFail(); + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + + $this->actingAs($admin) + ->post(route('admin.management.wheelchairs.store'), [ + 'election_id' => $pusat->election_id, + 'pusat_mengundi_id' => $pusat->id, + 'allocated_quantity' => 4, + 'notes' => 'Tambahan kerusi roda.', + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('wheelchair_allocations', [ + 'election_id' => $pusat->election_id, + 'pusat_mengundi_id' => $pusat->id, + 'allocated_quantity' => 4, + ]); + } + + public function test_admin_can_record_taken_and_returned_wheelchairs(): void + { + $this->seed(DatabaseSeeder::class); + + $admin = User::query()->where('email', 'admin@prn.local')->firstOrFail(); + $allocation = WheelchairAllocation::query()->firstOrFail(); + $allocation->update(['allocated_quantity' => 3]); + + $this->actingAs($admin) + ->post(route('admin.management.wheelchairs.transactions.store', $allocation), [ + 'transaction_type' => 'taken', + 'quantity' => 2, + 'taken_by_name' => 'Petugas Logistik', + 'taken_at' => now()->format('Y-m-d H:i:s'), + 'notes' => 'Ambil pagi.', + ]) + ->assertRedirect(route('admin.management.wheelchairs.show', $allocation)); + + $this->actingAs($admin) + ->post(route('admin.management.wheelchairs.transactions.store', $allocation), [ + 'transaction_type' => 'returned', + 'quantity' => 1, + 'returned_at' => now()->format('Y-m-d H:i:s'), + 'return_condition' => 'baik', + ]) + ->assertRedirect(route('admin.management.wheelchairs.show', $allocation)); + + $this->assertDatabaseHas('wheelchair_transactions', [ + 'wheelchair_allocation_id' => $allocation->id, + 'transaction_type' => 'taken', + 'quantity' => 2, + 'taken_by_name' => 'Petugas Logistik', + 'recorded_by_user_id' => $admin->id, + ]); + $this->assertDatabaseHas('wheelchair_transactions', [ + 'wheelchair_allocation_id' => $allocation->id, + 'transaction_type' => 'returned', + 'quantity' => 1, + 'return_condition' => 'baik', + ]); + } + + public function test_taken_quantity_cannot_exceed_available_allocation(): void + { + $this->seed(DatabaseSeeder::class); + + $admin = User::query()->where('email', 'admin@prn.local')->firstOrFail(); + $allocation = WheelchairAllocation::query()->firstOrFail(); + $allocation->update(['allocated_quantity' => 2]); + + $this->actingAs($admin) + ->post(route('admin.management.wheelchairs.transactions.store', $allocation), [ + 'transaction_type' => 'taken', + 'quantity' => 3, + 'taken_by_name' => 'Petugas Logistik', + ]) + ->assertSessionHasErrors('quantity'); + + $this->assertDatabaseMissing('wheelchair_transactions', [ + 'wheelchair_allocation_id' => $allocation->id, + 'quantity' => 3, + ]); + } + + public function test_return_quantity_cannot_exceed_outstanding_quantity(): void + { + $this->seed(DatabaseSeeder::class); + + $admin = User::query()->where('email', 'admin@prn.local')->firstOrFail(); + $allocation = WheelchairAllocation::query()->firstOrFail(); + $allocation->update(['allocated_quantity' => 2]); + + WheelchairTransaction::query()->create([ + 'wheelchair_allocation_id' => $allocation->id, + 'transaction_type' => 'taken', + 'quantity' => 1, + 'taken_at' => now(), + 'taken_by_name' => 'Petugas Logistik', + 'recorded_by_user_id' => $admin->id, + ]); + + $this->actingAs($admin) + ->post(route('admin.management.wheelchairs.transactions.store', $allocation), [ + 'transaction_type' => 'returned', + 'quantity' => 2, + 'return_condition' => 'baik', + ]) + ->assertSessionHasErrors('quantity'); + } + + public function test_allocation_cannot_be_reduced_below_outstanding_quantity(): void + { + $this->seed(DatabaseSeeder::class); + + $admin = User::query()->where('email', 'admin@prn.local')->firstOrFail(); + $allocation = WheelchairAllocation::query()->firstOrFail(); + $allocation->update(['allocated_quantity' => 3]); + + WheelchairTransaction::query()->create([ + 'wheelchair_allocation_id' => $allocation->id, + 'transaction_type' => 'taken', + 'quantity' => 2, + 'taken_at' => now(), + 'taken_by_name' => 'Petugas Logistik', + 'recorded_by_user_id' => $admin->id, + ]); + + $this->actingAs($admin) + ->patch(route('admin.management.wheelchairs.update', $allocation), [ + 'election_id' => $allocation->election_id, + 'pusat_mengundi_id' => $allocation->pusat_mengundi_id, + 'allocated_quantity' => 1, + ]) + ->assertSessionHasErrors('allocated_quantity'); + } + + public function test_admin_kewangan_cannot_manage_wheelchairs(): void + { + $this->seed(DatabaseSeeder::class); + + $finance = User::query()->where('email', 'kewangan@prn.local')->firstOrFail(); + + $this->actingAs($finance) + ->get(route('admin.management.wheelchairs.index')) + ->assertForbidden(); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,10 @@ +seed(DatabaseSeeder::class); + + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $position = Position::query()->where('code', 'PAPM')->firstOrFail(); + $assignmentOne = StaffAssignment::query()->create([ + 'election_id' => $pusat->election_id, + 'position_id' => $position->id, + 'pusat_mengundi_id' => $pusat->id, + 'status' => 'active', + 'assigned_at' => now(), + 'source' => 'test', + ]); + $assignmentTwo = StaffAssignment::query()->create([ + 'election_id' => $pusat->election_id, + 'position_id' => $position->id, + 'pusat_mengundi_id' => $pusat->id, + 'status' => 'active', + 'assigned_at' => now(), + 'source' => 'test', + ]); + + Attendance::query()->create([ + 'election_id' => $pusat->election_id, + 'staff_assignment_id' => $assignmentOne->id, + 'pusat_mengundi_id' => $pusat->id, + 'position_id' => $position->id, + 'status' => 'present', + ]); + Attendance::query()->create([ + 'election_id' => $pusat->election_id, + 'staff_assignment_id' => $assignmentTwo->id, + 'pusat_mengundi_id' => $pusat->id, + 'position_id' => $position->id, + 'status' => 'absent', + ]); + + $summary = app(AttendanceSummaryService::class)->totalsForPusat($pusat); + + $this->assertSame(5, $summary['total_staff']); + $this->assertSame(1, $summary['present']); + $this->assertSame(1, $summary['absent']); + $this->assertSame(3, $summary['not_recorded']); + } +} diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php new file mode 100644 index 0000000..5773b0c --- /dev/null +++ b/tests/Unit/ExampleTest.php @@ -0,0 +1,16 @@ +assertTrue(true); + } +} diff --git a/tests/Unit/KtmVacancyServiceTest.php b/tests/Unit/KtmVacancyServiceTest.php new file mode 100644 index 0000000..993eef6 --- /dev/null +++ b/tests/Unit/KtmVacancyServiceTest.php @@ -0,0 +1,58 @@ +seed(DatabaseSeeder::class); + + $service = new KtmVacancyService; + $pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail(); + $ktmAssignment = $this->ktmAssignment($pusat); + $kpPosition = Position::query()->where('code', 'KP')->firstOrFail(); + + $this->assertNotEmpty($service->optionsForRoles($pusat, ['KP'])['KP']); + + foreach (range(1, 4) as $sequence) { + StaffAssignment::query()->create([ + 'election_id' => $pusat->election_id, + 'user_id' => User::factory()->create()->id, + 'position_id' => $kpPosition->id, + 'pusat_mengundi_id' => $pusat->id, + 'saluran_mengundi_id' => $ktmAssignment->saluran_mengundi_id, + 'reports_to_assignment_id' => $ktmAssignment->id, + 'status' => 'active', + 'assigned_at' => now()->subMinutes($sequence), + 'source' => 'test', + ]); + } + + $this->assertFalse($service->hasVacancyForAssignment($ktmAssignment->id, $pusat, 'KP')); + } + + private function ktmAssignment(PusatMengundi $pusatMengundi): StaffAssignment + { + $ktmPosition = Position::query()->where('code', 'KTM')->firstOrFail(); + + return StaffAssignment::query() + ->where('election_id', $pusatMengundi->election_id) + ->where('pusat_mengundi_id', $pusatMengundi->id) + ->where('position_id', $ktmPosition->id) + ->where('status', 'active') + ->whereNotNull('saluran_mengundi_id') + ->firstOrFail(); + } +} diff --git a/tests/Unit/RegistrationPeriodServiceTest.php b/tests/Unit/RegistrationPeriodServiceTest.php new file mode 100644 index 0000000..83574f5 --- /dev/null +++ b/tests/Unit/RegistrationPeriodServiceTest.php @@ -0,0 +1,57 @@ +seed(DatabaseSeeder::class); + + $service = new RegistrationPeriodService; + $election = Election::query()->where('code', 'PRN2026')->firstOrFail(); + + $this->assertTrue($service->isOpen($election)); + + $election->settings()->update([ + 'is_registration_open' => false, + 'is_registration_open_override' => null, + ]); + $election->refresh()->load('settings'); + + $this->assertFalse($service->isOpen($election)); + } + + public function test_registration_override_can_explicitly_open_or_close(): void + { + $this->seed(DatabaseSeeder::class); + + $service = new RegistrationPeriodService; + $election = Election::query()->where('code', 'PRN2026')->firstOrFail(); + + $election->settings()->update([ + 'registration_start_date' => now()->subWeeks(4)->toDateString(), + 'registration_end_date' => now()->subDay()->toDateString(), + 'is_registration_open' => true, + 'is_registration_open_override' => true, + ]); + $election->refresh()->load('settings'); + + $this->assertTrue($service->isOpen($election)); + + $election->settings()->update([ + 'is_registration_open_override' => false, + ]); + $election->refresh()->load('settings'); + + $this->assertFalse($service->isOpen($election)); + } +} diff --git a/tests/Unit/SensitiveDataTest.php b/tests/Unit/SensitiveDataTest.php new file mode 100644 index 0000000..bfeb2b3 --- /dev/null +++ b/tests/Unit/SensitiveDataTest.php @@ -0,0 +1,17 @@ +assertSame('9001****5555', SensitiveData::maskIc('900101105555')); + $this->assertSame('123***7890', SensitiveData::maskAccount('1234567890')); + $this->assertSame('012****789', SensitiveData::maskPhone('0123456789')); + $this->assertSame('-', SensitiveData::maskAccount(null)); + } +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..421b569 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + }), + ], +});