diff --git a/README.md b/README.md
index 5cecd01..24a70ef 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-# SUTERA 3.0 – Guide
+# MyKOPKB 1.0 – Guide
-Welcome to **SUTERA 3.0**, the streamlined training deployment setup using **Docker**, **Laravel**, and **Vite**.
+Welcome to **MyKOPKB 1.0**, the streamlined training deployment setup using **Docker**, **Laravel**, and **Vite**.
---
@@ -13,10 +13,6 @@ Welcome to **SUTERA 3.0**, the streamlined training deployment setup using **Doc
---
-## Formula Used:
-1. KEUPAYAAN (%) = (PEGANGAN/PERJAWATAN) * 100
-2. SIAPSIAGA or KESIAGAAN (%) = (BDG/perjawatan) * 100
-3. SERVISIBILITI (%) = (BDG/pegangan) * 100
## Laravel command to clear
```bash
@@ -38,58 +34,15 @@ sail artisan permission:cache-reset
- Servisibility always higher than siapsiaga
- Solution — put specific routes first before resource routes
- to use soft delete, make id and deleted_at as unique
+
+# Nwidart Command
- to make new module, run:
```bash
sail artisan module:make Example
```
-- There are two User model files
-
-## PKJ Default Varian (DO NOT DELETE) or update. If require update, be sure to update the export name for each PKJ report:
-- BANTUAN MOBILITI
-- BEKALAN AIR
-- EOD/IED/PEMUSNAHAN
-- JURUUKUR
-- KENDERAAN JENIS A
-- KENDERAAN JENIS B
-- OPTRONIK DAN PERALATAN LATIHAN
-- PELURU DAN BAHAN LETUPAN
-- PEMUSNAHAN
-- PERALATAN KETUKANGAN
-- PERALATAN KOMUNIKASI PERTAHANAN
-- PERALATAN PD&P
-- PERALATAN PERAIRAN
-- PERALATAN PNBK
-- SENJATA PERTAHANAN
-
-
-```sql
---- KJC TO UPDATE PEGANGAN IN PERJAWATAN TABLE; CHECK WILL BE UPDATED FIRST
-SELECT
- ae.id,
- ae.entitlement,
- ae.holding AS current_holding,
- (
- SELECT COUNT(*)
- FROM kjc_asset_holdings ah
- WHERE ah.kjc_category_id = ae.kjc_category_id
- AND ah.kjc_category_id = ae.kjc_category_id
- AND ah.unit_id = ae.unit_id
- AND ah.deleted_at IS NULL
- ) AS actual_holding_count
-FROM kjc_asset_entitlements ae
-WHERE ae.deleted_at IS NULL;
-
---- EXECUTE IT
-UPDATE kjc_asset_entitlements ae
-SET ae.holding = (
- SELECT COUNT(*)
- FROM kjc_asset_holdings ah
- WHERE ah.kjc_category_id = ae.kjc_category_id
- AND ah.kjc_category_id = ae.kjc_category_id
- AND ah.unit_id = ae.unit_id
- AND ah.deleted_at IS NULL
-)
-WHERE ae.deleted_at IS NULL;
+- create migration in module
+```bash
+sail artisan module:make-migration add_more_details_to_users_table User
```
## Run specific laravel migration:
@@ -117,23 +70,7 @@ SELECT last_value FROM public.permissions_id_seq;
SELECT setval('public.permissions_id_seq', COALESCE((SELECT MAX(id) FROM permissions), 0) + 1, false);
```
-## Run capture data command
-```bash
-# kjc
-sail artisan kjc:historical:trigger
-
-# pkj
-sail artisan pkj:historical:trigger
-```
-
-## Auto-generate weekly KJC report (Jabatan Arah RAJD) - Scheduled to run every Monday at 3:00 AM.
-```bash
-# Manual trigger
-sail artisan kjc:auto-generate-weekly-report "Jabatan Arah RAJD"
-
-# Force regenerate even if reports exist
-sail artisan kjc:auto-generate-weekly-report "Jabatan Arah RAJD" --force
-```
+---
# Git
## DO NOT RUN THIS BECAUSE IT WILL REMOVE ALL THE UNTAGGED FILES IN LOCAL
diff --git a/TODO.md b/TODO.md
index 9069e57..ce714ce 100644
--- a/TODO.md
+++ b/TODO.md
@@ -12,4 +12,7 @@
[ ] wasi/penama
[ ] pendaftaran anggota/meneruskan anggota/pencen
[ ] daftar lembaga (backdated)
-[ ] boleh print semua borang
\ No newline at end of file
+[ ] boleh print semua borang
+[ ] jana surat lepas lulus anggota
+
+##
\ No newline at end of file
diff --git a/be/.env.example b/be/.env.example
index 2e82c21..30da688 100644
--- a/be/.env.example
+++ b/be/.env.example
@@ -66,6 +66,14 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
+# Browsershot (PDF generation via headless Chrome + Puppeteer in be/)
+# BROWSERSHOT_NODE_BINARY=
+# BROWSERSHOT_NPM_BINARY=
+# BROWSERSHOT_NODE_MODULE_PATH=
+# BROWSERSHOT_CHROME_PATH=
+# BROWSERSHOT_NO_SANDBOX=false
+# BROWSERSHOT_TIMEOUT=60
+
# HttpOnly Sanctum token cookie (set on api.* domain)
AUTH_COOKIE_NAME=auth_token
AUTH_COOKIE_LIFETIME=720
diff --git a/be/Modules/Activity/Actions/.gitkeep b/be/Modules/Activity/Actions/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/Activity/Config/.gitkeep b/be/Modules/Activity/Config/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/Activity/Config/config.php b/be/Modules/Activity/Config/config.php
new file mode 100644
index 0000000..c4ab535
--- /dev/null
+++ b/be/Modules/Activity/Config/config.php
@@ -0,0 +1,5 @@
+ 'Activity',
+];
diff --git a/be/Modules/Activity/Console/.gitkeep b/be/Modules/Activity/Console/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/Activity/Database/Factories/.gitkeep b/be/Modules/Activity/Database/Factories/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/Activity/Database/Migrations/.gitkeep b/be/Modules/Activity/Database/Migrations/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/database/migrations/2025_07_25_051334_create_countries_table.php b/be/Modules/Activity/Database/Migrations/2026_06_24_003108_create_activity_types_table.php
similarity index 66%
rename from be/database/migrations/2025_07_25_051334_create_countries_table.php
rename to be/Modules/Activity/Database/Migrations/2026_06_24_003108_create_activity_types_table.php
index 76ad330..e6c73a5 100644
--- a/be/database/migrations/2025_07_25_051334_create_countries_table.php
+++ b/be/Modules/Activity/Database/Migrations/2026_06_24_003108_create_activity_types_table.php
@@ -11,12 +11,12 @@ return new class extends Migration
*/
public function up(): void
{
- Schema::create('countries', function (Blueprint $table) {
+ Schema::create('activity_types', function (Blueprint $table) {
$table->uuid('id')->primary();
- $table->string('code', 2);
- $table->string('name', 100);
+ $table->string('name');
+ $table->string('code')->nullable();
+
$table->timestamps();
- $table->softDeletes();
});
}
@@ -25,6 +25,6 @@ return new class extends Migration
*/
public function down(): void
{
- Schema::dropIfExists('countries');
+ Schema::dropIfExists('activity_types');
}
};
diff --git a/be/Modules/Activity/Database/Migrations/2026_06_24_083114_create_activities_table.php b/be/Modules/Activity/Database/Migrations/2026_06_24_083114_create_activities_table.php
new file mode 100644
index 0000000..c189a33
--- /dev/null
+++ b/be/Modules/Activity/Database/Migrations/2026_06_24_083114_create_activities_table.php
@@ -0,0 +1,36 @@
+uuid('id')->primary();
+ $table->foreignUuid('activity_type_id')->constrained('activity_types')->onDelete('cascade');
+ $table->string('title');
+ $table->string('reference_number')->nullable();
+ $table->text('description')->nullable();
+ $table->timestamp('start_datetime')->nullable();
+ $table->timestamp('end_datetime')->nullable();
+ $table->string('organizer')->nullable();
+ $table->string('location')->nullable();
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('activities');
+ }
+};
diff --git a/be/Modules/Activity/Database/Migrations/2026_06_25_004305_create_activity_reports_table.php b/be/Modules/Activity/Database/Migrations/2026_06_25_004305_create_activity_reports_table.php
new file mode 100644
index 0000000..2a4f5e6
--- /dev/null
+++ b/be/Modules/Activity/Database/Migrations/2026_06_25_004305_create_activity_reports_table.php
@@ -0,0 +1,30 @@
+uuid('id')->primary();
+ $table->foreignUuid('activity_id')->constrained('activities')->onDelete('cascade');
+ $table->text('report_text');
+ $table->foreignUuid('prepared_by')->constrained('users')->onDelete('cascade');
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('activity_reports');
+ }
+};
diff --git a/be/Modules/Activity/Database/Seeders/.gitkeep b/be/Modules/Activity/Database/Seeders/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/Activity/Database/Seeders/ActivityDatabaseSeeder.php b/be/Modules/Activity/Database/Seeders/ActivityDatabaseSeeder.php
new file mode 100644
index 0000000..63dbc3b
--- /dev/null
+++ b/be/Modules/Activity/Database/Seeders/ActivityDatabaseSeeder.php
@@ -0,0 +1,16 @@
+call([]);
+ }
+}
diff --git a/be/Modules/Activity/Emails/.gitkeep b/be/Modules/Activity/Emails/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/Activity/Entities/.gitkeep b/be/Modules/Activity/Entities/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/Activity/Entities/Activity.php b/be/Modules/Activity/Entities/Activity.php
new file mode 100644
index 0000000..407b8d5
--- /dev/null
+++ b/be/Modules/Activity/Entities/Activity.php
@@ -0,0 +1,65 @@
+ 'string',
+ 'reference_number' => 'string',
+ 'description' => 'string',
+ 'start_datetime' => 'datetime',
+ 'end_datetime' => 'datetime',
+ 'organizer' => 'string',
+ 'location' => 'string',
+ 'is_active' => 'boolean',
+ ];
+ }
+
+ public function activityType(): BelongsTo
+ {
+ return $this->belongsTo(ActivityType::class);
+ }
+
+ public function reports(): HasMany
+ {
+ return $this->hasMany(ActivityReport::class)->orderByDesc('created_at');
+ }
+
+ public function galleryImages(): MorphMany
+ {
+ return $this->documents()->where('type', self::GALLERY_DOCUMENT_TYPE);
+ }
+
+ public function attachments(): MorphMany
+ {
+ return $this->documents()->where('type', '!=', self::GALLERY_DOCUMENT_TYPE);
+ }
+}
diff --git a/be/Modules/Activity/Entities/ActivityReport.php b/be/Modules/Activity/Entities/ActivityReport.php
new file mode 100644
index 0000000..eed0baf
--- /dev/null
+++ b/be/Modules/Activity/Entities/ActivityReport.php
@@ -0,0 +1,38 @@
+ 'string',
+ ];
+ }
+
+ public function activity(): BelongsTo
+ {
+ return $this->belongsTo(Activity::class);
+ }
+
+ public function preparedBy(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'prepared_by');
+ }
+}
diff --git a/be/Modules/Activity/Entities/ActivityType.php b/be/Modules/Activity/Entities/ActivityType.php
new file mode 100644
index 0000000..692fa9e
--- /dev/null
+++ b/be/Modules/Activity/Entities/ActivityType.php
@@ -0,0 +1,32 @@
+ 'string',
+ 'code' => 'string',
+ ];
+ }
+
+ public function activities(): HasMany
+ {
+ return $this->hasMany(Activity::class);
+ }
+}
diff --git a/be/Modules/Activity/Helpers/.gitkeep b/be/Modules/Activity/Helpers/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/Activity/Http/Controllers/.gitkeep b/be/Modules/Activity/Http/Controllers/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/Activity/Http/Controllers/ActivityController.php b/be/Modules/Activity/Http/Controllers/ActivityController.php
new file mode 100644
index 0000000..a0ebdbf
--- /dev/null
+++ b/be/Modules/Activity/Http/Controllers/ActivityController.php
@@ -0,0 +1,345 @@
+authorize('viewAny', $this->modelClass);
+
+ try {
+ $perPage = $request->get('per_page', 10) ?? 10;
+ $perPage = min($perPage, 1000);
+ $search = (string) ($request->get('search', '') ?? '');
+ $sortBy = (string) ($request->get('sort_by', 'start_datetime') ?? 'start_datetime');
+ $sortOrder = (string) ($request->get('sort_order', 'desc') ?? 'desc');
+
+ $items = $this->getIndexData($request, $perPage, $search, $sortBy, $sortOrder);
+
+ if ($items instanceof LengthAwarePaginator) {
+ return response()->json([
+ 'success' => true,
+ 'data' => ActivityListResource::collection($items->items()),
+ 'pagination' => [
+ 'current_page' => $items->currentPage(),
+ 'per_page' => $items->perPage(),
+ 'total' => $items->total(),
+ 'last_page' => $items->lastPage(),
+ 'from' => $items->firstItem(),
+ 'to' => $items->lastItem(),
+ 'has_more_pages' => $items->hasMorePages(),
+ ],
+ 'message' => $this->getSuccessMessage('index'),
+ ]);
+ }
+
+ return response()->json([
+ 'success' => true,
+ 'data' => ActivityListResource::collection($items),
+ 'message' => $this->getSuccessMessage('index'),
+ ]);
+ } catch (Exception $e) {
+ Log::error("Error fetching {$this->resourceNamePlural}: ".$e->getMessage());
+
+ return $this->errorResponse($this->getErrorMessage('index'), 500);
+ }
+ }
+
+ protected function getIndexData(Request $request, int $perPage = 10, string $search = '', string $sortBy = 'start_datetime', string $sortOrder = 'desc')
+ {
+ if ($perPage > 0) {
+ return $this->repository->getIndexPaginated($perPage, $search, $sortBy, $sortOrder);
+ }
+
+ return $this->repository->all($search);
+ }
+
+ public function store(Request $request): JsonResponse
+ {
+ $this->authorize('create', $this->modelClass);
+
+ try {
+ $validated = $this->validateRequest($request);
+ $documents = collect($request->file('documents', []))
+ ->filter()
+ ->all();
+ $galleryImages = collect($request->file('gallery', []))
+ ->filter()
+ ->all();
+
+ $activity = $this->activityService->create(
+ $this->prepareStoreData(collect($validated)->except(['documents', 'gallery'])->all()),
+ $documents,
+ $galleryImages,
+ );
+
+ ActivityLogger::log("Created {$this->resourceName}: {$this->getItemName($activity)}", $activity);
+
+ return response()->json([
+ 'success' => true,
+ 'data' => new $this->resourceClass($activity),
+ 'message' => $this->getSuccessMessage('store'),
+ ], 201);
+ } catch (Exception $e) {
+ Log::error("Error creating {$this->resourceName}: ".$e->getMessage());
+
+ return $this->errorResponse($this->getErrorMessage('store').': '.$e->getMessage(), 500);
+ }
+ }
+
+ public function update(Request $request, string $id): JsonResponse
+ {
+ $this->authorize('update', $this->modelClass);
+
+ try {
+ $activity = $this->repository->findById($id);
+
+ if (! $activity) {
+ return $this->errorResponse($this->getNotFoundMessage(), 404);
+ }
+
+ $validated = $this->validateRequest($request);
+ $documents = collect($request->file('documents', []))
+ ->filter()
+ ->all();
+ $galleryImages = collect($request->file('gallery', []))
+ ->filter()
+ ->all();
+
+ $activity = $this->activityService->update(
+ $activity,
+ $this->prepareUpdateData(collect($validated)->except(['documents', 'gallery'])->all(), $activity),
+ $documents,
+ $galleryImages,
+ );
+
+ ActivityLogger::log("Updated {$this->resourceName}: {$this->getItemName($activity)}", $activity);
+
+ return response()->json([
+ 'success' => true,
+ 'data' => new $this->resourceClass($activity),
+ 'message' => $this->getSuccessMessage('update'),
+ ]);
+ } catch (Exception $e) {
+ Log::error("Error updating {$this->resourceName}: ".$e->getMessage());
+
+ return $this->errorResponse($this->getErrorMessage('update').': '.$e->getMessage(), 500);
+ }
+ }
+
+ protected function loadShowRelations($item)
+ {
+ return $this->repository->findByIdWithRelations($item->id) ?? $item;
+ }
+
+ protected function getItemName($item): string
+ {
+ return $item->title ?? $item->id;
+ }
+
+ protected function checkDependencies($activity): ?JsonResponse
+ {
+ if ($activity->reports()->count() > 0) {
+ return response()->json([
+ 'success' => false,
+ 'message' => 'Aktiviti tidak boleh dipadam kerana masih mempunyai laporan.',
+ ], 422);
+ }
+
+ return null;
+ }
+
+ public function uploadDocument(
+ UploadActivityDocumentRequest $request,
+ Activity $activity
+ ): JsonResponse {
+ $this->authorize('update', $this->modelClass);
+
+ try {
+ $activity = $this->activityService->uploadDocument(
+ $activity,
+ $request->file('file'),
+ $request->validated('type', 'general'),
+ $request->validated('description'),
+ );
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Dokumen berjaya dimuat naik.',
+ 'data' => new ActivityResource($activity),
+ ]);
+ } catch (Exception $e) {
+ Log::error("Error uploading document for {$this->resourceName}: ".$e->getMessage());
+
+ return $this->errorResponse('Gagal memuat naik dokumen: '.$e->getMessage(), 500);
+ }
+ }
+
+ public function uploadGalleryImage(UploadActivityGalleryImageRequest $request, Activity $activity): JsonResponse {
+ $this->authorize('update', $this->modelClass);
+
+ try {
+ $activity = $this->activityService->uploadGalleryImage(
+ $activity,
+ $request->file('file'),
+ $request->validated('description'),
+ );
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Imej galeri berjaya dimuat naik.',
+ 'data' => new ActivityResource($activity),
+ ]);
+ } catch (Exception $e) {
+ Log::error("Error uploading gallery image for {$this->resourceName}: ".$e->getMessage());
+
+ return $this->errorResponse('Gagal memuat naik imej galeri: '.$e->getMessage(), 500);
+ }
+ }
+
+ public function downloadDocument(Activity $activity, string $documentId): StreamedResponse
+ {
+ $this->authorize('view', $this->modelClass);
+
+ $document = $activity->documents()
+ ->where('type', '!=', Activity::GALLERY_DOCUMENT_TYPE)
+ ->findOrFail($documentId);
+
+ return $this->documentService->downloadDocument($document->id);
+ }
+
+ public function serveGalleryImage(Activity $activity, string $imageId): BinaryFileResponse
+ {
+ $this->authorize('view', $this->modelClass);
+
+ $document = $activity->galleryImages()->findOrFail($imageId);
+
+ $disk = Storage::disk(Document::STORAGE_DISK);
+
+ if (! $disk->exists($document->path)) {
+ abort(404, 'File not found');
+ }
+
+ return response()->file($disk->path($document->path), [
+ 'Content-Type' => $document->mime_type ?? 'application/octet-stream',
+ 'Content-Disposition' => 'inline; filename="'.$document->name.'"',
+ ]);
+ }
+
+ public function deleteDocument(Activity $activity, string $documentId): JsonResponse
+ {
+ $this->authorize('update', $this->modelClass);
+
+ try {
+ $activity->documents()
+ ->where('type', '!=', Activity::GALLERY_DOCUMENT_TYPE)
+ ->findOrFail($documentId);
+
+ $activity->deleteDocument($documentId);
+ $activity = $this->repository->findByIdWithRelations($activity->id);
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Dokumen berjaya dipadam.',
+ 'data' => new ActivityResource($activity),
+ ]);
+ } catch (Exception $e) {
+ Log::error("Error deleting document for {$this->resourceName}: ".$e->getMessage());
+
+ return $this->errorResponse('Gagal memadam dokumen: '.$e->getMessage(), 500);
+ }
+ }
+
+ public function deleteGalleryImage(Activity $activity, string $imageId): JsonResponse
+ {
+ $this->authorize('update', $this->modelClass);
+
+ try {
+ $activity->galleryImages()->findOrFail($imageId);
+ $activity->deleteDocument($imageId);
+ $activity = $this->repository->findByIdWithRelations($activity->id);
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Imej galeri berjaya dipadam.',
+ 'data' => new ActivityResource($activity),
+ ]);
+ } catch (Exception $e) {
+ Log::error("Error deleting gallery image for {$this->resourceName}: ".$e->getMessage());
+
+ return $this->errorResponse('Gagal memadam imej galeri: '.$e->getMessage(), 500);
+ }
+ }
+
+ public function destroy(string $id): JsonResponse
+ {
+ $this->authorize('deleteAny', $this->modelClass);
+
+ try {
+ $activity = $this->repository->findByIdWithRelations($id);
+
+ if (! $activity) {
+ return $this->errorResponse($this->getNotFoundMessage(), 404);
+ }
+
+ $dependencyCheck = $this->checkDependencies($activity);
+ if ($dependencyCheck !== null) {
+ return $dependencyCheck;
+ }
+
+ $activity->documents->each(
+ fn ($document) => $this->documentService->deleteDocument($document->id)
+ );
+
+ $this->repository->delete($id);
+
+ ActivityLogger::log("Deleted {$this->resourceName}: {$this->getItemName($activity)}", $activity);
+
+ return response()->json([
+ 'success' => true,
+ 'message' => $this->getSuccessMessage('destroy'),
+ ]);
+ } catch (Exception $e) {
+ Log::error("Error deleting {$this->resourceName}: ".$e->getMessage());
+
+ return $this->errorResponse($this->getErrorMessage('destroy').': '.$e->getMessage(), 500);
+ }
+ }
+}
diff --git a/be/Modules/Activity/Http/Controllers/ActivityReportController.php b/be/Modules/Activity/Http/Controllers/ActivityReportController.php
new file mode 100644
index 0000000..006ba41
--- /dev/null
+++ b/be/Modules/Activity/Http/Controllers/ActivityReportController.php
@@ -0,0 +1,40 @@
+ auth()->id(),
+ ]);
+ }
+
+ protected function getItemName($item): string
+ {
+ return $item->activity?->title ?? $item->id;
+ }
+}
diff --git a/be/Modules/Activity/Http/Controllers/ActivityTypeController.php b/be/Modules/Activity/Http/Controllers/ActivityTypeController.php
new file mode 100644
index 0000000..7707fc4
--- /dev/null
+++ b/be/Modules/Activity/Http/Controllers/ActivityTypeController.php
@@ -0,0 +1,42 @@
+name ?? $item->id;
+ }
+
+ protected function checkDependencies($activityType): ?JsonResponse
+ {
+ if ($activityType->activities()->count() > 0) {
+ return response()->json([
+ 'success' => false,
+ 'message' => 'Jenis aktiviti tidak boleh dipadam kerana masih digunakan oleh aktiviti.',
+ ], 422);
+ }
+
+ return null;
+ }
+}
diff --git a/be/Modules/Activity/Http/Requests/.gitkeep b/be/Modules/Activity/Http/Requests/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/Activity/Http/Requests/ActivityReportRequest.php b/be/Modules/Activity/Http/Requests/ActivityReportRequest.php
new file mode 100644
index 0000000..73ff3aa
--- /dev/null
+++ b/be/Modules/Activity/Http/Requests/ActivityReportRequest.php
@@ -0,0 +1,40 @@
+isMethod('POST');
+ $isUpdate = $this->isMethod('PUT') || $this->isMethod('PATCH');
+
+ if (! $isCreate && ! $isUpdate) {
+ return [
+ 'activity_id' => 'nullable|uuid|exists:activities,id',
+ 'report_text' => 'nullable|string',
+ ];
+ }
+
+ return [
+ 'activity_id' => 'required|uuid|exists:activities,id',
+ 'report_text' => 'required|string',
+ ];
+ }
+
+ public function messages(): array
+ {
+ return [
+ 'activity_id.required' => 'Aktiviti diperlukan.',
+ 'activity_id.exists' => 'Aktiviti tidak sah.',
+ 'report_text.required' => 'Laporan diperlukan.',
+ ];
+ }
+}
diff --git a/be/Modules/Activity/Http/Requests/ActivityRequest.php b/be/Modules/Activity/Http/Requests/ActivityRequest.php
new file mode 100644
index 0000000..fed44a4
--- /dev/null
+++ b/be/Modules/Activity/Http/Requests/ActivityRequest.php
@@ -0,0 +1,76 @@
+isMethod('POST');
+ $isUpdate = $this->isMethod('PUT') || $this->isMethod('PATCH');
+
+ if (! $isCreate && ! $isUpdate) {
+ return [
+ 'activity_type_id' => 'nullable|uuid|exists:activity_types,id',
+ 'title' => 'nullable|string|max:255',
+ 'reference_number' => 'nullable|string|max:255',
+ 'description' => 'nullable|string',
+ 'start_datetime' => 'nullable|date',
+ 'end_datetime' => 'nullable|date|after_or_equal:start_datetime',
+ 'organizer' => 'nullable|string|max:255',
+ 'location' => 'nullable|string|max:255',
+ 'is_active' => 'nullable|boolean',
+ ];
+ }
+
+ $referenceRule = Rule::unique('activities', 'reference_number');
+
+ if ($isUpdate) {
+ $referenceRule = $referenceRule->ignore($this->route('activity'));
+ }
+
+ return [
+ 'activity_type_id' => 'required|uuid|exists:activity_types,id',
+ 'title' => 'required|string|max:255',
+ 'reference_number' => ['nullable', 'string', 'max:255', $referenceRule],
+ 'description' => 'nullable|string',
+ 'start_datetime' => 'nullable|date',
+ 'end_datetime' => 'nullable|date|after_or_equal:start_datetime',
+ 'organizer' => 'nullable|string|max:255',
+ 'location' => 'nullable|string|max:255',
+ 'is_active' => 'required|boolean',
+ 'documents' => 'nullable|array',
+ 'documents.*' => 'file|mimes:pdf,jpg,jpeg,png,doc,docx|max:10240',
+ 'gallery' => 'nullable|array',
+ 'gallery.*' => 'file|mimes:jpg,jpeg,png,webp,gif|max:10240',
+ ];
+ }
+
+ public function messages(): array
+ {
+ return [
+ 'activity_type_id.required' => 'Jenis aktiviti diperlukan.',
+ 'activity_type_id.exists' => 'Jenis aktiviti tidak sah.',
+ 'title.required' => 'Tajuk aktiviti diperlukan.',
+ 'title.max' => 'Tajuk aktiviti tidak boleh melebihi 255 aksara.',
+ 'reference_number.unique' => 'Nombor rujukan sudah wujud.',
+ 'end_datetime.after_or_equal' => 'Tarikh tamat mestilah selepas atau sama dengan tarikh mula.',
+ 'is_active.required' => 'Status aktif diperlukan.',
+ 'is_active.boolean' => 'Status aktif tidak sah.',
+ 'documents.*.file' => 'Dokumen mestilah fail yang sah.',
+ 'documents.*.mimes' => 'Format dokumen tidak disokong.',
+ 'documents.*.max' => 'Saiz dokumen melebihi had maksimum 10MB.',
+ 'gallery.*.file' => 'Imej galeri mestilah fail yang sah.',
+ 'gallery.*.mimes' => 'Format imej galeri tidak disokong. Gunakan jpg, jpeg, png, webp atau gif.',
+ 'gallery.*.max' => 'Saiz imej galeri melebihi had maksimum 10MB.',
+ ];
+ }
+}
diff --git a/be/Modules/Activity/Http/Requests/ActivityTypeRequest.php b/be/Modules/Activity/Http/Requests/ActivityTypeRequest.php
new file mode 100644
index 0000000..723dc2b
--- /dev/null
+++ b/be/Modules/Activity/Http/Requests/ActivityTypeRequest.php
@@ -0,0 +1,48 @@
+isMethod('POST');
+ $isUpdate = $this->isMethod('PUT') || $this->isMethod('PATCH');
+
+ if (! $isCreate && ! $isUpdate) {
+ return [
+ 'name' => 'nullable|string|max:255',
+ 'code' => 'nullable|string|max:255',
+ ];
+ }
+
+ $codeRule = Rule::unique('activity_types', 'code');
+
+ if ($isUpdate) {
+ $codeRule = $codeRule->ignore($this->route('activity_type'));
+ }
+
+ return [
+ 'name' => 'required|string|max:255',
+ 'code' => ['nullable', 'string', 'max:255', $codeRule],
+ ];
+ }
+
+ public function messages(): array
+ {
+ return [
+ 'name.required' => 'Nama jenis aktiviti diperlukan.',
+ 'name.max' => 'Nama jenis aktiviti tidak boleh melebihi 255 aksara.',
+ 'code.max' => 'Kod jenis aktiviti tidak boleh melebihi 255 aksara.',
+ 'code.unique' => 'Kod jenis aktiviti sudah wujud.',
+ ];
+ }
+}
diff --git a/be/Modules/Activity/Http/Requests/UploadActivityDocumentRequest.php b/be/Modules/Activity/Http/Requests/UploadActivityDocumentRequest.php
new file mode 100644
index 0000000..e715ab7
--- /dev/null
+++ b/be/Modules/Activity/Http/Requests/UploadActivityDocumentRequest.php
@@ -0,0 +1,39 @@
+ [
+ 'nullable',
+ 'string',
+ 'max:255',
+ Rule::notIn([Activity::GALLERY_DOCUMENT_TYPE]),
+ ],
+ 'description' => 'nullable|string|max:1000',
+ 'file' => 'required|file|mimes:pdf,jpg,jpeg,png,doc,docx|max:10240',
+ ];
+ }
+
+ public function messages(): array
+ {
+ return [
+ 'type.not_in' => 'Imej galeri mesti dimuat naik melalui endpoint galeri aktiviti.',
+ 'file.required' => 'Fail diperlukan.',
+ 'file.mimes' => 'Format fail tidak disokong.',
+ 'file.max' => 'Saiz fail melebihi had maksimum 10MB.',
+ ];
+ }
+}
diff --git a/be/Modules/Activity/Http/Requests/UploadActivityGalleryImageRequest.php b/be/Modules/Activity/Http/Requests/UploadActivityGalleryImageRequest.php
new file mode 100644
index 0000000..c8d21e1
--- /dev/null
+++ b/be/Modules/Activity/Http/Requests/UploadActivityGalleryImageRequest.php
@@ -0,0 +1,30 @@
+ 'required|file|mimes:jpg,jpeg,png,webp,gif|max:10240',
+ 'description' => 'nullable|string|max:1000',
+ ];
+ }
+
+ public function messages(): array
+ {
+ return [
+ 'file.required' => 'Imej diperlukan.',
+ 'file.mimes' => 'Format imej tidak disokong. Gunakan jpg, jpeg, png, webp atau gif.',
+ 'file.max' => 'Saiz imej melebihi had maksimum 10MB.',
+ ];
+ }
+}
diff --git a/be/Modules/Activity/Jobs/.gitkeep b/be/Modules/Activity/Jobs/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/Activity/Notifications/.gitkeep b/be/Modules/Activity/Notifications/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/Activity/Policies/.gitkeep b/be/Modules/Activity/Policies/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/Activity/Policies/ActivityPolicy.php b/be/Modules/Activity/Policies/ActivityPolicy.php
new file mode 100644
index 0000000..ac7a46b
--- /dev/null
+++ b/be/Modules/Activity/Policies/ActivityPolicy.php
@@ -0,0 +1,41 @@
+hasPermissionTo('lihat aktiviti');
+ }
+
+ public function view($user, ?Activity $activity = null): bool
+ {
+ return $user->hasPermissionTo('lihat aktiviti');
+ }
+
+ public function create($user): bool
+ {
+ return $user->hasPermissionTo('tambah aktiviti');
+ }
+
+ public function update($user, ?Activity $activity = null): bool
+ {
+ return $user->hasPermissionTo('kemaskini aktiviti');
+ }
+
+ public function deleteAny($user): bool
+ {
+ return $user->hasPermissionTo('hapus aktiviti');
+ }
+
+ public function delete($user, ?Activity $activity = null): bool
+ {
+ return $user->hasPermissionTo('hapus aktiviti');
+ }
+}
diff --git a/be/Modules/Activity/Policies/ActivityReportPolicy.php b/be/Modules/Activity/Policies/ActivityReportPolicy.php
new file mode 100644
index 0000000..a82f136
--- /dev/null
+++ b/be/Modules/Activity/Policies/ActivityReportPolicy.php
@@ -0,0 +1,41 @@
+hasPermissionTo('tambah laporan aktiviti');
+ }
+
+ public function update($user, ?ActivityReport $activityReport = null): bool
+ {
+ return $user->hasPermissionTo('kemaskini laporan aktiviti');
+ }
+
+ public function deleteAny($user): bool
+ {
+ return $user->hasPermissionTo('hapus laporan aktiviti');
+ }
+
+ public function delete($user, ?ActivityReport $activityReport = null): bool
+ {
+ return $user->hasPermissionTo('hapus laporan aktiviti');
+ }
+}
diff --git a/be/Modules/Activity/Policies/ActivityTypePolicy.php b/be/Modules/Activity/Policies/ActivityTypePolicy.php
new file mode 100644
index 0000000..4824517
--- /dev/null
+++ b/be/Modules/Activity/Policies/ActivityTypePolicy.php
@@ -0,0 +1,41 @@
+hasPermissionTo('tambah jenis aktiviti');
+ }
+
+ public function update($user, ?ActivityType $activityType = null): bool
+ {
+ return $user->hasPermissionTo('kemaskini jenis aktiviti');
+ }
+
+ public function deleteAny($user): bool
+ {
+ return $user->hasPermissionTo('hapus jenis aktiviti');
+ }
+
+ public function delete($user, ?ActivityType $activityType = null): bool
+ {
+ return $user->hasPermissionTo('hapus jenis aktiviti');
+ }
+}
diff --git a/be/Modules/Activity/Providers/.gitkeep b/be/Modules/Activity/Providers/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/Activity/Providers/ActivityServiceProvider.php b/be/Modules/Activity/Providers/ActivityServiceProvider.php
new file mode 100644
index 0000000..d41dab5
--- /dev/null
+++ b/be/Modules/Activity/Providers/ActivityServiceProvider.php
@@ -0,0 +1,184 @@
+registerCommands();
+ $this->registerCommandSchedules();
+ $this->registerTranslations();
+ $this->registerConfig();
+ $this->registerViews();
+ $this->loadMigrationsFrom(module_path($this->name, 'Database/Migrations'));
+ $this->registerPolicies();
+ }
+
+ /**
+ * Register the service provider.
+ */
+ public function register(): void
+ {
+ $this->app->register(EventServiceProvider::class);
+ $this->app->register(RouteServiceProvider::class);
+
+ $this->app->bind(
+ \Modules\Activity\Repositories\Contracts\ActivityTypeRepositoryInterface::class,
+ \Modules\Activity\Repositories\ActivityTypeRepository::class
+ );
+
+ $this->app->bind(
+ \Modules\Activity\Repositories\Contracts\ActivityRepositoryInterface::class,
+ \Modules\Activity\Repositories\ActivityRepository::class
+ );
+
+ $this->app->bind(
+ \Modules\Activity\Repositories\Contracts\ActivityReportRepositoryInterface::class,
+ \Modules\Activity\Repositories\ActivityReportRepository::class
+ );
+ }
+
+ protected function registerPolicies(): void
+ {
+ Gate::policy(ActivityType::class, ActivityTypePolicy::class);
+ Gate::policy(Activity::class, ActivityPolicy::class);
+ Gate::policy(ActivityReport::class, ActivityReportPolicy::class);
+ }
+
+ /**
+ * Register commands in the format of Command::class
+ */
+ protected function registerCommands(): void
+ {
+ // $this->commands([]);
+ }
+
+ /**
+ * Register command Schedules.
+ */
+ protected function registerCommandSchedules(): void
+ {
+ // $this->app->booted(function () {
+ // $schedule = $this->app->make(Schedule::class);
+ // $schedule->command('inspire')->hourly();
+ // });
+ }
+
+ /**
+ * Register translations.
+ */
+ public function registerTranslations(): void
+ {
+ $langPath = resource_path('lang/modules/'.$this->nameLower);
+
+ if (is_dir($langPath)) {
+ $this->loadTranslationsFrom($langPath, $this->nameLower);
+ $this->loadJsonTranslationsFrom($langPath);
+ } else {
+ $this->loadTranslationsFrom(module_path($this->name, 'Lang'), $this->nameLower);
+ $this->loadJsonTranslationsFrom(module_path($this->name, 'Lang'));
+ }
+ }
+
+ /**
+ * Register config.
+ */
+ protected function registerConfig(): void
+ {
+ $configPath = module_path($this->name, config('modules.paths.generator.config.path'));
+
+ if (is_dir($configPath)) {
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($configPath));
+
+ foreach ($iterator as $file) {
+ if ($file->isFile() && $file->getExtension() === 'php') {
+ $config = str_replace($configPath.DIRECTORY_SEPARATOR, '', $file->getPathname());
+ $config_key = str_replace([DIRECTORY_SEPARATOR, '.php'], ['.', ''], $config);
+ $segments = explode('.', $this->nameLower.'.'.$config_key);
+
+ // Remove duplicated adjacent segments
+ $normalized = [];
+ foreach ($segments as $segment) {
+ if (end($normalized) !== $segment) {
+ $normalized[] = $segment;
+ }
+ }
+
+ $key = ($config === 'config.php') ? $this->nameLower : implode('.', $normalized);
+
+ $this->publishes([$file->getPathname() => config_path($config)], 'config');
+ $this->merge_config_from($file->getPathname(), $key);
+ }
+ }
+ }
+ }
+
+ /**
+ * Merge config from the given path recursively.
+ */
+ protected function merge_config_from(string $path, string $key): void
+ {
+ $existing = config($key, []);
+ $module_config = require $path;
+
+ config([$key => array_replace_recursive($existing, $module_config)]);
+ }
+
+ /**
+ * Register views.
+ */
+ public function registerViews(): void
+ {
+ $viewPath = resource_path('views/modules/'.$this->nameLower);
+ $sourcePath = module_path($this->name, 'Resources/Views');
+
+ $this->publishes([$sourcePath => $viewPath], ['views', $this->nameLower.'-module-views']);
+
+ $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), $this->nameLower);
+
+ Blade::componentNamespace(config('modules.namespace').'\\'.$this->name.'\\View\\Components', $this->nameLower);
+ }
+
+ /**
+ * Get the services provided by the provider.
+ */
+ public function provides(): array
+ {
+ return [];
+ }
+
+ private function getPublishableViewPaths(): array
+ {
+ $paths = [];
+ foreach (config('view.paths') as $path) {
+ if (is_dir($path.'/modules/'.$this->nameLower)) {
+ $paths[] = $path.'/modules/'.$this->nameLower;
+ }
+ }
+
+ return $paths;
+ }
+}
diff --git a/be/Modules/Activity/Providers/EventServiceProvider.php b/be/Modules/Activity/Providers/EventServiceProvider.php
new file mode 100644
index 0000000..67ff054
--- /dev/null
+++ b/be/Modules/Activity/Providers/EventServiceProvider.php
@@ -0,0 +1,27 @@
+>
+ */
+ protected $listen = [];
+
+ /**
+ * Indicates if events should be discovered.
+ *
+ * @var bool
+ */
+ protected static $shouldDiscoverEvents = true;
+
+ /**
+ * Configure the proper event listeners for email verification.
+ */
+ protected function configureEmailVerification(): void {}
+}
diff --git a/be/Modules/Activity/Providers/RouteServiceProvider.php b/be/Modules/Activity/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..f2d7d5e
--- /dev/null
+++ b/be/Modules/Activity/Providers/RouteServiceProvider.php
@@ -0,0 +1,50 @@
+mapApiRoutes();
+
+ }
+
+ /**
+ * Define the "web" routes for the application.
+ *
+ * These routes all receive session state, CSRF protection, etc.
+ */
+ protected function mapWebRoutes(): void
+ {
+ Route::middleware('web')->group(module_path($this->name, '/Routes/web.php'));
+ }
+
+ /**
+ * Define the "api" routes for the application.
+ *
+ * These routes are typically stateless.
+ */
+ protected function mapApiRoutes(): void
+ {
+ Route::middleware('api')->name('api.')->group(module_path($this->name, '/Routes/api.php'));
+ }
+}
diff --git a/be/Modules/Activity/Repositories/.gitkeep b/be/Modules/Activity/Repositories/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/Activity/Repositories/ActivityReportRepository.php b/be/Modules/Activity/Repositories/ActivityReportRepository.php
new file mode 100644
index 0000000..54e2458
--- /dev/null
+++ b/be/Modules/Activity/Repositories/ActivityReportRepository.php
@@ -0,0 +1,98 @@
+with($this->defaultRelations)
+ ->orderBy('created_at', 'desc');
+ }
+
+ protected function applySearch($query, string $search)
+ {
+ if ($search !== '') {
+ $query->where(function ($q) use ($search) {
+ $q->where('report_text', 'ILIKE', "%{$search}%")
+ ->orWhereHas('activity', function ($activityQuery) use ($search) {
+ $activityQuery->where('title', 'ILIKE', "%{$search}%");
+ });
+ });
+ }
+
+ return $query;
+ }
+
+ public function getAllPaginated(int $perPage = 10, string $search = '')
+ {
+ $query = $this->baseQuery();
+ $this->applySearch($query, $search);
+
+ return $query->paginate($perPage);
+ }
+
+ public function getAllWithRelationsPaginated(
+ int $perPage = 10,
+ string $search = '',
+ string $sortBy = 'created_at',
+ string $sortOrder = 'desc'
+ ) {
+ $allowedSortColumns = ['id', 'report_text', 'created_at'];
+ $sortBy = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'created_at';
+ $sortOrder = strtolower($sortOrder) === 'desc' ? 'desc' : 'asc';
+
+ $query = ActivityReport::query()
+ ->with($this->defaultRelations)
+ ->orderBy($sortBy, $sortOrder);
+ $this->applySearch($query, $search);
+
+ return $query->paginate($perPage);
+ }
+
+ public function getAllWithRelations(string $search = ''): Collection
+ {
+ $query = $this->baseQuery();
+ $this->applySearch($query, $search);
+
+ return $query->get();
+ }
+
+ public function create(array $data): ActivityReport
+ {
+ return ActivityReport::create($data);
+ }
+
+ public function findById(string $id): ?ActivityReport
+ {
+ return ActivityReport::query()
+ ->with($this->defaultRelations)
+ ->find($id);
+ }
+
+ public function delete(string $id): bool
+ {
+ $report = ActivityReport::query()->find($id);
+
+ if ($report) {
+ return $report->delete();
+ }
+
+ return false;
+ }
+
+ public function all(string $search = ''): Collection
+ {
+ $query = $this->baseQuery();
+ $this->applySearch($query, $search);
+
+ return $query->get();
+ }
+}
diff --git a/be/Modules/Activity/Repositories/ActivityRepository.php b/be/Modules/Activity/Repositories/ActivityRepository.php
new file mode 100644
index 0000000..d45617d
--- /dev/null
+++ b/be/Modules/Activity/Repositories/ActivityRepository.php
@@ -0,0 +1,151 @@
+with($this->defaultRelations)
+ ->orderBy('start_datetime', 'desc');
+ }
+
+ protected function applySearch($query, string $search)
+ {
+ if ($search !== '') {
+ $query->where(function ($q) use ($search) {
+ $q->where('title', 'ILIKE', "%{$search}%")
+ ->orWhere('reference_number', 'ILIKE', "%{$search}%")
+ ->orWhere('organizer', 'ILIKE', "%{$search}%")
+ ->orWhere('location', 'ILIKE', "%{$search}%");
+ });
+ }
+
+ return $query;
+ }
+
+ public function getAllPaginated(int $perPage = 10, string $search = '')
+ {
+ $query = $this->baseQuery();
+ $this->applySearch($query, $search);
+
+ return $query->paginate($perPage);
+ }
+
+ public function getAllWithRelationsPaginated(
+ int $perPage = 10,
+ string $search = '',
+ string $sortBy = 'title',
+ string $sortOrder = 'asc'
+ ) {
+ $allowedSortColumns = [
+ 'id',
+ 'title',
+ 'reference_number',
+ 'start_datetime',
+ 'end_datetime',
+ 'organizer',
+ 'location',
+ 'is_active',
+ 'created_at',
+ ];
+ $sortBy = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'start_datetime';
+ $sortOrder = strtolower($sortOrder) === 'desc' ? 'desc' : 'asc';
+
+ $query = Activity::query()
+ ->with($this->defaultRelations)
+ ->orderBy($sortBy, $sortOrder);
+ $this->applySearch($query, $search);
+
+ return $query->paginate($perPage);
+ }
+
+ public function getIndexPaginated(
+ int $perPage = 10,
+ string $search = '',
+ string $sortBy = 'start_datetime',
+ string $sortOrder = 'desc'
+ ) {
+ $allowedSortColumns = [
+ 'id',
+ 'title',
+ 'reference_number',
+ 'start_datetime',
+ 'end_datetime',
+ 'organizer',
+ 'location',
+ 'is_active',
+ 'created_at',
+ ];
+ $sortBy = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'start_datetime';
+ $sortOrder = strtolower($sortOrder) === 'desc' ? 'desc' : 'asc';
+
+ $query = Activity::query()
+ ->with([
+ 'activityType',
+ 'galleryImages' => fn ($q) => $q->orderBy('created_at')->limit(1),
+ ])
+ ->withCount(['galleryImages', 'attachments'])
+ ->orderBy($sortBy, $sortOrder);
+ $this->applySearch($query, $search);
+
+ return $query->paginate($perPage);
+ }
+
+ public function getAllWithRelations(string $search = ''): Collection
+ {
+ $query = $this->baseQuery();
+ $this->applySearch($query, $search);
+
+ return $query->get();
+ }
+
+ public function create(array $data): Activity
+ {
+ return Activity::create($data);
+ }
+
+ public function findById(string $id): ?Activity
+ {
+ return Activity::query()->find($id);
+ }
+
+ public function findByIdWithRelations(string $id): ?Activity
+ {
+ return Activity::query()
+ ->with($this->detailRelations)
+ ->find($id);
+ }
+
+ public function delete(string $id): bool
+ {
+ $activity = $this->findById($id);
+
+ if ($activity) {
+ return $activity->delete();
+ }
+
+ return false;
+ }
+
+ public function all(string $search = ''): Collection
+ {
+ $query = $this->baseQuery();
+ $this->applySearch($query, $search);
+
+ return $query->get();
+ }
+}
diff --git a/be/Modules/Activity/Repositories/ActivityTypeRepository.php b/be/Modules/Activity/Repositories/ActivityTypeRepository.php
new file mode 100644
index 0000000..8d79d89
--- /dev/null
+++ b/be/Modules/Activity/Repositories/ActivityTypeRepository.php
@@ -0,0 +1,88 @@
+orderBy('name');
+ }
+
+ protected function applySearch($query, string $search)
+ {
+ if ($search !== '') {
+ $query->where(function ($q) use ($search) {
+ $q->where('name', 'ILIKE', "%{$search}%")
+ ->orWhere('code', 'ILIKE', "%{$search}%");
+ });
+ }
+
+ return $query;
+ }
+
+ public function getAllPaginated(int $perPage = 10, string $search = '')
+ {
+ $query = $this->baseQuery();
+ $this->applySearch($query, $search);
+
+ return $query->paginate($perPage);
+ }
+
+ public function getAllWithRelationsPaginated(
+ int $perPage = 10,
+ string $search = '',
+ string $sortBy = 'name',
+ string $sortOrder = 'asc'
+ ) {
+ $allowedSortColumns = ['id', 'name', 'code', 'created_at'];
+ $sortBy = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'name';
+ $sortOrder = strtolower($sortOrder) === 'desc' ? 'desc' : 'asc';
+
+ $query = ActivityType::query()->orderBy($sortBy, $sortOrder);
+ $this->applySearch($query, $search);
+
+ return $query->paginate($perPage);
+ }
+
+ public function getAllWithRelations(string $search = ''): Collection
+ {
+ $query = $this->baseQuery();
+ $this->applySearch($query, $search);
+
+ return $query->get();
+ }
+
+ public function create(array $data): ActivityType
+ {
+ return ActivityType::create($data);
+ }
+
+ public function findById(string $id): ?ActivityType
+ {
+ return ActivityType::query()->find($id);
+ }
+
+ public function delete(string $id): bool
+ {
+ $activityType = $this->findById($id);
+
+ if ($activityType) {
+ return $activityType->delete();
+ }
+
+ return false;
+ }
+
+ public function all(string $search = ''): Collection
+ {
+ $query = $this->baseQuery();
+ $this->applySearch($query, $search);
+
+ return $query->get();
+ }
+}
diff --git a/be/Modules/Activity/Repositories/Contracts/.gitkeep b/be/Modules/Activity/Repositories/Contracts/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/Activity/Repositories/Contracts/ActivityReportRepositoryInterface.php b/be/Modules/Activity/Repositories/Contracts/ActivityReportRepositoryInterface.php
new file mode 100644
index 0000000..a771c18
--- /dev/null
+++ b/be/Modules/Activity/Repositories/Contracts/ActivityReportRepositoryInterface.php
@@ -0,0 +1,28 @@
+prefix('v1')->group(function () {
+ Route::apiResource('activity-types', ActivityTypeController::class)->names('activity-type');
+ Route::apiResource('activities', ActivityController::class)->names('activity');
+
+ // gallery images
+ Route::post('activities/{activity}/gallery', [ActivityController::class, 'uploadGalleryImage'])->name('activity.upload-gallery-image');
+ Route::get('activities/{activity}/gallery/{imageId}', [ActivityController::class, 'serveGalleryImage'])->name('activity.serve-gallery-image');
+ Route::delete('activities/{activity}/gallery/{imageId}', [ActivityController::class, 'deleteGalleryImage'])->name('activity.delete-gallery-image');
+
+ // documents
+ Route::post('activities/{activity}/documents', [ActivityController::class, 'uploadDocument'])->name('activity.upload-document');
+ Route::get('activities/{activity}/documents/{documentId}/download', [ActivityController::class, 'downloadDocument'])->name('activity.download-document');
+ Route::delete('activities/{activity}/documents/{documentId}', [ActivityController::class, 'deleteDocument'])->name('activity.delete-document');
+
+ // reports
+ Route::apiResource('activity-reports', ActivityReportController::class)->names('activity-report');
+});
diff --git a/be/Modules/Activity/Routes/web.php b/be/Modules/Activity/Routes/web.php
new file mode 100644
index 0000000..31f6ce8
--- /dev/null
+++ b/be/Modules/Activity/Routes/web.php
@@ -0,0 +1,8 @@
+group(function () {
+ Route::resource('activities', ActivityController::class)->names('activity');
+});
diff --git a/be/Modules/Activity/Services/.gitkeep b/be/Modules/Activity/Services/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/Activity/Services/ActivityService.php b/be/Modules/Activity/Services/ActivityService.php
new file mode 100644
index 0000000..d47c76b
--- /dev/null
+++ b/be/Modules/Activity/Services/ActivityService.php
@@ -0,0 +1,140 @@
+ $data
+ * @param array|array $documents
+ * @param array $galleryImages
+ */
+ public function create(array $data, array $documents = [], array $galleryImages = []): Activity
+ {
+ return DB::transaction(function () use ($data, $documents, $galleryImages) {
+ $activity = $this->repository->create($data);
+
+ $this->uploadDocuments($activity, $documents);
+ $this->uploadGalleryImages($activity, $galleryImages);
+
+ return $this->repository->findByIdWithRelations($activity->id);
+ });
+ }
+
+ /**
+ * @param array $data
+ * @param array|array $documents
+ * @param array $galleryImages
+ */
+ public function update(Activity $activity, array $data, array $documents = [], array $galleryImages = []): Activity
+ {
+ return DB::transaction(function () use ($activity, $data, $documents, $galleryImages) {
+ $activity->update($data);
+
+ $this->uploadDocuments($activity, $documents);
+ $this->uploadGalleryImages($activity, $galleryImages);
+
+ return $this->repository->findByIdWithRelations($activity->id);
+ });
+ }
+
+ public function uploadDocument(
+ Activity $activity,
+ UploadedFile $file,
+ string $type = 'general',
+ ?string $description = null
+ ): Activity {
+ if ($type === Activity::GALLERY_DOCUMENT_TYPE) {
+ throw new Exception('Imej galeri mesti dimuat naik melalui endpoint galeri aktiviti.');
+ }
+
+ $this->documentService->validateFile($file);
+ $this->documentService->uploadDocument($activity, $file, $type, $description);
+
+ return $this->repository->findByIdWithRelations($activity->id);
+ }
+
+ public function uploadGalleryImage(
+ Activity $activity,
+ UploadedFile $file,
+ ?string $description = null
+ ): Activity {
+ $this->validateGalleryImage($file);
+ $this->documentService->uploadDocument(
+ $activity,
+ $file,
+ Activity::GALLERY_DOCUMENT_TYPE,
+ $description
+ );
+
+ return $this->repository->findByIdWithRelations($activity->id);
+ }
+
+ /**
+ * @param array|array $documents
+ */
+ protected function uploadDocuments(Activity $activity, array $documents): void
+ {
+ foreach ($documents as $key => $file) {
+ if (! $file instanceof UploadedFile) {
+ continue;
+ }
+
+ $documentType = is_string($key) ? $key : 'general';
+
+ if ($documentType === Activity::GALLERY_DOCUMENT_TYPE) {
+ $this->validateGalleryImage($file);
+ $this->documentService->uploadDocument($activity, $file, Activity::GALLERY_DOCUMENT_TYPE);
+
+ continue;
+ }
+
+ $this->documentService->validateFile($file);
+ $this->documentService->uploadDocument($activity, $file, $documentType);
+ }
+ }
+
+ /**
+ * @param array $galleryImages
+ */
+ protected function uploadGalleryImages(Activity $activity, array $galleryImages): void
+ {
+ foreach ($galleryImages as $file) {
+ if (! $file instanceof UploadedFile) {
+ continue;
+ }
+
+ $this->validateGalleryImage($file);
+ $this->documentService->uploadDocument(
+ $activity,
+ $file,
+ Activity::GALLERY_DOCUMENT_TYPE
+ );
+ }
+ }
+
+ protected function validateGalleryImage(UploadedFile $file): void
+ {
+ $validator = Validator::make(
+ ['file' => $file],
+ ['file' => 'required|file|mimes:jpg,jpeg,png,webp,gif|max:10240']
+ );
+
+ if ($validator->fails()) {
+ throw new Exception($validator->errors()->first('file'));
+ }
+ }
+}
diff --git a/be/Modules/Activity/Tests/Feature/.gitkeep b/be/Modules/Activity/Tests/Feature/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/Activity/Tests/Unit/.gitkeep b/be/Modules/Activity/Tests/Unit/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/Activity/Transformers/.gitkeep b/be/Modules/Activity/Transformers/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/Activity/Transformers/ActivityDocumentResource.php b/be/Modules/Activity/Transformers/ActivityDocumentResource.php
new file mode 100644
index 0000000..9d436d5
--- /dev/null
+++ b/be/Modules/Activity/Transformers/ActivityDocumentResource.php
@@ -0,0 +1,32 @@
+documentable_id;
+
+ return [
+ 'id' => $this->id,
+ 'activity_id' => $activityId,
+ 'name' => $this->name,
+ 'type' => $this->type,
+ 'description' => $this->description,
+ 'mime_type' => $this->mime_type,
+ 'file_size' => $this->file_size,
+ 'uploaded_by' => $this->uploaded_by,
+ 'url' => $activityId
+ ? url(route('api.activity.download-document', [
+ 'activity' => $activityId,
+ 'documentId' => $this->id,
+ ]))
+ : null,
+ 'created_at' => $this->created_at?->toISOString(),
+ ];
+ }
+}
diff --git a/be/Modules/Activity/Transformers/ActivityGalleryImageResource.php b/be/Modules/Activity/Transformers/ActivityGalleryImageResource.php
new file mode 100644
index 0000000..df3e4e9
--- /dev/null
+++ b/be/Modules/Activity/Transformers/ActivityGalleryImageResource.php
@@ -0,0 +1,31 @@
+documentable_id;
+
+ return [
+ 'id' => $this->id,
+ 'activity_id' => $activityId,
+ 'name' => $this->name,
+ 'description' => $this->description,
+ 'mime_type' => $this->mime_type,
+ 'file_size' => $this->file_size,
+ 'uploaded_by' => $this->uploaded_by,
+ 'url' => $activityId
+ ? url(route('api.activity.serve-gallery-image', [
+ 'activity' => $activityId,
+ 'imageId' => $this->id,
+ ]))
+ : null,
+ 'created_at' => $this->created_at?->toISOString(),
+ ];
+ }
+}
diff --git a/be/Modules/Activity/Transformers/ActivityListResource.php b/be/Modules/Activity/Transformers/ActivityListResource.php
new file mode 100644
index 0000000..8c47d33
--- /dev/null
+++ b/be/Modules/Activity/Transformers/ActivityListResource.php
@@ -0,0 +1,34 @@
+ $this->id,
+ 'activity_type_id' => $this->activity_type_id,
+ 'activity_type' => new ActivityTypeResource($this->whenLoaded('activityType')),
+ 'title' => $this->title,
+ 'reference_number' => $this->reference_number,
+ 'description' => $this->description,
+ 'start_datetime' => $this->start_datetime?->toISOString(),
+ 'end_datetime' => $this->end_datetime?->toISOString(),
+ 'organizer' => $this->organizer,
+ 'location' => $this->location,
+ 'is_active' => $this->is_active,
+ 'gallery_image_count' => $this->gallery_images_count ?? 0,
+ 'documents_count' => $this->attachments_count ?? 0,
+ 'cover_image' => $this->when(
+ $this->relationLoaded('galleryImages') && $this->galleryImages->isNotEmpty(),
+ fn () => new ActivityGalleryImageResource($this->galleryImages->first())
+ ),
+ 'created_at' => $this->created_at?->toISOString(),
+ 'updated_at' => $this->updated_at?->toISOString(),
+ ];
+ }
+}
diff --git a/be/Modules/Activity/Transformers/ActivityReportResource.php b/be/Modules/Activity/Transformers/ActivityReportResource.php
new file mode 100644
index 0000000..cc19c9b
--- /dev/null
+++ b/be/Modules/Activity/Transformers/ActivityReportResource.php
@@ -0,0 +1,27 @@
+ $this->id,
+ 'activity_id' => $this->activity_id,
+ 'activity' => new ActivityResource($this->whenLoaded('activity')),
+ 'report_text' => $this->report_text,
+ 'prepared_by' => $this->prepared_by,
+ 'prepared_by_user' => $this->whenLoaded('preparedBy', fn () => [
+ 'id' => $this->preparedBy->id,
+ 'name' => $this->preparedBy->name,
+ 'email' => $this->preparedBy->email,
+ ]),
+ 'created_at' => $this->created_at?->toISOString(),
+ 'updated_at' => $this->updated_at?->toISOString(),
+ ];
+ }
+}
diff --git a/be/Modules/Activity/Transformers/ActivityResource.php b/be/Modules/Activity/Transformers/ActivityResource.php
new file mode 100644
index 0000000..f2f850b
--- /dev/null
+++ b/be/Modules/Activity/Transformers/ActivityResource.php
@@ -0,0 +1,52 @@
+ $this->id,
+ 'activity_type_id' => $this->activity_type_id,
+ 'activity_type' => new ActivityTypeResource($this->whenLoaded('activityType')),
+ 'title' => $this->title,
+ 'reference_number' => $this->reference_number,
+ 'description' => $this->description,
+ 'start_datetime' => $this->start_datetime?->toISOString(),
+ 'end_datetime' => $this->end_datetime?->toISOString(),
+ 'organizer' => $this->organizer,
+ 'location' => $this->location,
+ 'is_active' => $this->is_active,
+ 'documents' => $this->when(
+ $this->relationLoaded('documents'),
+ fn () => ActivityDocumentResource::collection($this->attachmentDocuments())
+ ),
+ 'gallery_images' => $this->when(
+ $this->relationLoaded('documents'),
+ fn () => ActivityGalleryImageResource::collection($this->galleryDocuments())
+ ),
+ 'reports' => ActivityReportResource::collection($this->whenLoaded('reports')),
+ 'created_at' => $this->created_at?->toISOString(),
+ 'updated_at' => $this->updated_at?->toISOString(),
+ ];
+ }
+
+ protected function galleryDocuments()
+ {
+ return $this->documents
+ ->where('type', Activity::GALLERY_DOCUMENT_TYPE)
+ ->values();
+ }
+
+ protected function attachmentDocuments()
+ {
+ return $this->documents
+ ->where('type', '!=', Activity::GALLERY_DOCUMENT_TYPE)
+ ->values();
+ }
+}
diff --git a/be/Modules/Activity/Transformers/ActivityTypeResource.php b/be/Modules/Activity/Transformers/ActivityTypeResource.php
new file mode 100644
index 0000000..ca6d008
--- /dev/null
+++ b/be/Modules/Activity/Transformers/ActivityTypeResource.php
@@ -0,0 +1,20 @@
+ $this->id,
+ 'name' => $this->name,
+ 'code' => $this->code,
+ 'created_at' => $this->created_at?->toISOString(),
+ 'updated_at' => $this->updated_at?->toISOString(),
+ ];
+ }
+}
diff --git a/be/Modules/Activity/composer.json b/be/Modules/Activity/composer.json
new file mode 100644
index 0000000..fb29613
--- /dev/null
+++ b/be/Modules/Activity/composer.json
@@ -0,0 +1,30 @@
+{
+ "name": "nwidart/activity",
+ "description": "",
+ "authors": [
+ {
+ "name": "Nicolas Widart",
+ "email": "n.widart@gmail.com"
+ }
+ ],
+ "extra": {
+ "laravel": {
+ "providers": [],
+ "aliases": {
+
+ }
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Modules\\Activity\\": "App",
+ "Modules\\Activity\\Database\\Factories\\": "database/factories/",
+ "Modules\\Activity\\Database\\Seeders\\": "database/seeders/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Modules\\Activity\\Tests\\": "tests/"
+ }
+ }
+}
diff --git a/be/Modules/Activity/module.json b/be/Modules/Activity/module.json
new file mode 100644
index 0000000..cdb201d
--- /dev/null
+++ b/be/Modules/Activity/module.json
@@ -0,0 +1,11 @@
+{
+ "name": "Activity",
+ "alias": "activity",
+ "description": "",
+ "keywords": [],
+ "priority": 0,
+ "providers": [
+ "Modules\\Activity\\Providers\\ActivityServiceProvider"
+ ],
+ "files": []
+}
diff --git a/be/Modules/Activity/package.json b/be/Modules/Activity/package.json
new file mode 100644
index 0000000..d6fbfc8
--- /dev/null
+++ b/be/Modules/Activity/package.json
@@ -0,0 +1,15 @@
+{
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build"
+ },
+ "devDependencies": {
+ "axios": "^1.1.2",
+ "laravel-vite-plugin": "^0.7.5",
+ "sass": "^1.69.5",
+ "postcss": "^8.3.7",
+ "vite": "^4.0.0"
+ }
+}
diff --git a/be/Modules/Auth/Emails/EmailVerificationOtpEmail.php b/be/Modules/Auth/Emails/EmailVerificationOtpEmail.php
index 3dcb338..2dd14a8 100644
--- a/be/Modules/Auth/Emails/EmailVerificationOtpEmail.php
+++ b/be/Modules/Auth/Emails/EmailVerificationOtpEmail.php
@@ -2,12 +2,14 @@
namespace Modules\Auth\Emails;
+use App\Notifications\Concerns\BuildsMailMessage;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class EmailVerificationOtpEmail extends Notification
{
+ use BuildsMailMessage;
use Queueable;
public function __construct(
@@ -23,13 +25,14 @@ class EmailVerificationOtpEmail extends Notification
{
$minutes = (int) config('auth.email_verification.expiry_minutes', 10);
- return (new MailMessage)
- ->subject('Pengesahan E-mel - Kod OTP')
- ->markdown('auth::emails.verification-otp', [
+ return $this->mailMessage(
+ subject: 'Pengesahan E-mel - Kod OTP',
+ view: 'auth::emails.verification-otp',
+ data: [
'name' => $notifiable->name,
'otp' => $this->otp,
'minutes' => $minutes,
- 'logoPath' => public_path('images/logo-kopkb.svg'),
- ]);
+ ],
+ );
}
}
diff --git a/be/Modules/Auth/Emails/PasswordResetOtpEmail.php b/be/Modules/Auth/Emails/PasswordResetOtpEmail.php
new file mode 100644
index 0000000..154063c
--- /dev/null
+++ b/be/Modules/Auth/Emails/PasswordResetOtpEmail.php
@@ -0,0 +1,38 @@
+mailMessage(
+ subject: 'Reset Kata Laluan - Kod OTP',
+ view: 'auth::emails.password-reset-otp',
+ data: [
+ 'name' => $notifiable->name,
+ 'otp' => $this->otp,
+ 'minutes' => $minutes,
+ ],
+ );
+ }
+}
diff --git a/be/Modules/Auth/Entities/PasswordResetOtp.php b/be/Modules/Auth/Entities/PasswordResetOtp.php
new file mode 100644
index 0000000..74fa260
--- /dev/null
+++ b/be/Modules/Auth/Entities/PasswordResetOtp.php
@@ -0,0 +1,44 @@
+ 'datetime',
+ 'attempts' => 'integer',
+ ];
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public function isExpired(): bool
+ {
+ return $this->expires_at->isPast();
+ }
+
+ public function hasExceededMaxAttempts(): bool
+ {
+ return $this->attempts >= (int) config('auth.password_reset.max_attempts', 5);
+ }
+}
diff --git a/be/Modules/Auth/Entities/User.php b/be/Modules/Auth/Entities/User.php
index 28ff4c1..3c094b5 100644
--- a/be/Modules/Auth/Entities/User.php
+++ b/be/Modules/Auth/Entities/User.php
@@ -16,14 +16,17 @@ use Spatie\Permission\PermissionRegistrar;
use Spatie\Permission\Traits\HasPermissions;
use Spatie\Permission\Traits\HasRoles;
use App\Services\ActiveRoleService;
-use App\Traits\HasVisibility;
use Lab404\Impersonate\Models\Impersonate;
use Modules\Role\Entities\Role;
+use Modules\User\Entities\Address;
+use Modules\User\Entities\BankDetail;
+use Modules\User\Entities\Employment;
+use Modules\User\Entities\Heir;
class User extends Authenticatable
{
/** @use HasFactory<\Modules\Auth\Database\Factories\UserFactory> */
- use HasApiTokens, HasFactory, HasPermissions, HasRoles, HasUuids, Impersonate, LogsActivity, Notifiable, SoftDeletes, HasVisibility;
+ use HasApiTokens, HasFactory, HasPermissions, HasRoles, HasUuids, Impersonate, LogsActivity, Notifiable, SoftDeletes;
protected $table = 'users';
@@ -44,6 +47,13 @@ class User extends Authenticatable
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
+ 'gender',
+ 'marriage_status',
+ 'member_number',
+ 'member_type',
+ 'join_date',
+ 'birth_date',
+ 'birth_place',
];
/**
@@ -70,6 +80,13 @@ class User extends Authenticatable
'email_verified_at' => 'datetime',
'password' => 'hashed',
'status' => 'string',
+ 'gender' => 'string',
+ 'marriage_status' => 'string',
+ 'member_number' => 'integer',
+ 'member_type' => 'string',
+ 'join_date' => 'date',
+ 'birth_date' => 'date',
+ 'birth_place' => 'string',
];
}
@@ -82,6 +99,26 @@ class User extends Authenticatable
return ActiveRoleService::getActiveRole($this);
}
+ public function addresses()
+ {
+ return $this->hasMany(Address::class);
+ }
+
+ public function employments()
+ {
+ return $this->hasMany(Employment::class);
+ }
+
+ public function bankDetails()
+ {
+ return $this->hasMany(BankDetail::class);
+ }
+
+ public function heirs()
+ {
+ return $this->hasMany(Heir::class);
+ }
+
public function hasPermissionAction($action): bool
{
$role = $this->activeRole();
diff --git a/be/Modules/Auth/Http/Controllers/PasswordResetController.php b/be/Modules/Auth/Http/Controllers/PasswordResetController.php
new file mode 100644
index 0000000..ffd5b20
--- /dev/null
+++ b/be/Modules/Auth/Http/Controllers/PasswordResetController.php
@@ -0,0 +1,63 @@
+validate([
+ 'email' => ['required', 'string', 'email'],
+ ]);
+
+ $user = User::where('email', $validated['email'])->first();
+
+ if ($user) {
+ $this->passwordResetOtpService->send($user);
+ }
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Jika e-mel wujud dalam sistem, kod OTP untuk reset kata laluan telah dihantar.',
+ ]);
+ }
+
+ public function reset(Request $request): JsonResponse
+ {
+ $validated = $request->validate([
+ 'email' => ['required', 'string', 'email'],
+ 'otp' => ['required', 'string', 'digits:6'],
+ 'password' => ['required', 'string', 'confirmed'],
+ ]);
+
+ $user = User::where('email', $validated['email'])->first();
+
+ if (! $user) {
+ return response()->json([
+ 'success' => false,
+ 'message' => 'Kod OTP tidak sah.',
+ ], 422);
+ }
+
+ $this->passwordResetOtpService->reset(
+ $user,
+ $validated['otp'],
+ $request->only(['password', 'password_confirmation']),
+ );
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Kata laluan anda telah berjaya ditetapkan semula. Sila log masuk.',
+ ]);
+ }
+}
diff --git a/be/Modules/Auth/Routes/api.php b/be/Modules/Auth/Routes/api.php
index 21d38a3..ea3eb18 100644
--- a/be/Modules/Auth/Routes/api.php
+++ b/be/Modules/Auth/Routes/api.php
@@ -2,6 +2,7 @@
use Illuminate\Support\Facades\Route;
use Modules\Auth\Http\Controllers\EmailVerificationController;
+use Modules\Auth\Http\Controllers\PasswordResetController;
use Modules\Auth\Http\Controllers\SessionController;
use Laravel\Fortify\Http\Controllers\AuthenticatedSessionController;
use Laravel\Fortify\Http\Controllers\RegisteredUserController;
@@ -15,6 +16,11 @@ Route::post('/verify-email', [EmailVerificationController::class, 'verify'])
Route::post('/verify-email/resend', [EmailVerificationController::class, 'resend'])
->middleware('throttle:email-verification-resend');
+Route::post('/forgot-password', [PasswordResetController::class, 'requestOtp'])
+ ->middleware('throttle:password-reset-request');
+Route::post('/reset-password', [PasswordResetController::class, 'reset'])
+ ->middleware('throttle:password-reset');
+
Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
// get current user
Route::get('/me', [SessionController::class, 'currentUser']);
diff --git a/be/Modules/Auth/Services/PasswordResetOtpService.php b/be/Modules/Auth/Services/PasswordResetOtpService.php
new file mode 100644
index 0000000..0fc8017
--- /dev/null
+++ b/be/Modules/Auth/Services/PasswordResetOtpService.php
@@ -0,0 +1,99 @@
+generateOtp();
+
+ PasswordResetOtp::query()
+ ->where('user_id', $user->id)
+ ->delete();
+
+ PasswordResetOtp::create([
+ 'user_id' => $user->id,
+ 'code' => Hash::make($otp),
+ 'expires_at' => now()->addMinutes($this->expiryMinutes()),
+ 'attempts' => 0,
+ ]);
+
+ $user->notify(new PasswordResetOtpEmail($otp));
+ }
+
+ public function reset(User $user, string $otp, array $input): void
+ {
+ validator($input, [
+ 'password' => $this->passwordRules(),
+ ])->validate();
+
+ $record = PasswordResetOtp::query()
+ ->where('user_id', $user->id)
+ ->latest()
+ ->first();
+
+ if (! $record) {
+ throw ValidationException::withMessages([
+ 'otp' => ['Kod OTP tidak dijumpai. Sila minta kod baharu.'],
+ ]);
+ }
+
+ if ($record->isExpired()) {
+ $record->delete();
+
+ throw ValidationException::withMessages([
+ 'otp' => ['Kod OTP telah tamat tempoh. Sila minta kod baharu.'],
+ ]);
+ }
+
+ if ($record->hasExceededMaxAttempts()) {
+ $record->delete();
+
+ throw ValidationException::withMessages([
+ 'otp' => ['Terlalu banyak percubaan. Sila minta kod baharu.'],
+ ]);
+ }
+
+ if (! Hash::check($otp, $record->code)) {
+ $record->increment('attempts');
+
+ if ($record->fresh()->hasExceededMaxAttempts()) {
+ $record->delete();
+ }
+
+ throw ValidationException::withMessages([
+ 'otp' => ['Kod OTP tidak sah.'],
+ ]);
+ }
+
+ $user->forceFill([
+ 'password' => Hash::make($input['password']),
+ ])->save();
+
+ $user->tokens()->delete();
+
+ PasswordResetOtp::query()
+ ->where('user_id', $user->id)
+ ->delete();
+ }
+
+ protected function generateOtp(): string
+ {
+ return str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
+ }
+
+ protected function expiryMinutes(): int
+ {
+ return (int) config('auth.password_reset.expiry_minutes', 10);
+ }
+}
diff --git a/be/Modules/Auth/Transformers/UserResource.php b/be/Modules/Auth/Transformers/UserResource.php
index a064874..98518ef 100644
--- a/be/Modules/Auth/Transformers/UserResource.php
+++ b/be/Modules/Auth/Transformers/UserResource.php
@@ -5,6 +5,10 @@ namespace Modules\Auth\Transformers;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
+use Modules\User\Transformers\AddressResource;
+use Modules\User\Transformers\BankDetailResource;
+use Modules\User\Transformers\EmploymentResource;
+use Modules\User\Transformers\HeirResource;
class UserResource extends JsonResource
{
@@ -28,9 +32,20 @@ class UserResource extends JsonResource
'two_factor_recovery_codes' => $this->two_factor_recovery_codes,
'two_factor_confirmed_at' => $this->two_factor_confirmed_at,
'email_verified_at' => $this->email_verified_at,
+ 'gender' => $this->gender,
+ 'marriage_status' => $this->marriage_status,
+ 'member_number' => $this->member_number,
+ 'member_type' => $this->member_type,
+ 'join_date' => $this->join_date,
+ 'birth_date' => $this->birth_date,
+ 'birth_place' => $this->birth_place,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'deleted_at' => $this->deleted_at,
+ 'addresses' => AddressResource::collection($this->whenLoaded('addresses')),
+ 'employments' => EmploymentResource::collection($this->whenLoaded('employments')),
+ 'bank_details' => BankDetailResource::collection($this->whenLoaded('bankDetails')),
+ 'heirs' => HeirResource::collection($this->whenLoaded('heirs')),
'roles' => $this->whenLoaded('roles', function () {
return $this->roles->map(function ($role) {
return [
diff --git a/be/Modules/Auth/resources/views/emails/password-reset-otp.blade.php b/be/Modules/Auth/resources/views/emails/password-reset-otp.blade.php
new file mode 100644
index 0000000..95a249d
--- /dev/null
+++ b/be/Modules/Auth/resources/views/emails/password-reset-otp.blade.php
@@ -0,0 +1,21 @@
+@component('mail::message')
+@include('emails.partials.header')
+
+# Reset Kata Laluan
+
+Assalamualaikum **{{ $name }}**,
+
+Kami menerima permintaan untuk menetapkan semula kata laluan akaun anda. Gunakan kod OTP di bawah untuk meneruskan.
+
+@component('mail::panel')
+
+ {{ $otp }}
+
+@endcomponent
+
+Kod ini akan tamat tempoh dalam **{{ $minutes }} minit**.
+
+Jika anda tidak membuat permintaan ini, abaikan e-mel ini.
+
+@include('emails.partials.footer')
+@endcomponent
diff --git a/be/Modules/Auth/resources/views/emails/verification-otp.blade.php b/be/Modules/Auth/resources/views/emails/verification-otp.blade.php
index 05c3263..dcaf385 100644
--- a/be/Modules/Auth/resources/views/emails/verification-otp.blade.php
+++ b/be/Modules/Auth/resources/views/emails/verification-otp.blade.php
@@ -1,9 +1,5 @@
@component('mail::message')
-@if (! empty($logoPath) && file_exists($logoPath))
-
-
-
-@endif
+@include('emails.partials.header')
# Pengesahan E-mel
@@ -21,6 +17,5 @@ Kod ini akan tamat tempoh dalam **{{ $minutes }} minit**.
Jika anda tidak membuat pendaftaran ini, abaikan e-mel ini.
-Terima kasih,
-{{ config('app.name') }}
+@include('emails.partials.footer')
@endcomponent
diff --git a/be/Modules/MembershipApplication/Actions/.gitkeep b/be/Modules/MembershipApplication/Actions/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/MembershipApplication/Config/.gitkeep b/be/Modules/MembershipApplication/Config/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/MembershipApplication/Config/config.php b/be/Modules/MembershipApplication/Config/config.php
new file mode 100644
index 0000000..9b65007
--- /dev/null
+++ b/be/Modules/MembershipApplication/Config/config.php
@@ -0,0 +1,5 @@
+ 'MembershipApplication',
+];
diff --git a/be/Modules/MembershipApplication/Console/.gitkeep b/be/Modules/MembershipApplication/Console/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/MembershipApplication/Database/Factories/.gitkeep b/be/Modules/MembershipApplication/Database/Factories/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/MembershipApplication/Database/Migrations/.gitkeep b/be/Modules/MembershipApplication/Database/Migrations/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/MembershipApplication/Database/Migrations/2026_06_14_022844_create_membership_applications_table.php b/be/Modules/MembershipApplication/Database/Migrations/2026_06_14_022844_create_membership_applications_table.php
new file mode 100644
index 0000000..ced621e
--- /dev/null
+++ b/be/Modules/MembershipApplication/Database/Migrations/2026_06_14_022844_create_membership_applications_table.php
@@ -0,0 +1,33 @@
+uuid('id')->primary();
+ $table->string('application_number');
+ $table->foreignUuid('user_id')->constrained('users')->onDelete('cascade')->nullable();
+ $table->string('status');
+ $table->string('board_result');
+ $table->string('submitted_at');
+ $table->string('completed_at');
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('membership_applications');
+ }
+};
diff --git a/be/Modules/MembershipApplication/Database/Migrations/2026_06_14_023448_create_membership_application_applicants_table.php b/be/Modules/MembershipApplication/Database/Migrations/2026_06_14_023448_create_membership_application_applicants_table.php
new file mode 100644
index 0000000..3f356be
--- /dev/null
+++ b/be/Modules/MembershipApplication/Database/Migrations/2026_06_14_023448_create_membership_application_applicants_table.php
@@ -0,0 +1,57 @@
+uuid('id')->primary();
+ $table->foreignUuid('membership_application_id')->constrained('membership_applications')->onDelete('cascade');
+ $table->string('name');
+ $table->string('email');
+ $table->string('ic_number');
+ $table->string('birth_date');
+ $table->string('birth_place');
+ $table->string('marriage_status')->comment('Belum Berkahwin, Berkahwin, Bercerai, Balu, Duda');
+
+ // contact information
+ $table->text('address');
+ $table->string('phone_number');
+ $table->string('office_number')->nullable();
+ $table->string('postcode');
+
+ // Employment
+ $table->string('employer_name');
+ $table->text('employer_address');
+ $table->string('current_position');
+ $table->date('start_work_date');
+
+ // contribution
+ $table->decimal('stock_monthly_contribution', 10, 2);
+ $table->decimal('fee_monthly_contribution', 10, 2);
+
+ $table->timestamps();
+ });
+
+ DB::statement('
+ ALTER TABLE membership_application_applicants
+ ADD COLUMN gender user_gender_enum NULL
+ ');
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('membership_application_applicants');
+ }
+};
diff --git a/be/Modules/MembershipApplication/Database/Migrations/2026_06_14_044009_create_membership_application_heirs_table.php b/be/Modules/MembershipApplication/Database/Migrations/2026_06_14_044009_create_membership_application_heirs_table.php
new file mode 100644
index 0000000..501a190
--- /dev/null
+++ b/be/Modules/MembershipApplication/Database/Migrations/2026_06_14_044009_create_membership_application_heirs_table.php
@@ -0,0 +1,32 @@
+uuid('id')->primary();
+ $table->foreignUuid('membership_application_id')->constrained('membership_applications')->onDelete('cascade');
+ $table->string('name');
+ $table->string('ic_number');
+ $table->string('relationship')->comment('Isteri, Anak, Orang Tua, Saudara, Lain-lain');
+ $table->string('phone_number');
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('membership_application_heirs');
+ }
+};
diff --git a/be/Modules/MembershipApplication/Database/Migrations/2026_06_14_044224_create_membership_application_references_table.php b/be/Modules/MembershipApplication/Database/Migrations/2026_06_14_044224_create_membership_application_references_table.php
new file mode 100644
index 0000000..58cbce4
--- /dev/null
+++ b/be/Modules/MembershipApplication/Database/Migrations/2026_06_14_044224_create_membership_application_references_table.php
@@ -0,0 +1,42 @@
+uuid('id')->primary();
+ $table->foreignUuid('membership_application_id')
+ ->constrained('membership_applications')
+ ->cascadeOnDelete();
+
+ $table->string('reference_type')->comment('PROPOSER, SUPPORTER');
+
+ // Applicant may select at submit; admin may fill in later
+ $table->foreignUuid('user_id')->nullable()->constrained('users')->nullOnDelete();
+
+ // Set only when admin assigns or updates this reference
+ $table->foreignUuid('assigned_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamp('assigned_at')->nullable();
+
+ $table->timestamps();
+
+ $table->unique(['membership_application_id', 'reference_type']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('membership_application_references');
+ }
+};
diff --git a/be/Modules/MembershipApplication/Database/Migrations/2026_06_14_050728_create_membership_application_reviews_table.php b/be/Modules/MembershipApplication/Database/Migrations/2026_06_14_050728_create_membership_application_reviews_table.php
new file mode 100644
index 0000000..cd5db29
--- /dev/null
+++ b/be/Modules/MembershipApplication/Database/Migrations/2026_06_14_050728_create_membership_application_reviews_table.php
@@ -0,0 +1,40 @@
+uuid('id')->primary();
+ $table->foreignUuid('membership_application_id')
+ ->constrained('membership_applications')
+ ->cascadeOnDelete();
+
+ $table->string('stage')->comment('MANAGEMENT, BOARD');
+ $table->string('decision')->comment('MANAGEMENT: APPROVED, REJECTED | BOARD: PASS, FAIL');
+ $table->text('remarks')->nullable();
+
+ $table->foreignUuid('reviewer_id')->constrained('users')->restrictOnDelete();
+ $table->timestamp('reviewed_at');
+
+ $table->timestamps();
+
+ $table->unique(['membership_application_id', 'stage']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('membership_application_reviews');
+ }
+};
diff --git a/be/Modules/MembershipApplication/Database/Seeders/.gitkeep b/be/Modules/MembershipApplication/Database/Seeders/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/MembershipApplication/Database/Seeders/MembershipApplicationDatabaseSeeder.php b/be/Modules/MembershipApplication/Database/Seeders/MembershipApplicationDatabaseSeeder.php
new file mode 100644
index 0000000..31aa8e8
--- /dev/null
+++ b/be/Modules/MembershipApplication/Database/Seeders/MembershipApplicationDatabaseSeeder.php
@@ -0,0 +1,16 @@
+call([]);
+ }
+}
diff --git a/be/Modules/MembershipApplication/Emails/.gitkeep b/be/Modules/MembershipApplication/Emails/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/MembershipApplication/Emails/MembershipApplicationFailedNotification.php b/be/Modules/MembershipApplication/Emails/MembershipApplicationFailedNotification.php
new file mode 100644
index 0000000..ba3f09a
--- /dev/null
+++ b/be/Modules/MembershipApplication/Emails/MembershipApplicationFailedNotification.php
@@ -0,0 +1,35 @@
+application->loadMissing('applicant');
+
+ return (new MailMessage)
+ ->subject('Keputusan Permohonan Keahlian')
+ ->markdown('membershipapplication::emails.failed', [
+ 'name' => $this->application->applicant->name,
+ 'applicationNumber' => $this->application->application_number,
+ 'logoPath' => public_path('images/logo-kopkb.svg'),
+ ]);
+ }
+}
diff --git a/be/Modules/MembershipApplication/Emails/MembershipApplicationPassedNotification.php b/be/Modules/MembershipApplication/Emails/MembershipApplicationPassedNotification.php
new file mode 100644
index 0000000..e9ccbd8
--- /dev/null
+++ b/be/Modules/MembershipApplication/Emails/MembershipApplicationPassedNotification.php
@@ -0,0 +1,35 @@
+application->loadMissing('applicant');
+
+ return (new MailMessage)
+ ->subject('Permohonan Keahlian Diluluskan')
+ ->markdown('membershipapplication::emails.passed', [
+ 'name' => $this->application->applicant->name,
+ 'applicationNumber' => $this->application->application_number,
+ 'logoPath' => public_path('images/logo-kopkb.svg'),
+ ]);
+ }
+}
diff --git a/be/Modules/MembershipApplication/Emails/MembershipApplicationSubmittedNotification.php b/be/Modules/MembershipApplication/Emails/MembershipApplicationSubmittedNotification.php
new file mode 100644
index 0000000..4b77632
--- /dev/null
+++ b/be/Modules/MembershipApplication/Emails/MembershipApplicationSubmittedNotification.php
@@ -0,0 +1,43 @@
+application->loadMissing(['applicant', 'heirs', 'documents', 'references.member']);
+
+ return (new MailMessage)
+ ->subject('Pengesahan Permohonan Keahlian')
+ ->markdown('membershipapplication::emails.submitted', [
+ 'application' => $this->application,
+ 'applicant' => $this->application->applicant,
+ 'heirs' => $this->application->heirs,
+ 'documents' => $this->application->documents,
+ 'documentLabels' => [
+ 'ic_copy' => 'Salinan Kad Pengenalan',
+ 'photo' => 'Gambar Passport',
+ 'salary_slip' => 'Slip Gaji',
+ 'employer_letter' => 'Surat Pengesahan Majikan',
+ ],
+ 'logoPath' => public_path('images/logo-kopkb.svg'),
+ ]);
+ }
+}
diff --git a/be/Modules/MembershipApplication/Entities/.gitkeep b/be/Modules/MembershipApplication/Entities/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/MembershipApplication/Entities/MembershipApplication.php b/be/Modules/MembershipApplication/Entities/MembershipApplication.php
new file mode 100644
index 0000000..57409a1
--- /dev/null
+++ b/be/Modules/MembershipApplication/Entities/MembershipApplication.php
@@ -0,0 +1,68 @@
+
+ */
+ protected $fillable = [
+ 'application_number',
+ 'user_id',
+ 'status',
+ 'board_result',
+ 'submitted_at',
+ 'completed_at',
+ ];
+
+ /**
+ * @var list
+ */
+ protected $hidden = [
+ ];
+
+ protected function casts(): array
+ {
+ return [
+ 'status' => ApplicationStatus::class,
+ ];
+ }
+
+ public function applicant(): HasOne
+ {
+ return $this->hasOne(MembershipApplicationApplicant::class);
+ }
+
+ public function heirs(): HasMany
+ {
+ return $this->hasMany(MembershipApplicationHeir::class);
+ }
+
+ public function references(): HasMany
+ {
+ return $this->hasMany(MembershipApplicationReference::class);
+ }
+
+ public function reviews(): HasMany
+ {
+ return $this->hasMany(MembershipApplicationReview::class);
+ }
+
+ public function user()
+ {
+ return $this->belongsTo(User::class);
+ }
+}
diff --git a/be/Modules/MembershipApplication/Entities/MembershipApplicationApplicant.php b/be/Modules/MembershipApplication/Entities/MembershipApplicationApplicant.php
new file mode 100644
index 0000000..81f484a
--- /dev/null
+++ b/be/Modules/MembershipApplication/Entities/MembershipApplicationApplicant.php
@@ -0,0 +1,58 @@
+
+ */
+ protected $fillable = [
+ 'membership_application_id',
+ 'name',
+ 'email',
+ 'ic_number',
+ 'birth_date',
+ 'birth_place',
+ 'gender',
+ 'marriage_status',
+ 'address',
+ 'phone_number',
+ 'office_number',
+ 'postcode',
+ 'employer_name',
+ 'employer_address',
+ 'current_position',
+ 'start_work_date',
+ 'stock_monthly_contribution',
+ 'fee_monthly_contribution',
+ ];
+
+ /**
+ * @var list
+ */
+ protected $hidden = [
+ ];
+
+ protected function casts(): array
+ {
+ return [
+ 'start_work_date' => 'date',
+ 'stock_monthly_contribution' => 'decimal:2',
+ 'fee_monthly_contribution' => 'decimal:2',
+ ];
+ }
+
+ public function membershipApplication(): BelongsTo
+ {
+ return $this->belongsTo(MembershipApplication::class);
+ }
+}
diff --git a/be/Modules/MembershipApplication/Entities/MembershipApplicationHeir.php b/be/Modules/MembershipApplication/Entities/MembershipApplicationHeir.php
new file mode 100644
index 0000000..1c11c0d
--- /dev/null
+++ b/be/Modules/MembershipApplication/Entities/MembershipApplicationHeir.php
@@ -0,0 +1,46 @@
+
+ */
+ protected $fillable = [
+ 'membership_application_id',
+ 'name',
+ 'ic_number',
+ 'relationship',
+ 'phone_number',
+ ];
+
+ /**
+ * @var list
+ */
+ protected $hidden = [
+ ];
+
+ protected function casts(): array
+ {
+ return [
+ 'name' => 'string',
+ 'ic_number' => 'string',
+ 'relationship' => 'string',
+ 'phone_number' => 'string',
+ ];
+ }
+
+ public function membershipApplication(): BelongsTo
+ {
+ return $this->belongsTo(MembershipApplication::class);
+ }
+}
diff --git a/be/Modules/MembershipApplication/Entities/MembershipApplicationReference.php b/be/Modules/MembershipApplication/Entities/MembershipApplicationReference.php
new file mode 100644
index 0000000..f4c729a
--- /dev/null
+++ b/be/Modules/MembershipApplication/Entities/MembershipApplicationReference.php
@@ -0,0 +1,56 @@
+
+ */
+ protected $fillable = [
+ 'membership_application_id',
+ 'reference_type',
+ 'user_id',
+ 'assigned_by',
+ 'assigned_at',
+ ];
+
+ /**
+ * @var list
+ */
+ protected $hidden = [
+ ];
+
+ protected function casts(): array
+ {
+ return [
+ 'reference_type' => ReferenceType::class,
+ 'assigned_at' => 'datetime',
+ ];
+ }
+
+ public function membershipApplication(): BelongsTo
+ {
+ return $this->belongsTo(MembershipApplication::class);
+ }
+
+ public function member(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'user_id');
+ }
+
+ public function assignedBy(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'assigned_by');
+ }
+}
diff --git a/be/Modules/MembershipApplication/Entities/MembershipApplicationReview.php b/be/Modules/MembershipApplication/Entities/MembershipApplicationReview.php
new file mode 100644
index 0000000..2fb89f2
--- /dev/null
+++ b/be/Modules/MembershipApplication/Entities/MembershipApplicationReview.php
@@ -0,0 +1,72 @@
+
+ */
+ protected $fillable = [
+ 'membership_application_id',
+ 'stage',
+ 'decision',
+ 'remarks',
+ 'reviewer_id',
+ 'reviewed_at',
+ ];
+
+ /**
+ * @var list
+ */
+ protected $hidden = [
+ ];
+
+ protected function casts(): array
+ {
+ return [
+ 'stage' => ReviewStage::class,
+ 'reviewed_at' => 'datetime',
+ ];
+ }
+
+ public function membershipApplication(): BelongsTo
+ {
+ return $this->belongsTo(MembershipApplication::class);
+ }
+
+ public function reviewer(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'reviewer_id');
+ }
+
+ public function managementDecision(): ?ManagementDecision
+ {
+ if ($this->stage !== ReviewStage::Management) {
+ return null;
+ }
+
+ return ManagementDecision::tryFrom($this->decision);
+ }
+
+ public function boardDecision(): ?BoardDecision
+ {
+ if ($this->stage !== ReviewStage::Board) {
+ return null;
+ }
+
+ return BoardDecision::tryFrom($this->decision);
+ }
+}
diff --git a/be/Modules/MembershipApplication/Enums/ApplicationStatus.php b/be/Modules/MembershipApplication/Enums/ApplicationStatus.php
new file mode 100644
index 0000000..29fc472
--- /dev/null
+++ b/be/Modules/MembershipApplication/Enums/ApplicationStatus.php
@@ -0,0 +1,12 @@
+authorize('viewAny', MembershipApplication::class);
+
+ $perPage = min((int) $request->get('per_page', 10), 100);
+ $search = (string) ($request->get('search', '') ?? '');
+ $status = $request->get('status');
+ $sortBy = (string) ($request->get('sort_by', 'submitted_at') ?? 'submitted_at');
+ $sortOrder = (string) ($request->get('sort_order', 'desc') ?? 'desc');
+
+ $applications = $this->membershipApplicationService->getPaginatedList(
+ $perPage,
+ $search,
+ $status,
+ $sortBy,
+ $sortOrder
+ );
+
+ return response()->json([
+ 'success' => true,
+ 'data' => MembershipApplicationListResource::collection($applications->items()),
+ 'pagination' => [
+ 'current_page' => $applications->currentPage(),
+ 'per_page' => $applications->perPage(),
+ 'total' => $applications->total(),
+ 'last_page' => $applications->lastPage(),
+ 'from' => $applications->firstItem(),
+ 'to' => $applications->lastItem(),
+ 'has_more_pages' => $applications->hasMorePages(),
+ ],
+ ]);
+ }
+
+ public function show(MembershipApplication $membershipApplication): JsonResponse
+ {
+ $this->authorize('view', $membershipApplication);
+
+ $application = $this->membershipApplicationService->getByIdWithRelations($membershipApplication->id);
+
+ if (! $application) {
+ return response()->json([
+ 'success' => false,
+ 'message' => 'Permohonan tidak dijumpai.',
+ ], 404);
+ }
+
+ return response()->json([
+ 'success' => true,
+ 'data' => new MembershipApplicationResource($application),
+ ]);
+ }
+
+ public function update(UpdateMembershipApplicationRequest $request, MembershipApplication $membershipApplication): JsonResponse
+ {
+ $this->authorize('update', $membershipApplication);
+
+ $application = $this->membershipApplicationService->update(
+ $membershipApplication,
+ $request->validated(),
+ );
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Permohonan keahlian berjaya dikemaskini.',
+ 'data' => new MembershipApplicationResource($application),
+ ]);
+ }
+
+ public function uploadDocument(UploadMembershipApplicationDocumentRequest $request, MembershipApplication $membershipApplication): JsonResponse
+ {
+ $this->authorize('update', $membershipApplication);
+
+ $application = $this->membershipApplicationService->uploadDocument(
+ $membershipApplication,
+ $request->validated('type'),
+ $request->file('file'),
+ );
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Dokumen berjaya dimuat naik.',
+ 'data' => new MembershipApplicationResource($application),
+ ]);
+ }
+
+ public function managementReview(ManagementReviewRequest $request, MembershipApplication $membershipApplication): JsonResponse {
+ $this->authorize('managementReview', $membershipApplication);
+
+ $application = $this->membershipApplicationService->managementReview(
+ $membershipApplication,
+ ManagementDecision::from($request->validated('decision')),
+ $request->validated('remarks'),
+ $request->user()
+ );
+
+ $message = $application->status === ApplicationStatus::PendingBoard
+ ? 'Permohonan diluluskan dan dihantar ke lembaga.'
+ : 'Permohonan ditolak.';
+
+ return response()->json([
+ 'success' => true,
+ 'message' => $message,
+ 'data' => new MembershipApplicationResource($application),
+ ]);
+ }
+
+ public function boardReview(BoardReviewRequest $request, MembershipApplication $membershipApplication): JsonResponse {
+ $this->authorize('boardReview', $membershipApplication);
+
+ $application = $this->membershipApplicationService->boardReview(
+ $membershipApplication,
+ BoardDecision::from($request->validated('decision')),
+ $request->validated('remarks'),
+ $request->user()
+ );
+
+ $message = $application->board_result === BoardResult::Pass->value
+ ? 'Permohonan lulus semakan lembaga.'
+ : 'Permohonan gagal semakan lembaga.';
+
+ return response()->json([
+ 'success' => true,
+ 'message' => $message,
+ 'data' => new MembershipApplicationResource($application),
+ ]);
+ }
+
+ public function assignReferences(AssignReferencesRequest $request, MembershipApplication $membershipApplication): JsonResponse {
+ $this->authorize('assignReferences', $membershipApplication);
+
+ $application = $this->membershipApplicationService->assignReferences(
+ $membershipApplication,
+ $request->validated(),
+ $request->user()
+ );
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Pencadang dan penyokong berjaya dikemaskini.',
+ 'data' => new MembershipApplicationResource($application),
+ ]);
+ }
+
+ public function complete(Request $request, MembershipApplication $membershipApplication): JsonResponse
+ {
+ $this->authorize('complete', $membershipApplication);
+
+ $application = $this->membershipApplicationService->complete(
+ $membershipApplication,
+ $request->user()
+ );
+
+ $message = $application->board_result === BoardResult::Pass->value
+ ? 'Permohonan selesai. Akaun ahli telah dicipta dan e-mel dihantar.'
+ : 'Permohonan selesai. E-mel keputusan dihantar.';
+
+ return response()->json([
+ 'success' => true,
+ 'message' => $message,
+ 'data' => new MembershipApplicationResource($application),
+ ]);
+ }
+
+ public function batchComplete(BatchCompleteRequest $request): JsonResponse
+ {
+ $this->authorize('completeBatch', MembershipApplication::class);
+
+ $result = $this->membershipApplicationService->completeBatch(
+ $request->validated('application_ids'),
+ $request->user()
+ );
+
+ $succeededCount = count($result['succeeded']);
+ $failedCount = count($result['failed']);
+
+ if ($succeededCount === 0) {
+ return response()->json([
+ 'success' => false,
+ 'message' => 'Tiada permohonan berjaya diselesaikan.',
+ 'data' => [
+ 'succeeded' => [],
+ 'failed' => $result['failed'],
+ ],
+ ], 422);
+ }
+
+ $message = $failedCount > 0
+ ? "{$succeededCount} permohonan berjaya, {$failedCount} gagal."
+ : "{$succeededCount} permohonan berjaya diselesaikan.";
+
+ return response()->json([
+ 'success' => true,
+ 'message' => $message,
+ 'data' => [
+ 'succeeded' => MembershipApplicationResource::collection($result['succeeded']),
+ 'failed' => $result['failed'],
+ ],
+ ]);
+ }
+
+ public function downloadDocument(MembershipApplication $membershipApplication, string $documentId): StreamedResponse {
+ $this->authorize('view', $membershipApplication);
+
+ $document = $membershipApplication->documents()->findOrFail($documentId);
+
+ return $this->documentService->downloadDocument($document->id);
+ }
+
+ public function approvedLetter(Request $request, MembershipApplication $membershipApplication): Response
+ {
+ $this->authorize('view', $membershipApplication);
+
+ $application = $this->membershipApplicationService->getByIdWithRelations($membershipApplication->id);
+
+ if (! $application) {
+ abort(404, 'Permohonan tidak dijumpai.');
+ }
+
+ $view = 'membershipapplication::pdf.approved-letter';
+ $data = ['application' => $application];
+ $format = $request->query('format', 'html');
+ $filename = 'surat-kelulusan-'.$application->application_number.'.pdf';
+
+ if ($format === 'pdf') {
+ return $this->letterService->pdfResponse($view, $data, $filename);
+ }
+
+ return $this->letterService->htmlResponse($view, $data);
+ }
+}
diff --git a/be/Modules/MembershipApplication/Http/Controllers/PublicMembershipApplicationController.php b/be/Modules/MembershipApplication/Http/Controllers/PublicMembershipApplicationController.php
new file mode 100644
index 0000000..5ae0c97
--- /dev/null
+++ b/be/Modules/MembershipApplication/Http/Controllers/PublicMembershipApplicationController.php
@@ -0,0 +1,60 @@
+file('documents', []))
+ ->filter()
+ ->all();
+
+ $application = $this->membershipApplicationService->submit(
+ $request->safe()->except(['documents']),
+ $documents
+ );
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Permohonan keahlian berjaya dihantar.',
+ 'data' => new MembershipApplicationResource($application),
+ ], 201);
+ }
+
+ public function lookupMember(LookupMemberRequest $request): JsonResponse
+ {
+ $member = $this->membershipApplicationService->lookupMemberByIcNumber(
+ $request->validated('ic_number')
+ );
+
+ if (! $member) {
+ return response()->json([
+ 'success' => false,
+ 'message' => 'Ahli dengan nombor kad pengenalan ini tidak dijumpai.',
+ 'data' => null,
+ ]);
+ }
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Ahli dijumpai.',
+ 'data' => [
+ 'id' => $member->id,
+ 'name' => $member->name,
+ 'ic_number' => $member->ic_number,
+ ],
+ ]);
+ }
+}
diff --git a/be/Modules/MembershipApplication/Http/Requests/.gitkeep b/be/Modules/MembershipApplication/Http/Requests/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/MembershipApplication/Http/Requests/AssignReferencesRequest.php b/be/Modules/MembershipApplication/Http/Requests/AssignReferencesRequest.php
new file mode 100644
index 0000000..90b7dc2
--- /dev/null
+++ b/be/Modules/MembershipApplication/Http/Requests/AssignReferencesRequest.php
@@ -0,0 +1,35 @@
+|string>
+ */
+ public function rules(): array
+ {
+ return [
+ 'proposer_id' => 'nullable|uuid|exists:users,id',
+ 'supporter_id' => 'nullable|uuid|exists:users,id',
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function messages(): array
+ {
+ return [
+ 'proposer_id.exists' => 'Pencadang tidak dijumpai.',
+ 'supporter_id.exists' => 'Penyokong tidak dijumpai.',
+ ];
+ }
+}
diff --git a/be/Modules/MembershipApplication/Http/Requests/BatchCompleteRequest.php b/be/Modules/MembershipApplication/Http/Requests/BatchCompleteRequest.php
new file mode 100644
index 0000000..deb9c19
--- /dev/null
+++ b/be/Modules/MembershipApplication/Http/Requests/BatchCompleteRequest.php
@@ -0,0 +1,38 @@
+|string>
+ */
+ public function rules(): array
+ {
+ return [
+ 'application_ids' => 'required|array|min:1|max:100',
+ 'application_ids.*' => 'required|uuid|distinct|exists:membership_applications,id',
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function messages(): array
+ {
+ return [
+ 'application_ids.required' => 'Senarai permohonan diperlukan.',
+ 'application_ids.min' => 'Sekurang-kurangnya satu permohonan diperlukan.',
+ 'application_ids.max' => 'Maksimum 100 permohonan setiap batch.',
+ 'application_ids.*.exists' => 'Salah satu permohonan tidak dijumpai.',
+ 'application_ids.*.distinct' => 'Permohonan duplikat tidak dibenarkan.',
+ ];
+ }
+}
diff --git a/be/Modules/MembershipApplication/Http/Requests/BoardReviewRequest.php b/be/Modules/MembershipApplication/Http/Requests/BoardReviewRequest.php
new file mode 100644
index 0000000..20ea6fe
--- /dev/null
+++ b/be/Modules/MembershipApplication/Http/Requests/BoardReviewRequest.php
@@ -0,0 +1,38 @@
+|string>
+ */
+ public function rules(): array
+ {
+ return [
+ 'decision' => ['required', Rule::enum(BoardDecision::class)],
+ 'remarks' => 'nullable|string|max:1000',
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function messages(): array
+ {
+ return [
+ 'decision.required' => 'Keputusan diperlukan.',
+ 'decision.enum' => 'Keputusan tidak sah. Pilih PASS atau FAIL.',
+ 'remarks.max' => 'Catatan tidak boleh melebihi 1000 aksara.',
+ ];
+ }
+}
diff --git a/be/Modules/MembershipApplication/Http/Requests/LookupMemberRequest.php b/be/Modules/MembershipApplication/Http/Requests/LookupMemberRequest.php
new file mode 100644
index 0000000..5265792
--- /dev/null
+++ b/be/Modules/MembershipApplication/Http/Requests/LookupMemberRequest.php
@@ -0,0 +1,34 @@
+|string>
+ */
+ public function rules(): array
+ {
+ return [
+ 'ic_number' => 'required|string|max:20',
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function messages(): array
+ {
+ return [
+ 'ic_number.required' => 'Nombor kad pengenalan diperlukan.',
+ 'ic_number.max' => 'Nombor kad pengenalan tidak sah.',
+ ];
+ }
+}
diff --git a/be/Modules/MembershipApplication/Http/Requests/ManagementReviewRequest.php b/be/Modules/MembershipApplication/Http/Requests/ManagementReviewRequest.php
new file mode 100644
index 0000000..2271a5e
--- /dev/null
+++ b/be/Modules/MembershipApplication/Http/Requests/ManagementReviewRequest.php
@@ -0,0 +1,38 @@
+|string>
+ */
+ public function rules(): array
+ {
+ return [
+ 'decision' => ['required', Rule::enum(ManagementDecision::class)],
+ 'remarks' => 'nullable|string|max:1000',
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function messages(): array
+ {
+ return [
+ 'decision.required' => 'Keputusan diperlukan.',
+ 'decision.enum' => 'Keputusan tidak sah. Pilih APPROVED atau REJECTED.',
+ 'remarks.max' => 'Catatan tidak boleh melebihi 1000 aksara.',
+ ];
+ }
+}
diff --git a/be/Modules/MembershipApplication/Http/Requests/StoreMembershipApplicationRequest.php b/be/Modules/MembershipApplication/Http/Requests/StoreMembershipApplicationRequest.php
new file mode 100644
index 0000000..8027dc1
--- /dev/null
+++ b/be/Modules/MembershipApplication/Http/Requests/StoreMembershipApplicationRequest.php
@@ -0,0 +1,94 @@
+|string>
+ */
+ public function rules(): array
+ {
+ return [
+ 'applicant.name' => 'required|string|max:255',
+ 'applicant.email' => 'required|email|max:255',
+ 'applicant.ic_number' => 'required|string|max:20',
+ 'applicant.birth_date' => 'required|date',
+ 'applicant.birth_place' => 'required|string|max:255',
+ 'applicant.gender' => ['required', Rule::in(['Lelaki', 'Perempuan'])],
+ 'applicant.marriage_status' => 'required|string|max:255',
+ 'applicant.address' => 'required|string',
+ 'applicant.phone_number' => 'required|string|max:20',
+ 'applicant.office_number' => 'nullable|string|max:20',
+ 'applicant.postcode' => 'required|string|max:10',
+ 'applicant.employer_name' => 'required|string|max:255',
+ 'applicant.employer_address' => 'required|string',
+ 'applicant.current_position' => 'required|string|max:255',
+ 'applicant.start_work_date' => 'required|date',
+ 'applicant.stock_monthly_contribution' => 'required|numeric|min:0',
+ 'applicant.fee_monthly_contribution' => 'required|numeric|min:0',
+
+ 'heirs' => 'required|array|min:1',
+ 'heirs.*.name' => 'required|string|max:255',
+ 'heirs.*.ic_number' => 'required|string|max:20',
+ 'heirs.*.relationship' => 'required|string|max:255',
+ 'heirs.*.phone_number' => 'required|string|max:20',
+
+ 'references.proposer_ic_number' => 'nullable|string|max:20',
+ 'references.supporter_ic_number' => 'nullable|string|max:20',
+
+ 'documents.ic_copy' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240',
+ 'documents.photo' => 'nullable|file|mimes:jpg,jpeg,png|max:10240',
+ 'documents.salary_slip' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
+ 'documents.employer_letter' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function messages(): array
+ {
+ return [
+ 'applicant.name.required' => 'Nama penuh diperlukan.',
+ 'applicant.email.required' => 'Emel diperlukan.',
+ 'applicant.email.email' => 'Emel tidak sah.',
+ 'applicant.ic_number.required' => 'Nombor kad pengenalan diperlukan.',
+ 'applicant.birth_date.required' => 'Tarikh lahir diperlukan.',
+ 'applicant.birth_place.required' => 'Tempat lahir diperlukan.',
+ 'applicant.gender.required' => 'Jantina diperlukan.',
+ 'applicant.gender.in' => 'Jantina tidak sah.',
+ 'applicant.marriage_status.required' => 'Status perkahwinan diperlukan.',
+ 'applicant.address.required' => 'Alamat diperlukan.',
+ 'applicant.phone_number.required' => 'Nombor telefon diperlukan.',
+ 'applicant.postcode.required' => 'Poskod diperlukan.',
+ 'applicant.employer_name.required' => 'Nama majikan diperlukan.',
+ 'applicant.employer_address.required' => 'Alamat majikan diperlukan.',
+ 'applicant.current_position.required' => 'Jawatan semasa diperlukan.',
+ 'applicant.start_work_date.required' => 'Tarikh mula berkhidmat diperlukan.',
+ 'applicant.stock_monthly_contribution.required' => 'Caruman saham bulanan diperlukan.',
+ 'applicant.fee_monthly_contribution.required' => 'Caruman yuran bulanan diperlukan.',
+
+ 'heirs.required' => 'Maklumat waris diperlukan.',
+ 'heirs.min' => 'Sekurang-kurangnya satu waris diperlukan.',
+ 'heirs.*.name.required' => 'Nama waris diperlukan.',
+ 'heirs.*.ic_number.required' => 'Nombor kad pengenalan waris diperlukan.',
+ 'heirs.*.relationship.required' => 'Hubungan waris diperlukan.',
+ 'heirs.*.phone_number.required' => 'Nombor telefon waris diperlukan.',
+
+ 'references.proposer_ic_number.max' => 'Nombor kad pengenalan pencadang tidak sah.',
+ 'references.supporter_ic_number.max' => 'Nombor kad pengenalan penyokong tidak sah.',
+
+ 'documents.ic_copy.required' => 'Salinan kad pengenalan diperlukan.',
+ 'documents.ic_copy.mimes' => 'Salinan kad pengenalan mestilah PDF, JPG, JPEG atau PNG.',
+ ];
+ }
+}
diff --git a/be/Modules/MembershipApplication/Http/Requests/UpdateMembershipApplicationRequest.php b/be/Modules/MembershipApplication/Http/Requests/UpdateMembershipApplicationRequest.php
new file mode 100644
index 0000000..b987064
--- /dev/null
+++ b/be/Modules/MembershipApplication/Http/Requests/UpdateMembershipApplicationRequest.php
@@ -0,0 +1,95 @@
+
+ */
+ public function validationData(): array
+ {
+ return $this->except(array_keys($this->route()?->parameters() ?? []));
+ }
+
+ /**
+ * @return array|string>
+ */
+ public function rules(): array
+ {
+ return [
+ 'applicant' => 'sometimes|array',
+ 'applicant.name' => 'sometimes|required|string|max:255',
+ 'applicant.email' => 'sometimes|required|email|max:255',
+ 'applicant.ic_number' => 'sometimes|required|string|max:20',
+ 'applicant.birth_date' => 'sometimes|required|date',
+ 'applicant.birth_place' => 'sometimes|required|string|max:255',
+ 'applicant.gender' => ['sometimes', 'required', Rule::in(['Lelaki', 'Perempuan'])],
+ 'applicant.marriage_status' => 'sometimes|required|string|max:255',
+ 'applicant.address' => 'sometimes|required|string',
+ 'applicant.phone_number' => 'sometimes|required|string|max:20',
+ 'applicant.office_number' => 'nullable|string|max:20',
+ 'applicant.postcode' => 'sometimes|required|string|max:10',
+ 'applicant.employer_name' => 'sometimes|required|string|max:255',
+ 'applicant.employer_address' => 'sometimes|required|string',
+ 'applicant.current_position' => 'sometimes|required|string|max:255',
+ 'applicant.start_work_date' => 'sometimes|required|date',
+ 'applicant.stock_monthly_contribution' => 'sometimes|required|numeric|min:0',
+ 'applicant.fee_monthly_contribution' => 'sometimes|required|numeric|min:0',
+
+ 'heirs' => 'sometimes|array|min:1',
+ 'heirs.*.name' => 'required|string|max:255',
+ 'heirs.*.ic_number' => 'required|string|max:20',
+ 'heirs.*.relationship' => 'required|string|max:255',
+ 'heirs.*.phone_number' => 'required|string|max:20',
+
+ 'references' => 'sometimes|array',
+ 'references.proposer_ic_number' => 'nullable|string|max:20',
+ 'references.supporter_ic_number' => 'nullable|string|max:20',
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function messages(): array
+ {
+ return [
+ 'applicant.name.required' => 'Nama penuh diperlukan.',
+ 'applicant.email.required' => 'Emel diperlukan.',
+ 'applicant.email.email' => 'Emel tidak sah.',
+ 'applicant.ic_number.required' => 'Nombor kad pengenalan diperlukan.',
+ 'applicant.birth_date.required' => 'Tarikh lahir diperlukan.',
+ 'applicant.birth_place.required' => 'Tempat lahir diperlukan.',
+ 'applicant.gender.required' => 'Jantina diperlukan.',
+ 'applicant.gender.in' => 'Jantina tidak sah.',
+ 'applicant.marriage_status.required' => 'Status perkahwinan diperlukan.',
+ 'applicant.address.required' => 'Alamat diperlukan.',
+ 'applicant.phone_number.required' => 'Nombor telefon diperlukan.',
+ 'applicant.postcode.required' => 'Poskod diperlukan.',
+ 'applicant.employer_name.required' => 'Nama majikan diperlukan.',
+ 'applicant.employer_address.required' => 'Alamat majikan diperlukan.',
+ 'applicant.current_position.required' => 'Jawatan semasa diperlukan.',
+ 'applicant.start_work_date.required' => 'Tarikh mula berkhidmat diperlukan.',
+ 'applicant.stock_monthly_contribution.required' => 'Caruman saham bulanan diperlukan.',
+ 'applicant.fee_monthly_contribution.required' => 'Caruman yuran bulanan diperlukan.',
+
+ 'heirs.min' => 'Sekurang-kurangnya satu waris diperlukan.',
+ 'heirs.*.name.required' => 'Nama waris diperlukan.',
+ 'heirs.*.ic_number.required' => 'Nombor kad pengenalan waris diperlukan.',
+ 'heirs.*.relationship.required' => 'Hubungan waris diperlukan.',
+ 'heirs.*.phone_number.required' => 'Nombor telefon waris diperlukan.',
+
+ 'references.proposer_ic_number.max' => 'Nombor kad pengenalan pencadang tidak sah.',
+ 'references.supporter_ic_number.max' => 'Nombor kad pengenalan penyokong tidak sah.',
+ ];
+ }
+}
diff --git a/be/Modules/MembershipApplication/Http/Requests/UploadMembershipApplicationDocumentRequest.php b/be/Modules/MembershipApplication/Http/Requests/UploadMembershipApplicationDocumentRequest.php
new file mode 100644
index 0000000..542495f
--- /dev/null
+++ b/be/Modules/MembershipApplication/Http/Requests/UploadMembershipApplicationDocumentRequest.php
@@ -0,0 +1,48 @@
+|string>
+ */
+ public function rules(): array
+ {
+ return [
+ 'type' => ['required', Rule::in(['ic_copy', 'photo', 'salary_slip', 'employer_letter'])],
+ 'file' => [
+ 'required',
+ 'file',
+ 'max:10240',
+ Rule::when(
+ $this->input('type') === 'photo',
+ 'mimes:jpg,jpeg,png',
+ 'mimes:pdf,jpg,jpeg,png',
+ ),
+ ],
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function messages(): array
+ {
+ return [
+ 'type.required' => 'Jenis dokumen diperlukan.',
+ 'type.in' => 'Jenis dokumen tidak sah.',
+ 'file.required' => 'Fail diperlukan.',
+ 'file.mimes' => 'Format fail tidak disokong.',
+ 'file.max' => 'Saiz fail melebihi had maksimum 10MB.',
+ ];
+ }
+}
diff --git a/be/Modules/MembershipApplication/Jobs/.gitkeep b/be/Modules/MembershipApplication/Jobs/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/MembershipApplication/Notifications/.gitkeep b/be/Modules/MembershipApplication/Notifications/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/MembershipApplication/Policies/.gitkeep b/be/Modules/MembershipApplication/Policies/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/MembershipApplication/Policies/MembershipApplicationPolicy.php b/be/Modules/MembershipApplication/Policies/MembershipApplicationPolicy.php
new file mode 100644
index 0000000..9ce7624
--- /dev/null
+++ b/be/Modules/MembershipApplication/Policies/MembershipApplicationPolicy.php
@@ -0,0 +1,75 @@
+hasPermissionTo('lihat permohonan keahlian');
+ }
+
+ /**
+ * Determine whether the user can view the model.
+ */
+ public function view($user, ?MembershipApplication $membershipApplication = null): bool
+ {
+ return $user->hasPermissionTo('lihat permohonan keahlian');
+ }
+
+ /**
+ * Management officer: approve or reject an application.
+ */
+ public function managementReview($user, ?MembershipApplication $membershipApplication = null): bool
+ {
+ return $user->hasPermissionTo('semak permohonan keahlian pentadbiran');
+ }
+
+ /**
+ * Board member: pass or fail an application.
+ */
+ public function boardReview($user, ?MembershipApplication $membershipApplication = null): bool
+ {
+ return $user->hasPermissionTo('semak permohonan keahlian lembaga');
+ }
+
+ /**
+ * General manager: send result notification and create account if passed.
+ */
+ public function complete($user, ?MembershipApplication $membershipApplication = null): bool
+ {
+ return $user->hasPermissionTo('selesaikan permohonan keahlian');
+ }
+
+ /**
+ * General manager: batch complete multiple applications.
+ */
+ public function completeBatch($user): bool
+ {
+ return $user->hasPermissionTo('selesaikan permohonan keahlian');
+ }
+
+ /**
+ * Admin: assign or update proposer and supporter.
+ */
+ public function assignReferences($user, ?MembershipApplication $membershipApplication = null): bool
+ {
+ return $user->hasPermissionTo('tetapkan pencadang penyokong');
+ }
+
+ /**
+ * Admin: edit application details before completion.
+ */
+ public function update($user, ?MembershipApplication $membershipApplication = null): bool
+ {
+ return $user->hasPermissionTo('kemaskini permohonan keahlian');
+ }
+}
diff --git a/be/Modules/MembershipApplication/Providers/.gitkeep b/be/Modules/MembershipApplication/Providers/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/MembershipApplication/Providers/EventServiceProvider.php b/be/Modules/MembershipApplication/Providers/EventServiceProvider.php
new file mode 100644
index 0000000..9af6b7f
--- /dev/null
+++ b/be/Modules/MembershipApplication/Providers/EventServiceProvider.php
@@ -0,0 +1,27 @@
+>
+ */
+ protected $listen = [];
+
+ /**
+ * Indicates if events should be discovered.
+ *
+ * @var bool
+ */
+ protected static $shouldDiscoverEvents = true;
+
+ /**
+ * Configure the proper event listeners for email verification.
+ */
+ protected function configureEmailVerification(): void {}
+}
diff --git a/be/Modules/MembershipApplication/Providers/MembershipApplicationServiceProvider.php b/be/Modules/MembershipApplication/Providers/MembershipApplicationServiceProvider.php
new file mode 100644
index 0000000..26c48fc
--- /dev/null
+++ b/be/Modules/MembershipApplication/Providers/MembershipApplicationServiceProvider.php
@@ -0,0 +1,186 @@
+registerRateLimiters();
+ $this->registerCommands();
+ $this->registerCommandSchedules();
+ $this->registerTranslations();
+ $this->registerConfig();
+ $this->registerViews();
+ $this->loadMigrationsFrom(module_path($this->name, 'Database/Migrations'));
+ $this->registerPolicies();
+ }
+
+ /**
+ * Register policies.
+ */
+ protected function registerPolicies(): void
+ {
+ Gate::policy(MembershipApplication::class, MembershipApplicationPolicy::class);
+ }
+
+ /**
+ * Register the service provider.
+ */
+ public function register(): void
+ {
+ $this->app->register(EventServiceProvider::class);
+ $this->app->register(RouteServiceProvider::class);
+
+ // Register repository binding
+ $this->app->bind(
+ \Modules\MembershipApplication\Repositories\Contracts\MembershipApplicationRepositoryInterface::class,
+ \Modules\MembershipApplication\Repositories\MembershipApplicationRepository::class
+ );
+ }
+
+ protected function registerRateLimiters(): void
+ {
+ RateLimiter::for('membership-application-submit', function ($request) {
+ return Limit::perHour(10)->by($request->ip());
+ });
+
+ RateLimiter::for('membership-application-lookup', function ($request) {
+ return Limit::perMinute(30)->by($request->ip());
+ });
+ }
+
+ /**
+ * Register commands in the format of Command::class
+ */
+ protected function registerCommands(): void
+ {
+ // $this->commands([]);
+ }
+
+ /**
+ * Register command Schedules.
+ */
+ protected function registerCommandSchedules(): void
+ {
+ // $this->app->booted(function () {
+ // $schedule = $this->app->make(Schedule::class);
+ // $schedule->command('inspire')->hourly();
+ // });
+ }
+
+ /**
+ * Register translations.
+ */
+ public function registerTranslations(): void
+ {
+ $langPath = resource_path('lang/modules/'.$this->nameLower);
+
+ if (is_dir($langPath)) {
+ $this->loadTranslationsFrom($langPath, $this->nameLower);
+ $this->loadJsonTranslationsFrom($langPath);
+ } else {
+ $this->loadTranslationsFrom(module_path($this->name, 'Lang'), $this->nameLower);
+ $this->loadJsonTranslationsFrom(module_path($this->name, 'Lang'));
+ }
+ }
+
+ /**
+ * Register config.
+ */
+ protected function registerConfig(): void
+ {
+ $configPath = module_path($this->name, config('modules.paths.generator.config.path'));
+
+ if (is_dir($configPath)) {
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($configPath));
+
+ foreach ($iterator as $file) {
+ if ($file->isFile() && $file->getExtension() === 'php') {
+ $config = str_replace($configPath.DIRECTORY_SEPARATOR, '', $file->getPathname());
+ $config_key = str_replace([DIRECTORY_SEPARATOR, '.php'], ['.', ''], $config);
+ $segments = explode('.', $this->nameLower.'.'.$config_key);
+
+ // Remove duplicated adjacent segments
+ $normalized = [];
+ foreach ($segments as $segment) {
+ if (end($normalized) !== $segment) {
+ $normalized[] = $segment;
+ }
+ }
+
+ $key = ($config === 'config.php') ? $this->nameLower : implode('.', $normalized);
+
+ $this->publishes([$file->getPathname() => config_path($config)], 'config');
+ $this->merge_config_from($file->getPathname(), $key);
+ }
+ }
+ }
+ }
+
+ /**
+ * Merge config from the given path recursively.
+ */
+ protected function merge_config_from(string $path, string $key): void
+ {
+ $existing = config($key, []);
+ $module_config = require $path;
+
+ config([$key => array_replace_recursive($existing, $module_config)]);
+ }
+
+ /**
+ * Register views.
+ */
+ public function registerViews(): void
+ {
+ $viewPath = resource_path('views/modules/'.$this->nameLower);
+ $sourcePath = module_path($this->name, 'Resources/Views');
+
+ $this->publishes([$sourcePath => $viewPath], ['views', $this->nameLower.'-module-views']);
+
+ $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), $this->nameLower);
+
+ Blade::componentNamespace(config('modules.namespace').'\\' . $this->name . '\\View\\Components', $this->nameLower);
+ }
+
+ /**
+ * Get the services provided by the provider.
+ */
+ public function provides(): array
+ {
+ return [];
+ }
+
+ private function getPublishableViewPaths(): array
+ {
+ $paths = [];
+ foreach (config('view.paths') as $path) {
+ if (is_dir($path.'/modules/'.$this->nameLower)) {
+ $paths[] = $path.'/modules/'.$this->nameLower;
+ }
+ }
+
+ return $paths;
+ }
+}
diff --git a/be/Modules/MembershipApplication/Providers/RouteServiceProvider.php b/be/Modules/MembershipApplication/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..41c7cf8
--- /dev/null
+++ b/be/Modules/MembershipApplication/Providers/RouteServiceProvider.php
@@ -0,0 +1,50 @@
+mapApiRoutes();
+
+ }
+
+ /**
+ * Define the "web" routes for the application.
+ *
+ * These routes all receive session state, CSRF protection, etc.
+ */
+ protected function mapWebRoutes(): void
+ {
+ Route::middleware('web')->group(module_path($this->name, '/Routes/web.php'));
+ }
+
+ /**
+ * Define the "api" routes for the application.
+ *
+ * These routes are typically stateless.
+ */
+ protected function mapApiRoutes(): void
+ {
+ Route::middleware('api')->name('api.')->group(module_path($this->name, '/Routes/api.php'));
+ }
+}
diff --git a/be/Modules/MembershipApplication/Repositories/.gitkeep b/be/Modules/MembershipApplication/Repositories/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/MembershipApplication/Repositories/Contracts/.gitkeep b/be/Modules/MembershipApplication/Repositories/Contracts/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/MembershipApplication/Repositories/Contracts/MembershipApplicationRepositoryInterface.php b/be/Modules/MembershipApplication/Repositories/Contracts/MembershipApplicationRepositoryInterface.php
new file mode 100644
index 0000000..61ee511
--- /dev/null
+++ b/be/Modules/MembershipApplication/Repositories/Contracts/MembershipApplicationRepositoryInterface.php
@@ -0,0 +1,23 @@
+find($id);
+ }
+
+ public function getAllPaginated(
+ int $perPage = 10,
+ string $search = '',
+ ?string $status = null,
+ string $sortBy = 'submitted_at',
+ string $sortOrder = 'desc'
+ ): LengthAwarePaginator {
+ $allowedSortColumns = [
+ 'application_number',
+ 'status',
+ 'submitted_at',
+ 'created_at',
+ ];
+ $sortBy = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'submitted_at';
+ $sortOrder = strtolower($sortOrder) === 'asc' ? 'asc' : 'desc';
+
+ $query = MembershipApplication::query()
+ ->with(['applicant:id,membership_application_id,name,email,ic_number'])
+ ->orderBy($sortBy, $sortOrder);
+
+ if ($status !== null && $status !== '') {
+ $query->where('status', $status);
+ }
+
+ if ($search !== '') {
+ $query->where(function ($q) use ($search) {
+ $q->where('application_number', 'ILIKE', "%{$search}%")
+ ->orWhereHas('applicant', function ($applicantQuery) use ($search) {
+ $applicantQuery->where('name', 'ILIKE', "%{$search}%")
+ ->orWhere('email', 'ILIKE', "%{$search}%")
+ ->orWhere('ic_number', 'ILIKE', "%{$search}%");
+ });
+ });
+ }
+
+ return $query->paginate($perPage);
+ }
+}
diff --git a/be/Modules/MembershipApplication/Resources/views/emails/failed.blade.php b/be/Modules/MembershipApplication/Resources/views/emails/failed.blade.php
new file mode 100644
index 0000000..62b70a8
--- /dev/null
+++ b/be/Modules/MembershipApplication/Resources/views/emails/failed.blade.php
@@ -0,0 +1,18 @@
+@component('mail::message')
+@if (! empty($logoPath) && file_exists($logoPath))
+
+
+
+@endif
+
+# Keputusan Permohonan Keahlian
+
+Assalamualaikum **{{ $name }}**,
+
+Permohonan keahlian anda (**{{ $applicationNumber }}**) **tidak diluluskan** pada peringkat lembaga.
+
+Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi.
+
+Terima kasih,
+{{ config('app.name') }}
+@endcomponent
diff --git a/be/Modules/MembershipApplication/Resources/views/emails/passed.blade.php b/be/Modules/MembershipApplication/Resources/views/emails/passed.blade.php
new file mode 100644
index 0000000..3c962c2
--- /dev/null
+++ b/be/Modules/MembershipApplication/Resources/views/emails/passed.blade.php
@@ -0,0 +1,18 @@
+@component('mail::message')
+@if (! empty($logoPath) && file_exists($logoPath))
+
+
+
+@endif
+
+# Keputusan Permohonan Keahlian
+
+Assalamualaikum **{{ $name }}**,
+
+Permohonan keahlian anda (**{{ $applicationNumber }}**) **diluluskan** pada peringkat lembaga.
+
+Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi.
+
+Terima kasih,
+{{ config('app.name') }}
+@endcomponent
diff --git a/be/Modules/MembershipApplication/Resources/views/emails/submitted.blade.php b/be/Modules/MembershipApplication/Resources/views/emails/submitted.blade.php
new file mode 100644
index 0000000..7a75b9e
--- /dev/null
+++ b/be/Modules/MembershipApplication/Resources/views/emails/submitted.blade.php
@@ -0,0 +1,79 @@
+@component('mail::message')
+@if (! empty($logoPath) && file_exists($logoPath))
+
+
+
+@endif
+
+# Pengesahan Permohonan Keahlian
+
+Assalamualaikum **{{ $applicant->name }}**,
+
+Terima kasih. Permohonan keahlian anda telah berjaya diterima.
+
+**No. Permohonan:** {{ $application->application_number }}
+
+Berikut adalah ringkasan maklumat yang anda hantar:
+
+## Maklumat Peribadi
+
+- **Nama:** {{ $applicant->name }}
+- **Emel:** {{ $applicant->email }}
+- **No. Kad Pengenalan:** {{ $applicant->ic_number }}
+- **Tarikh Lahir:** {{ $applicant->birth_date }}
+- **Tempat Lahir:** {{ $applicant->birth_place }}
+- **Jantina:** {{ $applicant->gender }}
+- **Status Perkahwinan:** {{ $applicant->marriage_status }}
+
+## Hubungan & Alamat
+
+- **Alamat:** {{ $applicant->address }}
+- **Poskod:** {{ $applicant->postcode }}
+- **No. Telefon:** {{ $applicant->phone_number }}
+- **No. Pejabat:** {{ $applicant->office_number ?: '-' }}
+
+## Maklumat Pekerjaan & Caruman
+
+- **Nama Majikan:** {{ $applicant->employer_name }}
+- **Alamat Majikan:** {{ $applicant->employer_address }}
+- **Jawatan Semasa:** {{ $applicant->current_position }}
+- **Tarikh Mula Berkhidmat:** {{ $applicant->start_work_date?->format('d/m/Y') ?? $applicant->start_work_date }}
+- **Caruman Saham (RM):** {{ number_format((float) $applicant->stock_monthly_contribution, 2) }}
+- **Caruman Yuran (RM):** {{ number_format((float) $applicant->fee_monthly_contribution, 2) }}
+
+## Maklumat Waris
+
+@forelse ($heirs as $index => $heir)
+**Waris {{ $index + 1 }}**
+- **Nama:** {{ $heir->name }}
+- **No. Kad Pengenalan:** {{ $heir->ic_number }}
+- **Hubungan:** {{ $heir->relationship }}
+- **No. Telefon:** {{ $heir->phone_number }}
+
+@empty
+Tiada maklumat waris.
+@endforelse
+
+## Pencadang & Penyokong
+
+@php
+ $proposer = $application->references->first(fn ($ref) => ($ref->reference_type?->value ?? $ref->reference_type) === 'PROPOSER');
+ $supporter = $application->references->first(fn ($ref) => ($ref->reference_type?->value ?? $ref->reference_type) === 'SUPPORTER');
+@endphp
+
+- **Pencadang:** {{ $proposer?->member?->name ? $proposer->member->name . ' (' . $proposer->member->ic_number . ')' : '-' }}
+- **Penyokong:** {{ $supporter?->member?->name ? $supporter->member->name . ' (' . $supporter->member->ic_number . ')' : '-' }}
+
+## Dokumen Dimuat Naik
+
+@forelse ($documents as $document)
+- **{{ $documentLabels[$document->type] ?? $document->type }}:** {{ $document->name }}
+@empty
+Tiada dokumen.
+@endforelse
+
+Anda akan menerima emel apabila keputusan permohonan tersedia.
+
+Terima kasih,
+{{ config('app.name') }}
+@endcomponent
diff --git a/be/Modules/MembershipApplication/Resources/views/pdf/approved-letter.blade.php b/be/Modules/MembershipApplication/Resources/views/pdf/approved-letter.blade.php
new file mode 100644
index 0000000..3358ce7
--- /dev/null
+++ b/be/Modules/MembershipApplication/Resources/views/pdf/approved-letter.blade.php
@@ -0,0 +1,12 @@
+{{--
+ Approval letter — extends the shared base layout.
+ Fill in sections below when the final design is ready.
+--}}
+@extends('pdf.layouts.letter')
+
+{{-- @section('letter-reference', $application->application_number) --}}
+{{-- @section('letter-date', ...) --}}
+{{-- @section('letter-recipient') ... @endsection --}}
+{{-- @section('letter-subject') PERKARA: ... @endsection --}}
+{{-- @section('letter-body') ... @endsection --}}
+{{-- @section('letter-signature') ... @endsection --}}
diff --git a/be/Modules/MembershipApplication/Routes/.gitkeep b/be/Modules/MembershipApplication/Routes/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/MembershipApplication/Routes/api.php b/be/Modules/MembershipApplication/Routes/api.php
new file mode 100644
index 0000000..7989d78
--- /dev/null
+++ b/be/Modules/MembershipApplication/Routes/api.php
@@ -0,0 +1,46 @@
+group(function () {
+ Route::middleware('throttle:membership-application-lookup')->get('membership-applications/member-lookup',[PublicMembershipApplicationController::class, 'lookupMember'])->name('membership-application.public.member-lookup');
+
+ Route::middleware('throttle:membership-application-submit')->post('membership-applications',[PublicMembershipApplicationController::class, 'store'])->name('membership-application.public.store');
+});
+
+// admin routes
+Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(function () {
+ // Membership application management routes
+ Route::get('membership-applications', [MembershipApplicationController::class, 'index'])->name('membership-application.index');
+
+ // Membership application detail routes
+ Route::get('membership-applications/{membershipApplication}', [MembershipApplicationController::class, 'show'])->name('membership-application.show');
+
+ // Membership application update routes - admin
+ Route::patch('membership-applications/{membershipApplication}', [MembershipApplicationController::class, 'update'])->name('membership-application.update');
+
+ // Membership application management review routes (level 1)
+ Route::post('membership-applications/{membershipApplication}/management-review', [MembershipApplicationController::class, 'managementReview'])->name('membership-application.management-review');
+
+ // Membership application board review routes (level 2)
+ Route::post('membership-applications/{membershipApplication}/board-review', [MembershipApplicationController::class, 'boardReview'])->name('membership-application.board-review');
+
+ // Membership application assign references routes
+ Route::put('membership-applications/{membershipApplication}/references', [MembershipApplicationController::class, 'assignReferences'])->name('membership-application.assign-references');
+
+ // Membership application batch complete routes (level 3)
+ Route::post('membership-applications/batch-complete', [MembershipApplicationController::class, 'batchComplete'])->name('membership-application.batch-complete');
+ // Membership application complete routes (level 3)
+ Route::post('membership-applications/{membershipApplication}/complete', [MembershipApplicationController::class, 'complete'])->name('membership-application.complete');
+
+ // Membership application upload document routes
+ Route::post('membership-applications/{membershipApplication}/documents', [MembershipApplicationController::class, 'uploadDocument'])->name('membership-application.upload-document');
+ // download document routes
+ Route::get('membership-applications/{membershipApplication}/documents/{documentId}/download', [MembershipApplicationController::class, 'downloadDocument'])->name('membership-application.download-document');
+
+ // approved letter preview (html or pdf)
+ Route::get('membership-applications/{membershipApplication}/approved-letter', [MembershipApplicationController::class, 'approvedLetter'])->name('membership-application.approved-letter');
+});
diff --git a/be/Modules/MembershipApplication/Routes/web.php b/be/Modules/MembershipApplication/Routes/web.php
new file mode 100644
index 0000000..ccef19d
--- /dev/null
+++ b/be/Modules/MembershipApplication/Routes/web.php
@@ -0,0 +1,8 @@
+group(function () {
+ Route::resource('membershipapplications', MembershipApplicationController::class)->names('membershipapplication');
+});
diff --git a/be/Modules/MembershipApplication/Services/.gitkeep b/be/Modules/MembershipApplication/Services/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/MembershipApplication/Services/MembershipApplicationService.php b/be/Modules/MembershipApplication/Services/MembershipApplicationService.php
new file mode 100644
index 0000000..7cb0712
--- /dev/null
+++ b/be/Modules/MembershipApplication/Services/MembershipApplicationService.php
@@ -0,0 +1,478 @@
+,
+ * heirs: list>,
+ * references?: array{proposer_ic_number?: string|null, supporter_ic_number?: string|null}
+ * } $data
+ * @param array $documents
+ */
+ public function submit(array $data, array $documents): MembershipApplication
+ {
+ $application = DB::transaction(function () use ($data, $documents) {
+ // membership_applications table
+ $application = $this->repository->create([
+ 'application_number' => $this->generateApplicationNumber(),
+ 'status' => ApplicationStatus::Submitted,
+ 'board_result' => '',
+ 'submitted_at' => now()->toDateTimeString(),
+ 'completed_at' => '',
+ ]);
+
+ // membership_application_applicants table
+ $application->applicant()->create($data['applicant']);
+
+ // membership_application_heirs table
+ foreach ($data['heirs'] as $heir) {
+ $application->heirs()->create($heir);
+ }
+
+ // membership_application_references table
+ $this->syncReferences(
+ $application,
+ $data['references'] ?? [],
+ (string) ($data['applicant']['ic_number'] ?? ''),
+ );
+
+ // documents table
+ foreach ($documents as $type => $file) {
+ $this->documentService->validateFile($file);
+ $this->documentService->uploadDocument($application, $file, $type);
+ }
+
+ return $this->repository->findByIdWithRelations($application->id);
+ });
+
+ $this->sendSubmissionConfirmation($application);
+
+ return $application;
+ }
+
+ public function getPaginatedList(int $perPage, string $search, ?string $status, string $sortBy, string $sortOrder): LengthAwarePaginator
+ {
+ return $this->repository->getAllPaginated($perPage, $search, $status, $sortBy, $sortOrder);
+ }
+
+ public function getByIdWithRelations(string $id): ?MembershipApplication
+ {
+ return $this->repository->findByIdWithRelations($id);
+ }
+
+ public function update(MembershipApplication $application, array $data): MembershipApplication
+ {
+ if ($application->status === ApplicationStatus::Completed) {
+ throw ValidationException::withMessages([
+ 'status' => ['Permohonan yang telah selesai tidak boleh dikemaskini.'],
+ ]);
+ }
+
+ return DB::transaction(function () use ($application, $data) {
+ $application->loadMissing('applicant');
+
+ if (isset($data['applicant'])) {
+ $application->applicant->update($data['applicant']);
+ }
+
+ if (isset($data['heirs'])) {
+ $application->heirs()->delete();
+ foreach ($data['heirs'] as $heir) {
+ $application->heirs()->create($heir);
+ }
+ }
+
+ if (array_key_exists('references', $data)) {
+ $this->syncReferences(
+ $application,
+ $data['references'] ?? [],
+ (string) ($data['applicant']['ic_number'] ?? $application->applicant->ic_number),
+ );
+ }
+
+ return $this->repository->findByIdWithRelations($application->id);
+ });
+ }
+
+ public function uploadDocument(MembershipApplication $application, string $type, UploadedFile $file): MembershipApplication
+ {
+ if ($application->status === ApplicationStatus::Completed) {
+ throw ValidationException::withMessages([
+ 'status' => ['Permohonan yang telah selesai tidak boleh dikemaskini.'],
+ ]);
+ }
+
+ $this->documentService->validateFile($file);
+
+ $application->documentsOfType($type)->get()->each(
+ fn ($document) => $this->documentService->deleteDocument($document->id)
+ );
+
+ $this->documentService->uploadDocument($application, $file, $type);
+
+ return $this->repository->findByIdWithRelations($application->id);
+ }
+
+ public function managementReview(MembershipApplication $application, ManagementDecision $decision, ?string $remarks, User $reviewer): MembershipApplication {
+ if ($application->status !== ApplicationStatus::Submitted) {
+ throw ValidationException::withMessages([
+ 'status' => ['Permohonan ini tidak boleh disemak pada peringkat pentadbiran.'],
+ ]);
+ }
+
+ return DB::transaction(function () use ($application, $decision, $remarks, $reviewer) {
+ $application->reviews()->create([
+ 'stage' => ReviewStage::Management,
+ 'decision' => $decision->value,
+ 'remarks' => $remarks,
+ 'reviewer_id' => $reviewer->id,
+ 'reviewed_at' => now(),
+ ]);
+
+ $application->update([
+ 'status' => $decision === ManagementDecision::Approved
+ ? ApplicationStatus::PendingBoard
+ : ApplicationStatus::ManagementRejected,
+ ]);
+
+ return $this->repository->findByIdWithRelations($application->id);
+ });
+ }
+
+ public function boardReview(MembershipApplication $application, BoardDecision $decision, ?string $remarks, User $reviewer): MembershipApplication
+ {
+ if ($application->status !== ApplicationStatus::PendingBoard) {
+ throw ValidationException::withMessages([
+ 'status' => ['Permohonan ini tidak boleh disemak pada peringkat lembaga.'],
+ ]);
+ }
+
+ return DB::transaction(function () use ($application, $decision, $remarks, $reviewer) {
+ $application->reviews()->create([
+ 'stage' => ReviewStage::Board,
+ 'decision' => $decision->value,
+ 'remarks' => $remarks,
+ 'reviewer_id' => $reviewer->id,
+ 'reviewed_at' => now(),
+ ]);
+
+ $application->update([
+ 'board_result' => $decision === BoardDecision::Pass
+ ? BoardResult::Pass->value
+ : BoardResult::Fail->value,
+ 'status' => ApplicationStatus::PendingNotification,
+ ]);
+
+ return $this->repository->findByIdWithRelations($application->id);
+ });
+ }
+
+ /**
+ * @param array{proposer_id?: string|null, supporter_id?: string|null} $references
+ */
+ public function assignReferences(MembershipApplication $application, array $references, User $assignedBy): MembershipApplication
+ {
+ return DB::transaction(function () use ($application, $references, $assignedBy) {
+ $referenceMap = [
+ 'proposer_id' => ReferenceType::Proposer,
+ 'supporter_id' => ReferenceType::Supporter,
+ ];
+
+ foreach ($referenceMap as $key => $type) {
+ if (! array_key_exists($key, $references)) {
+ continue;
+ }
+
+ $application->references()->updateOrCreate(
+ ['reference_type' => $type->value],
+ [
+ 'user_id' => $references[$key],
+ 'assigned_by' => $references[$key] ? $assignedBy->id : null,
+ 'assigned_at' => $references[$key] ? now() : null,
+ ]
+ );
+ }
+
+ return $this->repository->findByIdWithRelations($application->id);
+ });
+ }
+
+ public function complete(MembershipApplication $application, User $processedBy): MembershipApplication
+ {
+ if ($application->status !== ApplicationStatus::PendingNotification) {
+ throw ValidationException::withMessages([
+ 'status' => ['Permohonan ini tidak boleh diselesaikan pada masa ini.'],
+ ]);
+ }
+
+ if (! in_array($application->board_result, [BoardResult::Pass->value, BoardResult::Fail->value], true)) {
+ throw ValidationException::withMessages([
+ 'board_result' => ['Keputusan lembaga belum ditetapkan.'],
+ ]);
+ }
+
+ return DB::transaction(function () use ($application) {
+ if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) {
+ $user = $this->createMemberFromApplication($application);
+ $application->update(['user_id' => $user->id]);
+ }
+
+ $this->sendResultNotification($application);
+
+ $application->update([
+ 'status' => ApplicationStatus::Completed,
+ 'completed_at' => now()->toDateTimeString(),
+ ]);
+
+ return $this->repository->findByIdWithRelations($application->id);
+ });
+ }
+
+ /**
+ * @param list $applicationIds
+ * @return array{
+ * succeeded: list,
+ * failed: list
+ * }
+ */
+ public function completeBatch(array $applicationIds, User $processedBy): array
+ {
+ $succeeded = [];
+ $failed = [];
+
+ foreach ($applicationIds as $id) {
+ $application = $this->repository->findById($id);
+
+ if (! $application) {
+ $failed[] = [
+ 'id' => $id,
+ 'application_number' => null,
+ 'message' => 'Permohonan tidak dijumpai.',
+ ];
+
+ continue;
+ }
+
+ try {
+ $succeeded[] = $this->complete($application, $processedBy);
+ } catch (ValidationException $e) {
+ $failed[] = [
+ 'id' => $id,
+ 'application_number' => $application->application_number,
+ 'message' => collect($e->errors())->flatten()->first() ?? 'Permohonan tidak boleh diselesaikan.',
+ ];
+ }
+ }
+
+ return [
+ 'succeeded' => $succeeded,
+ 'failed' => $failed,
+ ];
+ }
+
+ protected function createMemberFromApplication(MembershipApplication $application): User
+ {
+ $application->loadMissing(['applicant', 'heirs']);
+ $applicant = $application->applicant;
+
+ if (User::where('email', $applicant->email)->exists()) {
+ throw ValidationException::withMessages([
+ 'email' => ['E-mel pemohon sudah didaftarkan dalam sistem.'],
+ ]);
+ }
+
+ $user = User::create([
+ 'name' => $applicant->name,
+ 'email' => $applicant->email,
+ 'password' => Hash::make(Str::password(16)),
+ 'ic_number' => $applicant->ic_number,
+ 'phone_number' => $applicant->phone_number,
+ 'position' => $applicant->current_position,
+ 'status' => 'active',
+ 'gender' => $applicant->gender,
+ 'marriage_status' => $applicant->marriage_status,
+ 'member_number' => $this->generateMemberNumber(),
+ 'member_type' => 'Anggota',
+ 'join_date' => now()->toDateString(),
+ 'birth_date' => $applicant->birth_date,
+ 'birth_place' => $applicant->birth_place,
+ ]);
+
+ $role = Role::where('name', 'Anggota')->first();
+ if ($role) {
+ $user->assignRole($role);
+ }
+
+ $user->addresses()->create([
+ 'address_type' => 'home',
+ 'address_line_1' => $applicant->address,
+ 'postcode' => $applicant->postcode,
+ ]);
+
+ $user->employments()->create([
+ 'company_name' => $applicant->employer_name,
+ 'job_title' => $applicant->current_position,
+ 'employment_type' => 'Permanent',
+ 'salary' => 0,
+ 'start_date' => $applicant->start_work_date,
+ 'is_current' => true,
+ ]);
+
+ foreach ($application->heirs as $index => $heir) {
+ $user->heirs()->create([
+ 'name' => $heir->name,
+ 'ic_number' => $heir->ic_number,
+ 'relationship' => $heir->relationship,
+ 'phone_number' => $heir->phone_number,
+ 'is_primary' => $index === 0,
+ ]);
+ }
+
+ app(EmailVerificationOtpService::class)->send($user);
+
+ return $user;
+ }
+
+ public function lookupMemberByIcNumber(string $icNumber): ?User
+ {
+ $icNumber = trim($icNumber);
+
+ if ($icNumber === '') {
+ return null;
+ }
+
+ return User::query()
+ ->where('ic_number', $icNumber)
+ ->where('status', 'active')
+ ->first();
+ }
+
+ protected function sendSubmissionConfirmation(MembershipApplication $application): void
+ {
+ $application->loadMissing(['applicant', 'heirs', 'documents', 'references.member']);
+
+ Notification::route('mail', $application->applicant->email)
+ ->notify(new MembershipApplicationSubmittedNotification($application));
+ }
+
+ protected function sendResultNotification(MembershipApplication $application): void
+ {
+ $application->loadMissing('applicant');
+
+ $notification = $application->board_result === BoardResult::Pass->value
+ ? new MembershipApplicationPassedNotification($application)
+ : new MembershipApplicationFailedNotification($application);
+
+ Notification::route('mail', $application->applicant->email)->notify($notification);
+ }
+
+ protected function generateMemberNumber(): int
+ {
+ return ((int) User::max('member_number')) + 1;
+ }
+
+ /**
+ * @param array{proposer_ic_number?: string|null, supporter_ic_number?: string|null} $references
+ */
+ protected function syncReferences(MembershipApplication $application, array $references, string $applicantIcNumber): void
+ {
+ $referenceMap = [
+ 'references.proposer_ic_number' => ['field' => 'proposer_ic_number', 'type' => ReferenceType::Proposer],
+ 'references.supporter_ic_number' => ['field' => 'supporter_ic_number', 'type' => ReferenceType::Supporter],
+ ];
+
+ $resolvedUserIds = [];
+ $applicantIcNumber = trim($applicantIcNumber);
+
+ foreach ($referenceMap as $errorKey => $config) {
+ if (! array_key_exists($config['field'], $references)) {
+ continue;
+ }
+
+ $icNumber = trim((string) ($references[$config['field']] ?? ''));
+
+ if ($icNumber === '') {
+ $application->references()->where('reference_type', $config['type']->value)->delete();
+
+ continue;
+ }
+
+ if ($applicantIcNumber !== '' && strcasecmp($icNumber, $applicantIcNumber) === 0) {
+ throw ValidationException::withMessages([
+ $errorKey => ['Pencadang/penyokong tidak boleh sama dengan pemohon.'],
+ ]);
+ }
+
+ $member = $this->lookupMemberByIcNumber($icNumber);
+
+ if (! $member) {
+ throw ValidationException::withMessages([
+ $errorKey => ['Ahli dengan nombor kad pengenalan ini tidak dijumpai.'],
+ ]);
+ }
+
+ if (in_array($member->id, $resolvedUserIds, true)) {
+ throw ValidationException::withMessages([
+ $errorKey => ['Pencadang dan penyokong mestilah ahli yang berbeza.'],
+ ]);
+ }
+
+ $resolvedUserIds[] = $member->id;
+
+ $application->references()->updateOrCreate(
+ ['reference_type' => $config['type']->value],
+ ['user_id' => $member->id]
+ );
+ }
+ }
+
+ protected function generateApplicationNumber(): string
+ {
+ $year = now()->year;
+ $prefix = "APP-{$year}-";
+
+ $latestNumber = MembershipApplication::query()
+ ->where('application_number', 'like', "{$prefix}%")
+ ->orderByDesc('application_number')
+ ->value('application_number');
+
+ $sequence = 1;
+
+ if ($latestNumber && preg_match('/-(\d+)$/', $latestNumber, $matches)) {
+ $sequence = ((int) $matches[1]) + 1;
+ }
+
+ return sprintf('%s%05d', $prefix, $sequence);
+ }
+}
diff --git a/be/Modules/MembershipApplication/Tests/Feature/.gitkeep b/be/Modules/MembershipApplication/Tests/Feature/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/MembershipApplication/Tests/Unit/.gitkeep b/be/Modules/MembershipApplication/Tests/Unit/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/MembershipApplication/Transformers/.gitkeep b/be/Modules/MembershipApplication/Transformers/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/be/Modules/MembershipApplication/Transformers/MembershipApplicationApplicantResource.php b/be/Modules/MembershipApplication/Transformers/MembershipApplicationApplicantResource.php
new file mode 100644
index 0000000..cea8e34
--- /dev/null
+++ b/be/Modules/MembershipApplication/Transformers/MembershipApplicationApplicantResource.php
@@ -0,0 +1,33 @@
+ $this->id,
+ 'name' => $this->name,
+ 'email' => $this->email,
+ 'ic_number' => $this->ic_number,
+ 'birth_date' => $this->birth_date,
+ 'birth_place' => $this->birth_place,
+ 'gender' => $this->gender,
+ 'marriage_status' => $this->marriage_status,
+ 'address' => $this->address,
+ 'phone_number' => $this->phone_number,
+ 'office_number' => $this->office_number,
+ 'postcode' => $this->postcode,
+ 'employer_name' => $this->employer_name,
+ 'employer_address' => $this->employer_address,
+ 'current_position' => $this->current_position,
+ 'start_work_date' => $this->start_work_date?->toDateString(),
+ 'stock_monthly_contribution' => $this->stock_monthly_contribution,
+ 'fee_monthly_contribution' => $this->fee_monthly_contribution,
+ ];
+ }
+}
diff --git a/be/Modules/MembershipApplication/Transformers/MembershipApplicationDocumentResource.php b/be/Modules/MembershipApplication/Transformers/MembershipApplicationDocumentResource.php
new file mode 100644
index 0000000..571a488
--- /dev/null
+++ b/be/Modules/MembershipApplication/Transformers/MembershipApplicationDocumentResource.php
@@ -0,0 +1,20 @@
+ $this->id,
+ 'name' => $this->name,
+ 'type' => $this->type,
+ 'mime_type' => $this->mime_type,
+ 'file_size' => $this->file_size,
+ ];
+ }
+}
diff --git a/be/Modules/MembershipApplication/Transformers/MembershipApplicationHeirResource.php b/be/Modules/MembershipApplication/Transformers/MembershipApplicationHeirResource.php
new file mode 100644
index 0000000..dc03a53
--- /dev/null
+++ b/be/Modules/MembershipApplication/Transformers/MembershipApplicationHeirResource.php
@@ -0,0 +1,20 @@
+ $this->id,
+ 'name' => $this->name,
+ 'ic_number' => $this->ic_number,
+ 'relationship' => $this->relationship,
+ 'phone_number' => $this->phone_number,
+ ];
+ }
+}
diff --git a/be/Modules/MembershipApplication/Transformers/MembershipApplicationListResource.php b/be/Modules/MembershipApplication/Transformers/MembershipApplicationListResource.php
new file mode 100644
index 0000000..eafacb6
--- /dev/null
+++ b/be/Modules/MembershipApplication/Transformers/MembershipApplicationListResource.php
@@ -0,0 +1,26 @@
+ $this->id,
+ 'application_number' => $this->application_number,
+ 'status' => $this->status?->value ?? $this->status,
+ 'board_result' => $this->board_result ?: null,
+ 'submitted_at' => $this->submitted_at,
+ 'applicant' => $this->whenLoaded('applicant', fn () => [
+ 'name' => $this->applicant->name,
+ 'email' => $this->applicant->email,
+ 'ic_number' => $this->applicant->ic_number,
+ ]),
+ 'created_at' => $this->created_at?->toISOString(),
+ ];
+ }
+}
diff --git a/be/Modules/MembershipApplication/Transformers/MembershipApplicationReferenceResource.php b/be/Modules/MembershipApplication/Transformers/MembershipApplicationReferenceResource.php
new file mode 100644
index 0000000..5a49e11
--- /dev/null
+++ b/be/Modules/MembershipApplication/Transformers/MembershipApplicationReferenceResource.php
@@ -0,0 +1,25 @@
+ $this->id,
+ 'reference_type' => $this->reference_type?->value ?? $this->reference_type,
+ 'user_id' => $this->user_id,
+ 'member' => $this->whenLoaded('member', fn () => [
+ 'id' => $this->member->id,
+ 'name' => $this->member->name,
+ 'ic_number' => $this->member->ic_number,
+ ]),
+ 'assigned_by' => $this->assigned_by,
+ 'assigned_at' => $this->assigned_at?->toISOString(),
+ ];
+ }
+}
diff --git a/be/Modules/MembershipApplication/Transformers/MembershipApplicationResource.php b/be/Modules/MembershipApplication/Transformers/MembershipApplicationResource.php
new file mode 100644
index 0000000..49989e3
--- /dev/null
+++ b/be/Modules/MembershipApplication/Transformers/MembershipApplicationResource.php
@@ -0,0 +1,28 @@
+ $this->id,
+ 'application_number' => $this->application_number,
+ 'status' => $this->status?->value ?? $this->status,
+ 'board_result' => $this->board_result ?: null,
+ 'submitted_at' => $this->submitted_at,
+ 'completed_at' => $this->completed_at ?: null,
+ 'applicant' => new MembershipApplicationApplicantResource($this->whenLoaded('applicant')),
+ 'heirs' => MembershipApplicationHeirResource::collection($this->whenLoaded('heirs')),
+ 'references' => MembershipApplicationReferenceResource::collection($this->whenLoaded('references')),
+ 'documents' => MembershipApplicationDocumentResource::collection($this->whenLoaded('documents')),
+ 'reviews' => MembershipApplicationReviewResource::collection($this->whenLoaded('reviews')),
+ 'created_at' => $this->created_at?->toISOString(),
+ 'updated_at' => $this->updated_at?->toISOString(),
+ ];
+ }
+}
diff --git a/be/Modules/MembershipApplication/Transformers/MembershipApplicationReviewResource.php b/be/Modules/MembershipApplication/Transformers/MembershipApplicationReviewResource.php
new file mode 100644
index 0000000..ba7843e
--- /dev/null
+++ b/be/Modules/MembershipApplication/Transformers/MembershipApplicationReviewResource.php
@@ -0,0 +1,24 @@
+ $this->id,
+ 'stage' => $this->stage?->value ?? $this->stage,
+ 'decision' => $this->decision,
+ 'remarks' => $this->remarks,
+ 'reviewer' => $this->whenLoaded('reviewer', fn () => [
+ 'id' => $this->reviewer->id,
+ 'name' => $this->reviewer->name,
+ ]),
+ 'reviewed_at' => $this->reviewed_at?->toISOString(),
+ ];
+ }
+}
diff --git a/be/Modules/MembershipApplication/composer.json b/be/Modules/MembershipApplication/composer.json
new file mode 100644
index 0000000..165fcd1
--- /dev/null
+++ b/be/Modules/MembershipApplication/composer.json
@@ -0,0 +1,30 @@
+{
+ "name": "nwidart/membershipapplication",
+ "description": "",
+ "authors": [
+ {
+ "name": "Nicolas Widart",
+ "email": "n.widart@gmail.com"
+ }
+ ],
+ "extra": {
+ "laravel": {
+ "providers": [],
+ "aliases": {
+
+ }
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Modules\\MembershipApplication\\": "App",
+ "Modules\\MembershipApplication\\Database\\Factories\\": "database/factories/",
+ "Modules\\MembershipApplication\\Database\\Seeders\\": "database/seeders/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Modules\\MembershipApplication\\Tests\\": "tests/"
+ }
+ }
+}
diff --git a/be/Modules/MembershipApplication/module.json b/be/Modules/MembershipApplication/module.json
new file mode 100644
index 0000000..95a81e7
--- /dev/null
+++ b/be/Modules/MembershipApplication/module.json
@@ -0,0 +1,11 @@
+{
+ "name": "MembershipApplication",
+ "alias": "membershipapplication",
+ "description": "",
+ "keywords": [],
+ "priority": 0,
+ "providers": [
+ "Modules\\MembershipApplication\\Providers\\MembershipApplicationServiceProvider"
+ ],
+ "files": []
+}
diff --git a/be/Modules/MembershipApplication/package.json b/be/Modules/MembershipApplication/package.json
new file mode 100644
index 0000000..d6fbfc8
--- /dev/null
+++ b/be/Modules/MembershipApplication/package.json
@@ -0,0 +1,15 @@
+{
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build"
+ },
+ "devDependencies": {
+ "axios": "^1.1.2",
+ "laravel-vite-plugin": "^0.7.5",
+ "sass": "^1.69.5",
+ "postcss": "^8.3.7",
+ "vite": "^4.0.0"
+ }
+}
diff --git a/be/Modules/Role/Http/Controllers/RoleController.php b/be/Modules/Role/Http/Controllers/RoleController.php
index f9a03ed..6320d20 100644
--- a/be/Modules/Role/Http/Controllers/RoleController.php
+++ b/be/Modules/Role/Http/Controllers/RoleController.php
@@ -13,6 +13,7 @@ use Modules\Role\Http\Requests\RoleRequest;
use Modules\Role\Repositories\Contracts\RoleRepositoryInterface;
use Modules\Role\Transformers\RoleResource;
use App\Models\Permission;
+use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;
class RoleController extends BaseCrudController
@@ -104,6 +105,10 @@ class RoleController extends BaseCrudController
*/
public function permissions(): JsonResponse
{
+ if (! Gate::any(['create', 'update'], $this->modelClass)) {
+ abort(403);
+ }
+
$permissions = Permission::all();
return response()->json([
@@ -124,6 +129,8 @@ class RoleController extends BaseCrudController
*/
public function assignPermissions(Request $request, string $id): JsonResponse
{
+ $this->authorize('update', $this->modelClass);
+
$request->validate([
'permissions' => 'required|array',
'permissions.*' => ['required', 'uuid', Rule::exists('permissions', 'id')],
@@ -143,7 +150,9 @@ class RoleController extends BaseCrudController
* Get role with permissions
*/
public function show(string $id): JsonResponse
- {
+ {
+ $this->authorize('view', $this->modelClass);
+
$role = $this->repository->find($id);
return response()->json([
@@ -157,6 +166,8 @@ class RoleController extends BaseCrudController
*/
public function index(Request $request): JsonResponse
{
+ $this->authorize('viewAny', $this->modelClass);
+
$search = $request->get('search', '');
$roles = $this->repository->withPermissions($search);
@@ -165,17 +176,4 @@ class RoleController extends BaseCrudController
'data' => RoleResource::collection($roles),
]);
}
-
- /**
- * Public endpoint for registration form (no auth required)
- */
- public function publicRole(): JsonResponse
- {
- $roles = $this->repository->all();
-
- return response()->json([
- 'success' => true,
- 'data' => RoleResource::collection($roles),
- ]);
- }
}
diff --git a/be/Modules/Role/Policies/RolePolicy.php b/be/Modules/Role/Policies/RolePolicy.php
index 07a49df..b756050 100644
--- a/be/Modules/Role/Policies/RolePolicy.php
+++ b/be/Modules/Role/Policies/RolePolicy.php
@@ -14,7 +14,7 @@ class RolePolicy
*/
public function viewAny($user): bool
{
- return $user->hasPermissionTo('lihat jenis pengguna');
+ return $user->hasPermissionTo('lihat peranan');
}
/**
@@ -22,7 +22,7 @@ class RolePolicy
*/
public function view($user, ?Role $role = null): bool
{
- return $user->hasPermissionTo('lihat jenis pengguna');
+ return $user->hasPermissionTo('lihat peranan');
}
/**
@@ -30,7 +30,7 @@ class RolePolicy
*/
public function create($user): bool
{
- return $user->hasPermissionTo('tambah jenis pengguna');
+ return $user->hasPermissionTo('tambah peranan');
}
/**
@@ -38,7 +38,7 @@ class RolePolicy
*/
public function update($user, ?Role $role = null): bool
{
- return $user->hasPermissionTo('kemaskini jenis pengguna');
+ return $user->hasPermissionTo('kemaskini peranan');
}
/**
@@ -46,7 +46,7 @@ class RolePolicy
*/
public function deleteAny($user): bool
{
- return $user->hasPermissionTo('hapus jenis pengguna');
+ return $user->hasPermissionTo('hapus peranan');
}
/**
@@ -54,6 +54,6 @@ class RolePolicy
*/
public function delete($user, ?Role $role = null): bool
{
- return $user->hasPermissionTo('hapus jenis pengguna');
+ return $user->hasPermissionTo('hapus peranan');
}
}
diff --git a/be/Modules/Role/Routes/api.php b/be/Modules/Role/Routes/api.php
index d75036b..8a9d6bf 100644
--- a/be/Modules/Role/Routes/api.php
+++ b/be/Modules/Role/Routes/api.php
@@ -3,11 +3,6 @@
use Illuminate\Support\Facades\Route;
use Modules\Role\Http\Controllers\RoleController;
-// Public route for registration form (no auth required)
-Route::prefix('v1')->group(function () {
- Route::get('public/roles', [RoleController::class, 'publicRole']);
-});
-
// Protected routes (auth required)
Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
Route::apiResource('roles', RoleController::class)->names('role');
diff --git a/be/Modules/User/Database/Migrations/2026_06_09_025926_add_more_details_to_users_table.php b/be/Modules/User/Database/Migrations/2026_06_09_025926_add_more_details_to_users_table.php
new file mode 100644
index 0000000..7c400dc
--- /dev/null
+++ b/be/Modules/User/Database/Migrations/2026_06_09_025926_add_more_details_to_users_table.php
@@ -0,0 +1,61 @@
+string('marriage_status')->nullable() ->comment('Belum Berkahwin, Berkahwin, Bercerai, Balu, Duda');
+ $table->integer('member_number')->nullable();
+ $table->string('member_type')->nullable() ->comment('Anggota, Pesara');
+ // join date
+ $table->date('join_date')->nullable();
+ // birth date
+ $table->date('birth_date')->nullable();
+ // birth place
+ $table->string('birth_place')->nullable();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->dropColumn('gender');
+ $table->dropColumn('marriage_status');
+ $table->dropColumn('member_number');
+ $table->dropColumn('member_type');
+ $table->dropColumn('join_date');
+ $table->dropColumn('birth_date');
+ $table->dropColumn('birth_place');
+ });
+
+ DB::statement("DROP TYPE IF EXISTS user_gender_enum");
+ }
+};
diff --git a/be/Modules/User/Database/Migrations/2026_06_09_081007_create_addresses_table.php b/be/Modules/User/Database/Migrations/2026_06_09_081007_create_addresses_table.php
new file mode 100644
index 0000000..112319e
--- /dev/null
+++ b/be/Modules/User/Database/Migrations/2026_06_09_081007_create_addresses_table.php
@@ -0,0 +1,35 @@
+uuid('id')->primary();
+ $table->foreignUuid('user_id')->constrained('users')->onDelete('cascade');
+ $table->string('address_type')->comment('home, office, billing')->nullable();
+ $table->string('address_line_1')->nullable();
+ $table->string('address_line_2')->nullable();
+ $table->string('city')->nullable();
+ $table->string('state')->nullable();
+ $table->string('postcode')->nullable();
+ $table->string('country')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('addresses');
+ }
+};
diff --git a/be/Modules/User/Database/Migrations/2026_06_10_022314_create_employments_table.php b/be/Modules/User/Database/Migrations/2026_06_10_022314_create_employments_table.php
new file mode 100644
index 0000000..f216891
--- /dev/null
+++ b/be/Modules/User/Database/Migrations/2026_06_10_022314_create_employments_table.php
@@ -0,0 +1,35 @@
+uuid('id')->primary();
+ $table->foreignUuid('user_id')->constrained('users')->onDelete('cascade');
+ $table->string('company_name');
+ $table->string('job_title');
+ $table->string('employment_type')->comment('Permanent, Contract, Internship, Freelance');
+ $table->decimal('salary', 10, 2);
+ $table->date('start_date');
+ $table->date('end_date')->nullable();
+ $table->boolean('is_current')->default(true);
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('employments');
+ }
+};
diff --git a/be/Modules/User/Database/Migrations/2026_06_10_040351_create_banks_table.php b/be/Modules/User/Database/Migrations/2026_06_10_040351_create_banks_table.php
new file mode 100644
index 0000000..85b4398
--- /dev/null
+++ b/be/Modules/User/Database/Migrations/2026_06_10_040351_create_banks_table.php
@@ -0,0 +1,31 @@
+uuid('id')->primary();
+ $table->string('name');
+ $table->string('code');
+ $table->string('swift_code')->nullable();
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('banks');
+ }
+};
diff --git a/be/Modules/User/Database/Migrations/2026_06_10_040453_create_bank_details_table.php b/be/Modules/User/Database/Migrations/2026_06_10_040453_create_bank_details_table.php
new file mode 100644
index 0000000..d9a5ae2
--- /dev/null
+++ b/be/Modules/User/Database/Migrations/2026_06_10_040453_create_bank_details_table.php
@@ -0,0 +1,32 @@
+uuid('id')->primary();
+ $table->foreignUuid('user_id')->constrained('users')->onDelete('cascade');
+ $table->foreignUuid('bank_id')->constrained('banks')->onDelete('cascade');
+ $table->string('account_number');
+ $table->string('account_name');
+ $table->string('account_type')->comment('Saving, Current');
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('bank_details');
+ }
+};
diff --git a/be/Modules/User/Database/Migrations/2026_06_10_072612_create_heirs_table.php b/be/Modules/User/Database/Migrations/2026_06_10_072612_create_heirs_table.php
new file mode 100644
index 0000000..8761403
--- /dev/null
+++ b/be/Modules/User/Database/Migrations/2026_06_10_072612_create_heirs_table.php
@@ -0,0 +1,34 @@
+uuid('id')->primary();
+ $table->foreignUuid('user_id')->constrained('users')->onDelete('cascade');
+ $table->string('name');
+ $table->string('ic_number');
+ $table->string('relationship')->comment('Isteri, Anak, Orang Tua, Saudara, Lain-lain');
+ $table->string('phone_number');
+ $table->text('address')->nullable();
+ $table->boolean('is_primary')->default(false)->comment('Primary heir');
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('heirs');
+ }
+};
diff --git a/be/Modules/User/Entities/Address.php b/be/Modules/User/Entities/Address.php
new file mode 100644
index 0000000..b102768
--- /dev/null
+++ b/be/Modules/User/Entities/Address.php
@@ -0,0 +1,63 @@
+ */
+ use HasUuids;
+
+ protected $table = 'addresses';
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'user_id',
+ 'address_type',
+ 'address_line_1',
+ 'address_line_2',
+ 'city',
+ 'state',
+ 'postcode',
+ 'country',
+ ];
+
+ /**
+ * The attributes that should be hidden for serialization.
+ *
+ * @var list
+ */
+ protected $hidden = [
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'address_type' => 'string',
+ 'address_line_1' => 'string',
+ 'address_line_2' => 'string',
+ 'city' => 'string',
+ 'state' => 'string',
+ 'postcode' => 'string',
+ 'country' => 'string',
+ ];
+ }
+
+ public function user()
+ {
+ return $this->belongsTo(User::class);
+ }
+}
diff --git a/be/Modules/User/Entities/Bank.php b/be/Modules/User/Entities/Bank.php
new file mode 100644
index 0000000..d13930e
--- /dev/null
+++ b/be/Modules/User/Entities/Bank.php
@@ -0,0 +1,56 @@
+ */
+ use HasUuids;
+
+ protected $table = 'banks';
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'name',
+ 'code',
+ 'swift_code',
+ 'is_active',
+ ];
+
+ /**
+ * The attributes that should be hidden for serialization.
+ *
+ * @var list
+ */
+ protected $hidden = [
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'name' => 'string',
+ 'code' => 'string',
+ 'swift_code' => 'string',
+ 'is_active' => 'boolean',
+ ];
+ }
+
+ public function bankDetails()
+ {
+ return $this->hasMany(BankDetail::class);
+ }
+}
diff --git a/be/Modules/User/Entities/BankDetail.php b/be/Modules/User/Entities/BankDetail.php
new file mode 100644
index 0000000..8fa1c96
--- /dev/null
+++ b/be/Modules/User/Entities/BankDetail.php
@@ -0,0 +1,64 @@
+ */
+ use HasUuids;
+
+ protected $table = 'bank_details';
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'user_id',
+ 'bank_id',
+ 'account_name',
+ 'account_number',
+ 'account_type',
+ ];
+
+ /**
+ * The attributes that should be hidden for serialization.
+ *
+ * @var list
+ */
+ protected $hidden = [
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'user_id' => 'string',
+ 'bank_id' => 'string',
+ 'account_name' => 'string',
+ 'account_number' => 'string',
+ 'account_type' => 'string',
+ ];
+ }
+
+ public function user()
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public function bank()
+ {
+ return $this->belongsTo(Bank::class);
+ }
+}
diff --git a/be/Modules/User/Entities/Employment.php b/be/Modules/User/Entities/Employment.php
new file mode 100644
index 0000000..330dc13
--- /dev/null
+++ b/be/Modules/User/Entities/Employment.php
@@ -0,0 +1,63 @@
+ */
+ use HasUuids;
+
+ protected $table = 'employments';
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'user_id',
+ 'company_name',
+ 'job_title',
+ 'employment_type',
+ 'salary',
+ 'start_date',
+ 'end_date',
+ 'is_current',
+ ];
+
+ /**
+ * The attributes that should be hidden for serialization.
+ *
+ * @var list
+ */
+ protected $hidden = [
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'company_name' => 'string',
+ 'job_title' => 'string',
+ 'employment_type' => 'string',
+ 'salary' => 'decimal:2',
+ 'start_date' => 'date',
+ 'end_date' => 'date',
+ 'is_current' => 'boolean',
+ ];
+ }
+
+ public function user()
+ {
+ return $this->belongsTo(User::class);
+ }
+}
diff --git a/be/Modules/User/Entities/Heir.php b/be/Modules/User/Entities/Heir.php
new file mode 100644
index 0000000..b60110a
--- /dev/null
+++ b/be/Modules/User/Entities/Heir.php
@@ -0,0 +1,61 @@
+ */
+ use HasUuids;
+
+ protected $table = 'heirs';
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'user_id',
+ 'name',
+ 'ic_number',
+ 'relationship',
+ 'phone_number',
+ 'address',
+ 'is_primary',
+ ];
+
+ /**
+ * The attributes that should be hidden for serialization.
+ *
+ * @var list
+ */
+ protected $hidden = [
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'name' => 'string',
+ 'ic_number' => 'string',
+ 'relationship' => 'string',
+ 'phone_number' => 'string',
+ 'address' => 'string',
+ 'is_primary' => 'boolean',
+ ];
+ }
+
+ public function user()
+ {
+ return $this->belongsTo(User::class);
+ }
+}
diff --git a/be/Modules/User/Http/Controllers/AddressController.php b/be/Modules/User/Http/Controllers/AddressController.php
new file mode 100644
index 0000000..543dd02
--- /dev/null
+++ b/be/Modules/User/Http/Controllers/AddressController.php
@@ -0,0 +1,51 @@
+ auth()->id(),
+ ]);
+ }
+
+ protected function getItemName($item): string
+ {
+ return $item->address_type ?? $item->id;
+ }
+
+ protected function prepareUpdateData(array $validated, $address): array
+ {
+ return $validated;
+ }
+
+ protected function checkDependencies($address): ?JsonResponse
+ {
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/be/Modules/User/Http/Controllers/BankController.php b/be/Modules/User/Http/Controllers/BankController.php
new file mode 100644
index 0000000..fb7ee02
--- /dev/null
+++ b/be/Modules/User/Http/Controllers/BankController.php
@@ -0,0 +1,59 @@
+name ?? $item->id;
+ }
+
+ protected function prepareUpdateData(array $validated, $bank): array
+ {
+ return $validated;
+ }
+
+ protected function checkDependencies($bank): ?JsonResponse
+ {
+ return null;
+ }
+
+ public function active(): JsonResponse
+ {
+ $banks = $this->repository->active();
+
+ return response()->json([
+ 'success' => true,
+ 'data' => $this->resourceClass::collection($banks),
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/be/Modules/User/Http/Controllers/BankDetailController.php b/be/Modules/User/Http/Controllers/BankDetailController.php
new file mode 100644
index 0000000..edbf1e3
--- /dev/null
+++ b/be/Modules/User/Http/Controllers/BankDetailController.php
@@ -0,0 +1,51 @@
+ auth()->id(),
+ ]);
+ }
+
+ protected function getItemName($item): string
+ {
+ return $item->account_number ?? $item->id;
+ }
+
+ protected function prepareUpdateData(array $validated, $bankDetail): array
+ {
+ return $validated;
+ }
+
+ protected function checkDependencies($bankDetail): ?JsonResponse
+ {
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/be/Modules/User/Http/Controllers/EmploymentController.php b/be/Modules/User/Http/Controllers/EmploymentController.php
new file mode 100644
index 0000000..87b5a41
--- /dev/null
+++ b/be/Modules/User/Http/Controllers/EmploymentController.php
@@ -0,0 +1,51 @@
+ auth()->id(),
+ ]);
+ }
+
+ protected function getItemName($item): string
+ {
+ return $item->company_name ?? $item->id;
+ }
+
+ protected function prepareUpdateData(array $validated, $employment): array
+ {
+ return $validated;
+ }
+
+ protected function checkDependencies($employment): ?JsonResponse
+ {
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/be/Modules/User/Http/Controllers/HeirController.php b/be/Modules/User/Http/Controllers/HeirController.php
new file mode 100644
index 0000000..c830bed
--- /dev/null
+++ b/be/Modules/User/Http/Controllers/HeirController.php
@@ -0,0 +1,51 @@
+ auth()->id(),
+ ]);
+ }
+
+ protected function getItemName($item): string
+ {
+ return $item->name ?? $item->id;
+ }
+
+ protected function prepareUpdateData(array $validated, $heir): array
+ {
+ return $validated;
+ }
+
+ protected function checkDependencies($heir): ?JsonResponse
+ {
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/be/Modules/User/Http/Controllers/UserController.php b/be/Modules/User/Http/Controllers/UserController.php
index 78e9aa0..2526ff6 100644
--- a/be/Modules/User/Http/Controllers/UserController.php
+++ b/be/Modules/User/Http/Controllers/UserController.php
@@ -3,6 +3,7 @@
namespace Modules\User\Http\Controllers;
use App\Http\Controllers\BaseCrudController;
+use App\Services\ActivityLogger;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -13,7 +14,7 @@ use Modules\Auth\Entities\User;
use Modules\User\Http\Requests\UserRequest;
use Modules\User\Repositories\Contracts\UserRepositoryInterface;
use Modules\User\Services\UserService;
-use Modules\User\Transformers\UserResource;
+use Modules\Auth\Transformers\UserResource;
use Modules\User\Transformers\UserListResource;
class UserController extends BaseCrudController
@@ -104,6 +105,13 @@ class UserController extends BaseCrudController
'phone_number' => 'sometimes|string|max:255',
'image' => 'sometimes|image|mimes:jpeg,png,jpg,gif|max:2048',
'image_url' => 'sometimes|string|max:255',
+ 'gender' => 'sometimes|string|max:255',
+ 'marriage_status' => 'sometimes|string|max:255',
+ 'member_number' => 'sometimes|integer',
+ 'member_type' => 'sometimes|string|max:255',
+ 'join_date' => 'sometimes|date',
+ 'birth_date' => 'sometimes|date',
+ 'birth_place' => 'sometimes|string|max:255',
]);
$user = $this->userService->updateProfile(
@@ -182,6 +190,69 @@ class UserController extends BaseCrudController
}
}
+ public function deletedIndex(Request $request): JsonResponse
+ {
+ $this->authorize('viewAny', $this->modelClass);
+
+ try {
+ $perPage = min((int) $request->get('per_page', 10), 500);
+ $items = $this->userService->getDeletedPaginatedList(
+ $perPage,
+ $request->get('search', ''),
+ $request->get('status', ''),
+ $request->get('sort_by', 'deleted_at'),
+ $request->get('sort_order', 'desc')
+ );
+
+ return response()->json([
+ 'success' => true,
+ 'data' => UserListResource::collection($items->items()),
+ 'pagination' => [
+ 'current_page' => $items->currentPage(),
+ 'per_page' => $items->perPage(),
+ 'total' => $items->total(),
+ 'last_page' => $items->lastPage(),
+ 'from' => $items->firstItem(),
+ 'to' => $items->lastItem(),
+ 'has_more_pages' => $items->hasMorePages(),
+ ],
+ 'message' => 'Deleted users retrieved successfully.',
+ ]);
+ } catch (Exception $e) {
+ Log::error("Error fetching deleted {$this->resourceNamePlural}: ".$e->getMessage());
+
+ return $this->errorResponse('Failed to retrieve deleted users.', 500);
+ }
+ }
+
+ public function restore(string $id): JsonResponse
+ {
+ $this->authorize('restore', $this->modelClass);
+
+ try {
+ $user = $this->userService->restoreUser($id);
+
+ if (! $user) {
+ return response()->json([
+ 'success' => false,
+ 'message' => 'Deleted user not found.',
+ ], 404);
+ }
+
+ ActivityLogger::log("Restored {$this->resourceName}: {$user->name}", $user);
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'User restored successfully.',
+ 'data' => (new UserListResource($user))->resolve(),
+ ]);
+ } catch (Exception $e) {
+ Log::error("Error restoring {$this->resourceName}: ".$e->getMessage());
+
+ return $this->errorResponse('Failed to restore user.', 500);
+ }
+ }
+
public function show($id): JsonResponse
{
$this->authorize('view', $this->modelClass);
diff --git a/be/Modules/User/Http/Requests/AddressRequest.php b/be/Modules/User/Http/Requests/AddressRequest.php
new file mode 100644
index 0000000..31a8d48
--- /dev/null
+++ b/be/Modules/User/Http/Requests/AddressRequest.php
@@ -0,0 +1,88 @@
+|string>
+ */
+ public function rules(): array
+ {
+ // Get the address ID from the route (with null check)
+ $addressId = $this->route() ? $this->route('address') : null;
+
+ // Check if this is a create or update operation
+ $isCreate = $this->isMethod('POST');
+ $isUpdate = $this->isMethod('PUT') || $this->isMethod('PATCH');
+
+ // If this is not a POST, PUT, or PATCH request, return minimal rules
+ if (! $isCreate && ! $isUpdate) {
+ return [
+ 'address_type' => 'nullable|string|max:255',
+ 'address_line_1' => 'nullable|string|max:255',
+ 'address_line_2' => 'nullable|string|max:255',
+ 'city' => 'nullable|string|max:255',
+ 'state' => 'nullable|string|max:255',
+ 'postcode' => 'nullable|string|max:255',
+ 'country' => 'nullable|string|max:255',
+ ];
+ }
+
+ // Build unique email validation rule
+ $addressUniqueRule = $addressId ? 'unique:addresses,address_type,'.$addressId : 'unique:addresses,address_type';
+
+ // If this is an update operation, make email and password optional
+ if ($isUpdate) {
+ return [
+ 'address_type' => 'required|string|max:255',
+ 'address_line_1' => 'required|string|max:255',
+ 'address_line_2' => 'nullable|string|max:255',
+ 'city' => 'required|string|max:255',
+ 'state' => 'required|string|max:255',
+ 'postcode' => 'required|string|max:255',
+ 'country' => 'required|string|max:255',
+ ];
+ }
+
+ // Default rules for create operations
+ return [
+ 'address_type' => 'required|string|max:255',
+ 'address_line_1' => 'required|string|max:255',
+ 'address_line_2' => 'nullable|string|max:255',
+ 'city' => 'required|string|max:255',
+ 'state' => 'required|string|max:255',
+ 'postcode' => 'required|string|max:255',
+ 'country' => 'required|string|max:255',
+ ];
+ }
+
+ /**
+ * Get custom messages for validator errors.
+ */
+ public function messages(): array
+ {
+ return [
+ 'address_type.required' => 'Jenis alamat diperlukan.',
+ 'address_type.max' => 'Jenis alamat tidak boleh melebihi 255 aksara.',
+ 'address_line_1.max' => 'Alamat 1 tidak boleh melebihi 255 aksara.',
+ 'address_line_2.max' => 'Alamat 2 tidak boleh melebihi 255 aksara.',
+ 'city.max' => 'Bandar tidak boleh melebihi 255 aksara.',
+ 'state.max' => 'Negeri tidak boleh melebihi 255 aksara.',
+ 'postcode.max' => 'Poskod tidak boleh melebihi 255 aksara.',
+ 'country.max' => 'Negara tidak boleh melebihi 255 aksara.',
+ ];
+ }
+}
diff --git a/be/Modules/User/Http/Requests/BankDetailRequest.php b/be/Modules/User/Http/Requests/BankDetailRequest.php
new file mode 100644
index 0000000..4fe2c80
--- /dev/null
+++ b/be/Modules/User/Http/Requests/BankDetailRequest.php
@@ -0,0 +1,67 @@
+|string>
+ */
+ public function rules(): array
+ {
+ // Check if this is a create or update operation
+ $isCreate = $this->isMethod('POST');
+ $isUpdate = $this->isMethod('PUT') || $this->isMethod('PATCH');
+
+ // If this is not a POST, PUT, or PATCH request, return minimal rules
+ if (! $isCreate && ! $isUpdate) {
+ return [
+ 'bank_id' => 'nullable|string|max:255',
+ 'account_name' => 'nullable|string|max:255',
+ 'account_number' => 'nullable|string|max:255',
+ 'account_type' => 'nullable|string|max:255',
+ ];
+ }
+
+ // Default rules for create and update operations
+ return [
+ 'bank_id' => 'required|string|max:255',
+ 'account_name' => 'required|string|max:255',
+ 'account_number' => 'required|string|max:255',
+ 'account_type' => 'required|string|max:255',
+ ];
+ }
+
+ /**
+ * Get custom messages for validator errors.
+ */
+ public function messages(): array
+ {
+ return [
+ 'bank_id.required' => 'ID bank diperlukan.',
+ 'bank_id.string' => 'ID bank tidak sah.',
+ 'bank_id.max' => 'ID bank tidak boleh melebihi 255 aksara.',
+ 'account_name.required' => 'Nama akaun diperlukan.',
+ 'account_name.string' => 'Nama akaun tidak sah.',
+ 'account_name.max' => 'Nama akaun tidak boleh melebihi 255 aksara.',
+ 'account_number.required' => 'Nombor akaun diperlukan.',
+ 'account_number.string' => 'Nombor akaun tidak sah.',
+ 'account_number.max' => 'Nombor akaun tidak boleh melebihi 255 aksara.',
+ 'account_type.required' => 'Jenis akaun diperlukan.',
+ 'account_type.string' => 'Jenis akaun tidak sah.',
+ 'account_type.max' => 'Jenis akaun tidak boleh melebihi 255 aksara.',
+ ];
+ }
+}
diff --git a/be/Modules/User/Http/Requests/BankRequest.php b/be/Modules/User/Http/Requests/BankRequest.php
new file mode 100644
index 0000000..242fa6d
--- /dev/null
+++ b/be/Modules/User/Http/Requests/BankRequest.php
@@ -0,0 +1,72 @@
+|string>
+ */
+ public function rules(): array
+ {
+ // Check if this is a create or update operation
+ $isCreate = $this->isMethod('POST');
+ $isUpdate = $this->isMethod('PUT') || $this->isMethod('PATCH');
+
+ // If this is not a POST, PUT, or PATCH request, return minimal rules
+ if (! $isCreate && ! $isUpdate) {
+ return [
+ 'name' => 'nullable|string|max:255',
+ 'code' => 'nullable|string|max:255',
+ 'swift_code' => 'nullable|string|max:255',
+ 'is_active' => 'nullable|boolean',
+ ];
+ }
+
+ // If this is an update operation, make email and password optional
+ if ($isUpdate) {
+ return [
+ 'name' => 'required|string|max:255',
+ 'code' => 'required|string|max:255',
+ 'swift_code' => 'nullable|string|max:255',
+ 'is_active' => 'required|boolean',
+ ];
+ }
+
+ // Default rules for create operations
+ return [
+ 'name' => 'required|string|max:255',
+ 'code' => 'required|string|max:255',
+ 'swift_code' => 'nullable|string|max:255',
+ 'is_active' => 'required|boolean',
+ ];
+ }
+
+ /**
+ * Get custom messages for validator errors.
+ */
+ public function messages(): array
+ {
+ return [
+ 'name.required' => 'Nama bank diperlukan.',
+ 'name.max' => 'Nama bank tidak boleh melebihi 255 aksara.',
+ 'code.required' => 'Kod bank diperlukan.',
+ 'code.max' => 'Kod bank tidak boleh melebihi 255 aksara.',
+ 'swift_code.max' => 'Swift code tidak boleh melebihi 255 aksara.',
+ 'is_active.required' => 'Status aktif diperlukan.',
+ 'is_active.boolean' => 'Status aktif tidak sah.',
+ ];
+ }
+}
diff --git a/be/Modules/User/Http/Requests/EmploymentRequest.php b/be/Modules/User/Http/Requests/EmploymentRequest.php
new file mode 100644
index 0000000..d02e12e
--- /dev/null
+++ b/be/Modules/User/Http/Requests/EmploymentRequest.php
@@ -0,0 +1,88 @@
+|string>
+ */
+ public function rules(): array
+ {
+ // Check if this is a create or update operation
+ $isCreate = $this->isMethod('POST');
+ $isUpdate = $this->isMethod('PUT') || $this->isMethod('PATCH');
+
+ // If this is not a POST, PUT, or PATCH request, return minimal rules
+ if (! $isCreate && ! $isUpdate) {
+ return [
+ 'company_name' => 'nullable|string|max:255',
+ 'job_title' => 'nullable|string|max:255',
+ 'employment_type' => 'nullable|string|max:255',
+ 'salary' => 'nullable|numeric|min:0',
+ 'start_date' => 'nullable|date',
+ 'end_date' => 'nullable|date',
+ 'is_current' => 'nullable|boolean',
+ ];
+ }
+
+ // If this is an update operation, make email and password optional
+ if ($isUpdate) {
+ return [
+ 'company_name' => 'required|string|max:255',
+ 'job_title' => 'required|string|max:255',
+ 'employment_type' => 'required|string|max:255',
+ 'salary' => 'required|numeric|min:0',
+ 'start_date' => 'required|date',
+ 'end_date' => 'nullable|date',
+ 'is_current' => 'required|boolean',
+ ];
+ }
+
+ // Default rules for create operations
+ return [
+ 'company_name' => 'required|string|max:255',
+ 'job_title' => 'required|string|max:255',
+ 'employment_type' => 'required|string|max:255',
+ 'salary' => 'required|numeric|min:0',
+ 'start_date' => 'required|date',
+ 'end_date' => 'nullable|date',
+ 'is_current' => 'required|boolean',
+ ];
+ }
+
+ /**
+ * Get custom messages for validator errors.
+ */
+ public function messages(): array
+ {
+ return [
+ 'company_name.required' => 'Nama syarikat diperlukan.',
+ 'company_name.max' => 'Nama syarikat tidak boleh melebihi 255 aksara.',
+ 'job_title.required' => 'Jawatan diperlukan.',
+ 'job_title.max' => 'Jawatan tidak boleh melebihi 255 aksara.',
+ 'employment_type.required' => 'Jenis kerja diperlukan.',
+ 'employment_type.max' => 'Jenis kerja tidak boleh melebihi 255 aksara.',
+ 'salary.required' => 'Gaji diperlukan.',
+ 'salary.numeric' => 'Gaji tidak sah.',
+ 'salary.min' => 'Gaji tidak boleh kurang dari 0.',
+ 'start_date.required' => 'Tarikh mulai kerja diperlukan.',
+ 'start_date.date' => 'Tarikh mulai kerja tidak sah.',
+ 'end_date.date' => 'Tarikh akhir kerja tidak sah.',
+ 'is_current.required' => 'Status kerja saat ini diperlukan.',
+ 'is_current.boolean' => 'Status kerja saat ini tidak sah.',
+ ];
+ }
+}
diff --git a/be/Modules/User/Http/Requests/HeirRequest.php b/be/Modules/User/Http/Requests/HeirRequest.php
new file mode 100644
index 0000000..bfc0362
--- /dev/null
+++ b/be/Modules/User/Http/Requests/HeirRequest.php
@@ -0,0 +1,83 @@
+|string>
+ */
+ public function rules(): array
+ {
+ // Check if this is a create or update operation
+ $isCreate = $this->isMethod('POST');
+ $isUpdate = $this->isMethod('PUT') || $this->isMethod('PATCH');
+
+ // If this is not a POST, PUT, or PATCH request, return minimal rules
+ if (! $isCreate && ! $isUpdate) {
+ return [
+ 'name' => 'nullable|string|max:255',
+ 'ic_number' => 'nullable|string|max:255',
+ 'relationship' => 'nullable|string|max:255',
+ 'phone_number' => 'nullable|string|max:255',
+ 'address' => 'nullable|string',
+ 'is_primary' => 'nullable|boolean',
+ ];
+ }
+
+ // If this is an update operation, make email and password optional
+ if ($isUpdate) {
+ return [
+ 'name' => 'required|string|max:255',
+ 'ic_number' => 'required|string|max:255',
+ 'relationship' => 'required|string|max:255',
+ 'phone_number' => 'required|string|max:255',
+ 'address' => 'required|string',
+ 'is_primary' => 'required|boolean',
+ ];
+ }
+
+ // Default rules for create operations
+ return [
+ 'name' => 'required|string|max:255',
+ 'ic_number' => 'required|string|max:255',
+ 'relationship' => 'required|string|max:255',
+ 'phone_number' => 'required|string|max:255',
+ 'address' => 'required|string',
+ 'is_primary' => 'required|boolean',
+ ];
+ }
+
+ /**
+ * Get custom messages for validator errors.
+ */
+ public function messages(): array
+ {
+ return [
+ 'name.required' => 'Nama diperlukan.',
+ 'name.max' => 'Nama tidak boleh melebihi 255 aksara.',
+ 'ic_number.required' => 'Nombor Kad Pengenalan diperlukan.',
+ 'ic_number.max' => 'Nombor Kad Pengenalan tidak boleh melebihi 255 aksara.',
+ 'relationship.required' => 'Hubungan diperlukan.',
+ 'relationship.max' => 'Hubungan tidak boleh melebihi 255 aksara.',
+ 'phone_number.required' => 'Nombor telefon diperlukan.',
+ 'phone_number.max' => 'Nombor telefon tidak boleh melebihi 255 aksara.',
+ 'address.required' => 'Alamat diperlukan.',
+ 'address.string' => 'Alamat tidak sah.',
+ 'is_primary.required' => 'Status pewaris utama diperlukan.',
+ 'is_primary.boolean' => 'Status pewaris utama tidak sah.',
+ ];
+ }
+}
diff --git a/be/Modules/User/Http/Requests/UserRequest.php b/be/Modules/User/Http/Requests/UserRequest.php
index 939fdfd..1fb5207 100644
--- a/be/Modules/User/Http/Requests/UserRequest.php
+++ b/be/Modules/User/Http/Requests/UserRequest.php
@@ -39,6 +39,13 @@ class UserRequest extends FormRequest
'phone_number' => 'nullable|string|max:255',
'image_url' => 'nullable|string|max:255',
'status' => 'nullable|string',
+ 'gender' => 'nullable|string|max:255',
+ 'marriage_status' => 'nullable|string|max:255',
+ 'member_number' => 'required|integer',
+ 'member_type' => 'required|string|max:255',
+ 'join_date' => 'nullable|date',
+ 'birth_date' => 'nullable|date',
+ 'birth_place' => 'nullable|string|max:255',
];
}
@@ -58,10 +65,17 @@ class UserRequest extends FormRequest
// ],
'password' => 'nullable|string|min:8',
'ic_number' => 'required|string|max:255',
- 'position' => 'required|string|max:255',
+ 'position' => 'nullable|string|max:255',
'phone_number' => 'nullable|string|max:255',
'image_url' => 'nullable|string|max:255',
'status' => 'nullable|string',
+ 'gender' => 'nullable|string|max:255',
+ 'marriage_status' => 'nullable|string|max:255',
+ 'member_number' => 'required|integer',
+ 'member_type' => 'required|string|max:255',
+ 'join_date' => 'nullable|date',
+ 'birth_date' => 'nullable|date',
+ 'birth_place' => 'nullable|string|max:255',
];
}
@@ -81,6 +95,13 @@ class UserRequest extends FormRequest
'phone_number' => 'nullable|string|max:255',
'image_url' => 'nullable|string|max:255',
'status' => 'required|string',
+ 'gender' => 'required|string|max:255',
+ 'marriage_status' => 'required|string|max:255',
+ 'member_number' => 'required|integer',
+ 'member_type' => 'required|string|max:255',
+ 'join_date' => 'required|date',
+ 'birth_date' => 'required|date',
+ 'birth_place' => 'required|string|max:255',
];
}
@@ -105,6 +126,20 @@ class UserRequest extends FormRequest
'image_url.max' => 'URL imej tidak boleh melebihi 255 aksara.',
'status.required' => 'Status diperlukan.',
'status.string' => 'Status aktif tidak sah.',
+ 'gender.required' => 'Jenis kelamin diperlukan.',
+ 'gender.max' => 'Jenis kelamin tidak boleh melebihi 255 aksara.',
+ 'marriage_status.required' => 'Status perkahwinan diperlukan.',
+ 'marriage_status.max' => 'Status perkahwinan tidak boleh melebihi 255 aksara.',
+ 'member_number.required' => 'Nombor anggota diperlukan.',
+ 'member_number.integer' => 'Nombor anggota tidak sah.',
+ 'member_type.required' => 'Jenis anggota diperlukan.',
+ 'member_type.max' => 'Jenis anggota tidak boleh melebihi 255 aksara.',
+ 'join_date.required' => 'Tarikh join diperlukan.',
+ 'join_date.date' => 'Tarikh join tidak sah.',
+ 'birth_date.required' => 'Tarikh lahir diperlukan.',
+ 'birth_date.date' => 'Tarikh lahir tidak sah.',
+ 'birth_place.required' => 'Tempat lahir diperlukan.',
+ 'birth_place.max' => 'Tempat lahir tidak boleh melebihi 255 aksara.',
];
}
}
diff --git a/be/Modules/User/Notifications/UserActivationNotification.php b/be/Modules/User/Notifications/UserActivationNotification.php
index 62cee5d..e18e998 100644
--- a/be/Modules/User/Notifications/UserActivationNotification.php
+++ b/be/Modules/User/Notifications/UserActivationNotification.php
@@ -42,8 +42,6 @@ class UserActivationNotification extends Notification
'message' => $this->getNotificationMessage(),
'user_name' => $this->newUser->name ?? 'Unknown',
'user_email' => $this->newUser->email ?? 'Unknown',
- 'user_army_number' => $this->newUser->army_number ?? 'Unknown',
- 'user_unit_name' => $this->newUser->unit->name ?? 'Unknown',
'user_status' => $this->newUser->status ?? 'pending',
];
}
@@ -55,10 +53,8 @@ class UserActivationNotification extends Notification
{
$userName = $this->newUser->name ?? 'Unknown';
$userEmail = $this->newUser->email ?? 'Unknown';
- $userArmyNumber = $this->newUser->army_number ?? 'Unknown';
- $unitName = $this->newUser->unit->name ?? 'Unknown';
- return "Pengguna baru memerlukan pengaktifan. Nama: {$userName}, Email: {$userEmail}, No. Tentera: {$userArmyNumber}, Unit: {$unitName}";
+ return "Pengguna baru memerlukan pengaktifan. Nama: {$userName}, Email: {$userEmail}";
}
}
diff --git a/be/Modules/User/Policies/AddressPolicy.php b/be/Modules/User/Policies/AddressPolicy.php
new file mode 100644
index 0000000..b33041d
--- /dev/null
+++ b/be/Modules/User/Policies/AddressPolicy.php
@@ -0,0 +1,41 @@
+user_id === $user->id;
+ }
+
+ public function create($user): bool
+ {
+ return true;
+ }
+
+ public function update($user, ?Address $address = null): bool
+ {
+ return $address === null || $address->user_id === $user->id;
+ }
+
+ public function deleteAny($user): bool
+ {
+ return true;
+ }
+
+ public function delete($user, ?Address $address = null): bool
+ {
+ return $address === null || $address->user_id === $user->id;
+ }
+}
diff --git a/be/Modules/User/Policies/BankDetailPolicy.php b/be/Modules/User/Policies/BankDetailPolicy.php
new file mode 100644
index 0000000..c52f6fa
--- /dev/null
+++ b/be/Modules/User/Policies/BankDetailPolicy.php
@@ -0,0 +1,41 @@
+user_id === $user->id;
+ }
+
+ public function create($user): bool
+ {
+ return true;
+ }
+
+ public function update($user, ?BankDetail $bankDetail = null): bool
+ {
+ return $bankDetail === null || $bankDetail->user_id === $user->id;
+ }
+
+ public function deleteAny($user): bool
+ {
+ return true;
+ }
+
+ public function delete($user, ?BankDetail $bankDetail = null): bool
+ {
+ return $bankDetail === null || $bankDetail->user_id === $user->id;
+ }
+}
diff --git a/be/Modules/User/Policies/BankPolicy.php b/be/Modules/User/Policies/BankPolicy.php
new file mode 100644
index 0000000..e0cc392
--- /dev/null
+++ b/be/Modules/User/Policies/BankPolicy.php
@@ -0,0 +1,41 @@
+hasPermissionTo('tambah bank');
+ }
+
+ public function update($user, ?Bank $bank = null): bool
+ {
+ return $user->hasPermissionTo('kemaskini bank');
+ }
+
+ public function deleteAny($user): bool
+ {
+ return $user->hasPermissionTo('hapus bank');
+ }
+
+ public function delete($user, ?Bank $bank = null): bool
+ {
+ return $user->hasPermissionTo('hapus bank');
+ }
+}
diff --git a/be/Modules/User/Policies/EmploymentPolicy.php b/be/Modules/User/Policies/EmploymentPolicy.php
new file mode 100644
index 0000000..1d03c84
--- /dev/null
+++ b/be/Modules/User/Policies/EmploymentPolicy.php
@@ -0,0 +1,41 @@
+user_id === $user->id;
+ }
+
+ public function create($user): bool
+ {
+ return true;
+ }
+
+ public function update($user, ?Employment $employement = null): bool
+ {
+ return $employement === null || $employement->user_id === $user->id;
+ }
+
+ public function deleteAny($user): bool
+ {
+ return true;
+ }
+
+ public function delete($user, ?Employment $employement = null): bool
+ {
+ return $employement === null || $employement->user_id === $user->id;
+ }
+}
diff --git a/be/Modules/User/Policies/HeirPolicy.php b/be/Modules/User/Policies/HeirPolicy.php
new file mode 100644
index 0000000..ebb5680
--- /dev/null
+++ b/be/Modules/User/Policies/HeirPolicy.php
@@ -0,0 +1,41 @@
+user_id === $user->id;
+ }
+
+ public function create($user): bool
+ {
+ return true;
+ }
+
+ public function update($user, ?Heir $heir = null): bool
+ {
+ return $heir === null || $heir->user_id === $user->id;
+ }
+
+ public function deleteAny($user): bool
+ {
+ return true;
+ }
+
+ public function delete($user, ?Heir $heir = null): bool
+ {
+ return $heir === null || $heir->user_id === $user->id;
+ }
+}
diff --git a/be/Modules/User/Policies/UserPolicy.php b/be/Modules/User/Policies/UserPolicy.php
index 559a272..22d6212 100644
--- a/be/Modules/User/Policies/UserPolicy.php
+++ b/be/Modules/User/Policies/UserPolicy.php
@@ -30,7 +30,7 @@ class UserPolicy
*/
public function create($user): bool
{
- return $user->hasPermissionTo('tambah pengguna');
+ return $user->hasPermissionTo('daftar pengguna baru');
}
/**
@@ -46,7 +46,7 @@ class UserPolicy
*/
public function deleteAny($user): bool
{
- return $user->hasPermissionTo('hapus pengguna');
+ return $user->hasPermissionTo('padam akaun pengguna');
}
/**
@@ -54,6 +54,14 @@ class UserPolicy
*/
public function delete($user, ?User $userModel = null): bool
{
- return $user->hasPermissionTo('hapus pengguna');
+ return $user->hasPermissionTo('padam akaun pengguna');
+ }
+
+ /**
+ * Determine whether the user can restore a soft-deleted model.
+ */
+ public function restore($user, ?User $userModel = null): bool
+ {
+ return $user->hasPermissionTo('padam akaun pengguna');
}
}
diff --git a/be/Modules/User/Providers/UserServiceProvider.php b/be/Modules/User/Providers/UserServiceProvider.php
index e135007..9c28f9e 100644
--- a/be/Modules/User/Providers/UserServiceProvider.php
+++ b/be/Modules/User/Providers/UserServiceProvider.php
@@ -7,6 +7,16 @@ use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use Modules\Auth\Entities\User;
use Modules\User\Policies\UserPolicy;
+use Modules\User\Entities\Address;
+use Modules\User\Policies\AddressPolicy;
+use Modules\User\Entities\Employment;
+use Modules\User\Policies\EmploymentPolicy;
+use Modules\User\Entities\Bank;
+use Modules\User\Policies\BankPolicy;
+use Modules\User\Entities\BankDetail;
+use Modules\User\Policies\BankDetailPolicy;
+use Modules\User\Entities\Heir;
+use Modules\User\Policies\HeirPolicy;
use Nwidart\Modules\Traits\PathNamespace;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
@@ -41,11 +51,36 @@ class UserServiceProvider extends ServiceProvider
$this->app->register(EventServiceProvider::class);
$this->app->register(RouteServiceProvider::class);
- // Register repository binding
+ // Register repository bindings
$this->app->bind(
\Modules\User\Repositories\Contracts\UserRepositoryInterface::class,
\Modules\User\Repositories\UserRepository::class
);
+
+ $this->app->bind(
+ \Modules\User\Repositories\Contracts\AddressRepositoryInterface::class,
+ \Modules\User\Repositories\AddressRepository::class
+ );
+
+ $this->app->bind(
+ \Modules\User\Repositories\Contracts\EmploymentRepositoryInterface::class,
+ \Modules\User\Repositories\EmploymentRepository::class
+ );
+
+ $this->app->bind(
+ \Modules\User\Repositories\Contracts\BankRepositoryInterface::class,
+ \Modules\User\Repositories\BankRepository::class
+ );
+
+ $this->app->bind(
+ \Modules\User\Repositories\Contracts\BankDetailRepositoryInterface::class,
+ \Modules\User\Repositories\BankDetailRepository::class
+ );
+
+ $this->app->bind(
+ \Modules\User\Repositories\Contracts\HeirRepositoryInterface::class,
+ \Modules\User\Repositories\HeirRepository::class
+ );
}
/**
@@ -54,6 +89,11 @@ class UserServiceProvider extends ServiceProvider
protected function registerPolicies(): void
{
Gate::policy(User::class, UserPolicy::class);
+ Gate::policy(Address::class, AddressPolicy::class);
+ Gate::policy(Employment::class, EmploymentPolicy::class);
+ Gate::policy(Bank::class, BankPolicy::class);
+ Gate::policy(BankDetail::class, BankDetailPolicy::class);
+ Gate::policy(Heir::class, HeirPolicy::class);
}
/**
diff --git a/be/Modules/User/Repositories/AddressRepository.php b/be/Modules/User/Repositories/AddressRepository.php
new file mode 100644
index 0000000..ed197d0
--- /dev/null
+++ b/be/Modules/User/Repositories/AddressRepository.php
@@ -0,0 +1,89 @@
+where('user_id', auth()->id())
+ ->orderBy('address_type');
+ }
+
+ public function getAllPaginated(int $perPage = 10, string $search = '')
+ {
+ $query = $this->baseQuery();
+
+ return $query->paginate($perPage);
+ }
+
+ public function getAllWithRelationsPaginated(
+ int $perPage = 10,
+ string $search = '',
+ string $sortBy = 'address_type',
+ string $sortOrder = 'asc'
+ ) {
+ $allowedSortColumns = [
+ 'id',
+ 'address_type',
+ 'address_line_1',
+ 'city',
+ 'state',
+ 'postcode',
+ 'country',
+ 'created_at',
+ ];
+ $sortBy = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'address_type';
+ $sortOrder = strtolower($sortOrder) === 'desc' ? 'desc' : 'asc';
+
+ $query = Address::query()
+ ->where('user_id', auth()->id())
+ ->with(['user:id,name,email'])
+ ->orderBy($sortBy, $sortOrder);
+
+ return $query->paginate($perPage);
+ }
+
+ public function getAllWithRelations(string $search = ''): Collection
+ {
+ $query = Address::query()
+ ->where('user_id', auth()->id())
+ ->with(['user:id,name,email'])
+ ->orderBy('address_type');
+
+ return $query->get();
+ }
+
+ public function create(array $data): Address
+ {
+ return Address::create($data);
+ }
+
+ public function findById(string $id): ?Address
+ {
+ return Address::query()
+ ->where('user_id', auth()->id())
+ ->with(['user:id,name,email'])
+ ->find($id);
+ }
+
+ public function delete(string $id): bool
+ {
+ $address = $this->findById($id);
+ if ($address) {
+ return $address->delete();
+ }
+
+ return false;
+ }
+
+ public function all(string $search = ''): Collection
+ {
+ return $this->baseQuery()->get();
+ }
+}
diff --git a/be/Modules/User/Repositories/BankDetailRepository.php b/be/Modules/User/Repositories/BankDetailRepository.php
new file mode 100644
index 0000000..47a9fb6
--- /dev/null
+++ b/be/Modules/User/Repositories/BankDetailRepository.php
@@ -0,0 +1,59 @@
+where('user_id', auth()->id());
+ }
+
+ public function getAllPaginated(int $perPage = 10, string $search = '')
+ {
+ $query = $this->baseQuery();
+
+ return $query->paginate($perPage);
+ }
+
+ public function getAllWithRelations(string $search = ''): Collection
+ {
+ $query = BankDetail::query()
+ ->where('user_id', auth()->id())
+ ->with(['user:id,name,email', 'bank:id,name'])
+ ->orderBy('account_number');
+
+ return $query->get();
+ }
+
+ public function create(array $data): BankDetail
+ {
+ return BankDetail::create($data);
+ }
+
+ public function findById(string $id): ?BankDetail
+ {
+ return BankDetail::query()
+ ->where('user_id', auth()->id())
+ ->find($id);
+ }
+
+ public function delete(string $id): bool
+ {
+ $bankDetail = $this->findById($id);
+ if ($bankDetail) {
+ return $bankDetail->delete();
+ }
+
+ return false;
+ }
+
+ public function all(string $search = ''): Collection
+ {
+ return $this->baseQuery()->get();
+ }
+}
diff --git a/be/Modules/User/Repositories/BankRepository.php b/be/Modules/User/Repositories/BankRepository.php
new file mode 100644
index 0000000..a762a6e
--- /dev/null
+++ b/be/Modules/User/Repositories/BankRepository.php
@@ -0,0 +1,81 @@
+orderBy('name');
+ }
+
+ public function getAllPaginated(int $perPage = 10, string $search = '')
+ {
+ $query = $this->baseQuery();
+
+ return $query->paginate($perPage);
+ }
+
+ public function getAllWithRelationsPaginated(
+ int $perPage = 10,
+ string $search = '',
+ string $sortBy = 'name',
+ string $sortOrder = 'asc'
+ ) {
+ $allowedSortColumns = [
+ 'name',
+ 'code',
+ 'swift_code',
+ 'is_active',
+ 'created_at',
+ ];
+ $sortBy = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'name';
+ $sortOrder = strtolower($sortOrder) === 'desc' ? 'desc' : 'asc';
+
+ $query = Bank::query()->orderBy($sortBy, $sortOrder);
+
+ return $query->paginate($perPage);
+ }
+
+ public function getAllWithRelations(string $search = ''): Collection
+ {
+ $query = Bank::query()->orderBy('name');
+
+ return $query->get();
+ }
+
+ public function create(array $data): Bank
+ {
+ return Bank::create($data);
+ }
+
+ public function findById(string $id): ?Bank
+ {
+ return Bank::query()
+ ->find($id);
+ }
+
+ // public function delete(string $id): bool
+ // {
+ // $bank = $this->findById($id);
+ // if ($bank) {
+ // return $bank->delete();
+ // }
+
+ // return false;
+ // }
+
+ public function all(string $search = ''): Collection
+ {
+ return $this->baseQuery()->get();
+ }
+
+ public function active(): Collection
+ {
+ return Bank::query()->where('is_active', true)->get();
+ }
+}
diff --git a/be/Modules/User/Repositories/Contracts/AddressRepositoryInterface.php b/be/Modules/User/Repositories/Contracts/AddressRepositoryInterface.php
new file mode 100644
index 0000000..ebad98c
--- /dev/null
+++ b/be/Modules/User/Repositories/Contracts/AddressRepositoryInterface.php
@@ -0,0 +1,28 @@
+where('user_id', auth()->id())
+ ->orderBy('company_name');
+ }
+
+ public function getAllPaginated(int $perPage = 10, string $search = '')
+ {
+ $query = $this->baseQuery();
+
+ return $query->paginate($perPage);
+ }
+
+ public function getAllWithRelationsPaginated(
+ int $perPage = 10,
+ string $search = '',
+ string $sortBy = 'company_name',
+ string $sortOrder = 'asc'
+ ) {
+ $allowedSortColumns = [
+ 'id',
+ 'company_name',
+ 'job_title',
+ 'employment_type',
+ 'salary',
+ 'start_date',
+ 'end_date',
+ 'is_current',
+ 'created_at',
+ ];
+ $sortBy = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'company_name';
+ $sortOrder = strtolower($sortOrder) === 'desc' ? 'desc' : 'asc';
+
+ $query = Employment::query()
+ ->where('user_id', auth()->id())
+ ->with(['user:id,name,email'])
+ ->orderBy($sortBy, $sortOrder);
+
+ return $query->paginate($perPage);
+ }
+
+ public function getAllWithRelations(string $search = ''): Collection
+ {
+ $query = Employment::query()
+ ->where('user_id', auth()->id())
+ ->with(['user:id,name,email'])
+ ->orderBy('company_name');
+
+ return $query->get();
+ }
+
+ public function create(array $data): Employment
+ {
+ return Employment::create($data);
+ }
+
+ public function findById(string $id): ?Employment
+ {
+ return Employment::query()
+ ->where('user_id', auth()->id())
+ ->with(['user:id,name,email'])
+ ->find($id);
+ }
+
+ public function delete(string $id): bool
+ {
+ $employement = $this->findById($id);
+ if ($employement) {
+ return $employement->delete();
+ }
+
+ return false;
+ }
+
+ public function all(string $search = ''): Collection
+ {
+ return $this->baseQuery()->get();
+ }
+}
diff --git a/be/Modules/User/Repositories/HeirRepository.php b/be/Modules/User/Repositories/HeirRepository.php
new file mode 100644
index 0000000..7d70282
--- /dev/null
+++ b/be/Modules/User/Repositories/HeirRepository.php
@@ -0,0 +1,89 @@
+where('user_id', auth()->id())
+ ->orderBy('name');
+ }
+
+ public function getAllPaginated(int $perPage = 10, string $search = '')
+ {
+ $query = $this->baseQuery();
+
+ return $query->paginate($perPage);
+ }
+
+ // public function getAllWithRelationsPaginated(
+ // int $perPage = 10,
+ // string $search = '',
+ // string $sortBy = 'name',
+ // string $sortOrder = 'asc'
+ // ) {
+ // $allowedSortColumns = [
+ // 'id',
+ // 'name',
+ // 'job_title',
+ // 'relationship',
+ // 'phone_number',
+ // 'address',
+ // 'is_primary',
+ // 'created_at',
+ // ];
+ // $sortBy = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'company_name';
+ // $sortOrder = strtolower($sortOrder) === 'desc' ? 'desc' : 'asc';
+
+ // $query = Employment::query()
+ // ->where('user_id', auth()->id())
+ // ->with(['user:id,name,email'])
+ // ->orderBy($sortBy, $sortOrder);
+
+ // return $query->paginate($perPage);
+ // }
+
+ public function getAllWithRelations(string $search = ''): Collection
+ {
+ $query = Heir::query()
+ ->where('user_id', auth()->id())
+ ->with(['user:id,name,email'])
+ ->orderBy('name');
+
+ return $query->get();
+ }
+
+ public function create(array $data): Heir
+ {
+ return Heir::create($data);
+ }
+
+ public function findById(string $id): ?Heir
+ {
+ return Heir::query()
+ ->where('user_id', auth()->id())
+ ->with(['user:id,name,email'])
+ ->find($id);
+ }
+
+ public function delete(string $id): bool
+ {
+ $heir = $this->findById($id);
+ if ($heir) {
+ return $heir->delete();
+ }
+
+ return false;
+ }
+
+ public function all(string $search = ''): Collection
+ {
+ return $this->baseQuery()->get();
+ }
+}
diff --git a/be/Modules/User/Repositories/UserRepository.php b/be/Modules/User/Repositories/UserRepository.php
index 5010a32..33ce614 100644
--- a/be/Modules/User/Repositories/UserRepository.php
+++ b/be/Modules/User/Repositories/UserRepository.php
@@ -8,24 +8,32 @@ use Modules\User\Repositories\Contracts\UserRepositoryInterface;
class UserRepository implements UserRepositoryInterface
{
+ private function applySearch($query, string $search): void
+ {
+ if (empty($search)) {
+ return;
+ }
+
+ $query->where(function ($q) use ($search) {
+ $q->where('name', 'ILIKE', "%{$search}%")
+ ->orWhere('ic_number', 'ILIKE', "%{$search}%")
+ ->orWhere('email', 'ILIKE', "%{$search}%")
+ ->orWhere('phone_number', 'ILIKE', "%{$search}%")
+ ->orWhere('member_number', 'ILIKE', "%{$search}%")
+ ->orWhereHas('roles', function ($roleQuery) use ($search) {
+ $roleQuery->whereRaw('LOWER(name) ILIKE ?', ['%' . strtolower($search) . '%']);
+ });
+ });
+ }
+
/**
* Get all Users with pagination and search
*/
public function getAllPaginated(int $perPage = 10, string $search = '')
{
- $query = User::visibleTo(auth()->user())->excludeDevelopersUnlessDeveloper()->orderBy('name');
+ $query = User::excludeDevelopersUnlessDeveloper()->orderBy('name');
- if (! empty($search)) {
- $query->where(function ($q) use ($search) {
- $q->where('name', 'ILIKE', "%{$search}%");
- $q->orWhere('ic_number', 'ILIKE', "%{$search}%");
- $q->orWhere('email', 'ILIKE', "%{$search}%");
- $q->orWhere('phone_number', 'ILIKE', "%{$search}%");
- $q->orWhereHas('roles', function ($roleQuery) use ($search) {
- $roleQuery->whereRaw('LOWER(name) ILIKE ?', ['%' . strtolower($search) . '%']);
- });
- });
- }
+ $this->applySearch($query, $search);
return $query->paginate($perPage);
}
@@ -46,38 +54,60 @@ class UserRepository implements UserRepositoryInterface
'email',
'position',
'status',
- 'ic_number',
- 'phone_number',
+ 'member_number',
'created_at',
+ 'deleted_at',
];
$sortBy = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'name';
$sortOrder = strtolower($sortOrder) === 'desc' ? 'desc' : 'asc';
- $query = User::visibleTo(auth()->user())->excludeDevelopersUnlessDeveloper()
+ $query = User::excludeDevelopersUnlessDeveloper()
->with([
'roles:id,name,guard_name'
])
->orderBy($sortBy, $sortOrder);
- if (! empty($search)) {
- $query->where(function ($q) use ($search) {
- $q->where('name', 'ILIKE', "%{$search}%")
- ->orWhere('ic_number', 'ILIKE', "%{$search}%")
- ->orWhere('email', 'ILIKE', "%{$search}%")
- ->orWhere('phone_number', 'ILIKE', "%{$search}%")
- ->orWhereHas('roles', function ($roleQuery) use ($search) {
- $roleQuery->whereRaw('LOWER(name) ILIKE ?', ['%' . strtolower($search) . '%']);
- });
- $q->orWhere('ic_number', 'ILIKE', "%{$search}%");
- $q->orWhere('email', 'ILIKE', "%{$search}%");
- $q->orWhere('phone_number', 'ILIKE', "%{$search}%");
- $q->orWhereHas('roles', function ($roleQuery) use ($search) {
- $roleQuery->whereRaw('LOWER(name) ILIKE ?', ['%' . strtolower($search) . '%']);
- });
- });
+ $this->applySearch($query, $search);
+
+ if (! empty($status)) {
+ $query->where('status', $status);
}
- // Add status filter
+ return $query->paginate($perPage);
+ }
+
+ /**
+ * Get soft-deleted Users with their relationships and pagination
+ */
+ public function getDeletedWithRelationsPaginated(
+ int $perPage = 10,
+ string $search = '',
+ string $status = '',
+ string $sortBy = 'deleted_at',
+ string $sortOrder = 'desc'
+ ) {
+ $allowedSortColumns = [
+ 'id',
+ 'name',
+ 'email',
+ 'position',
+ 'status',
+ 'member_number',
+ 'created_at',
+ 'deleted_at',
+ ];
+ $sortBy = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'deleted_at';
+ $sortOrder = strtolower($sortOrder) === 'desc' ? 'desc' : 'asc';
+
+ $query = User::onlyTrashed()
+ ->excludeDevelopersUnlessDeveloper()
+ ->with([
+ 'roles:id,name,guard_name',
+ ])
+ ->orderBy($sortBy, $sortOrder);
+
+ $this->applySearch($query, $search);
+
if (! empty($status)) {
$query->where('status', $status);
}
@@ -90,19 +120,9 @@ class UserRepository implements UserRepositoryInterface
*/
public function getAllWithRelations(string $search = ''): Collection
{
- $query = User::visibleTo(auth()->user())->excludeDevelopersUnlessDeveloper()->orderBy('name');
+ $query = User::excludeDevelopersUnlessDeveloper()->orderBy('name');
- if (! empty($search)) {
- $query->where(function ($q) use ($search) {
- $q->where('name', 'ILIKE', "%{$search}%")
- ->orWhere('ic_number', 'ILIKE', "%{$search}%")
- ->orWhere('email', 'ILIKE', "%{$search}%")
- ->orWhere('phone_number', 'ILIKE', "%{$search}%")
- ->orWhereHas('roles', function ($roleQuery) use ($search) {
- $roleQuery->whereRaw('LOWER(name) ILIKE ?', ['%' . strtolower($search) . '%']);
- });
- });
- }
+ $this->applySearch($query, $search);
return $query->get();
}
@@ -136,24 +156,36 @@ class UserRepository implements UserRepositoryInterface
return false;
}
+ /**
+ * Find soft-deleted User by ID
+ */
+ public function findTrashedById(string $id): ?User
+ {
+ return User::onlyTrashed()->with(['roles'])->find($id);
+ }
+
+ /**
+ * Restore a soft-deleted User
+ */
+ public function restore(string $id): bool
+ {
+ $user = User::onlyTrashed()->find($id);
+
+ if ($user) {
+ return (bool) $user->restore();
+ }
+
+ return false;
+ }
+
/**
* Get all Ranks
*/
public function all(string $search = ''): Collection
{
- $query = User::visibleTo(auth()->user())->excludeDevelopersUnlessDeveloper()->orderBy('name');
+ $query = User::excludeDevelopersUnlessDeveloper()->orderBy('name');
- if (! empty($search)) {
- $query->where(function ($q) use ($search) {
- $q->where('name', 'ILIKE', "%{$search}%")
- ->orWhere('ic_number', 'ILIKE', "%{$search}%")
- ->orWhere('email', 'ILIKE', "%{$search}%")
- ->orWhere('phone_number', 'ILIKE', "%{$search}%")
- ->orWhereHas('roles', function ($roleQuery) use ($search) {
- $roleQuery->whereRaw('LOWER(name) ILIKE ?', ['%' . strtolower($search) . '%']);
- });
- });
- }
+ $this->applySearch($query, $search);
return $query->get();
}
diff --git a/be/Modules/User/Routes/api.php b/be/Modules/User/Routes/api.php
index d09e4f1..28c0793 100644
--- a/be/Modules/User/Routes/api.php
+++ b/be/Modules/User/Routes/api.php
@@ -3,10 +3,25 @@
use Illuminate\Support\Facades\Route;
use Modules\User\Http\Controllers\UserController;
use App\Http\Controllers\ImpersonateController;
+use Modules\User\Http\Controllers\AddressController;
+use Modules\User\Http\Controllers\EmploymentController;
+use Modules\User\Http\Controllers\BankController;
+use Modules\User\Http\Controllers\BankDetailController;
+use Modules\User\Http\Controllers\HeirController;
Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(function () {
+ Route::get('users/deleted', [UserController::class, 'deletedIndex'])->name('users.deleted.index');
+ Route::post('users/{id}/restore', [UserController::class, 'restore'])->name('users.restore');
Route::apiResource('users', UserController::class)->names('user');
-
+ Route::apiResource('addresses', AddressController::class)->names('address');
+ // employement
+ Route::apiResource('employments', EmploymentController::class)->names('employment');
+ // bank and bank detail
+ Route::get('banks/active', [BankController::class, 'active'])->name('bank.active');
+ Route::apiResource('banks', BankController::class)->names('bank');
+ Route::apiResource('bank_details', BankDetailController::class)->names('bank_detail');
+ // heir
+ Route::apiResource('heirs', HeirController::class)->names('heir');
// User role management routes
Route::post('users/{user}/roles', [UserController::class, 'assignRoles'])->name('users.roles.assign');
diff --git a/be/Modules/User/Services/UserService.php b/be/Modules/User/Services/UserService.php
index 0a9d9bf..90f20fb 100644
--- a/be/Modules/User/Services/UserService.php
+++ b/be/Modules/User/Services/UserService.php
@@ -32,12 +32,43 @@ class UserService
);
}
+ public function getDeletedPaginatedList(
+ int $perPage,
+ string $search,
+ string $status,
+ string $sortBy,
+ string $sortOrder
+ ): LengthAwarePaginator {
+ return $this->repository->getDeletedWithRelationsPaginated(
+ $perPage,
+ $search,
+ $status,
+ $sortBy,
+ $sortOrder
+ );
+ }
+
+ public function restoreUser(string $id): ?User
+ {
+ if (! $this->repository->restore($id)) {
+ return null;
+ }
+
+ return $this->repository->findById($id);
+ }
+
public function getUserWithRelations(string $id): ?User
{
$user = $this->repository->findById($id);
if ($user) {
- $user->load(['roles.permissions']);
+ $user->load([
+ 'roles.permissions',
+ 'addresses',
+ 'employments',
+ 'bankDetails.bank',
+ 'heirs',
+ ]);
}
return $user;
diff --git a/be/Modules/User/Transformers/AddressResource.php b/be/Modules/User/Transformers/AddressResource.php
new file mode 100644
index 0000000..ffbf6ae
--- /dev/null
+++ b/be/Modules/User/Transformers/AddressResource.php
@@ -0,0 +1,26 @@
+ $this->id,
+ 'user_id' => $this->user_id,
+ 'address_type' => $this->address_type,
+ 'address_line_1' => $this->address_line_1,
+ 'address_line_2' => $this->address_line_2,
+ 'city' => $this->city,
+ 'state' => $this->state,
+ 'postcode' => $this->postcode,
+ 'country' => $this->country,
+ 'created_at' => $this->created_at?->toISOString(),
+ 'updated_at' => $this->updated_at?->toISOString(),
+ ];
+ }
+}
diff --git a/be/Modules/User/Transformers/BankDetailResource.php b/be/Modules/User/Transformers/BankDetailResource.php
new file mode 100644
index 0000000..9acd024
--- /dev/null
+++ b/be/Modules/User/Transformers/BankDetailResource.php
@@ -0,0 +1,25 @@
+ $this->id,
+ 'user_id' => $this->user_id,
+ 'bank_id' => $this->bank_id,
+ 'account_number' => $this->account_number,
+ 'account_name' => $this->account_name,
+ 'account_type' => $this->account_type,
+ 'bank' => new BankResource($this->whenLoaded('bank')),
+ 'created_at' => $this->created_at?->toISOString(),
+ 'updated_at' => $this->updated_at?->toISOString(),
+ ];
+ }
+}
diff --git a/be/Modules/User/Transformers/BankResource.php b/be/Modules/User/Transformers/BankResource.php
new file mode 100644
index 0000000..065dc31
--- /dev/null
+++ b/be/Modules/User/Transformers/BankResource.php
@@ -0,0 +1,22 @@
+ $this->id,
+ 'name' => $this->name,
+ 'code' => $this->code,
+ 'swift_code' => $this->swift_code,
+ 'is_active' => $this->is_active,
+ 'created_at' => $this->created_at?->toISOString(),
+ 'updated_at' => $this->updated_at?->toISOString(),
+ ];
+ }
+}
diff --git a/be/Modules/User/Transformers/EmploymentResource.php b/be/Modules/User/Transformers/EmploymentResource.php
new file mode 100644
index 0000000..f734dbc
--- /dev/null
+++ b/be/Modules/User/Transformers/EmploymentResource.php
@@ -0,0 +1,26 @@
+ $this->id,
+ 'user_id' => $this->user_id,
+ 'company_name' => $this->company_name,
+ 'job_title' => $this->job_title,
+ 'employment_type' => $this->employment_type,
+ 'salary' => $this->salary,
+ 'start_date' => $this->start_date,
+ 'end_date' => $this->end_date,
+ 'is_current' => $this->is_current,
+ 'created_at' => $this->created_at?->toISOString(),
+ 'updated_at' => $this->updated_at?->toISOString(),
+ ];
+ }
+}
diff --git a/be/Modules/User/Transformers/HeirResource.php b/be/Modules/User/Transformers/HeirResource.php
new file mode 100644
index 0000000..f32c38a
--- /dev/null
+++ b/be/Modules/User/Transformers/HeirResource.php
@@ -0,0 +1,25 @@
+ $this->id,
+ 'user_id' => $this->user_id,
+ 'name' => $this->name,
+ 'ic_number' => $this->ic_number,
+ 'relationship' => $this->relationship,
+ 'phone_number' => $this->phone_number,
+ 'address' => $this->address,
+ 'is_primary' => $this->is_primary,
+ 'created_at' => $this->created_at?->toISOString(),
+ 'updated_at' => $this->updated_at?->toISOString(),
+ ];
+ }
+}
diff --git a/be/Modules/User/Transformers/UserListResource.php b/be/Modules/User/Transformers/UserListResource.php
index 5426fee..136aef2 100644
--- a/be/Modules/User/Transformers/UserListResource.php
+++ b/be/Modules/User/Transformers/UserListResource.php
@@ -20,6 +20,13 @@ class UserListResource extends JsonResource
'image_url' => $this->image_url ? Storage::disk('public')->url($this->image_url) : null,
'status' => $this->status,
'email_verified_at' => $this->email_verified_at,
+ 'gender' => $this->gender,
+ 'marriage_status' => $this->marriage_status,
+ 'member_number' => $this->member_number,
+ 'member_type' => $this->member_type,
+ 'join_date' => $this->join_date,
+ 'birth_date' => $this->birth_date,
+ 'birth_place' => $this->birth_place,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'deleted_at' => $this->deleted_at,
diff --git a/be/Modules/User/Transformers/UserResource.php b/be/Modules/User/Transformers/UserResource.php
deleted file mode 100644
index b7021e4..0000000
--- a/be/Modules/User/Transformers/UserResource.php
+++ /dev/null
@@ -1,54 +0,0 @@
- $this->id,
- 'name' => $this->name,
- 'email' => $this->email,
- 'ic_number' => $this->ic_number,
- 'position' => $this->position,
- 'phone_number' => $this->phone_number,
- 'image_url' => $this->image_url ? Storage::disk('public')->url($this->image_url) : null,
- 'status' => $this->status,
- 'two_factor_secret' => $this->two_factor_secret,
- 'two_factor_recovery_codes' => $this->two_factor_recovery_codes,
- 'two_factor_confirmed_at' => $this->two_factor_confirmed_at,
- 'email_verified_at' => $this->email_verified_at,
- 'created_at' => $this->created_at,
- 'updated_at' => $this->updated_at,
- 'deleted_at' => $this->deleted_at,
- 'roles' => $this->whenLoaded('roles', function () {
- return $this->roles->map(function ($role) {
- return [
- 'id' => $role->id,
- 'name' => $role->name,
- 'guard_name' => $role->guard_name,
- 'permissions' => $this->when($role->relationLoaded('permissions'), function () use ($role) {
- return $role->permissions->map(function ($permission) {
- return [
- 'id' => $permission->id,
- 'name' => $permission->name,
- 'guard_name' => $permission->guard_name,
- 'route_name' => $permission->route_name,
- 'created_at' => $permission->created_at,
- 'updated_at' => $permission->updated_at,
- ];
- });
- }),
- 'created_at' => $role->created_at,
- 'updated_at' => $role->updated_at,
- ];
- });
- }),
- ];
- }
-}
diff --git a/be/app/Http/Controllers/ContactController.php b/be/app/Http/Controllers/ContactController.php
deleted file mode 100644
index 5fae078..0000000
--- a/be/app/Http/Controllers/ContactController.php
+++ /dev/null
@@ -1,209 +0,0 @@
-contactService = $contactService;
- }
-
- /**
- * Get all active contact persons
- */
- public function getContacts(): JsonResponse
- {
- try {
- $contacts = $this->contactService->getActiveContacts();
-
- return response()->json([
- 'success' => true,
- 'data' => $contacts,
- 'message' => 'Contact persons retrieved successfully'
- ]);
- } catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => 'Failed to retrieve contact persons',
- 'error' => $e->getMessage()
- ], 500);
- }
- }
-
- /**
- * Get contact settings for admin (including inactive)
- */
- public function getContactSettings(): JsonResponse
- {
- try {
- $settings = $this->contactService->getAllContactSettings();
-
- return response()->json([
- 'success' => true,
- 'data' => $settings,
- 'message' => 'Contact settings retrieved successfully'
- ]);
- } catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => 'Failed to retrieve contact settings',
- 'error' => $e->getMessage()
- ], 500);
- }
- }
-
- /**
- * Update contact settings (Admin only)
- */
- public function updateContacts(Request $request): JsonResponse
- {
- $request->validate([
- 'contacts' => 'required|array',
- 'contacts.*.name' => 'required|string|max:100',
- 'contacts.*.position' => 'nullable|string|max:100',
- 'contacts.*.email' => 'nullable|email|max:255',
- 'contacts.*.phone' => 'nullable|string|max:50',
- 'contacts.*.department' => 'nullable|string|max:100',
- 'contacts.*.is_active' => 'boolean',
- 'contacts.*.sort_order' => 'integer|min:0'
- ]);
-
- try {
- DB::beginTransaction();
-
- $result = $this->contactService->updateContactSettings($request->contacts);
-
- DB::commit();
-
- return response()->json([
- 'success' => true,
- 'data' => $result,
- 'message' => 'Contact settings updated successfully'
- ]);
- } catch (\Exception $e) {
- DB::rollBack();
-
- return response()->json([
- 'success' => false,
- 'message' => 'Failed to update contact settings',
- 'error' => $e->getMessage()
- ], 500);
- }
- }
-
- /**
- * Add a new contact person (Admin only)
- */
- public function addContact(Request $request): JsonResponse
- {
- $request->validate([
- 'name' => 'required|string|max:100',
- 'position' => 'nullable|string|max:100',
- 'email' => 'nullable|email|max:255',
- 'phone' => 'nullable|string|max:50',
- 'department' => 'nullable|string|max:100',
- 'is_active' => 'boolean',
- 'sort_order' => 'integer|min:0'
- ]);
-
- try {
- $result = $this->contactService->addContactPerson($request->all());
-
- return response()->json([
- 'success' => true,
- 'data' => $result,
- 'message' => 'Contact person added successfully'
- ], 201);
- } catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => 'Failed to add contact person',
- 'error' => $e->getMessage()
- ], 500);
- }
- }
-
- /**
- * Update a specific contact person (Admin only)
- */
- public function updateContact(Request $request, int $id): JsonResponse
- {
- $request->validate([
- 'name' => 'required|string|max:100',
- 'position' => 'nullable|string|max:100',
- 'email' => 'nullable|email|max:255',
- 'phone' => 'nullable|string|max:50',
- 'department' => 'nullable|string|max:100',
- 'is_active' => 'boolean',
- 'sort_order' => 'integer|min:0'
- ]);
-
- try {
- $result = $this->contactService->updateContactPerson($id, $request->all());
-
- return response()->json([
- 'success' => true,
- 'data' => $result,
- 'message' => 'Contact person updated successfully'
- ]);
- } catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => 'Failed to update contact person',
- 'error' => $e->getMessage()
- ], 500);
- }
- }
-
- /**
- * Delete a contact person (Admin only)
- */
- public function deleteContact(int $id): JsonResponse
- {
- try {
- $result = $this->contactService->deleteContactPerson($id);
-
- return response()->json([
- 'success' => true,
- 'data' => $result,
- 'message' => 'Contact person deleted successfully'
- ]);
- } catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => 'Failed to delete contact person',
- 'error' => $e->getMessage()
- ], 500);
- }
- }
-
- /**
- * Toggle active status of a contact person (Admin only)
- */
- public function toggleContact(int $id): JsonResponse
- {
- try {
- $result = $this->contactService->toggleContactPerson($id);
-
- return response()->json([
- 'success' => true,
- 'data' => $result,
- 'message' => 'Contact person status toggled successfully'
- ]);
- } catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => 'Failed to toggle contact person status',
- 'error' => $e->getMessage()
- ], 500);
- }
- }
-}
diff --git a/be/app/Http/Controllers/CountryController.php b/be/app/Http/Controllers/CountryController.php
deleted file mode 100644
index 1beeaf8..0000000
--- a/be/app/Http/Controllers/CountryController.php
+++ /dev/null
@@ -1,24 +0,0 @@
-json([
- 'success' => true,
- 'data' => $countries,
- 'message' => 'Countries fetched successfully',
- ]);
- return response()->json([
- 'success' => true,
- 'data' => $countries,
- 'message' => 'Countries fetched successfully',
- ]);
- }
-}
\ No newline at end of file
diff --git a/be/app/Http/Controllers/LetterPreviewController.php b/be/app/Http/Controllers/LetterPreviewController.php
new file mode 100644
index 0000000..32d79d4
--- /dev/null
+++ b/be/app/Http/Controllers/LetterPreviewController.php
@@ -0,0 +1,49 @@
+environment('local', 'development')) {
+ abort(404);
+ }
+
+ $view = 'pdf.preview';
+ $format = $request->query('format', 'html');
+
+ if ($format === 'pdf') {
+ return $this->letterService->pdfResponse($view, [], 'letter-preview.pdf');
+ }
+
+ return $this->letterService->htmlResponse($view);
+ }
+
+ public function showMembershipApprovedLetter(Request $request): Response
+ {
+ if (! app()->environment('local', 'development')) {
+ abort(404);
+ }
+
+ $view = 'pdf.preview-approved-membership';
+ $format = $request->query('format', 'html');
+
+ if ($format === 'pdf') {
+ return $this->letterService->pdfResponse($view, [], 'letter-preview-approved-membership.pdf');
+ }
+
+ return $this->letterService->htmlResponse($view);
+ }
+}
\ No newline at end of file
diff --git a/be/app/Http/Controllers/OnlineUsersController.php b/be/app/Http/Controllers/OnlineUsersController.php
deleted file mode 100644
index 3e53297..0000000
--- a/be/app/Http/Controllers/OnlineUsersController.php
+++ /dev/null
@@ -1,183 +0,0 @@
-onlineUsersService = $onlineUsersService;
- }
-
- /**
- * Get list of online users
- *
- * @param Request $request
- * @return JsonResponse
- */
- public function index(Request $request): JsonResponse
- {
- try {
- $timeoutMinutes = 5; // Fixed to 5 minutes
-
- $onlineUsers = $this->onlineUsersService->getOnlineUsers($timeoutMinutes);
-
- return response()->json([
- 'success' => true,
- 'data' => $onlineUsers,
- 'meta' => [
- 'timeout_minutes' => $timeoutMinutes,
- 'total_online' => $onlineUsers->count(),
- 'timestamp' => now()->toISOString()
- ],
- 'message' => 'Online users retrieved successfully.'
- ]);
-
- } catch (\Exception $e) {
- Log::error('Error fetching online users: ' . $e->getMessage());
-
- return response()->json([
- 'success' => false,
- 'message' => 'Failed to retrieve online users.',
- 'error' => config('app.debug') ? $e->getMessage() : 'Internal server error'
- ], 500);
- }
- }
-
- /**
- * Get online users count
- *
- * @param Request $request
- * @return JsonResponse
- */
- public function count(Request $request): JsonResponse
- {
- try {
- $timeoutMinutes = 5; // Fixed to 5 minutes
-
- $count = $this->onlineUsersService->getOnlineUsersCount($timeoutMinutes);
-
- return response()->json([
- 'success' => true,
- 'data' => [
- 'count' => $count,
- 'timeout_minutes' => $timeoutMinutes
- ],
- 'message' => 'Online users count retrieved successfully.'
- ]);
-
- } catch (\Exception $e) {
- Log::error('Error fetching online users count: ' . $e->getMessage());
-
- return response()->json([
- 'success' => false,
- 'message' => 'Failed to retrieve online users count.',
- 'error' => config('app.debug') ? $e->getMessage() : 'Internal server error'
- ], 500);
- }
- }
-
- /**
- * Get online users statistics
- *
- * @return JsonResponse
- */
- public function stats(): JsonResponse
- {
- try {
- $stats = $this->onlineUsersService->getOnlineUsersStats();
-
- return response()->json([
- 'success' => true,
- 'data' => $stats,
- 'message' => 'Online users statistics retrieved successfully.'
- ]);
-
- } catch (\Exception $e) {
- Log::error('Error fetching online users stats: ' . $e->getMessage());
-
- return response()->json([
- 'success' => false,
- 'message' => 'Failed to retrieve online users statistics.',
- 'error' => config('app.debug') ? $e->getMessage() : 'Internal server error'
- ], 500);
- }
- }
-
- /**
- * Get current user's session information
- *
- * @param Request $request
- * @return JsonResponse
- */
- public function mySession(Request $request): JsonResponse
- {
- try {
- $user = $request->user();
-
- if (!$user) {
- return response()->json([
- 'success' => false,
- 'message' => 'User not authenticated.'
- ], 401);
- }
-
- $sessionInfo = $this->onlineUsersService->getUserSessionInfo($user->id);
-
- if (!$sessionInfo) {
- return response()->json([
- 'success' => false,
- 'message' => 'No active session found.'
- ], 404);
- }
-
- return response()->json([
- 'success' => true,
- 'data' => $sessionInfo,
- 'message' => 'Session information retrieved successfully.'
- ]);
-
- } catch (\Exception $e) {
- Log::error('Error fetching user session info: ' . $e->getMessage());
-
- return response()->json([
- 'success' => false,
- 'message' => 'Failed to retrieve session information.',
- 'error' => config('app.debug') ? $e->getMessage() : 'Internal server error'
- ], 500);
- }
- }
-
- /**
- * Clear online users cache (admin only)
- *
- * @return JsonResponse
- */
- public function clearCache(): JsonResponse
- {
- try {
- $this->onlineUsersService->clearCache();
-
- return response()->json([
- 'success' => true,
- 'message' => 'Online users cache cleared successfully.'
- ]);
-
- } catch (\Exception $e) {
- Log::error('Error clearing online users cache: ' . $e->getMessage());
-
- return response()->json([
- 'success' => false,
- 'message' => 'Failed to clear cache.',
- 'error' => config('app.debug') ? $e->getMessage() : 'Internal server error'
- ], 500);
- }
- }
-}
diff --git a/be/app/Http/Controllers/SocialMediaController.php b/be/app/Http/Controllers/SocialMediaController.php
deleted file mode 100644
index 3421812..0000000
--- a/be/app/Http/Controllers/SocialMediaController.php
+++ /dev/null
@@ -1,206 +0,0 @@
-socialMediaService = $socialMediaService;
- }
-
- /**
- * Get all active social media platforms
- */
- public function getSocialMedia(): JsonResponse
- {
- try {
- $socialMedia = $this->socialMediaService->getActiveSocialMedia();
-
- return response()->json([
- 'success' => true,
- 'data' => $socialMedia,
- 'message' => 'Social media platforms retrieved successfully'
- ]);
- } catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => 'Failed to retrieve social media platforms',
- 'error' => $e->getMessage()
- ], 500);
- }
- }
-
- /**
- * Update social media settings (Admin only)
- */
- public function updateSocialMedia(Request $request): JsonResponse
- {
- $request->validate([
- 'social_media' => 'required|array',
- 'social_media.*.platform' => 'required|string|max:50',
- 'social_media.*.name' => 'required|string|max:100',
- 'social_media.*.url' => 'required|url|max:255',
- 'social_media.*.icon' => 'nullable|string|max:100',
- 'social_media.*.is_active' => 'boolean',
- 'social_media.*.sort_order' => 'integer|min:0'
- ]);
-
- try {
- DB::beginTransaction();
-
- $result = $this->socialMediaService->updateSocialMediaSettings($request->social_media);
-
- DB::commit();
-
- return response()->json([
- 'success' => true,
- 'data' => $result,
- 'message' => 'Social media settings updated successfully'
- ]);
- } catch (\Exception $e) {
- DB::rollBack();
-
- return response()->json([
- 'success' => false,
- 'message' => 'Failed to update social media settings',
- 'error' => $e->getMessage()
- ], 500);
- }
- }
-
- /**
- * Get social media settings for admin (including inactive)
- */
- public function getSocialMediaSettings(): JsonResponse
- {
- try {
- $settings = $this->socialMediaService->getAllSocialMediaSettings();
-
- return response()->json([
- 'success' => true,
- 'data' => $settings,
- 'message' => 'Social media settings retrieved successfully'
- ]);
- } catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => 'Failed to retrieve social media settings',
- 'error' => $e->getMessage()
- ], 500);
- }
- }
-
- /**
- * Add a new social media platform (Admin only)
- */
- public function addSocialMedia(Request $request): JsonResponse
- {
- $request->validate([
- 'platform' => 'required|string|max:50',
- 'name' => 'required|string|max:100',
- 'url' => 'required|url|max:255',
- 'icon' => 'nullable|string|max:100',
- 'is_active' => 'boolean',
- 'sort_order' => 'integer|min:0'
- ]);
-
- try {
- $result = $this->socialMediaService->addSocialMediaPlatform($request->all());
-
- return response()->json([
- 'success' => true,
- 'data' => $result,
- 'message' => 'Social media platform added successfully'
- ], 201);
- } catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => 'Failed to add social media platform',
- 'error' => $e->getMessage()
- ], 500);
- }
- }
-
- /**
- * Update a specific social media platform (Admin only)
- */
- public function updateSocialMediaPlatform(Request $request, int $id): JsonResponse
- {
- $request->validate([
- 'platform' => 'required|string|max:50',
- 'name' => 'required|string|max:100',
- 'url' => 'required|url|max:255',
- 'icon' => 'nullable|string|max:100',
- 'is_active' => 'boolean',
- 'sort_order' => 'integer|min:0'
- ]);
-
- try {
- $result = $this->socialMediaService->updateSocialMediaPlatform($id, $request->all());
-
- return response()->json([
- 'success' => true,
- 'data' => $result,
- 'message' => 'Social media platform updated successfully'
- ]);
- } catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => 'Failed to update social media platform',
- 'error' => $e->getMessage()
- ], 500);
- }
- }
-
- /**
- * Delete a social media platform (Admin only)
- */
- public function deleteSocialMediaPlatform(int $id): JsonResponse
- {
- try {
- $result = $this->socialMediaService->deleteSocialMediaPlatform($id);
-
- return response()->json([
- 'success' => true,
- 'data' => $result,
- 'message' => 'Social media platform deleted successfully'
- ]);
- } catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => 'Failed to delete social media platform',
- 'error' => $e->getMessage()
- ], 500);
- }
- }
-
- /**
- * Toggle active status of a social media platform (Admin only)
- */
- public function toggleSocialMediaPlatform(int $id): JsonResponse
- {
- try {
- $result = $this->socialMediaService->toggleSocialMediaPlatform($id);
-
- return response()->json([
- 'success' => true,
- 'data' => $result,
- 'message' => 'Social media platform status toggled successfully'
- ]);
- } catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => 'Failed to toggle social media platform status',
- 'error' => $e->getMessage()
- ], 500);
- }
- }
-}
\ No newline at end of file
diff --git a/be/app/Models/Country.php b/be/app/Models/Country.php
deleted file mode 100644
index 431c4bf..0000000
--- a/be/app/Models/Country.php
+++ /dev/null
@@ -1,12 +0,0 @@
-
+ */
+ protected $fillable = [
+ 'documentable_type',
+ 'documentable_id',
+ 'name',
+ 'path',
+ 'file_size',
+ 'mime_type',
+ 'type',
+ 'description',
+ 'uploaded_by',
+ ];
+
+ /**
+ * The attributes that should be hidden for serialization.
+ *
+ * @var list
+ */
+ protected $hidden = [
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'name' => 'string',
+ 'path' => 'string',
+ 'file_size' => 'integer',
+ 'mime_type' => 'string',
+ 'type' => 'string',
+ 'description' => 'string',
+ ];
+ }
+
+ public function documentable(): MorphTo
+ {
+ return $this->morphTo();
+ }
+
+ public function uploadedBy(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'uploaded_by');
+ }
+}
diff --git a/be/app/Notifications/Concerns/BuildsMailMessage.php b/be/app/Notifications/Concerns/BuildsMailMessage.php
new file mode 100644
index 0000000..6516731
--- /dev/null
+++ b/be/app/Notifications/Concerns/BuildsMailMessage.php
@@ -0,0 +1,17 @@
+subject($subject)
+ ->markdown($view, array_merge([
+ 'logoPath' => config('mail.logo_path'),
+ ], $data));
+ }
+}
diff --git a/be/app/Providers/FortifyServiceProvider.php b/be/app/Providers/FortifyServiceProvider.php
index f9abbaa..0077392 100644
--- a/be/app/Providers/FortifyServiceProvider.php
+++ b/be/app/Providers/FortifyServiceProvider.php
@@ -68,6 +68,18 @@ class FortifyServiceProvider extends ServiceProvider
return Limit::perMinute(1)->by($throttleKey);
});
+ RateLimiter::for('password-reset-request', function (Request $request) {
+ $throttleKey = Str::transliterate(Str::lower($request->input('email', '')).'|'.$request->ip());
+
+ return Limit::perMinute(1)->by($throttleKey);
+ });
+
+ RateLimiter::for('password-reset', function (Request $request) {
+ $throttleKey = Str::transliterate(Str::lower($request->input('email', '')).'|'.$request->ip());
+
+ return Limit::perMinute(5)->by($throttleKey);
+ });
+
Fortify::authenticateUsing(function (Request $request) {
$user = User::where('email', $request->email)->first();
diff --git a/be/app/Services/ContactService.php b/be/app/Services/ContactService.php
deleted file mode 100644
index e98d14a..0000000
--- a/be/app/Services/ContactService.php
+++ /dev/null
@@ -1,198 +0,0 @@
-where('is_active', true)
- ->orderBy('sort_order', 'asc')
- ->orderBy('name', 'asc')
- ->get()
- ->toArray();
-
- return array_map(function ($item) {
- return [
- 'id' => $item->id,
- 'name' => $item->name,
- 'position' => $item->position,
- 'email' => $item->email,
- 'phone' => $item->phone,
- 'department' => $item->department,
- 'sort_order' => $item->sort_order,
- ];
- }, $contacts);
- } catch (\Exception $e) {
- Log::error('Error fetching active contacts: ' . $e->getMessage());
- throw $e;
- }
- }
-
- /**
- * Get all contact settings (including inactive) for admin
- */
- public function getAllContactSettings(): array
- {
- try {
- $settings = DB::table('contact_settings')
- ->orderBy('sort_order', 'asc')
- ->orderBy('name', 'asc')
- ->get()
- ->toArray();
-
- return array_map(function ($item) {
- return [
- 'id' => $item->id,
- 'name' => $item->name,
- 'position' => $item->position,
- 'email' => $item->email,
- 'phone' => $item->phone,
- 'department' => $item->department,
- 'is_active' => (bool) $item->is_active,
- 'sort_order' => $item->sort_order,
- 'created_at' => $item->created_at,
- 'updated_at' => $item->updated_at,
- ];
- }, $settings);
- } catch (\Exception $e) {
- Log::error('Error fetching all contact settings: ' . $e->getMessage());
- throw $e;
- }
- }
-
- /**
- * Update contact settings (bulk update)
- */
- public function updateContactSettings(array $contactData): array
- {
- try {
- // Clear existing settings
- DB::table('contact_settings')->truncate();
-
- // Insert new settings
- $insertData = [];
- foreach ($contactData as $index => $item) {
- $insertData[] = [
- 'name' => $item['name'],
- 'position' => $item['position'] ?? null,
- 'email' => $item['email'] ?? null,
- 'phone' => $item['phone'] ?? null,
- 'department' => $item['department'] ?? null,
- 'is_active' => $item['is_active'] ?? true,
- 'sort_order' => $item['sort_order'] ?? $index,
- 'created_at' => now(),
- 'updated_at' => now(),
- ];
- }
-
- DB::table('contact_settings')->insert($insertData);
-
- // Return updated settings
- return $this->getAllContactSettings();
- } catch (\Exception $e) {
- Log::error('Error updating contact settings: ' . $e->getMessage());
- throw $e;
- }
- }
-
- /**
- * Add a single contact person
- */
- public function addContactPerson(array $data): array
- {
- try {
- $id = DB::table('contact_settings')->insertGetId([
- 'name' => $data['name'],
- 'position' => $data['position'] ?? null,
- 'email' => $data['email'] ?? null,
- 'phone' => $data['phone'] ?? null,
- 'department' => $data['department'] ?? null,
- 'is_active' => $data['is_active'] ?? true,
- 'sort_order' => $data['sort_order'] ?? 0,
- 'created_at' => now(),
- 'updated_at' => now(),
- ]);
-
- return $this->getAllContactSettings();
- } catch (\Exception $e) {
- Log::error('Error adding contact person: ' . $e->getMessage());
- throw $e;
- }
- }
-
- /**
- * Update a single contact person
- */
- public function updateContactPerson(int $id, array $data): array
- {
- try {
- DB::table('contact_settings')
- ->where('id', $id)
- ->update([
- 'name' => $data['name'],
- 'position' => $data['position'] ?? null,
- 'email' => $data['email'] ?? null,
- 'phone' => $data['phone'] ?? null,
- 'department' => $data['department'] ?? null,
- 'is_active' => $data['is_active'] ?? true,
- 'sort_order' => $data['sort_order'] ?? 0,
- 'updated_at' => now(),
- ]);
-
- return $this->getAllContactSettings();
- } catch (\Exception $e) {
- Log::error('Error updating contact person: ' . $e->getMessage());
- throw $e;
- }
- }
-
- /**
- * Delete a contact person
- */
- public function deleteContactPerson(int $id): array
- {
- try {
- DB::table('contact_settings')->where('id', $id)->delete();
-
- return $this->getAllContactSettings();
- } catch (\Exception $e) {
- Log::error('Error deleting contact person: ' . $e->getMessage());
- throw $e;
- }
- }
-
- /**
- * Toggle active status of a contact person
- */
- public function toggleContactPerson(int $id): array
- {
- try {
- $contact = DB::table('contact_settings')->where('id', $id)->first();
-
- if (!$contact) {
- throw new \Exception('Contact person not found');
- }
-
- DB::table('contact_settings')
- ->where('id', $id)
- ->update([
- 'is_active' => !$contact->is_active,
- 'updated_at' => now(),
- ]);
-
- return $this->getAllContactSettings();
- } catch (\Exception $e) {
- Log::error('Error toggling contact person: ' . $e->getMessage());
- throw $e;
- }
- }
-}
diff --git a/be/app/Services/DocumentService.php b/be/app/Services/DocumentService.php
index 4aaaee8..4b78240 100644
--- a/be/app/Services/DocumentService.php
+++ b/be/app/Services/DocumentService.php
@@ -19,22 +19,16 @@ class DocumentService
string $documentType = 'general',
?string $description = null
): Document {
- // Generate unique filename
$fileName = time().'_'.$file->getClientOriginalName();
-
- // Store file in a folder named after the model
$folderName = strtolower(class_basename($model));
- $filePath = $file->storeAs("documents/{$folderName}", $fileName, 'public');
+ $filePath = $file->storeAs("documents/{$folderName}", $fileName, Document::STORAGE_DISK);
- // Create document record
- return Document::create([
- 'documentable_type' => get_class($model),
- 'documentable_id' => $model->id,
- 'document_name' => $file->getClientOriginalName(),
- 'document_path' => $filePath,
+ return $model->documents()->create([
+ 'name' => $file->getClientOriginalName(),
+ 'path' => $filePath,
'file_size' => $file->getSize(),
'mime_type' => $file->getClientMimeType(),
- 'document_type' => $documentType,
+ 'type' => $documentType,
'description' => $description,
'uploaded_by' => auth()->id(),
]);
@@ -43,7 +37,7 @@ class DocumentService
/**
* Delete a document.
*/
- public function deleteDocument(int $documentId): bool
+ public function deleteDocument(string $documentId): bool
{
$document = Document::findOrFail($documentId);
@@ -58,7 +52,7 @@ class DocumentService
$query = $model->documents();
if ($documentType) {
- $query->where('document_type', $documentType);
+ $query->where('type', $documentType);
}
return $query->with('uploadedBy')->get();
@@ -67,7 +61,7 @@ class DocumentService
/**
* Get document by ID with validation.
*/
- public function getDocument(int $documentId): Document
+ public function getDocument(string $documentId): Document
{
return Document::with('uploadedBy')->findOrFail($documentId);
}
@@ -75,15 +69,17 @@ class DocumentService
/**
* Download a document.
*/
- public function downloadDocument(int $documentId)
+ public function downloadDocument(string $documentId)
{
$document = $this->getDocument($documentId);
- if (! Storage::exists($document->document_path)) {
+ $disk = Storage::disk(Document::STORAGE_DISK);
+
+ if (! $disk->exists($document->path)) {
throw new Exception('File not found');
}
- return Storage::download($document->document_path, $document->document_name);
+ return $disk->download($document->path, $document->name);
}
/**
@@ -94,7 +90,7 @@ class DocumentService
$query = $model->documents();
if ($documentType) {
- $query->where('document_type', $documentType);
+ $query->where('type', $documentType);
}
return $query->count();
@@ -140,12 +136,10 @@ class DocumentService
$supportedTypes = $this->getSupportedFileTypes();
$maxSize = $this->getMaxFileSize() * 1024; // Convert to bytes
- // Check file size
if ($file->getSize() > $maxSize) {
throw new Exception('File size exceeds maximum limit of '.$this->getMaxFileSize().'KB');
}
- // Check mime type
if (! in_array($file->getClientMimeType(), $supportedTypes)) {
throw new Exception('File type not supported. Supported types: '.implode(', ', array_keys($supportedTypes)));
}
diff --git a/be/app/Services/LetterService.php b/be/app/Services/LetterService.php
new file mode 100644
index 0000000..efc6931
--- /dev/null
+++ b/be/app/Services/LetterService.php
@@ -0,0 +1,102 @@
+ $data
+ */
+ public function renderHtml(string $view, array $data = []): string
+ {
+ return View::make($view, $this->prepareViewData($data))->render();
+ }
+
+ /**
+ * @param array $data
+ */
+ public function htmlResponse(string $view, array $data = []): Response
+ {
+ return response($this->renderHtml($view, $data), SymfonyResponse::HTTP_OK, [
+ 'Content-Type' => 'text/html; charset=UTF-8',
+ ]);
+ }
+
+ /**
+ * @param array $data
+ */
+ public function pdfResponse(string $view, array $data = [], string $filename = 'letter.pdf'): Response
+ {
+ $pdf = $this->makeBrowsershot($this->renderHtml($view, $data))->pdf();
+
+ return response($pdf, SymfonyResponse::HTTP_OK, [
+ 'Content-Type' => 'application/pdf',
+ 'Content-Disposition' => 'inline; filename="'.$filename.'"',
+ ]);
+ }
+
+ /**
+ * @param array $overrides
+ * @return array
+ */
+ public function prepareViewData(array $overrides = []): array
+ {
+ $logoPath = $overrides['logoPath'] ?? config('mail.logo_path');
+
+ return array_merge([
+ 'letterLayout' => LetterLayout::resolve(),
+ 'logoPath' => $logoPath,
+ 'logoUrl' => $this->resolveLogoDataUri($logoPath),
+ 'organizationAddress' => $overrides['organizationAddress'] ?? null,
+ 'organizationContact' => $overrides['organizationContact'] ?? null,
+ ], $overrides);
+ }
+
+ protected function makeBrowsershot(string $html): Browsershot
+ {
+ $layout = LetterLayout::resolve();
+ [$top, $right, $bottom, $left] = $layout['margins_mm'];
+
+ $browsershot = Browsershot::html($html)
+ ->setNodeModulePath(config('browsershot.node_module_path'))
+ ->timeout(config('browsershot.timeout'))
+ ->format($layout['page_format'])
+ ->margins($top, $right, $bottom, $left)
+ ->showBackground();
+
+ if ($nodeBinary = config('browsershot.node_binary')) {
+ $browsershot->setNodeBinary($nodeBinary);
+ }
+
+ if ($npmBinary = config('browsershot.npm_binary')) {
+ $browsershot->setNpmBinary($npmBinary);
+ }
+
+ if ($chromePath = config('browsershot.chrome_path')) {
+ $browsershot->setChromePath($chromePath);
+ }
+
+ if (config('browsershot.no_sandbox')) {
+ $browsershot->noSandbox();
+ }
+
+ return $browsershot;
+ }
+
+ protected function resolveLogoDataUri(?string $logoPath): ?string
+ {
+ if (empty($logoPath) || ! file_exists($logoPath)) {
+ return null;
+ }
+
+ $mime = mime_content_type($logoPath) ?: 'image/svg+xml';
+
+ return 'data:'.$mime.';base64,'.base64_encode(file_get_contents($logoPath));
+ }
+}
diff --git a/be/app/Services/OnlineUsersService.php b/be/app/Services/OnlineUsersService.php
deleted file mode 100644
index 8d6a3a4..0000000
--- a/be/app/Services/OnlineUsersService.php
+++ /dev/null
@@ -1,212 +0,0 @@
-subSeconds($timeoutSeconds);
-
- // Get active Sanctum tokens with user information
- $onlineTokens = PersonalAccessToken::with(['tokenable.unit', 'tokenable.rank', 'tokenable.position'])
- ->where('tokenable_type', User::class)
- ->where('last_used_at', '>', $cutoffTime)
- ->where(function ($query) {
- $query->whereNull('expires_at')
- ->orWhere('expires_at', '>', now());
- })
- ->whereHas('tokenable', function ($query) {
- $query->whereNull('deleted_at');
- })
- ->orderBy('last_used_at', 'desc')
- ->get();
-
- // Group by user to handle multiple tokens
- $onlineUsers = $onlineTokens->groupBy('tokenable_id')->map(function ($tokens) {
- $token = $tokens->first();
- $user = $token->tokenable;
- $latestToken = $tokens->sortByDesc('last_used_at')->first();
-
- return [
- 'id' => $user->id,
- 'name' => $user->name,
- 'email' => $user->email,
- 'army_number' => $user->army_number,
- 'image_url' => $user->image_url ? Storage::disk('public')->url($user->image_url) : null,
- 'status' => $user->status,
- 'unit_name' => $user->unit?->name,
- 'rank_name' => $user->rank?->name,
- 'position_name' => $user->position?->name,
- 'ip_address' => 'N/A', // Sanctum doesn't store IP by default
- 'user_agent' => $this->parseUserAgent('Sanctum Token'),
- 'last_activity' => $latestToken->last_used_at?->timestamp ?? $latestToken->created_at->timestamp,
- 'last_activity_human' => $latestToken->last_used_at?->diffForHumans() ?? $latestToken->created_at->diffForHumans(),
- 'session_count' => $tokens->count(),
- 'is_online' => true
- ];
- });
-
- return $onlineUsers->values();
- });
- }
-
- /**
- * Get online users count
- *
- * @param int $timeoutMinutes Minutes of inactivity to consider user offline
- * @return int
- */
- public function getOnlineUsersCount(int $timeoutMinutes = 5): int
- {
- $cacheKey = "online_users_count_{$timeoutMinutes}";
-
- return Cache::remember($cacheKey, 30, function () use ($timeoutMinutes) {
- $timeoutSeconds = $timeoutMinutes * 60;
- $cutoffTime = now()->subSeconds($timeoutSeconds);
-
- return PersonalAccessToken::where('tokenable_type', User::class)
- ->where('last_used_at', '>', $cutoffTime)
- ->where(function ($query) {
- $query->whereNull('expires_at')
- ->orWhere('expires_at', '>', now());
- })
- ->whereHas('tokenable', function ($query) {
- $query->whereNull('deleted_at');
- })
- ->distinct('tokenable_id')
- ->count('tokenable_id');
- });
- }
-
- /**
- * Get user's current session information
- *
- * @param int $userId
- * @return array|null
- */
- public function getUserSessionInfo(int $userId): ?array
- {
- $token = PersonalAccessToken::where('tokenable_type', User::class)
- ->where('tokenable_id', $userId)
- ->where(function ($query) {
- $query->whereNull('expires_at')
- ->orWhere('expires_at', '>', now());
- })
- ->orderBy('last_used_at', 'desc')
- ->first();
-
- if (!$token) {
- return null;
- }
-
- return [
- 'session_id' => $token->id,
- 'ip_address' => 'N/A', // Sanctum doesn't store IP by default
- 'user_agent' => $this->parseUserAgent('Sanctum Token'),
- 'last_activity' => $token->last_used_at?->timestamp ?? $token->created_at->timestamp,
- 'last_activity_human' => $token->last_used_at?->diffForHumans() ?? $token->created_at->diffForHumans(),
- 'is_online' => $token->last_used_at && $token->last_used_at->gt(now()->subMinutes(5))
- ];
- }
-
- /**
- * Parse user agent string to extract browser and OS info
- *
- * @param string $userAgent
- * @return array
- */
- private function parseUserAgent(string $userAgent): array
- {
- $browser = 'Unknown';
- $os = 'Unknown';
-
- // Simple browser detection
- if (strpos($userAgent, 'Chrome') !== false) {
- $browser = 'Chrome';
- } elseif (strpos($userAgent, 'Firefox') !== false) {
- $browser = 'Firefox';
- } elseif (strpos($userAgent, 'Safari') !== false) {
- $browser = 'Safari';
- } elseif (strpos($userAgent, 'Edge') !== false) {
- $browser = 'Edge';
- }
-
- // Simple OS detection
- if (strpos($userAgent, 'Windows') !== false) {
- $os = 'Windows';
- } elseif (strpos($userAgent, 'Mac') !== false) {
- $os = 'macOS';
- } elseif (strpos($userAgent, 'Linux') !== false) {
- $os = 'Linux';
- } elseif (strpos($userAgent, 'Android') !== false) {
- $os = 'Android';
- } elseif (strpos($userAgent, 'iOS') !== false) {
- $os = 'iOS';
- }
-
- return [
- 'browser' => $browser,
- 'os' => $os,
- 'raw' => $userAgent
- ];
- }
-
- /**
- * Clear online users cache
- */
- public function clearCache(): void
- {
- Cache::forget('online_users_5');
- Cache::forget('online_users_10');
- Cache::forget('online_users_15');
- Cache::forget('online_users_30');
- Cache::forget('online_users_count_5');
- Cache::forget('online_users_count_10');
- Cache::forget('online_users_count_15');
- Cache::forget('online_users_count_30');
- }
-
- /**
- * Get online users statistics
- *
- * @return array
- */
- public function getOnlineUsersStats(): array
- {
- $stats = [];
-
- // Get stats for different timeout periods
- $timeouts = [5, 10, 15, 30];
-
- foreach ($timeouts as $timeout) {
- $stats["online_{$timeout}min"] = $this->getOnlineUsersCount($timeout);
- }
-
- // Get total registered users
- $stats['total_users'] = User::count();
-
- // Get users by status
- $stats['active_users'] = User::where('status', 'active')->count();
- $stats['inactive_users'] = User::where('status', 'inactive')->count();
-
- return $stats;
- }
-}
diff --git a/be/app/Services/SocialMediaService.php b/be/app/Services/SocialMediaService.php
deleted file mode 100644
index 433dc8b..0000000
--- a/be/app/Services/SocialMediaService.php
+++ /dev/null
@@ -1,193 +0,0 @@
-where('is_active', true)
- ->orderBy('sort_order', 'asc')
- ->orderBy('name', 'asc')
- ->get()
- ->toArray();
-
- return array_map(function ($item) {
- return [
- 'id' => $item->id,
- 'platform' => $item->platform,
- 'name' => $item->name,
- 'url' => $item->url,
- 'icon' => $item->icon,
- 'sort_order' => $item->sort_order,
- ];
- }, $socialMedia);
- } catch (\Exception $e) {
- Log::error('Error fetching active social media: ' . $e->getMessage());
- throw $e;
- }
- }
-
- /**
- * Get all social media settings (including inactive) for admin
- */
- public function getAllSocialMediaSettings(): array
- {
- try {
- $settings = DB::table('social_media_settings')
- ->orderBy('sort_order', 'asc')
- ->orderBy('name', 'asc')
- ->get()
- ->toArray();
-
- return array_map(function ($item) {
- return [
- 'id' => $item->id,
- 'platform' => $item->platform,
- 'name' => $item->name,
- 'url' => $item->url,
- 'icon' => $item->icon,
- 'is_active' => (bool) $item->is_active,
- 'sort_order' => $item->sort_order,
- 'created_at' => $item->created_at,
- 'updated_at' => $item->updated_at,
- ];
- }, $settings);
- } catch (\Exception $e) {
- Log::error('Error fetching all social media settings: ' . $e->getMessage());
- throw $e;
- }
- }
-
- /**
- * Update social media settings (bulk update)
- */
- public function updateSocialMediaSettings(array $socialMediaData): array
- {
- try {
- // Clear existing settings
- DB::table('social_media_settings')->truncate();
-
- // Insert new settings
- $insertData = [];
- foreach ($socialMediaData as $index => $item) {
- $insertData[] = [
- 'platform' => $item['platform'],
- 'name' => $item['name'],
- 'url' => $item['url'],
- 'icon' => $item['icon'] ?? null,
- 'is_active' => $item['is_active'] ?? true,
- 'sort_order' => $item['sort_order'] ?? $index,
- 'created_at' => now(),
- 'updated_at' => now(),
- ];
- }
-
- DB::table('social_media_settings')->insert($insertData);
-
- // Return updated settings
- return $this->getAllSocialMediaSettings();
- } catch (\Exception $e) {
- Log::error('Error updating social media settings: ' . $e->getMessage());
- throw $e;
- }
- }
-
- /**
- * Add a single social media platform
- */
- public function addSocialMediaPlatform(array $data): array
- {
- try {
- $id = DB::table('social_media_settings')->insertGetId([
- 'platform' => $data['platform'],
- 'name' => $data['name'],
- 'url' => $data['url'],
- 'icon' => $data['icon'] ?? null,
- 'is_active' => $data['is_active'] ?? true,
- 'sort_order' => $data['sort_order'] ?? 0,
- 'created_at' => now(),
- 'updated_at' => now(),
- ]);
-
- return $this->getAllSocialMediaSettings();
- } catch (\Exception $e) {
- Log::error('Error adding social media platform: ' . $e->getMessage());
- throw $e;
- }
- }
-
- /**
- * Update a single social media platform
- */
- public function updateSocialMediaPlatform(int $id, array $data): array
- {
- try {
- DB::table('social_media_settings')
- ->where('id', $id)
- ->update([
- 'platform' => $data['platform'],
- 'name' => $data['name'],
- 'url' => $data['url'],
- 'icon' => $data['icon'] ?? null,
- 'is_active' => $data['is_active'] ?? true,
- 'sort_order' => $data['sort_order'] ?? 0,
- 'updated_at' => now(),
- ]);
-
- return $this->getAllSocialMediaSettings();
- } catch (\Exception $e) {
- Log::error('Error updating social media platform: ' . $e->getMessage());
- throw $e;
- }
- }
-
- /**
- * Delete a social media platform
- */
- public function deleteSocialMediaPlatform(int $id): array
- {
- try {
- DB::table('social_media_settings')->where('id', $id)->delete();
-
- return $this->getAllSocialMediaSettings();
- } catch (\Exception $e) {
- Log::error('Error deleting social media platform: ' . $e->getMessage());
- throw $e;
- }
- }
-
- /**
- * Toggle active status of a social media platform
- */
- public function toggleSocialMediaPlatform(int $id): array
- {
- try {
- $platform = DB::table('social_media_settings')->where('id', $id)->first();
-
- if (!$platform) {
- throw new \Exception('Social media platform not found');
- }
-
- DB::table('social_media_settings')
- ->where('id', $id)
- ->update([
- 'is_active' => !$platform->is_active,
- 'updated_at' => now(),
- ]);
-
- return $this->getAllSocialMediaSettings();
- } catch (\Exception $e) {
- Log::error('Error toggling social media platform: ' . $e->getMessage());
- throw $e;
- }
- }
-}
diff --git a/be/app/Services/VisibilityService.php b/be/app/Services/VisibilityService.php
deleted file mode 100644
index dad6050..0000000
--- a/be/app/Services/VisibilityService.php
+++ /dev/null
@@ -1,304 +0,0 @@
-can('akses peringkat keseluruhan', Unit::class)) {
- return $query;
- }
-
- // Get user's unit
- $userUnit = Unit::find($user->unit_id);
- if (!$userUnit) {
- return $query->whereRaw('1 = 0');
- }
-
- // Government permission: Can view all units under their government
- // Government users do NOT see units without formations
- // SEL PERO 91 REJ (process models): ONLY see units without formation - not their gov/formation units
- if ($user->can('akses peringkat formasi')) {
- if ($includeUnitsWithoutFormation && $user->hasRole('SEL PERO 91 REJ')) {
- $unitIds = Unit::whereNull('formation_id')
- ->whereNull('repair_formation_id')
- ->pluck('id')
- ->toArray();
- } else {
- $unitIds = $this->getUnitsUnderGovernment($userUnit);
- $unitIds = $this->excludeUnitsWithoutFormation($unitIds);
- }
-
- if (!empty($unitIds)) {
- return $query->whereIn($unitColumn, $unitIds);
- }
- return $query->whereRaw('1 = 0');
- }
-
- // DIV permission: Can view all units under their formation (including repair formations)
- if ($user->can('akses peringkat divisyen')) {
- $unitIds = $this->getVisibleUnitIdsForFormationUser($user, $userUnit, $includeUnitsWithoutFormation);
-
- if (!empty($unitIds)) {
- return $query->whereIn($unitColumn, $unitIds);
- }
- return $query->whereRaw('1 = 0');
- }
-
- // Unit permission: Can only view data within their unit
- return $query->where($unitColumn, $user->unit_id);
- }
-
- /**
- * Apply visibility scoping for models with indirect unit relationships
- *
- * @param bool $includeUnitsWithoutFormation When true (process models), SEL PERO 91 REJ
- * users can see units without formations. Government users never see units without formations.
- */
- public function applyVisibilityToIndirectQuery(Builder $query, $user, array $unitRelationship, bool $includeUnitsWithoutFormation = false): Builder
- {
- // Check if user has akses peringkat keseluruhan permission (super admin)
- if ($user->can('akses peringkat keseluruhan', Unit::class)) {
- return $query;
- }
-
- // Get user's unit
- $userUnit = Unit::find($user->unit_id);
- if (!$userUnit) {
- return $query->whereRaw('1 = 0');
- }
-
- // Government permission: Can view all units under their government
- // SEL PERO 91 REJ (process models): ONLY see units without formation - not their gov/formation units
- if ($user->can('akses peringkat formasi')) {
- if ($includeUnitsWithoutFormation && $user->hasRole('SEL PERO 91 REJ')) {
- $unitIds = Unit::whereNull('formation_id')
- ->whereNull('repair_formation_id')
- ->pluck('id')
- ->toArray();
- } else {
- $unitIds = $this->getUnitsUnderGovernment($userUnit);
- $unitIds = $this->excludeUnitsWithoutFormation($unitIds);
- }
-
- if (!empty($unitIds)) {
- return $query->whereHas($unitRelationship['relationship'], function ($subQuery) use ($unitIds, $unitRelationship) {
- $subQuery->whereIn($unitRelationship['unitColumn'], $unitIds);
- });
- }
- return $query->whereRaw('1 = 0');
- }
-
- // DIV permission: Can view all units under their formation (including repair formations)
- if ($user->can('akses peringkat divisyen')) {
- $unitIds = $this->getVisibleUnitIdsForFormationUser($user, $userUnit, $includeUnitsWithoutFormation);
-
- if (!empty($unitIds)) {
- return $query->whereHas($unitRelationship['relationship'], function ($subQuery) use ($unitIds, $unitRelationship) {
- $subQuery->whereIn($unitRelationship['unitColumn'], $unitIds);
- });
- }
- return $query->whereRaw('1 = 0');
- }
-
- // Unit permission: Can only view data within their unit
- return $query->whereHas($unitRelationship['relationship'], function ($subQuery) use ($user, $unitRelationship) {
- $subQuery->where($unitRelationship['unitColumn'], $user->unit_id);
- });
- }
-
- /**
- * Get all units under the same government as the user's unit
- * Handles both direct government relationships and formation-government relationships
- * Priority: Direct government_id takes precedence over formation government
- *
- * @param Unit $userUnit
- * @return array
- */
- public function getUnitsUnderGovernment(Unit $userUnit): array
- {
- $unitIds = collect();
-
- // Case 1: User's unit has direct government relationship (HIGHEST PRIORITY)
- if ($userUnit->government_id) {
- $directUnits = Unit::where('government_id', $userUnit->government_id)
- ->pluck('id');
- $unitIds = $unitIds->merge($directUnits);
-
- // Also get units in formations under the same direct government
- $formationUnits = Unit::whereHas('formation', function ($query) use ($userUnit) {
- $query->where('government_id', $userUnit->government_id);
- })->pluck('id');
- $unitIds = $unitIds->merge($formationUnits);
-
- return $unitIds->unique()->toArray();
- }
-
- // Case 2: User's unit belongs to a formation that has a government (FALLBACK)
- if ($userUnit->formation_id) {
- $formation = Formation::find($userUnit->formation_id);
- if ($formation && $formation->government_id) {
- // Get all units in formations under the same government
- $formationUnits = Unit::whereHas('formation', function ($query) use ($formation) {
- $query->where('government_id', $formation->government_id);
- })->pluck('id');
- $unitIds = $unitIds->merge($formationUnits);
-
- // Also get direct government units under the same government
- $directGovUnits = Unit::where('government_id', $formation->government_id)
- ->pluck('id');
- $unitIds = $unitIds->merge($directGovUnits);
- }
- }
-
- return $unitIds->unique()->toArray();
- }
-
- /**
- * Get the repair formation ID for a unit
- * Priority: repair_formation_id > formation_id > null
- *
- * @param Unit $unit
- * @return int|null
- */
- public function getRepairFormationId(Unit $unit): ?int
- {
- // Priority 1: Check repair_formation_id (custom repair formation)
- if ($unit->repair_formation_id) {
- return $unit->repair_formation_id;
- }
-
- // Priority 2: Fall back to regular formation_id
- if ($unit->formation_id) {
- return $unit->formation_id;
- }
-
- // Priority 3: No formation (government-level only)
- return null;
- }
-
- /**
- * Get visible unit IDs for a user
- *
- * @param bool $includeUnitsWithoutFormation When true (process models), SEL PERO 91 REJ
- * users can see units without formations. Government users never see units without formations.
- */
- public function getVisibleUnitIds($user, bool $includeUnitsWithoutFormation = false): array
- {
- // Check if user has akses peringkat keseluruhan permission (super admin)
- if ($user->can('akses peringkat keseluruhan', Unit::class)) {
- return Unit::pluck('id')->toArray();
- }
-
- // Get user's unit
- $userUnit = Unit::find($user->unit_id);
- if (!$userUnit) {
- return [];
- }
-
- // Government permission: Can view all units under their government
- // SEL PERO 91 REJ (process models): ONLY see units without formation - not their gov/formation units
- if ($user->can('akses peringkat formasi')) {
- if ($includeUnitsWithoutFormation && $user->hasRole('SEL PERO 91 REJ')) {
- return Unit::whereNull('formation_id')
- ->whereNull('repair_formation_id')
- ->pluck('id')
- ->toArray();
- }
- $unitIds = $this->getUnitsUnderGovernment($userUnit);
- return $this->excludeUnitsWithoutFormation($unitIds);
- }
-
- // DIV permission: Can view all units under their formation (including repair formations)
- if ($user->can('akses peringkat divisyen')) {
- return $this->getVisibleUnitIdsForFormationUser($user, $userUnit, $includeUnitsWithoutFormation);
- }
-
- // Unit permission: Can only view data within their unit
- return [$user->unit_id];
- }
-
- /**
- * Exclude units that have no formation (both formation_id and repair_formation_id are null).
- * Government users do not see these units.
- */
- protected function excludeUnitsWithoutFormation(array $unitIds): array
- {
- if (empty($unitIds)) {
- return [];
- }
-
- return Unit::whereIn('id', $unitIds)
- ->where(function ($q) {
- $q->whereNotNull('formation_id')
- ->orWhereNotNull('repair_formation_id');
- })
- ->pluck('id')
- ->toArray();
- }
-
- /**
- * Get visible unit IDs for a user with akses peringkat divisyen permission.
- *
- * Asset view (includeUnitsWithoutFormation=false): Only units where formation_id matches.
- * Excludes units that have repair_formation_id but formation_id null (e.g. 10 SKN RAJD uses
- * 3 DIV for repair only - 3 DIV users see their repairs but NOT their asset data).
- *
- * Process view (includeUnitsWithoutFormation=true): Units where repair_formation_id OR
- * formation_id matches. SEL PERO 91 REJ also sees units without any formation.
- */
- protected function getVisibleUnitIdsForFormationUser($user, Unit $userUnit, bool $includeUnitsWithoutFormation): array
- {
- $userRepairFormationId = $this->getRepairFormationId($userUnit);
-
- if ($userRepairFormationId) {
- if (!$includeUnitsWithoutFormation) {
- // Asset view: Only units that belong to the formation (formation_id matches).
- // Exclude units that use formation only for repair (formation_id null, repair_formation_id set).
- return Unit::where('formation_id', $userRepairFormationId)
- ->pluck('id')
- ->toArray();
- }
-
- // SEL PERO 91 REJ: ONLY see units without formation - not their formation's units
- // (formation units are handled by SEL PERO DIV)
- if ($user->hasRole('SEL PERO 91 REJ')) {
- return Unit::whereNull('formation_id')
- ->whereNull('repair_formation_id')
- ->pluck('id')
- ->toArray();
- }
-
- // Process view: Units in user's formation (repair_formation_id or formation_id)
- return Unit::where(function ($q) use ($userRepairFormationId) {
- $q->where('repair_formation_id', $userRepairFormationId)
- ->orWhere(function ($q2) use ($userRepairFormationId) {
- $q2->whereNull('repair_formation_id')
- ->where('formation_id', $userRepairFormationId);
- });
- })->pluck('id')->toArray();
- }
-
- // User's unit has no formation - check if they have SEL PERO 91 REJ (process models only)
- if ($includeUnitsWithoutFormation && $user->hasRole('SEL PERO 91 REJ')) {
- return Unit::whereNull('formation_id')
- ->whereNull('repair_formation_id')
- ->pluck('id')
- ->toArray();
- }
-
- return [];
- }
-}
-
diff --git a/be/app/Support/LetterLayout.php b/be/app/Support/LetterLayout.php
new file mode 100644
index 0000000..c8543fa
--- /dev/null
+++ b/be/app/Support/LetterLayout.php
@@ -0,0 +1,83 @@
+
+ * }
+ */
+ public static function resolve(): array
+ {
+ $page = config('letter.page');
+ $content = config('letter.content');
+ $typography = config('letter.typography');
+ $spacing = config('letter.spacing');
+ $colors = config('letter.colors');
+
+ $marginTop = self::length($page['margin_top_mm']);
+ $marginRight = self::length($page['margin_right_mm']);
+ $marginBottom = self::length($page['margin_bottom_mm']);
+ $marginLeft = self::length($page['margin_left_mm']);
+ $contentPaddingX = self::length($content['padding_x_mm']);
+ $pageHeight = $page['height_mm'].'mm';
+
+ return [
+ 'page_format' => $page['format'],
+ 'margins_mm' => [
+ $page['margin_top_mm'],
+ $page['margin_right_mm'],
+ $page['margin_bottom_mm'],
+ $page['margin_left_mm'],
+ ],
+ 'css' => [
+ 'margin_top' => $marginTop,
+ 'margin_right' => $marginRight,
+ 'margin_bottom' => $marginBottom,
+ 'margin_left' => $marginLeft,
+ 'content_padding_x' => $contentPaddingX,
+ 'page_height' => $pageHeight,
+ 'sheet_min_height' => sprintf(
+ 'calc(%s - %s - %s)',
+ $pageHeight,
+ $marginTop,
+ $marginBottom,
+ ),
+ 'font_family' => $typography['font_family'],
+ 'font_size' => $typography['font_size'],
+ 'line_height' => (string) $typography['line_height'],
+ 'body_color' => $typography['body_color'],
+ 'signatory_title_size' => $typography['signatory_title_size'],
+ 'signatory_title_color' => $typography['signatory_title_color'],
+ 'footer_font_size' => $typography['footer_font_size'],
+ 'footer_line_height' => (string) $typography['footer_line_height'],
+ 'letterhead_registration_size' => $typography['letterhead_registration_size'],
+ 'letterhead_margin_bottom' => $spacing['letterhead_margin_bottom'].'px',
+ 'meta_margin_bottom' => $spacing['meta_margin_bottom'].'px',
+ 'recipient_margin_bottom' => $spacing['recipient_margin_bottom'].'px',
+ 'subject_margin_bottom' => $spacing['subject_margin_bottom'].'px',
+ 'body_margin_bottom' => $spacing['body_margin_bottom'].'px',
+ 'body_paragraph_margin_bottom' => $spacing['body_paragraph_margin_bottom'].'px',
+ 'signature_block_margin_bottom' => $spacing['signature_block_margin_bottom'].'px',
+ 'signature_company_margin_bottom' => $spacing['signature_company_margin_bottom'].'px',
+ 'signature_space_height' => $spacing['signature_space_height'].'px',
+ 'logo_height' => $spacing['logo_height'].'px',
+ 'color_blue' => $colors['blue'],
+ 'color_orange' => $colors['orange'],
+ 'color_white' => $colors['white'],
+ 'color_black' => $colors['black'],
+ ],
+ ];
+ }
+
+ private static function length(int $millimetres): string
+ {
+ $centimetres = $millimetres / 10;
+
+ return rtrim(rtrim(number_format($centimetres, 2, '.', ''), '0'), '.').'cm';
+ }
+}
diff --git a/be/app/Traits/HasDocuments.php b/be/app/Traits/HasDocuments.php
index e4580a3..ffd6cd5 100644
--- a/be/app/Traits/HasDocuments.php
+++ b/be/app/Traits/HasDocuments.php
@@ -20,7 +20,7 @@ trait HasDocuments
*/
public function documentsOfType($type)
{
- return $this->documents()->where('document_type', $type);
+ return $this->documents()->where('type', $type);
}
/**
@@ -33,15 +33,15 @@ trait HasDocuments
// Store file in a folder named after the model
$folderName = strtolower(class_basename($this));
- $filePath = $file->storeAs("documents/{$folderName}", $fileName);
+ $filePath = $file->storeAs("documents/{$folderName}", $fileName, Document::STORAGE_DISK);
// Create document record
return $this->documents()->create([
- 'document_name' => $file->getClientOriginalName(),
- 'document_path' => $filePath,
+ 'name' => $file->getClientOriginalName(),
+ 'path' => $filePath,
'file_size' => $file->getSize(),
'mime_type' => $file->getClientMimeType(),
- 'document_type' => $documentType,
+ 'type' => $documentType,
'description' => $description,
'uploaded_by' => auth()->id(),
]);
diff --git a/be/app/Traits/HasVisibility.php b/be/app/Traits/HasVisibility.php
deleted file mode 100644
index 1d42fd0..0000000
--- a/be/app/Traits/HasVisibility.php
+++ /dev/null
@@ -1,58 +0,0 @@
-includeUnitsWithoutFormation
- : false;
-
- // Check if model has a direct unit column
- $unitColumn = property_exists($this, 'unitColumn') ? $this->unitColumn : 'unit_id';
-
- // Check if model has an indirect unit relationship
- $unitRelationship = property_exists($this, 'unitRelationship') ? $this->unitRelationship : null;
-
- if ($unitRelationship) {
- return $this->getVisibilityService()->applyVisibilityToIndirectQuery(
- $query,
- $user,
- $unitRelationship,
- $includeUnitsWithoutFormation
- );
- }
-
- return $this->getVisibilityService()->applyVisibilityToQuery(
- $query,
- $user,
- $unitColumn,
- $includeUnitsWithoutFormation
- );
- }
-
- /**
- * Get all units under the same government as the user's unit
- * Handles both direct government relationships and formation-government relationships
- * Priority: Direct government_id takes precedence over formation government
- *
- * @deprecated Use VisibilityService::getUnitsUnderGovernment() instead
- */
- protected function getUnitsUnderGovernment($userUnit)
- {
- return $this->getVisibilityService()->getUnitsUnderGovernment($userUnit);
- }
-}
\ No newline at end of file
diff --git a/be/composer.json b/be/composer.json
index 5fda7bc..93d2c6f 100644
--- a/be/composer.json
+++ b/be/composer.json
@@ -19,6 +19,7 @@
"laravel/tinker": "^2.10.1",
"maatwebsite/excel": "^3.1",
"nwidart/laravel-modules": "^12.0",
+ "spatie/browsershot": "*",
"spatie/laravel-activitylog": "^4.10",
"spatie/laravel-permission": "^6.21"
},
diff --git a/be/composer.lock b/be/composer.lock
index 18879a6..2f61a0a 100644
--- a/be/composer.lock
+++ b/be/composer.lock
@@ -4,20 +4,20 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "74a39a84834cfbce955691dc2137f5b4",
+ "content-hash": "d542997c95e6b07d8e6db6e796b81d4f",
"packages": [
{
"name": "bacon/bacon-qr-code",
- "version": "v3.0.1",
+ "version": "v3.1.1",
"source": {
"type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git",
- "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f"
+ "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/f9cc1f52b5a463062251d666761178dbdb6b544f",
- "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f",
+ "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
+ "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
"shasum": ""
},
"require": {
@@ -27,8 +27,9 @@
},
"require-dev": {
"phly/keep-a-changelog": "^2.12",
- "phpunit/phpunit": "^10.5.11 || 11.0.4",
+ "phpunit/phpunit": "^10.5.11 || ^11.0.4",
"spatie/phpunit-snapshot-assertions": "^5.1.5",
+ "spatie/pixelmatch-php": "^1.2.0",
"squizlabs/php_codesniffer": "^3.9"
},
"suggest": {
@@ -56,22 +57,22 @@
"homepage": "https://github.com/Bacon/BaconQrCode",
"support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues",
- "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.1"
+ "source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1"
},
- "time": "2024-10-01T13:55:55+00:00"
+ "time": "2026-04-05T21:06:35+00:00"
},
{
"name": "brick/math",
- "version": "0.14.0",
+ "version": "0.14.8",
"source": {
"type": "git",
"url": "https://github.com/brick/math.git",
- "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2"
+ "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/brick/math/zipball/113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2",
- "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2",
+ "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629",
+ "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629",
"shasum": ""
},
"require": {
@@ -110,7 +111,7 @@
],
"support": {
"issues": "https://github.com/brick/math/issues",
- "source": "https://github.com/brick/math/tree/0.14.0"
+ "source": "https://github.com/brick/math/tree/0.14.8"
},
"funding": [
{
@@ -118,7 +119,7 @@
"type": "github"
}
],
- "time": "2025-08-29T12:40:03+00:00"
+ "time": "2026-02-10T14:33:43+00:00"
},
{
"name": "carbonphp/carbon-doctrine-types",
@@ -191,28 +192,29 @@
},
{
"name": "composer/pcre",
- "version": "3.3.2",
+ "version": "3.4.0",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
- "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
+ "reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
- "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+ "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
+ "reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
- "phpstan/phpstan": "<1.11.10"
+ "phpstan/phpstan": "<2.2.2"
},
"require-dev": {
- "phpstan/phpstan": "^1.12 || ^2",
- "phpstan/phpstan-strict-rules": "^1 || ^2",
- "phpunit/phpunit": "^8 || ^9"
+ "phpstan/phpstan": "^2",
+ "phpstan/phpstan-deprecation-rules": "^2",
+ "phpstan/phpstan-strict-rules": "^2",
+ "phpunit/phpunit": "^9"
},
"type": "library",
"extra": {
@@ -250,7 +252,7 @@
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
- "source": "https://github.com/composer/pcre/tree/3.3.2"
+ "source": "https://github.com/composer/pcre/tree/3.4.0"
},
"funding": [
{
@@ -260,13 +262,9 @@
{
"url": "https://github.com/composer",
"type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/composer/composer",
- "type": "tidelift"
}
],
- "time": "2024-11-12T16:29:46+00:00"
+ "time": "2026-06-07T11:47:49+00:00"
},
{
"name": "composer/semver",
@@ -470,6 +468,54 @@
},
"time": "2024-07-08T12:26:09+00:00"
},
+ {
+ "name": "doctrine/deprecations",
+ "version": "1.1.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/deprecations.git",
+ "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
+ "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "phpunit/phpunit": "<=7.5 || >=14"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^9 || ^12 || ^14",
+ "phpstan/phpstan": "1.4.10 || 2.1.30",
+ "phpstan/phpstan-phpunit": "^1.0 || ^2",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
+ "psr/log": "^1 || ^2 || ^3"
+ },
+ "suggest": {
+ "psr/log": "Allows logging deprecations via PSR-3 logger implementation"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Deprecations\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
+ "homepage": "https://www.doctrine-project.org/",
+ "support": {
+ "issues": "https://github.com/doctrine/deprecations/issues",
+ "source": "https://github.com/doctrine/deprecations/tree/1.1.6"
+ },
+ "time": "2026-02-07T07:09:04+00:00"
+ },
{
"name": "doctrine/inflector",
"version": "2.1.0",
@@ -639,16 +685,16 @@
},
{
"name": "dompdf/dompdf",
- "version": "v3.1.2",
+ "version": "v3.1.5",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
- "reference": "b3493e35d31a5e76ec24c3b64a29b0034b2f32a6"
+ "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/dompdf/dompdf/zipball/b3493e35d31a5e76ec24c3b64a29b0034b2f32a6",
- "reference": "b3493e35d31a5e76ec24c3b64a29b0034b2f32a6",
+ "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
+ "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"shasum": ""
},
"require": {
@@ -697,22 +743,22 @@
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
- "source": "https://github.com/dompdf/dompdf/tree/v3.1.2"
+ "source": "https://github.com/dompdf/dompdf/tree/v3.1.5"
},
- "time": "2025-09-23T03:06:41+00:00"
+ "time": "2026-03-03T13:54:37+00:00"
},
{
"name": "dompdf/php-font-lib",
- "version": "1.0.1",
+ "version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
- "reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d"
+ "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d",
- "reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d",
+ "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
+ "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"shasum": ""
},
"require": {
@@ -720,7 +766,7 @@
"php": "^7.1 || ^8.0"
},
"require-dev": {
- "symfony/phpunit-bridge": "^3 || ^4 || ^5 || ^6"
+ "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
},
"type": "library",
"autoload": {
@@ -742,31 +788,31 @@
"homepage": "https://github.com/dompdf/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
- "source": "https://github.com/dompdf/php-font-lib/tree/1.0.1"
+ "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
},
- "time": "2024-12-02T14:37:59+00:00"
+ "time": "2026-01-20T14:10:26+00:00"
},
{
"name": "dompdf/php-svg-lib",
- "version": "1.0.0",
+ "version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
- "reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af"
+ "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/eb045e518185298eb6ff8d80d0d0c6b17aecd9af",
- "reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af",
+ "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
+ "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
- "sabberworm/php-css-parser": "^8.4"
+ "sabberworm/php-css-parser": "^8.4 || ^9.0"
},
"require-dev": {
- "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5"
+ "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
},
"type": "library",
"autoload": {
@@ -788,35 +834,34 @@
"homepage": "https://github.com/dompdf/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
- "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.0"
+ "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
},
- "time": "2024-04-29T13:26:35+00:00"
+ "time": "2026-01-02T16:01:13+00:00"
},
{
"name": "dragonmantank/cron-expression",
- "version": "v3.4.0",
+ "version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/dragonmantank/cron-expression.git",
- "reference": "8c784d071debd117328803d86b2097615b457500"
+ "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500",
- "reference": "8c784d071debd117328803d86b2097615b457500",
+ "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013",
+ "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013",
"shasum": ""
},
"require": {
- "php": "^7.2|^8.0",
- "webmozart/assert": "^1.0"
+ "php": "^8.2|^8.3|^8.4|^8.5"
},
"replace": {
"mtdowling/cron-expression": "^1.0"
},
"require-dev": {
- "phpstan/extension-installer": "^1.0",
- "phpstan/phpstan": "^1.0",
- "phpunit/phpunit": "^7.0|^8.0|^9.0"
+ "phpstan/extension-installer": "^1.4.3",
+ "phpstan/phpstan": "^1.12.32|^2.1.31",
+ "phpunit/phpunit": "^8.5.48|^9.0"
},
"type": "library",
"extra": {
@@ -847,7 +892,7 @@
],
"support": {
"issues": "https://github.com/dragonmantank/cron-expression/issues",
- "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0"
+ "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0"
},
"funding": [
{
@@ -855,7 +900,7 @@
"type": "github"
}
],
- "time": "2024-10-09T13:47:03+00:00"
+ "time": "2025-10-31T18:51:33+00:00"
},
{
"name": "egulias/email-validator",
@@ -926,20 +971,20 @@
},
{
"name": "ezyang/htmlpurifier",
- "version": "v4.18.0",
+ "version": "v4.19.0",
"source": {
"type": "git",
"url": "https://github.com/ezyang/htmlpurifier.git",
- "reference": "cb56001e54359df7ae76dc522d08845dc741621b"
+ "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/cb56001e54359df7ae76dc522d08845dc741621b",
- "reference": "cb56001e54359df7ae76dc522d08845dc741621b",
+ "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf",
+ "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf",
"shasum": ""
},
"require": {
- "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0"
+ "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
},
"require-dev": {
"cerdic/css-tidy": "^1.7 || ^2.0",
@@ -981,37 +1026,37 @@
],
"support": {
"issues": "https://github.com/ezyang/htmlpurifier/issues",
- "source": "https://github.com/ezyang/htmlpurifier/tree/v4.18.0"
+ "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0"
},
- "time": "2024-11-01T03:51:45+00:00"
+ "time": "2025-10-17T16:34:55+00:00"
},
{
"name": "fruitcake/php-cors",
- "version": "v1.3.0",
+ "version": "v1.4.0",
"source": {
"type": "git",
"url": "https://github.com/fruitcake/php-cors.git",
- "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b"
+ "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/3d158f36e7875e2f040f37bc0573956240a5a38b",
- "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b",
+ "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379",
+ "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379",
"shasum": ""
},
"require": {
- "php": "^7.4|^8.0",
- "symfony/http-foundation": "^4.4|^5.4|^6|^7"
+ "php": "^8.1",
+ "symfony/http-foundation": "^5.4|^6.4|^7.3|^8"
},
"require-dev": {
- "phpstan/phpstan": "^1.4",
+ "phpstan/phpstan": "^2",
"phpunit/phpunit": "^9",
- "squizlabs/php_codesniffer": "^3.5"
+ "squizlabs/php_codesniffer": "^4"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.2-dev"
+ "dev-master": "1.3-dev"
}
},
"autoload": {
@@ -1042,7 +1087,7 @@
],
"support": {
"issues": "https://github.com/fruitcake/php-cors/issues",
- "source": "https://github.com/fruitcake/php-cors/tree/v1.3.0"
+ "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0"
},
"funding": [
{
@@ -1054,28 +1099,28 @@
"type": "github"
}
],
- "time": "2023-10-12T05:21:21+00:00"
+ "time": "2025-12-03T09:33:47+00:00"
},
{
"name": "graham-campbell/result-type",
- "version": "v1.1.3",
+ "version": "v1.1.4",
"source": {
"type": "git",
"url": "https://github.com/GrahamCampbell/Result-Type.git",
- "reference": "3ba905c11371512af9d9bdd27d99b782216b6945"
+ "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945",
- "reference": "3ba905c11371512af9d9bdd27d99b782216b6945",
+ "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.3"
+ "phpoption/phpoption": "^1.9.5"
},
"require-dev": {
- "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28"
+ "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7"
},
"type": "library",
"autoload": {
@@ -1104,7 +1149,7 @@
],
"support": {
"issues": "https://github.com/GrahamCampbell/Result-Type/issues",
- "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3"
+ "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4"
},
"funding": [
{
@@ -1116,29 +1161,30 @@
"type": "tidelift"
}
],
- "time": "2024-07-20T21:45:45+00:00"
+ "time": "2025-12-27T19:43:20+00:00"
},
{
"name": "guzzlehttp/guzzle",
- "version": "7.10.0",
+ "version": "7.12.3",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
- "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4"
+ "reference": "9aa17bcdd777ee31df9fc83c337ca4ca2340def3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
- "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
+ "url": "https://api.github.com/repos/guzzle/guzzle/zipball/9aa17bcdd777ee31df9fc83c337ca4ca2340def3",
+ "reference": "9aa17bcdd777ee31df9fc83c337ca4ca2340def3",
"shasum": ""
},
"require": {
"ext-json": "*",
- "guzzlehttp/promises": "^2.3",
- "guzzlehttp/psr7": "^2.8",
+ "guzzlehttp/promises": "^2.5",
+ "guzzlehttp/psr7": "^2.12.3",
"php": "^7.2.5 || ^8.0",
"psr/http-client": "^1.0",
- "symfony/deprecation-contracts": "^2.2 || ^3.0"
+ "symfony/deprecation-contracts": "^2.5 || ^3.0",
+ "symfony/polyfill-php80": "^1.25"
},
"provide": {
"psr/http-client-implementation": "1.0"
@@ -1147,8 +1193,9 @@
"bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*",
"guzzle/client-integration-tests": "3.0.2",
+ "guzzlehttp/test-server": "^0.5.1",
"php-http/message-factory": "^1.1",
- "phpunit/phpunit": "^8.5.39 || ^9.6.20",
+ "phpunit/phpunit": "^8.5.52 || ^9.6.34",
"psr/log": "^1.1 || ^2.0 || ^3.0"
},
"suggest": {
@@ -1226,7 +1273,7 @@
],
"support": {
"issues": "https://github.com/guzzle/guzzle/issues",
- "source": "https://github.com/guzzle/guzzle/tree/7.10.0"
+ "source": "https://github.com/guzzle/guzzle/tree/7.12.3"
},
"funding": [
{
@@ -1242,28 +1289,29 @@
"type": "tidelift"
}
],
- "time": "2025-08-23T22:36:01+00:00"
+ "time": "2026-06-23T15:29:02+00:00"
},
{
"name": "guzzlehttp/promises",
- "version": "2.3.0",
+ "version": "2.5.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
- "reference": "481557b130ef3790cf82b713667b43030dc9c957"
+ "reference": "4360e982f87f5f258bf872d094647791db2f4c8e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957",
- "reference": "481557b130ef3790cf82b713667b43030dc9c957",
+ "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e",
+ "reference": "4360e982f87f5f258bf872d094647791db2f4c8e",
"shasum": ""
},
"require": {
- "php": "^7.2.5 || ^8.0"
+ "php": "^7.2.5 || ^8.0",
+ "symfony/deprecation-contracts": "^2.5 || ^3.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
- "phpunit/phpunit": "^8.5.44 || ^9.6.25"
+ "phpunit/phpunit": "^8.5.52 || ^9.6.34"
},
"type": "library",
"extra": {
@@ -1309,7 +1357,7 @@
],
"support": {
"issues": "https://github.com/guzzle/promises/issues",
- "source": "https://github.com/guzzle/promises/tree/2.3.0"
+ "source": "https://github.com/guzzle/promises/tree/2.5.0"
},
"funding": [
{
@@ -1325,27 +1373,29 @@
"type": "tidelift"
}
],
- "time": "2025-08-22T14:34:08+00:00"
+ "time": "2026-06-02T12:23:43+00:00"
},
{
"name": "guzzlehttp/psr7",
- "version": "2.8.0",
+ "version": "2.12.3",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
- "reference": "21dc724a0583619cd1652f673303492272778051"
+ "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051",
- "reference": "21dc724a0583619cd1652f673303492272778051",
+ "url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
+ "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0",
- "ralouphie/getallheaders": "^3.0"
+ "ralouphie/getallheaders": "^3.0",
+ "symfony/deprecation-contracts": "^2.5 || ^3.0",
+ "symfony/polyfill-php80": "^1.25"
},
"provide": {
"psr/http-factory-implementation": "1.0",
@@ -1353,8 +1403,9 @@
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
- "http-interop/http-factory-tests": "0.9.0",
- "phpunit/phpunit": "^8.5.44 || ^9.6.25"
+ "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"
@@ -1425,7 +1476,7 @@
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
- "source": "https://github.com/guzzle/psr7/tree/2.8.0"
+ "source": "https://github.com/guzzle/psr7/tree/2.12.3"
},
"funding": [
{
@@ -1441,29 +1492,29 @@
"type": "tidelift"
}
],
- "time": "2025-08-23T21:21:41+00:00"
+ "time": "2026-06-23T15:21:08+00:00"
},
{
"name": "guzzlehttp/uri-template",
- "version": "v1.0.5",
+ "version": "v1.0.8",
"source": {
"type": "git",
"url": "https://github.com/guzzle/uri-template.git",
- "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1"
+ "reference": "9c19128923b05a5d7355e5d2318d7808b7e33bbd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1",
- "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1",
+ "url": "https://api.github.com/repos/guzzle/uri-template/zipball/9c19128923b05a5d7355e5d2318d7808b7e33bbd",
+ "reference": "9c19128923b05a5d7355e5d2318d7808b7e33bbd",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
- "symfony/polyfill-php80": "^1.24"
+ "symfony/polyfill-php80": "^1.25"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
- "phpunit/phpunit": "^8.5.44 || ^9.6.25",
+ "phpunit/phpunit": "^8.5.52 || ^9.6.34",
"uri-template/tests": "1.0.0"
},
"type": "library",
@@ -1511,7 +1562,7 @@
],
"support": {
"issues": "https://github.com/guzzle/uri-template/issues",
- "source": "https://github.com/guzzle/uri-template/tree/v1.0.5"
+ "source": "https://github.com/guzzle/uri-template/tree/v1.0.8"
},
"funding": [
{
@@ -1527,29 +1578,29 @@
"type": "tidelift"
}
],
- "time": "2025-08-22T14:27:06+00:00"
+ "time": "2026-06-23T13:02:23+00:00"
},
{
"name": "lab404/laravel-impersonate",
- "version": "1.7.7",
+ "version": "1.7.8",
"source": {
"type": "git",
"url": "https://github.com/404labfr/laravel-impersonate.git",
- "reference": "5033f3433a55ca8bb2cc3e4a018a39dd8a327a9f"
+ "reference": "0008a39da8914cc946b6a5ed211230708ee736b3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/404labfr/laravel-impersonate/zipball/5033f3433a55ca8bb2cc3e4a018a39dd8a327a9f",
- "reference": "5033f3433a55ca8bb2cc3e4a018a39dd8a327a9f",
+ "url": "https://api.github.com/repos/404labfr/laravel-impersonate/zipball/0008a39da8914cc946b6a5ed211230708ee736b3",
+ "reference": "0008a39da8914cc946b6a5ed211230708ee736b3",
"shasum": ""
},
"require": {
- "laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0",
+ "laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
"php": "^7.2 | ^8.0"
},
"require-dev": {
"mockery/mockery": "^1.3.3",
- "orchestra/testbench": "^4.0 | ^5.0 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0",
+ "orchestra/testbench": "^4.0 | ^5.0 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0",
"phpunit/phpunit": "^7.5 | ^8.0 | ^9.0 | ^10.0 | ^11.0"
},
"type": "library",
@@ -1592,37 +1643,36 @@
],
"support": {
"issues": "https://github.com/404labfr/laravel-impersonate/issues",
- "source": "https://github.com/404labfr/laravel-impersonate/tree/1.7.7"
+ "source": "https://github.com/404labfr/laravel-impersonate/tree/1.7.8"
},
- "time": "2025-02-24T16:18:38+00:00"
+ "time": "2026-03-17T15:24:14+00:00"
},
{
"name": "laravel/fortify",
- "version": "v1.30.0",
+ "version": "v1.37.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/fortify.git",
- "reference": "005f4d535ae671312d267d942b964807fc0ef6f8"
+ "reference": "5d4b6a53527edd19ecc4f13e8e74ec91bdefab0c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/fortify/zipball/005f4d535ae671312d267d942b964807fc0ef6f8",
- "reference": "005f4d535ae671312d267d942b964807fc0ef6f8",
+ "url": "https://api.github.com/repos/laravel/fortify/zipball/5d4b6a53527edd19ecc4f13e8e74ec91bdefab0c",
+ "reference": "5d4b6a53527edd19ecc4f13e8e74ec91bdefab0c",
"shasum": ""
},
"require": {
"bacon/bacon-qr-code": "^3.0",
"ext-json": "*",
- "illuminate/support": "^10.0|^11.0|^12.0",
- "php": "^8.1",
- "pragmarx/google2fa": "^8.0",
- "symfony/console": "^6.0|^7.0"
+ "illuminate/console": "^11.0|^12.0|^13.0",
+ "illuminate/support": "^11.0|^12.0|^13.0",
+ "laravel/passkeys": "^0.2.0",
+ "php": "^8.2",
+ "pragmarx/google2fa": "^9.0"
},
"require-dev": {
- "mockery/mockery": "^1.0",
- "orchestra/testbench": "^8.16|^9.0|^10.0",
- "phpstan/phpstan": "^1.10",
- "phpunit/phpunit": "^10.4|^11.3"
+ "orchestra/testbench": "^9.15|^10.8|^11.0",
+ "phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
@@ -1659,20 +1709,20 @@
"issues": "https://github.com/laravel/fortify/issues",
"source": "https://github.com/laravel/fortify"
},
- "time": "2025-08-29T20:15:47+00:00"
+ "time": "2026-05-15T22:59:10+00:00"
},
{
"name": "laravel/framework",
- "version": "v12.29.0",
+ "version": "v12.62.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
- "reference": "a9e4c73086f5ba38383e9c1d74b84fe46aac730b"
+ "reference": "f7e61eb1e0e06a38996802b769bce9127aec227c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/framework/zipball/a9e4c73086f5ba38383e9c1d74b84fe46aac730b",
- "reference": "a9e4c73086f5ba38383e9c1d74b84fe46aac730b",
+ "url": "https://api.github.com/repos/laravel/framework/zipball/f7e61eb1e0e06a38996802b769bce9127aec227c",
+ "reference": "f7e61eb1e0e06a38996802b769bce9127aec227c",
"shasum": ""
},
"require": {
@@ -1693,14 +1743,13 @@
"guzzlehttp/uri-template": "^1.0",
"laravel/prompts": "^0.3.0",
"laravel/serializable-closure": "^1.3|^2.0",
- "league/commonmark": "^2.7",
+ "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",
- "phiki/phiki": "v2.0.0",
"php": "^8.2",
"psr/container": "^1.1.1|^2.0.1",
"psr/log": "^1.0|^2.0|^3.0",
@@ -1714,8 +1763,8 @@
"symfony/mailer": "^7.2.0",
"symfony/mime": "^7.2.0",
"symfony/polyfill-php83": "^1.33",
- "symfony/polyfill-php84": "^1.33",
- "symfony/polyfill-php85": "^1.33",
+ "symfony/polyfill-php84": "^1.34",
+ "symfony/polyfill-php85": "^1.34",
"symfony/process": "^7.2.0",
"symfony/routing": "^7.2.0",
"symfony/uid": "^7.2.0",
@@ -1761,6 +1810,7 @@
"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",
@@ -1785,13 +1835,13 @@
"league/flysystem-sftp-v3": "^3.25.1",
"mockery/mockery": "^1.6.10",
"opis/json-schema": "^2.4.1",
- "orchestra/testbench-core": "^10.6.5",
+ "orchestra/testbench-core": "^10.9.0",
"pda/pheanstalk": "^5.0.6|^7.0.0",
"php-http/discovery": "^1.15",
- "phpstan/phpstan": "^2.0",
+ "phpstan/phpstan": "^2.1.41",
"phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1",
"predis/predis": "^2.3|^3.0",
- "resend/resend-php": "^0.10.0",
+ "resend/resend-php": "^0.10.0|^1.0",
"symfony/cache": "^7.2.0",
"symfony/http-client": "^7.2.0",
"symfony/psr-http-message-bridge": "^7.2.0",
@@ -1825,7 +1875,7 @@
"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).",
+ "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).",
"symfony/cache": "Required to PSR-6 cache bridge (^7.2).",
"symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).",
"symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).",
@@ -1847,6 +1897,7 @@
"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"
],
@@ -1855,7 +1906,8 @@
"Illuminate\\Support\\": [
"src/Illuminate/Macroable/",
"src/Illuminate/Collections/",
- "src/Illuminate/Conditionable/"
+ "src/Illuminate/Conditionable/",
+ "src/Illuminate/Reflection/"
]
}
},
@@ -1879,42 +1931,42 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
- "time": "2025-09-16T14:15:03+00:00"
+ "time": "2026-06-09T13:50:13+00:00"
},
{
"name": "laravel/horizon",
- "version": "v5.36.0",
+ "version": "v5.47.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/horizon.git",
- "reference": "eccc804c9da78064c97a8f506bb148f05c816409"
+ "reference": "a6ac142293ad02db4d7cccb961dd32f56ef1462d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/horizon/zipball/eccc804c9da78064c97a8f506bb148f05c816409",
- "reference": "eccc804c9da78064c97a8f506bb148f05c816409",
+ "url": "https://api.github.com/repos/laravel/horizon/zipball/a6ac142293ad02db4d7cccb961dd32f56ef1462d",
+ "reference": "a6ac142293ad02db4d7cccb961dd32f56ef1462d",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-pcntl": "*",
"ext-posix": "*",
- "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0",
- "illuminate/queue": "^9.21|^10.0|^11.0|^12.0",
- "illuminate/support": "^9.21|^10.0|^11.0|^12.0",
+ "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0|^13.0",
+ "illuminate/queue": "^9.21|^10.0|^11.0|^12.0|^13.0",
+ "illuminate/support": "^9.21|^10.0|^11.0|^12.0|^13.0",
+ "laravel/sentinel": "^1.0",
"nesbot/carbon": "^2.17|^3.0",
"php": "^8.0",
"ramsey/uuid": "^4.0",
- "symfony/console": "^6.0|^7.0",
- "symfony/error-handler": "^6.0|^7.0",
+ "symfony/console": "^6.0|^7.0|^8.0",
+ "symfony/error-handler": "^6.0|^7.0|^8.0",
"symfony/polyfill-php83": "^1.28",
- "symfony/process": "^6.0|^7.0"
+ "symfony/process": "^6.0|^7.0|^8.0"
},
"require-dev": {
"mockery/mockery": "^1.0",
- "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0",
+ "orchestra/testbench": "^7.56|^8.37|^9.16|^10.9|^11.0",
"phpstan/phpstan": "^1.10|^2.0",
- "phpunit/phpunit": "^9.0|^10.4|^11.5|^12.0",
"predis/predis": "^1.1|^2.0|^3.0"
},
"suggest": {
@@ -1957,40 +2009,108 @@
],
"support": {
"issues": "https://github.com/laravel/horizon/issues",
- "source": "https://github.com/laravel/horizon/tree/v5.36.0"
+ "source": "https://github.com/laravel/horizon/tree/v5.47.2"
},
- "time": "2025-10-10T13:44:39+00:00"
+ "time": "2026-06-03T15:11:37+00:00"
},
{
- "name": "laravel/prompts",
- "version": "v0.3.6",
+ "name": "laravel/passkeys",
+ "version": "v0.2.1",
"source": {
"type": "git",
- "url": "https://github.com/laravel/prompts.git",
- "reference": "86a8b692e8661d0fb308cec64f3d176821323077"
+ "url": "https://github.com/laravel/passkeys-server.git",
+ "reference": "a76656ada41b2b4a591f075eddae5ddc67e8ab9c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/prompts/zipball/86a8b692e8661d0fb308cec64f3d176821323077",
- "reference": "86a8b692e8661d0fb308cec64f3d176821323077",
+ "url": "https://api.github.com/repos/laravel/passkeys-server/zipball/a76656ada41b2b4a591f075eddae5ddc67e8ab9c",
+ "reference": "a76656ada41b2b4a591f075eddae5ddc67e8ab9c",
+ "shasum": ""
+ },
+ "require": {
+ "illuminate/contracts": "^11.0|^12.0|^13.0",
+ "illuminate/database": "^11.0|^12.0|^13.0",
+ "illuminate/http": "^11.0|^12.0|^13.0",
+ "illuminate/routing": "^11.0|^12.0|^13.0",
+ "illuminate/support": "^11.0|^12.0|^13.0",
+ "php": "^8.2",
+ "web-auth/webauthn-lib": "5.3.x"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.28.0",
+ "orchestra/testbench": "^9.0|^10.0|^11.0",
+ "pestphp/pest": "^3.0|^4.0",
+ "phpstan/phpstan": "^2.0",
+ "rector/rector": "^2.3"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Laravel\\Passkeys\\PasskeysServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Passkeys\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "Passwordless authentication using WebAuthn/passkeys for Laravel",
+ "homepage": "https://github.com/laravel/passkeys-server",
+ "keywords": [
+ "Authentication",
+ "Passwordless",
+ "laravel",
+ "passkeys",
+ "webauthn"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/passkeys-server/issues",
+ "source": "https://github.com/laravel/passkeys-server"
+ },
+ "time": "2026-05-18T16:26:00+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"
+ "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",
+ "illuminate/collections": "^10.0|^11.0|^12.0|^13.0",
"mockery/mockery": "^1.5",
- "pestphp/pest": "^2.3|^3.4",
- "phpstan/phpstan": "^1.11",
- "phpstan/phpstan-mockery": "^1.1"
+ "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."
@@ -2016,38 +2136,37 @@
"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.6"
+ "source": "https://github.com/laravel/prompts/tree/v0.3.18"
},
- "time": "2025-07-07T14:17:42+00:00"
+ "time": "2026-05-19T00:47:18+00:00"
},
{
"name": "laravel/sanctum",
- "version": "v4.2.0",
+ "version": "v4.3.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/sanctum.git",
- "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677"
+ "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/sanctum/zipball/fd6df4f79f48a72992e8d29a9c0ee25422a0d677",
- "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677",
+ "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e",
+ "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e",
"shasum": ""
},
"require": {
"ext-json": "*",
- "illuminate/console": "^11.0|^12.0",
- "illuminate/contracts": "^11.0|^12.0",
- "illuminate/database": "^11.0|^12.0",
- "illuminate/support": "^11.0|^12.0",
+ "illuminate/console": "^11.0|^12.0|^13.0",
+ "illuminate/contracts": "^11.0|^12.0|^13.0",
+ "illuminate/database": "^11.0|^12.0|^13.0",
+ "illuminate/support": "^11.0|^12.0|^13.0",
"php": "^8.2",
- "symfony/console": "^7.0"
+ "symfony/console": "^7.0|^8.0"
},
"require-dev": {
"mockery/mockery": "^1.6",
- "orchestra/testbench": "^9.0|^10.0",
- "phpstan/phpstan": "^1.10",
- "phpunit/phpunit": "^11.3"
+ "orchestra/testbench": "^9.15|^10.8|^11.0",
+ "phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
@@ -2082,31 +2201,87 @@
"issues": "https://github.com/laravel/sanctum/issues",
"source": "https://github.com/laravel/sanctum"
},
- "time": "2025-07-09T19:45:24+00:00"
+ "time": "2026-04-30T11:46:25+00:00"
},
{
- "name": "laravel/serializable-closure",
- "version": "v2.0.4",
+ "name": "laravel/sentinel",
+ "version": "v1.1.0",
"source": {
"type": "git",
- "url": "https://github.com/laravel/serializable-closure.git",
- "reference": "b352cf0534aa1ae6b4d825d1e762e35d43f8a841"
+ "url": "https://github.com/laravel/sentinel.git",
+ "reference": "972d9885d9d14312a118e9565c4e6ecc5e751ea1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b352cf0534aa1ae6b4d825d1e762e35d43f8a841",
- "reference": "b352cf0534aa1ae6b4d825d1e762e35d43f8a841",
+ "url": "https://api.github.com/repos/laravel/sentinel/zipball/972d9885d9d14312a118e9565c4e6ecc5e751ea1",
+ "reference": "972d9885d9d14312a118e9565c4e6ecc5e751ea1",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "illuminate/container": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0",
+ "php": "^8.0"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.27",
+ "orchestra/testbench": "^6.47.1|^7.56|^8.37|^9.16|^10.9|^11.0",
+ "phpstan/phpstan": "^2.1.33"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Laravel\\Sentinel\\SentinelServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Sentinel\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ },
+ {
+ "name": "Mior Muhammad Zaki",
+ "email": "mior@laravel.com"
+ }
+ ],
+ "support": {
+ "source": "https://github.com/laravel/sentinel/tree/v1.1.0"
+ },
+ "time": "2026-03-24T14:03:38+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",
+ "illuminate/support": "^10.0|^11.0|^12.0|^13.0",
"nesbot/carbon": "^2.67|^3.0",
- "pestphp/pest": "^2.36|^3.0",
+ "pestphp/pest": "^2.36|^3.0|^4.0",
"phpstan/phpstan": "^2.0",
- "symfony/var-dumper": "^6.2.0|^7.0.0"
+ "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0"
},
"type": "library",
"extra": {
@@ -2143,20 +2318,20 @@
"issues": "https://github.com/laravel/serializable-closure/issues",
"source": "https://github.com/laravel/serializable-closure"
},
- "time": "2025-03-19T13:51:03+00:00"
+ "time": "2026-04-16T14:03:50+00:00"
},
{
"name": "laravel/tinker",
- "version": "v2.10.1",
+ "version": "v2.11.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/tinker.git",
- "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3"
+ "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/tinker/zipball/22177cc71807d38f2810c6204d8f7183d88a57d3",
- "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3",
+ "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741",
+ "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741",
"shasum": ""
},
"require": {
@@ -2165,7 +2340,7 @@
"illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
"php": "^7.2.5|^8.0",
"psy/psysh": "^0.11.1|^0.12.0",
- "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0"
+ "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0"
},
"require-dev": {
"mockery/mockery": "~1.3.3|^1.4.2",
@@ -2207,22 +2382,22 @@
],
"support": {
"issues": "https://github.com/laravel/tinker/issues",
- "source": "https://github.com/laravel/tinker/tree/v2.10.1"
+ "source": "https://github.com/laravel/tinker/tree/v2.11.1"
},
- "time": "2025-01-27T14:24:01+00:00"
+ "time": "2026-02-06T14:12:35+00:00"
},
{
"name": "league/commonmark",
- "version": "2.7.1",
+ "version": "2.8.2",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
- "reference": "10732241927d3971d28e7ea7b5712721fa2296ca"
+ "reference": "59fb075d2101740c337c7216e3f32b36c204218b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/10732241927d3971d28e7ea7b5712721fa2296ca",
- "reference": "10732241927d3971d28e7ea7b5712721fa2296ca",
+ "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b",
+ "reference": "59fb075d2101740c337c7216e3f32b36c204218b",
"shasum": ""
},
"require": {
@@ -2247,9 +2422,9 @@
"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",
- "symfony/process": "^5.4 | ^6.0 | ^7.0",
- "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0",
+ "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"
},
@@ -2259,7 +2434,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "2.8-dev"
+ "dev-main": "2.9-dev"
}
},
"autoload": {
@@ -2316,7 +2491,7 @@
"type": "tidelift"
}
],
- "time": "2025-07-20T12:47:49+00:00"
+ "time": "2026-03-19T13:16:38+00:00"
},
{
"name": "league/config",
@@ -2402,16 +2577,16 @@
},
{
"name": "league/flysystem",
- "version": "3.30.0",
+ "version": "3.35.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem.git",
- "reference": "2203e3151755d874bb2943649dae1eb8533ac93e"
+ "reference": "d0a405f03d980461e4b6e08cfed2c162650f9f6b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2203e3151755d874bb2943649dae1eb8533ac93e",
- "reference": "2203e3151755d874bb2943649dae1eb8533ac93e",
+ "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/d0a405f03d980461e4b6e08cfed2c162650f9f6b",
+ "reference": "d0a405f03d980461e4b6e08cfed2c162650f9f6b",
"shasum": ""
},
"require": {
@@ -2479,22 +2654,22 @@
],
"support": {
"issues": "https://github.com/thephpleague/flysystem/issues",
- "source": "https://github.com/thephpleague/flysystem/tree/3.30.0"
+ "source": "https://github.com/thephpleague/flysystem/tree/3.35.0"
},
- "time": "2025-06-25T13:29:59+00:00"
+ "time": "2026-06-22T20:12:06+00:00"
},
{
"name": "league/flysystem-local",
- "version": "3.30.0",
+ "version": "3.31.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-local.git",
- "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10"
+ "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/6691915f77c7fb69adfb87dcd550052dc184ee10",
- "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10",
+ "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079",
+ "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079",
"shasum": ""
},
"require": {
@@ -2528,9 +2703,9 @@
"local"
],
"support": {
- "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.0"
+ "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0"
},
- "time": "2025-05-21T10:34:19+00:00"
+ "time": "2026-01-23T15:30:45+00:00"
},
{
"name": "league/mime-type-detection",
@@ -2590,33 +2765,38 @@
},
{
"name": "league/uri",
- "version": "7.5.1",
+ "version": "7.8.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/uri.git",
- "reference": "81fb5145d2644324614cc532b28efd0215bda430"
+ "reference": "08cf38e3924d4f56238125547b5720496fac8fd4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430",
- "reference": "81fb5145d2644324614cc532b28efd0215bda430",
+ "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4",
+ "reference": "08cf38e3924d4f56238125547b5720496fac8fd4",
"shasum": ""
},
"require": {
- "league/uri-interfaces": "^7.5",
- "php": "^8.1"
+ "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",
- "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain",
- "league/uri-components": "Needed to easily manipulate URI objects components",
+ "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",
@@ -2644,6 +2824,7 @@
"description": "URI manipulation library",
"homepage": "https://uri.thephpleague.com",
"keywords": [
+ "URN",
"data-uri",
"file-uri",
"ftp",
@@ -2656,9 +2837,11 @@
"psr-7",
"query-string",
"querystring",
+ "rfc2141",
"rfc3986",
"rfc3987",
"rfc6570",
+ "rfc8141",
"uri",
"uri-template",
"url",
@@ -2668,7 +2851,7 @@
"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.5.1"
+ "source": "https://github.com/thephpleague/uri/tree/7.8.1"
},
"funding": [
{
@@ -2676,26 +2859,25 @@
"type": "github"
}
],
- "time": "2024-12-08T08:40:02+00:00"
+ "time": "2026-03-15T20:22:25+00:00"
},
{
"name": "league/uri-interfaces",
- "version": "7.5.0",
+ "version": "7.8.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/uri-interfaces.git",
- "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742"
+ "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742",
- "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742",
+ "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928",
+ "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928",
"shasum": ""
},
"require": {
"ext-filter": "*",
"php": "^8.1",
- "psr/http-factory": "^1",
"psr/http-message": "^1.1 || ^2.0"
},
"suggest": {
@@ -2703,6 +2885,7 @@
"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",
@@ -2727,7 +2910,7 @@
"homepage": "https://nyamsprod.com"
}
],
- "description": "Common interfaces and classes for URI representation and interaction",
+ "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI",
"homepage": "https://uri.thephpleague.com",
"keywords": [
"data-uri",
@@ -2752,7 +2935,7 @@
"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.5.0"
+ "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1"
},
"funding": [
{
@@ -2760,33 +2943,33 @@
"type": "github"
}
],
- "time": "2024-12-08T08:18:47+00:00"
+ "time": "2026-03-08T20:05:35+00:00"
},
{
"name": "maatwebsite/excel",
- "version": "3.1.67",
+ "version": "3.1.69",
"source": {
"type": "git",
"url": "https://github.com/SpartnerNL/Laravel-Excel.git",
- "reference": "e508e34a502a3acc3329b464dad257378a7edb4d"
+ "reference": "ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/e508e34a502a3acc3329b464dad257378a7edb4d",
- "reference": "e508e34a502a3acc3329b464dad257378a7edb4d",
+ "url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760",
+ "reference": "ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760",
"shasum": ""
},
"require": {
"composer/semver": "^3.3",
"ext-json": "*",
- "illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0",
+ "illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0||^13.0",
"php": "^7.0||^8.0",
- "phpoffice/phpspreadsheet": "^1.30.0",
+ "phpoffice/phpspreadsheet": "^1.30.4",
"psr/simple-cache": "^1.0||^2.0||^3.0"
},
"require-dev": {
- "laravel/scout": "^7.0||^8.0||^9.0||^10.0",
- "orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0",
+ "laravel/scout": "^7.0||^8.0||^9.0||^10.0||^11.0",
+ "orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0||^11.0",
"predis/predis": "^1.1"
},
"type": "library",
@@ -2829,7 +3012,7 @@
],
"support": {
"issues": "https://github.com/SpartnerNL/Laravel-Excel/issues",
- "source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.67"
+ "source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.69"
},
"funding": [
{
@@ -2841,20 +3024,20 @@
"type": "github"
}
],
- "time": "2025-08-26T09:13:16+00:00"
+ "time": "2026-04-30T20:03:58+00:00"
},
{
"name": "maennchen/zipstream-php",
- "version": "3.2.0",
+ "version": "3.2.2",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
- "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416"
+ "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
- "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
+ "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
+ "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
"shasum": ""
},
"require": {
@@ -2865,7 +3048,7 @@
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
- "friendsofphp/php-cs-fixer": "^3.16",
+ "friendsofphp/php-cs-fixer": "^3.86",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
@@ -2911,7 +3094,7 @@
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
- "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.0"
+ "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
},
"funding": [
{
@@ -2919,7 +3102,7 @@
"type": "github"
}
],
- "time": "2025-07-17T11:15:13+00:00"
+ "time": "2026-04-11T18:38:28+00:00"
},
{
"name": "markbaker/complex",
@@ -3030,16 +3213,16 @@
},
{
"name": "masterminds/html5",
- "version": "2.10.0",
+ "version": "2.10.1",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
- "reference": "fcf91eb64359852f00d921887b219479b4f21251"
+ "reference": "fd5018f6815fff903946d0564977b44ce8010e29"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
- "reference": "fcf91eb64359852f00d921887b219479b4f21251",
+ "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29",
+ "reference": "fd5018f6815fff903946d0564977b44ce8010e29",
"shasum": ""
},
"require": {
@@ -3047,7 +3230,7 @@
"php": ">=5.3.0"
},
"require-dev": {
- "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
+ "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10"
},
"type": "library",
"extra": {
@@ -3091,22 +3274,22 @@
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
- "source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
+ "source": "https://github.com/Masterminds/html5-php/tree/2.10.1"
},
- "time": "2025-07-25T09:04:22+00:00"
+ "time": "2026-06-23T18:43:15+00:00"
},
{
"name": "monolog/monolog",
- "version": "3.9.0",
+ "version": "3.10.0",
"source": {
"type": "git",
"url": "https://github.com/Seldaek/monolog.git",
- "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6"
+ "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6",
- "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6",
+ "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
+ "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
"shasum": ""
},
"require": {
@@ -3124,7 +3307,7 @@
"graylog2/gelf-php": "^1.4.2 || ^2.0",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/psr7": "^2.2",
- "mongodb/mongodb": "^1.8",
+ "mongodb/mongodb": "^1.8 || ^2.0",
"php-amqplib/php-amqplib": "~2.4 || ^3",
"php-console/php-console": "^3.1.8",
"phpstan/phpstan": "^2",
@@ -3184,7 +3367,7 @@
],
"support": {
"issues": "https://github.com/Seldaek/monolog/issues",
- "source": "https://github.com/Seldaek/monolog/tree/3.9.0"
+ "source": "https://github.com/Seldaek/monolog/tree/3.10.0"
},
"funding": [
{
@@ -3196,20 +3379,20 @@
"type": "tidelift"
}
],
- "time": "2025-03-24T10:02:05+00:00"
+ "time": "2026-01-02T08:56:05+00:00"
},
{
"name": "nesbot/carbon",
- "version": "3.10.3",
+ "version": "3.13.0",
"source": {
"type": "git",
"url": "https://github.com/CarbonPHP/carbon.git",
- "reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f"
+ "reference": "40f6618f052df16b545f626fbf9a878e6497d16a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f",
- "reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f",
+ "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/40f6618f052df16b545f626fbf9a878e6497d16a",
+ "reference": "40f6618f052df16b545f626fbf9a878e6497d16a",
"shasum": ""
},
"require": {
@@ -3217,9 +3400,9 @@
"ext-json": "*",
"php": "^8.1",
"psr/clock": "^1.0",
- "symfony/clock": "^6.3.12 || ^7.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"
+ "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0"
},
"provide": {
"psr/clock-implementation": "1.0"
@@ -3233,7 +3416,7 @@
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.1.22",
"phpunit/phpunit": "^10.5.53",
- "squizlabs/php_codesniffer": "^3.13.4"
+ "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0"
},
"bin": [
"bin/carbon"
@@ -3276,14 +3459,14 @@
}
],
"description": "An API extension for DateTime that supports 281 different languages.",
- "homepage": "https://carbon.nesbot.com",
+ "homepage": "https://carbonphp.github.io/carbon/",
"keywords": [
"date",
"datetime",
"time"
],
"support": {
- "docs": "https://carbon.nesbot.com/docs",
+ "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html",
"issues": "https://github.com/CarbonPHP/carbon/issues",
"source": "https://github.com/CarbonPHP/carbon"
},
@@ -3301,29 +3484,31 @@
"type": "tidelift"
}
],
- "time": "2025-09-06T13:39:36+00:00"
+ "time": "2026-06-18T13:49:15+00:00"
},
{
"name": "nette/schema",
- "version": "v1.3.2",
+ "version": "v1.3.5",
"source": {
"type": "git",
"url": "https://github.com/nette/schema.git",
- "reference": "da801d52f0354f70a638673c4a0f04e16529431d"
+ "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nette/schema/zipball/da801d52f0354f70a638673c4a0f04e16529431d",
- "reference": "da801d52f0354f70a638673c4a0f04e16529431d",
+ "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002",
+ "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002",
"shasum": ""
},
"require": {
"nette/utils": "^4.0",
- "php": "8.1 - 8.4"
+ "php": "8.1 - 8.5"
},
"require-dev": {
- "nette/tester": "^2.5.2",
- "phpstan/phpstan-nette": "^1.0",
+ "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",
@@ -3333,6 +3518,9 @@
}
},
"autoload": {
+ "psr-4": {
+ "Nette\\": "src"
+ },
"classmap": [
"src/"
]
@@ -3361,26 +3549,26 @@
],
"support": {
"issues": "https://github.com/nette/schema/issues",
- "source": "https://github.com/nette/schema/tree/v1.3.2"
+ "source": "https://github.com/nette/schema/tree/v1.3.5"
},
- "time": "2024-10-06T23:10:23+00:00"
+ "time": "2026-02-23T03:47:12+00:00"
},
{
"name": "nette/utils",
- "version": "v4.0.8",
+ "version": "v4.1.4",
"source": {
"type": "git",
"url": "https://github.com/nette/utils.git",
- "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede"
+ "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nette/utils/zipball/c930ca4e3cf4f17dcfb03037703679d2396d2ede",
- "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede",
+ "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
+ "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
"shasum": ""
},
"require": {
- "php": "8.0 - 8.5"
+ "php": "8.2 - 8.5"
},
"conflict": {
"nette/finder": "<3",
@@ -3388,8 +3576,10 @@
},
"require-dev": {
"jetbrains/phpstorm-attributes": "^1.2",
+ "nette/phpstan-rules": "^1.0",
"nette/tester": "^2.5",
- "phpstan/phpstan-nette": "^2.0@stable",
+ "phpstan/extension-installer": "^1.4@stable",
+ "phpstan/phpstan": "^2.1@stable",
"tracy/tracy": "^2.9"
},
"suggest": {
@@ -3403,7 +3593,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "4.0-dev"
+ "dev-master": "4.1-dev"
}
},
"autoload": {
@@ -3450,22 +3640,22 @@
],
"support": {
"issues": "https://github.com/nette/utils/issues",
- "source": "https://github.com/nette/utils/tree/v4.0.8"
+ "source": "https://github.com/nette/utils/tree/v4.1.4"
},
- "time": "2025-08-06T21:43:34+00:00"
+ "time": "2026-05-11T20:49:54+00:00"
},
{
"name": "nikic/php-parser",
- "version": "v5.6.1",
+ "version": "v5.7.0",
"source": {
"type": "git",
"url": "https://github.com/nikic/PHP-Parser.git",
- "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2"
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2",
- "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
"shasum": ""
},
"require": {
@@ -3508,37 +3698,37 @@
],
"support": {
"issues": "https://github.com/nikic/PHP-Parser/issues",
- "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.1"
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
},
- "time": "2025-08-13T20:13:15+00:00"
+ "time": "2025-12-06T11:56:16+00:00"
},
{
"name": "nunomaduro/termwind",
- "version": "v2.3.1",
+ "version": "v2.4.0",
"source": {
"type": "git",
"url": "https://github.com/nunomaduro/termwind.git",
- "reference": "dfa08f390e509967a15c22493dc0bac5733d9123"
+ "reference": "712a31b768f5daea284c2169a7d227031001b9a8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/dfa08f390e509967a15c22493dc0bac5733d9123",
- "reference": "dfa08f390e509967a15c22493dc0bac5733d9123",
+ "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8",
+ "reference": "712a31b768f5daea284c2169a7d227031001b9a8",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^8.2",
- "symfony/console": "^7.2.6"
+ "symfony/console": "^7.4.4 || ^8.0.4"
},
"require-dev": {
- "illuminate/console": "^11.44.7",
- "laravel/pint": "^1.22.0",
+ "illuminate/console": "^11.47.0",
+ "laravel/pint": "^1.27.1",
"mockery/mockery": "^1.6.12",
- "pestphp/pest": "^2.36.0 || ^3.8.2",
- "phpstan/phpstan": "^1.12.25",
+ "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.2.6",
+ "symfony/var-dumper": "^7.3.5 || ^8.0.4",
"thecodingmachine/phpstan-strict-rules": "^1.0.0"
},
"type": "library",
@@ -3570,7 +3760,7 @@
"email": "enunomaduro@gmail.com"
}
],
- "description": "Its like Tailwind CSS, but for the console.",
+ "description": "It's like Tailwind CSS, but for the console.",
"keywords": [
"cli",
"console",
@@ -3581,7 +3771,7 @@
],
"support": {
"issues": "https://github.com/nunomaduro/termwind/issues",
- "source": "https://github.com/nunomaduro/termwind/tree/v2.3.1"
+ "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0"
},
"funding": [
{
@@ -3597,20 +3787,20 @@
"type": "github"
}
],
- "time": "2025-05-08T08:14:37+00:00"
+ "time": "2026-02-16T23:10:27+00:00"
},
{
"name": "nwidart/laravel-modules",
- "version": "v12.0.4",
+ "version": "v12.0.5",
"source": {
"type": "git",
"url": "https://github.com/nWidart/laravel-modules.git",
- "reference": "6e1f50de63366206b06ec53bbc823282977ddd06"
+ "reference": "5fe38e88b66394debeac785278a7e40eb81df51a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nWidart/laravel-modules/zipball/6e1f50de63366206b06ec53bbc823282977ddd06",
- "reference": "6e1f50de63366206b06ec53bbc823282977ddd06",
+ "url": "https://api.github.com/repos/nWidart/laravel-modules/zipball/5fe38e88b66394debeac785278a7e40eb81df51a",
+ "reference": "5fe38e88b66394debeac785278a7e40eb81df51a",
"shasum": ""
},
"require": {
@@ -3674,7 +3864,7 @@
],
"support": {
"issues": "https://github.com/nWidart/laravel-modules/issues",
- "source": "https://github.com/nWidart/laravel-modules/tree/v12.0.4"
+ "source": "https://github.com/nWidart/laravel-modules/tree/v12.0.5"
},
"funding": [
{
@@ -3686,28 +3876,30 @@
"type": "github"
}
],
- "time": "2025-06-29T09:23:53+00:00"
+ "time": "2026-03-19T18:16:17+00:00"
},
{
"name": "paragonie/constant_time_encoding",
- "version": "v3.0.0",
+ "version": "v3.1.3",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
- "reference": "df1e7fde177501eee2037dd159cf04f5f301a512"
+ "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/df1e7fde177501eee2037dd159cf04f5f301a512",
- "reference": "df1e7fde177501eee2037dd159cf04f5f301a512",
+ "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
+ "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"shasum": ""
},
"require": {
"php": "^8"
},
"require-dev": {
- "phpunit/phpunit": "^9",
- "vimeo/psalm": "^4|^5"
+ "infection/infection": "^0",
+ "nikic/php-fuzzer": "^0",
+ "phpunit/phpunit": "^9|^10|^11",
+ "vimeo/psalm": "^4|^5|^6"
},
"type": "library",
"autoload": {
@@ -3753,48 +3945,34 @@
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
- "time": "2024-05-08T12:36:18+00:00"
+ "time": "2025-09-24T15:06:41+00:00"
},
{
- "name": "phiki/phiki",
- "version": "v2.0.0",
+ "name": "phpdocumentor/reflection-common",
+ "version": "2.2.0",
"source": {
"type": "git",
- "url": "https://github.com/phikiphp/phiki.git",
- "reference": "461f6dd7e91dc3a95463b42f549ac7d0aab4702f"
+ "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
+ "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phikiphp/phiki/zipball/461f6dd7e91dc3a95463b42f549ac7d0aab4702f",
- "reference": "461f6dd7e91dc3a95463b42f549ac7d0aab4702f",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b",
+ "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b",
"shasum": ""
},
"require": {
- "ext-mbstring": "*",
- "league/commonmark": "^2.5.3",
- "php": "^8.2",
- "psr/simple-cache": "^3.0"
- },
- "require-dev": {
- "illuminate/support": "^11.45",
- "laravel/pint": "^1.18.1",
- "orchestra/testbench": "^9.15",
- "pestphp/pest": "^3.5.1",
- "phpstan/extension-installer": "^1.4.3",
- "phpstan/phpstan": "^2.0",
- "symfony/var-dumper": "^7.1.6"
+ "php": "^7.2 || ^8.0"
},
"type": "library",
"extra": {
- "laravel": {
- "providers": [
- "Phiki\\Adapters\\Laravel\\PhikiServiceProvider"
- ]
+ "branch-alias": {
+ "dev-2.x": "2.x-dev"
}
},
"autoload": {
"psr-4": {
- "Phiki\\": "src/"
+ "phpDocumentor\\Reflection\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -3803,41 +3981,160 @@
],
"authors": [
{
- "name": "Ryan Chandler",
- "email": "support@ryangjchandler.co.uk",
- "homepage": "https://ryangjchandler.co.uk",
- "role": "Developer"
+ "name": "Jaap van Otterdijk",
+ "email": "opensource@ijaap.nl"
}
],
- "description": "Syntax highlighting using TextMate grammars in PHP.",
+ "description": "Common reflection classes used by phpdocumentor to reflect the code structure",
+ "homepage": "http://www.phpdoc.org",
+ "keywords": [
+ "FQSEN",
+ "phpDocumentor",
+ "phpdoc",
+ "reflection",
+ "static analysis"
+ ],
"support": {
- "issues": "https://github.com/phikiphp/phiki/issues",
- "source": "https://github.com/phikiphp/phiki/tree/v2.0.0"
+ "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
+ "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x"
},
- "funding": [
- {
- "url": "https://github.com/sponsors/ryangjchandler",
- "type": "github"
- },
- {
- "url": "https://buymeacoffee.com/ryangjchandler",
- "type": "other"
- }
- ],
- "time": "2025-08-28T18:20:27+00:00"
+ "time": "2020-06-27T09:03:43+00:00"
},
{
- "name": "phpoffice/phpspreadsheet",
- "version": "1.30.0",
+ "name": "phpdocumentor/reflection-docblock",
+ "version": "6.0.3",
"source": {
"type": "git",
- "url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
- "reference": "2f39286e0136673778b7a142b3f0d141e43d1714"
+ "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
+ "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/2f39286e0136673778b7a142b3f0d141e43d1714",
- "reference": "2f39286e0136673778b7a142b3f0d141e43d1714",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582",
+ "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/deprecations": "^1.1",
+ "ext-filter": "*",
+ "php": "^7.4 || ^8.0",
+ "phpdocumentor/reflection-common": "^2.2",
+ "phpdocumentor/type-resolver": "^2.0",
+ "phpstan/phpdoc-parser": "^2.0",
+ "webmozart/assert": "^1.9.1 || ^2"
+ },
+ "require-dev": {
+ "mockery/mockery": "~1.3.5 || ~1.6.0",
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "^1.8",
+ "phpstan/phpstan-mockery": "^1.1",
+ "phpstan/phpstan-webmozart-assert": "^1.2",
+ "phpunit/phpunit": "^9.5",
+ "psalm/phar": "^5.26",
+ "shipmonk/dead-code-detector": "^0.5.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mike van Riel",
+ "email": "me@mikevanriel.com"
+ },
+ {
+ "name": "Jaap van Otterdijk",
+ "email": "opensource@ijaap.nl"
+ }
+ ],
+ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
+ "support": {
+ "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
+ "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3"
+ },
+ "time": "2026-03-18T20:49:53+00:00"
+ },
+ {
+ "name": "phpdocumentor/type-resolver",
+ "version": "2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/TypeResolver.git",
+ "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9",
+ "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/deprecations": "^1.0",
+ "php": "^7.4 || ^8.0",
+ "phpdocumentor/reflection-common": "^2.0",
+ "phpstan/phpdoc-parser": "^2.0"
+ },
+ "require-dev": {
+ "ext-tokenizer": "*",
+ "phpbench/phpbench": "^1.2",
+ "phpstan/extension-installer": "^1.4",
+ "phpstan/phpstan": "^2.1",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpunit/phpunit": "^9.5",
+ "psalm/phar": "^4"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-1.x": "1.x-dev",
+ "dev-2.x": "2.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mike van Riel",
+ "email": "me@mikevanriel.com"
+ }
+ ],
+ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
+ "support": {
+ "issues": "https://github.com/phpDocumentor/TypeResolver/issues",
+ "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0"
+ },
+ "time": "2026-01-06T21:53:42+00:00"
+ },
+ {
+ "name": "phpoffice/phpspreadsheet",
+ "version": "1.30.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
+ "reference": "97bcabd32a64924688487dcd64aceaf158affb5c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/97bcabd32a64924688487dcd64aceaf158affb5c",
+ "reference": "97bcabd32a64924688487dcd64aceaf158affb5c",
"shasum": ""
},
"require": {
@@ -3859,13 +4156,12 @@
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
- "php": "^7.4 || ^8.0",
- "psr/http-client": "^1.0",
- "psr/http-factory": "^1.0",
+ "php": ">=7.4.0 <8.5.0",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
+ "doctrine/instantiator": "^1.5",
"dompdf/dompdf": "^1.0 || ^2.0 || ^3.0",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.3",
@@ -3912,6 +4208,9 @@
},
{
"name": "Adrien Crivelli"
+ },
+ {
+ "name": "Owen Leibman"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
@@ -3928,22 +4227,22 @@
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
- "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.0"
+ "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.5"
},
- "time": "2025-08-10T06:28:02+00:00"
+ "time": "2026-05-31T05:13:11+00:00"
},
{
"name": "phpoption/phpoption",
- "version": "1.9.4",
+ "version": "1.9.5",
"source": {
"type": "git",
"url": "https://github.com/schmittjoh/php-option.git",
- "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d"
+ "reference": "75365b91986c2405cf5e1e012c5595cd487a98be"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d",
- "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d",
+ "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be",
+ "reference": "75365b91986c2405cf5e1e012c5595cd487a98be",
"shasum": ""
},
"require": {
@@ -3993,7 +4292,7 @@
],
"support": {
"issues": "https://github.com/schmittjoh/php-option/issues",
- "source": "https://github.com/schmittjoh/php-option/tree/1.9.4"
+ "source": "https://github.com/schmittjoh/php-option/tree/1.9.5"
},
"funding": [
{
@@ -4005,20 +4304,67 @@
"type": "tidelift"
}
],
- "time": "2025-08-21T11:53:16+00:00"
+ "time": "2025-12-27T19:41:33+00:00"
},
{
- "name": "pragmarx/google2fa",
- "version": "v8.0.3",
+ "name": "phpstan/phpdoc-parser",
+ "version": "2.3.2",
"source": {
"type": "git",
- "url": "https://github.com/antonioribeiro/google2fa.git",
- "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad"
+ "url": "https://github.com/phpstan/phpdoc-parser.git",
+ "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad",
- "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad",
+ "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a",
+ "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0"
+ },
+ "require-dev": {
+ "doctrine/annotations": "^2.0",
+ "nikic/php-parser": "^5.3.0",
+ "php-parallel-lint/php-parallel-lint": "^1.2",
+ "phpstan/extension-installer": "^1.0",
+ "phpstan/phpstan": "^2.0",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpunit/phpunit": "^9.6",
+ "symfony/process": "^5.2"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PHPStan\\PhpDocParser\\": [
+ "src/"
+ ]
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHPDoc parser with support for nullable, intersection and generic types",
+ "support": {
+ "issues": "https://github.com/phpstan/phpdoc-parser/issues",
+ "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2"
+ },
+ "time": "2026-01-25T14:56:51+00:00"
+ },
+ {
+ "name": "pragmarx/google2fa",
+ "version": "v9.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/antonioribeiro/google2fa.git",
+ "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf",
+ "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf",
"shasum": ""
},
"require": {
@@ -4055,9 +4401,9 @@
],
"support": {
"issues": "https://github.com/antonioribeiro/google2fa/issues",
- "source": "https://github.com/antonioribeiro/google2fa/tree/v8.0.3"
+ "source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0"
},
- "time": "2024-09-05T11:56:40+00:00"
+ "time": "2025-09-19T22:51:08+00:00"
},
{
"name": "psr/clock",
@@ -4473,16 +4819,16 @@
},
{
"name": "psy/psysh",
- "version": "v0.12.10",
+ "version": "v0.12.23",
"source": {
"type": "git",
"url": "https://github.com/bobthecow/psysh.git",
- "reference": "6e80abe6f2257121f1eb9a4c55bf29d921025b22"
+ "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/bobthecow/psysh/zipball/6e80abe6f2257121f1eb9a4c55bf29d921025b22",
- "reference": "6e80abe6f2257121f1eb9a4c55bf29d921025b22",
+ "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4dcc0f08047d52bbde475eda481146fd8e27e1a4",
+ "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4",
"shasum": ""
},
"require": {
@@ -4490,18 +4836,19 @@
"ext-tokenizer": "*",
"nikic/php-parser": "^5.0 || ^4.0",
"php": "^8.0 || ^7.4",
- "symfony/console": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4",
- "symfony/var-dumper": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.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"
+ "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-pdo-sqlite": "The doc command requires SQLite to work.",
"ext-posix": "If you have PCNTL, you'll want the POSIX extension as well."
},
"bin": [
@@ -4545,9 +4892,9 @@
],
"support": {
"issues": "https://github.com/bobthecow/psysh/issues",
- "source": "https://github.com/bobthecow/psysh/tree/v0.12.10"
+ "source": "https://github.com/bobthecow/psysh/tree/v0.12.23"
},
- "time": "2025-08-04T12:39:37+00:00"
+ "time": "2026-05-23T13:41:31+00:00"
},
{
"name": "ralouphie/getallheaders",
@@ -4671,20 +5018,20 @@
},
{
"name": "ramsey/uuid",
- "version": "4.9.1",
+ "version": "4.9.3",
"source": {
"type": "git",
"url": "https://github.com/ramsey/uuid.git",
- "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440"
+ "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440",
- "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440",
+ "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8",
+ "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8",
"shasum": ""
},
"require": {
- "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14",
+ "brick/math": ">=0.8.16 <=0.18",
"php": "^8.0",
"ramsey/collection": "^1.2 || ^2.0"
},
@@ -4743,31 +5090,41 @@
],
"support": {
"issues": "https://github.com/ramsey/uuid/issues",
- "source": "https://github.com/ramsey/uuid/tree/4.9.1"
+ "source": "https://github.com/ramsey/uuid/tree/4.9.3"
},
- "time": "2025-09-04T20:59:21+00:00"
+ "time": "2026-06-18T03:57:49+00:00"
},
{
"name": "sabberworm/php-css-parser",
- "version": "v8.9.0",
+ "version": "v9.4.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
- "reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9"
+ "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/d8e916507b88e389e26d4ab03c904a082aa66bb9",
- "reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9",
+ "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
+ "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
"shasum": ""
},
"require": {
"ext-iconv": "*",
- "php": "^5.6.20 || ^7.0.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0"
+ "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
+ "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
},
"require-dev": {
- "phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.41",
- "rawr/cross-data-providers": "^2.0.0"
+ "php-parallel-lint/php-parallel-lint": "1.4.0",
+ "phpstan/extension-installer": "1.4.3",
+ "phpstan/phpstan": "1.12.33 || 2.2.2",
+ "phpstan/phpstan-phpunit": "1.4.2 || 2.0.16",
+ "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11",
+ "phpunit/phpunit": "8.5.52",
+ "rawr/phpunit-data-provider": "3.3.1",
+ "rector/rector": "1.2.10 || 2.4.6",
+ "rector/type-perfect": "1.0.0 || 2.1.3",
+ "squizlabs/php_codesniffer": "4.0.1",
+ "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
@@ -4775,10 +5132,14 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "9.0.x-dev"
+ "dev-main": "9.5.x-dev"
}
},
"autoload": {
+ "files": [
+ "src/Rule/Rule.php",
+ "src/RuleSet/RuleContainer.php"
+ ],
"psr-4": {
"Sabberworm\\CSS\\": "src/"
}
@@ -4809,35 +5170,103 @@
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
- "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.9.0"
+ "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0"
},
- "time": "2025-07-11T13:20:48+00:00"
+ "time": "2026-06-18T15:10:53+00:00"
},
{
- "name": "spatie/laravel-activitylog",
- "version": "4.10.2",
+ "name": "spatie/browsershot",
+ "version": "5.4.0",
"source": {
"type": "git",
- "url": "https://github.com/spatie/laravel-activitylog.git",
- "reference": "bb879775d487438ed9a99e64f09086b608990c10"
+ "url": "https://github.com/spatie/browsershot.git",
+ "reference": "dcf7a65fd1d0fc8fd113739b84982377728d0b2f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/bb879775d487438ed9a99e64f09086b608990c10",
- "reference": "bb879775d487438ed9a99e64f09086b608990c10",
+ "url": "https://api.github.com/repos/spatie/browsershot/zipball/dcf7a65fd1d0fc8fd113739b84982377728d0b2f",
+ "reference": "dcf7a65fd1d0fc8fd113739b84982377728d0b2f",
"shasum": ""
},
"require": {
- "illuminate/config": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0",
- "illuminate/database": "^8.69 || ^9.27 || ^10.0 || ^11.0 || ^12.0",
- "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0",
+ "ext-fileinfo": "*",
+ "ext-json": "*",
+ "php": "^8.2",
+ "spatie/temporary-directory": "^2.0",
+ "symfony/process": "^6.0|^7.0|^8.0"
+ },
+ "require-dev": {
+ "pestphp/pest": "^3.0|^4.0",
+ "spatie/image": "^3.6",
+ "spatie/pdf-to-text": "^1.52",
+ "spatie/phpunit-snapshot-assertions": "^5.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Spatie\\Browsershot\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Freek Van der Herten",
+ "email": "freek@spatie.be",
+ "homepage": "https://github.com/freekmurze",
+ "role": "Developer"
+ }
+ ],
+ "description": "Convert a webpage to an image or pdf using headless Chrome",
+ "homepage": "https://github.com/spatie/browsershot",
+ "keywords": [
+ "chrome",
+ "convert",
+ "headless",
+ "image",
+ "pdf",
+ "puppeteer",
+ "screenshot",
+ "webpage"
+ ],
+ "support": {
+ "source": "https://github.com/spatie/browsershot/tree/5.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/spatie",
+ "type": "github"
+ }
+ ],
+ "time": "2026-05-26T13:13:33+00:00"
+ },
+ {
+ "name": "spatie/laravel-activitylog",
+ "version": "4.12.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/spatie/laravel-activitylog.git",
+ "reference": "2a2024fcac05628b0d1bfdbb1b94dda8b0661dc0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/2a2024fcac05628b0d1bfdbb1b94dda8b0661dc0",
+ "reference": "2a2024fcac05628b0d1bfdbb1b94dda8b0661dc0",
+ "shasum": ""
+ },
+ "require": {
+ "illuminate/config": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0",
+ "illuminate/database": "^8.69 || ^9.27 || ^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",
"spatie/laravel-package-tools": "^1.6.3"
},
"require-dev": {
"ext-json": "*",
- "orchestra/testbench": "^6.23 || ^7.0 || ^8.0 || ^9.0 || ^10.0",
- "pestphp/pest": "^1.20 || ^2.0 || ^3.0"
+ "orchestra/testbench": "^6.23 || ^7.0 || ^8.0 || ^9.6 || ^10.0 || ^11.0",
+ "pestphp/pest": "^1.20 || ^2.0 || ^3.0 || ^4.0"
},
"type": "library",
"extra": {
@@ -4890,7 +5319,7 @@
],
"support": {
"issues": "https://github.com/spatie/laravel-activitylog/issues",
- "source": "https://github.com/spatie/laravel-activitylog/tree/4.10.2"
+ "source": "https://github.com/spatie/laravel-activitylog/tree/4.12.3"
},
"funding": [
{
@@ -4902,33 +5331,33 @@
"type": "github"
}
],
- "time": "2025-06-15T06:59:49+00:00"
+ "time": "2026-03-24T12:33:53+00:00"
},
{
"name": "spatie/laravel-package-tools",
- "version": "1.92.7",
+ "version": "1.93.1",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-package-tools.git",
- "reference": "f09a799850b1ed765103a4f0b4355006360c49a5"
+ "reference": "d5552849801f2642aea710557463234b59ef65eb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/f09a799850b1ed765103a4f0b4355006360c49a5",
- "reference": "f09a799850b1ed765103a4f0b4355006360c49a5",
+ "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb",
+ "reference": "d5552849801f2642aea710557463234b59ef65eb",
"shasum": ""
},
"require": {
- "illuminate/contracts": "^9.28|^10.0|^11.0|^12.0",
- "php": "^8.0"
+ "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
+ "php": "^8.1"
},
"require-dev": {
"mockery/mockery": "^1.5",
- "orchestra/testbench": "^7.7|^8.0|^9.0|^10.0",
- "pestphp/pest": "^1.23|^2.1|^3.1",
- "phpunit/php-code-coverage": "^9.0|^10.0|^11.0",
- "phpunit/phpunit": "^9.5.24|^10.5|^11.5",
- "spatie/pest-plugin-test-time": "^1.1|^2.2"
+ "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": {
@@ -4955,7 +5384,7 @@
],
"support": {
"issues": "https://github.com/spatie/laravel-package-tools/issues",
- "source": "https://github.com/spatie/laravel-package-tools/tree/1.92.7"
+ "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1"
},
"funding": [
{
@@ -4963,34 +5392,35 @@
"type": "github"
}
],
- "time": "2025-07-17T15:46:43+00:00"
+ "time": "2026-05-19T14:06:37+00:00"
},
{
"name": "spatie/laravel-permission",
- "version": "6.21.0",
+ "version": "6.25.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-permission.git",
- "reference": "6a118e8855dfffcd90403aab77bbf35a03db51b3"
+ "reference": "d7d4cb0d58616722f1afc90e0484e4825155b9b3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/6a118e8855dfffcd90403aab77bbf35a03db51b3",
- "reference": "6a118e8855dfffcd90403aab77bbf35a03db51b3",
+ "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/d7d4cb0d58616722f1afc90e0484e4825155b9b3",
+ "reference": "d7d4cb0d58616722f1afc90e0484e4825155b9b3",
"shasum": ""
},
"require": {
- "illuminate/auth": "^8.12|^9.0|^10.0|^11.0|^12.0",
- "illuminate/container": "^8.12|^9.0|^10.0|^11.0|^12.0",
- "illuminate/contracts": "^8.12|^9.0|^10.0|^11.0|^12.0",
- "illuminate/database": "^8.12|^9.0|^10.0|^11.0|^12.0",
+ "illuminate/auth": "^8.12|^9.0|^10.0|^11.0|^12.0|^13.0",
+ "illuminate/container": "^8.12|^9.0|^10.0|^11.0|^12.0|^13.0",
+ "illuminate/contracts": "^8.12|^9.0|^10.0|^11.0|^12.0|^13.0",
+ "illuminate/database": "^8.12|^9.0|^10.0|^11.0|^12.0|^13.0",
"php": "^8.0"
},
"require-dev": {
- "laravel/passport": "^11.0|^12.0",
+ "laravel/passport": "^11.0|^12.0|^13.0",
"laravel/pint": "^1.0",
- "orchestra/testbench": "^6.23|^7.0|^8.0|^9.0|^10.0",
- "phpunit/phpunit": "^9.4|^10.1|^11.5"
+ "orchestra/testbench": "^6.23|^7.0|^8.0|^9.0|^10.0|^11.0",
+ "pestphp/pest": "^2.0|^3.0|^4.0",
+ "pestphp/pest-plugin-laravel": "^2.0|^3.0|^4.0"
},
"type": "library",
"extra": {
@@ -5038,7 +5468,7 @@
],
"support": {
"issues": "https://github.com/spatie/laravel-permission/issues",
- "source": "https://github.com/spatie/laravel-permission/tree/6.21.0"
+ "source": "https://github.com/spatie/laravel-permission/tree/6.25.0"
},
"funding": [
{
@@ -5046,26 +5476,267 @@
"type": "github"
}
],
- "time": "2025-07-23T16:08:05+00:00"
+ "time": "2026-03-17T22:46:46+00:00"
},
{
- "name": "symfony/clock",
- "version": "v7.3.0",
+ "name": "spatie/temporary-directory",
+ "version": "2.4.0",
"source": {
"type": "git",
- "url": "https://github.com/symfony/clock.git",
- "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24"
+ "url": "https://github.com/spatie/temporary-directory.git",
+ "reference": "32cbb9645b28839cf4f476708e99a2c70e6802c9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24",
- "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24",
+ "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/32cbb9645b28839cf4f476708e99a2c70e6802c9",
+ "reference": "32cbb9645b28839cf4f476708e99a2c70e6802c9",
"shasum": ""
},
"require": {
- "php": ">=8.2",
- "psr/clock": "^1.0",
- "symfony/polyfill-php83": "^1.28"
+ "php": "^8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Spatie\\TemporaryDirectory\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Alex Vanderbist",
+ "email": "alex@spatie.be",
+ "homepage": "https://spatie.be",
+ "role": "Developer"
+ }
+ ],
+ "description": "Easily create, use and destroy temporary directories",
+ "homepage": "https://github.com/spatie/temporary-directory",
+ "keywords": [
+ "php",
+ "spatie",
+ "temporary-directory"
+ ],
+ "support": {
+ "issues": "https://github.com/spatie/temporary-directory/issues",
+ "source": "https://github.com/spatie/temporary-directory/tree/2.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://spatie.be/open-source/support-us",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/spatie",
+ "type": "github"
+ }
+ ],
+ "time": "2026-06-22T07:55:44+00:00"
+ },
+ {
+ "name": "spomky-labs/cbor-php",
+ "version": "3.2.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Spomky-Labs/cbor-php.git",
+ "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32",
+ "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32",
+ "shasum": ""
+ },
+ "require": {
+ "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
+ "ext-mbstring": "*",
+ "php": ">=8.0"
+ },
+ "require-dev": {
+ "ext-json": "*",
+ "roave/security-advisories": "dev-latest",
+ "symfony/error-handler": "^6.4|^7.1|^8.0",
+ "symfony/var-dumper": "^6.4|^7.1|^8.0"
+ },
+ "suggest": {
+ "ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags",
+ "ext-gmp": "GMP or BCMath extensions will drastically improve the library performance"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "CBOR\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Florent Morselli",
+ "homepage": "https://github.com/Spomky"
+ },
+ {
+ "name": "All contributors",
+ "homepage": "https://github.com/Spomky-Labs/cbor-php/contributors"
+ }
+ ],
+ "description": "CBOR Encoder/Decoder for PHP",
+ "keywords": [
+ "Concise Binary Object Representation",
+ "RFC7049",
+ "cbor"
+ ],
+ "support": {
+ "issues": "https://github.com/Spomky-Labs/cbor-php/issues",
+ "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.2.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Spomky",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/FlorentMorselli",
+ "type": "patreon"
+ }
+ ],
+ "time": "2026-04-01T12:15:20+00:00"
+ },
+ {
+ "name": "spomky-labs/pki-framework",
+ "version": "1.4.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Spomky-Labs/pki-framework.git",
+ "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/aa576cbd07128075bef97ac2f8af9854e67513d8",
+ "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8",
+ "shasum": ""
+ },
+ "require": {
+ "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
+ "ext-mbstring": "*",
+ "php": ">=8.1",
+ "psr/clock": "^1.0"
+ },
+ "require-dev": {
+ "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0",
+ "ext-gmp": "*",
+ "ext-openssl": "*",
+ "infection/infection": "^0.28|^0.29|^0.31|^0.32",
+ "php-parallel-lint/php-parallel-lint": "^1.3",
+ "phpstan/extension-installer": "^1.3|^2.0",
+ "phpstan/phpstan": "^1.8|^2.0",
+ "phpstan/phpstan-deprecation-rules": "^1.0|^2.0",
+ "phpstan/phpstan-phpunit": "^1.1|^2.0",
+ "phpstan/phpstan-strict-rules": "^1.3|^2.0",
+ "phpunit/phpunit": "^10.1|^11.0|^12.0|^13.0",
+ "rector/rector": "^1.0|^2.0",
+ "roave/security-advisories": "dev-latest",
+ "symfony/string": "^6.4|^7.0|^8.0",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0",
+ "symplify/easy-coding-standard": "^12.0|^13.0"
+ },
+ "suggest": {
+ "ext-bcmath": "For better performance (or GMP)",
+ "ext-gmp": "For better performance (or BCMath)",
+ "ext-openssl": "For OpenSSL based cyphering"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "SpomkyLabs\\Pki\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Joni Eskelinen",
+ "email": "jonieske@gmail.com",
+ "role": "Original developer"
+ },
+ {
+ "name": "Florent Morselli",
+ "email": "florent.morselli@spomky-labs.com",
+ "role": "Spomky-Labs PKI Framework developer"
+ }
+ ],
+ "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.",
+ "homepage": "https://github.com/spomky-labs/pki-framework",
+ "keywords": [
+ "DER",
+ "Private Key",
+ "ac",
+ "algorithm identifier",
+ "asn.1",
+ "asn1",
+ "attribute certificate",
+ "certificate",
+ "certification request",
+ "cryptography",
+ "csr",
+ "decrypt",
+ "ec",
+ "encrypt",
+ "pem",
+ "pkcs",
+ "public key",
+ "rsa",
+ "sign",
+ "signature",
+ "verify",
+ "x.509",
+ "x.690",
+ "x509",
+ "x690"
+ ],
+ "support": {
+ "issues": "https://github.com/Spomky-Labs/pki-framework/issues",
+ "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Spomky",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/FlorentMorselli",
+ "type": "patreon"
+ }
+ ],
+ "time": "2026-03-23T22:56:56+00:00"
+ },
+ {
+ "name": "symfony/clock",
+ "version": "v8.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/clock.git",
+ "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6",
+ "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4.1",
+ "psr/clock": "^1.0"
},
"provide": {
"psr/clock-implementation": "1.0"
@@ -5104,7 +5775,7 @@
"time"
],
"support": {
- "source": "https://github.com/symfony/clock/tree/v7.3.0"
+ "source": "https://github.com/symfony/clock/tree/v8.1.0"
},
"funding": [
{
@@ -5115,25 +5786,29 @@
"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": "2024-09-25T14:21:43+00:00"
+ "time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/console",
- "version": "v7.3.3",
+ "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7"
+ "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7",
- "reference": "cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7",
+ "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217",
+ "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217",
"shasum": ""
},
"require": {
@@ -5141,7 +5816,7 @@
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-mbstring": "~1.0",
"symfony/service-contracts": "^2.5|^3",
- "symfony/string": "^7.2"
+ "symfony/string": "^7.2|^8.0"
},
"conflict": {
"symfony/dependency-injection": "<6.4",
@@ -5155,16 +5830,16 @@
},
"require-dev": {
"psr/log": "^1|^2|^3",
- "symfony/config": "^6.4|^7.0",
- "symfony/dependency-injection": "^6.4|^7.0",
- "symfony/event-dispatcher": "^6.4|^7.0",
- "symfony/http-foundation": "^6.4|^7.0",
- "symfony/http-kernel": "^6.4|^7.0",
- "symfony/lock": "^6.4|^7.0",
- "symfony/messenger": "^6.4|^7.0",
- "symfony/process": "^6.4|^7.0",
- "symfony/stopwatch": "^6.4|^7.0",
- "symfony/var-dumper": "^6.4|^7.0"
+ "symfony/config": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
+ "symfony/http-foundation": "^6.4|^7.0|^8.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/lock": "^6.4|^7.0|^8.0",
+ "symfony/messenger": "^6.4|^7.0|^8.0",
+ "symfony/process": "^6.4|^7.0|^8.0",
+ "symfony/stopwatch": "^6.4|^7.0|^8.0",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -5198,7 +5873,7 @@
"terminal"
],
"support": {
- "source": "https://github.com/symfony/console/tree/v7.3.3"
+ "source": "https://github.com/symfony/console/tree/v7.4.13"
},
"funding": [
{
@@ -5218,24 +5893,24 @@
"type": "tidelift"
}
],
- "time": "2025-08-25T06:35:40+00:00"
+ "time": "2026-05-24T08:56:14+00:00"
},
{
"name": "symfony/css-selector",
- "version": "v7.3.0",
+ "version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/css-selector.git",
- "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2"
+ "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2",
- "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2",
+ "url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd",
+ "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd",
"shasum": ""
},
"require": {
- "php": ">=8.2"
+ "php": ">=8.4.1"
},
"type": "library",
"autoload": {
@@ -5267,7 +5942,7 @@
"description": "Converts CSS selectors to XPath expressions",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/css-selector/tree/v7.3.0"
+ "source": "https://github.com/symfony/css-selector/tree/v8.1.0"
},
"funding": [
{
@@ -5278,25 +5953,29 @@
"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": "2024-09-25T14:21:43+00:00"
+ "time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/deprecation-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
- "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
+ "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
- "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b",
+ "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b",
"shasum": ""
},
"require": {
@@ -5309,7 +5988,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -5334,7 +6013,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -5345,41 +6024,46 @@
"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": "2024-09-25T14:21:43+00:00"
+ "time": "2026-04-13T15:52:40+00:00"
},
{
"name": "symfony/error-handler",
- "version": "v7.3.2",
+ "version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/error-handler.git",
- "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3"
+ "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/error-handler/zipball/0b31a944fcd8759ae294da4d2808cbc53aebd0c3",
- "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3",
+ "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa",
+ "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa",
"shasum": ""
},
"require": {
"php": ">=8.2",
"psr/log": "^1|^2|^3",
- "symfony/var-dumper": "^6.4|^7.0"
+ "symfony/polyfill-php85": "^1.32",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0"
},
"conflict": {
"symfony/deprecation-contracts": "<2.5",
"symfony/http-kernel": "<6.4"
},
"require-dev": {
- "symfony/console": "^6.4|^7.0",
+ "symfony/console": "^6.4|^7.0|^8.0",
"symfony/deprecation-contracts": "^2.5|^3",
- "symfony/http-kernel": "^6.4|^7.0",
- "symfony/serializer": "^6.4|^7.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/serializer": "^6.4|^7.0|^8.0",
"symfony/webpack-encore-bundle": "^1.0|^2.0"
},
"bin": [
@@ -5411,7 +6095,7 @@
"description": "Provides tools to manage errors and ease debugging PHP code",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/error-handler/tree/v7.3.2"
+ "source": "https://github.com/symfony/error-handler/tree/v7.4.8"
},
"funding": [
{
@@ -5431,28 +6115,29 @@
"type": "tidelift"
}
],
- "time": "2025-07-07T08:17:57+00:00"
+ "time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/event-dispatcher",
- "version": "v7.3.3",
+ "version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
- "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191"
+ "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b7dc69e71de420ac04bc9ab830cf3ffebba48191",
- "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102",
+ "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102",
"shasum": ""
},
"require": {
- "php": ">=8.2",
+ "php": ">=8.4.1",
+ "symfony/deprecation-contracts": "^2.5|^3",
"symfony/event-dispatcher-contracts": "^2.5|^3"
},
"conflict": {
- "symfony/dependency-injection": "<6.4",
+ "symfony/security-http": "<7.4",
"symfony/service-contracts": "<2.5"
},
"provide": {
@@ -5461,13 +6146,14 @@
},
"require-dev": {
"psr/log": "^1|^2|^3",
- "symfony/config": "^6.4|^7.0",
- "symfony/dependency-injection": "^6.4|^7.0",
- "symfony/error-handler": "^6.4|^7.0",
- "symfony/expression-language": "^6.4|^7.0",
- "symfony/http-foundation": "^6.4|^7.0",
+ "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": "^6.4|^7.0"
+ "symfony/stopwatch": "^7.4|^8.0"
},
"type": "library",
"autoload": {
@@ -5495,7 +6181,7 @@
"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/v7.3.3"
+ "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.0"
},
"funding": [
{
@@ -5515,20 +6201,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-13T11:49:31+00:00"
+ "time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/event-dispatcher-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher-contracts.git",
- "reference": "59eb412e93815df44f05f342958efa9f46b1e586"
+ "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586",
- "reference": "59eb412e93815df44f05f342958efa9f46b1e586",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32",
+ "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32",
"shasum": ""
},
"require": {
@@ -5542,7 +6228,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -5575,7 +6261,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -5586,32 +6272,36 @@
"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": "2024-09-25T14:21:43+00:00"
+ "time": "2026-01-05T13:30:16+00:00"
},
{
"name": "symfony/finder",
- "version": "v7.3.2",
+ "version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
- "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe"
+ "reference": "e0be088d22278583a82da281886e8c3592fbf149"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe",
- "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149",
+ "reference": "e0be088d22278583a82da281886e8c3592fbf149",
"shasum": ""
},
"require": {
"php": ">=8.2"
},
"require-dev": {
- "symfony/filesystem": "^6.4|^7.0"
+ "symfony/filesystem": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -5639,7 +6329,7 @@
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/finder/tree/v7.3.2"
+ "source": "https://github.com/symfony/finder/tree/v7.4.8"
},
"funding": [
{
@@ -5659,27 +6349,26 @@
"type": "tidelift"
}
],
- "time": "2025-07-15T13:41:35+00:00"
+ "time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/http-foundation",
- "version": "v7.3.3",
+ "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
- "reference": "7475561ec27020196c49bb7c4f178d33d7d3dc00"
+ "reference": "bc354f47c62301e990b7874fa662326368508e2c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-foundation/zipball/7475561ec27020196c49bb7c4f178d33d7d3dc00",
- "reference": "7475561ec27020196c49bb7c4f178d33d7d3dc00",
+ "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c",
+ "reference": "bc354f47c62301e990b7874fa662326368508e2c",
"shasum": ""
},
"require": {
"php": ">=8.2",
- "symfony/deprecation-contracts": "^2.5|^3.0",
- "symfony/polyfill-mbstring": "~1.1",
- "symfony/polyfill-php83": "^1.27"
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/polyfill-mbstring": "^1.1"
},
"conflict": {
"doctrine/dbal": "<3.6",
@@ -5688,13 +6377,13 @@
"require-dev": {
"doctrine/dbal": "^3.6|^4",
"predis/predis": "^1.1|^2.0",
- "symfony/cache": "^6.4.12|^7.1.5",
- "symfony/clock": "^6.4|^7.0",
- "symfony/dependency-injection": "^6.4|^7.0",
- "symfony/expression-language": "^6.4|^7.0",
- "symfony/http-kernel": "^6.4|^7.0",
- "symfony/mime": "^6.4|^7.0",
- "symfony/rate-limiter": "^6.4|^7.0"
+ "symfony/cache": "^6.4.12|^7.1.5|^8.0",
+ "symfony/clock": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/expression-language": "^6.4|^7.0|^8.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/mime": "^6.4|^7.0|^8.0",
+ "symfony/rate-limiter": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -5722,7 +6411,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/http-foundation/tree/v7.3.3"
+ "source": "https://github.com/symfony/http-foundation/tree/v7.4.13"
},
"funding": [
{
@@ -5742,29 +6431,29 @@
"type": "tidelift"
}
],
- "time": "2025-08-20T08:04:18+00:00"
+ "time": "2026-05-24T11:20:33+00:00"
},
{
"name": "symfony/http-kernel",
- "version": "v7.3.3",
+ "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
- "reference": "72c304de37e1a1cec6d5d12b81187ebd4850a17b"
+ "reference": "9df847980c436451f4f51d1284491bb4356dd989"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-kernel/zipball/72c304de37e1a1cec6d5d12b81187ebd4850a17b",
- "reference": "72c304de37e1a1cec6d5d12b81187ebd4850a17b",
+ "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9df847980c436451f4f51d1284491bb4356dd989",
+ "reference": "9df847980c436451f4f51d1284491bb4356dd989",
"shasum": ""
},
"require": {
"php": ">=8.2",
"psr/log": "^1|^2|^3",
"symfony/deprecation-contracts": "^2.5|^3",
- "symfony/error-handler": "^6.4|^7.0",
- "symfony/event-dispatcher": "^7.3",
- "symfony/http-foundation": "^7.3",
+ "symfony/error-handler": "^6.4|^7.0|^8.0",
+ "symfony/event-dispatcher": "^7.3|^8.0",
+ "symfony/http-foundation": "^7.4|^8.0",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
@@ -5774,6 +6463,7 @@
"symfony/console": "<6.4",
"symfony/dependency-injection": "<6.4",
"symfony/doctrine-bridge": "<6.4",
+ "symfony/flex": "<2.10",
"symfony/form": "<6.4",
"symfony/http-client": "<6.4",
"symfony/http-client-contracts": "<2.5",
@@ -5791,27 +6481,27 @@
},
"require-dev": {
"psr/cache": "^1.0|^2.0|^3.0",
- "symfony/browser-kit": "^6.4|^7.0",
- "symfony/clock": "^6.4|^7.0",
- "symfony/config": "^6.4|^7.0",
- "symfony/console": "^6.4|^7.0",
- "symfony/css-selector": "^6.4|^7.0",
- "symfony/dependency-injection": "^6.4|^7.0",
- "symfony/dom-crawler": "^6.4|^7.0",
- "symfony/expression-language": "^6.4|^7.0",
- "symfony/finder": "^6.4|^7.0",
+ "symfony/browser-kit": "^6.4|^7.0|^8.0",
+ "symfony/clock": "^6.4|^7.0|^8.0",
+ "symfony/config": "^6.4|^7.0|^8.0",
+ "symfony/console": "^6.4|^7.0|^8.0",
+ "symfony/css-selector": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0",
+ "symfony/dom-crawler": "^6.4|^7.0|^8.0",
+ "symfony/expression-language": "^6.4|^7.0|^8.0",
+ "symfony/finder": "^6.4|^7.0|^8.0",
"symfony/http-client-contracts": "^2.5|^3",
- "symfony/process": "^6.4|^7.0",
- "symfony/property-access": "^7.1",
- "symfony/routing": "^6.4|^7.0",
- "symfony/serializer": "^7.1",
- "symfony/stopwatch": "^6.4|^7.0",
- "symfony/translation": "^6.4|^7.0",
+ "symfony/process": "^6.4|^7.0|^8.0",
+ "symfony/property-access": "^7.1|^8.0",
+ "symfony/routing": "^6.4|^7.0|^8.0",
+ "symfony/serializer": "^7.1|^8.0",
+ "symfony/stopwatch": "^6.4|^7.0|^8.0",
+ "symfony/translation": "^6.4|^7.0|^8.0",
"symfony/translation-contracts": "^2.5|^3",
- "symfony/uid": "^6.4|^7.0",
- "symfony/validator": "^6.4|^7.0",
- "symfony/var-dumper": "^6.4|^7.0",
- "symfony/var-exporter": "^6.4|^7.0",
+ "symfony/uid": "^6.4|^7.0|^8.0",
+ "symfony/validator": "^6.4|^7.0|^8.0",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0",
+ "symfony/var-exporter": "^6.4|^7.0|^8.0",
"twig/twig": "^3.12"
},
"type": "library",
@@ -5840,7 +6530,7 @@
"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/v7.3.3"
+ "source": "https://github.com/symfony/http-kernel/tree/v7.4.13"
},
"funding": [
{
@@ -5860,20 +6550,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-29T08:23:45+00:00"
+ "time": "2026-05-27T08:31:43+00:00"
},
{
"name": "symfony/mailer",
- "version": "v7.3.3",
+ "version": "v7.4.12",
"source": {
"type": "git",
"url": "https://github.com/symfony/mailer.git",
- "reference": "a32f3f45f1990db8c4341d5122a7d3a381c7e575"
+ "reference": "5cefb712a25f320579615ba9e1942abaeade7dff"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/mailer/zipball/a32f3f45f1990db8c4341d5122a7d3a381c7e575",
- "reference": "a32f3f45f1990db8c4341d5122a7d3a381c7e575",
+ "url": "https://api.github.com/repos/symfony/mailer/zipball/5cefb712a25f320579615ba9e1942abaeade7dff",
+ "reference": "5cefb712a25f320579615ba9e1942abaeade7dff",
"shasum": ""
},
"require": {
@@ -5881,8 +6571,8 @@
"php": ">=8.2",
"psr/event-dispatcher": "^1",
"psr/log": "^1|^2|^3",
- "symfony/event-dispatcher": "^6.4|^7.0",
- "symfony/mime": "^7.2",
+ "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
+ "symfony/mime": "^7.2|^8.0",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
@@ -5893,10 +6583,10 @@
"symfony/twig-bridge": "<6.4"
},
"require-dev": {
- "symfony/console": "^6.4|^7.0",
- "symfony/http-client": "^6.4|^7.0",
- "symfony/messenger": "^6.4|^7.0",
- "symfony/twig-bridge": "^6.4|^7.0"
+ "symfony/console": "^6.4|^7.0|^8.0",
+ "symfony/http-client": "^6.4|^7.0|^8.0",
+ "symfony/messenger": "^6.4|^7.0|^8.0",
+ "symfony/twig-bridge": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -5924,7 +6614,7 @@
"description": "Helps sending emails",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/mailer/tree/v7.3.3"
+ "source": "https://github.com/symfony/mailer/tree/v7.4.12"
},
"funding": [
{
@@ -5944,43 +6634,44 @@
"type": "tidelift"
}
],
- "time": "2025-08-13T11:49:31+00:00"
+ "time": "2026-05-20T07:20:23+00:00"
},
{
"name": "symfony/mime",
- "version": "v7.3.2",
+ "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
- "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1"
+ "reference": "a845722765c4f6b2ce88beaf4f4479975b186770"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/mime/zipball/e0a0f859148daf1edf6c60b398eb40bfc96697d1",
- "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1",
+ "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770",
+ "reference": "a845722765c4f6b2ce88beaf4f4479975b186770",
"shasum": ""
},
"require": {
"php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-intl-idn": "^1.10",
"symfony/polyfill-mbstring": "^1.0"
},
"conflict": {
"egulias/email-validator": "~3.0.0",
- "phpdocumentor/reflection-docblock": "<3.2.2",
- "phpdocumentor/type-resolver": "<1.4.0",
+ "phpdocumentor/reflection-docblock": "<5.2|>=7",
+ "phpdocumentor/type-resolver": "<1.5.1",
"symfony/mailer": "<6.4",
"symfony/serializer": "<6.4.3|>7.0,<7.0.3"
},
"require-dev": {
"egulias/email-validator": "^2.1.10|^3.1|^4",
"league/html-to-markdown": "^5.0",
- "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0",
- "symfony/dependency-injection": "^6.4|^7.0",
- "symfony/process": "^6.4|^7.0",
- "symfony/property-access": "^6.4|^7.0",
- "symfony/property-info": "^6.4|^7.0",
- "symfony/serializer": "^6.4.3|^7.0.3"
+ "phpdocumentor/reflection-docblock": "^5.2|^6.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/process": "^6.4|^7.0|^8.0",
+ "symfony/property-access": "^6.4|^7.0|^8.0",
+ "symfony/property-info": "^6.4|^7.0|^8.0",
+ "symfony/serializer": "^6.4.3|^7.0.3|^8.0"
},
"type": "library",
"autoload": {
@@ -6012,7 +6703,7 @@
"mime-type"
],
"support": {
- "source": "https://github.com/symfony/mime/tree/v7.3.2"
+ "source": "https://github.com/symfony/mime/tree/v7.4.13"
},
"funding": [
{
@@ -6032,20 +6723,20 @@
"type": "tidelift"
}
],
- "time": "2025-07-15T13:41:35+00:00"
+ "time": "2026-05-23T16:22:37+00:00"
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
- "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
- "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2",
"shasum": ""
},
"require": {
@@ -6095,7 +6786,7 @@
"portable"
],
"support": {
- "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
},
"funding": [
{
@@ -6115,20 +6806,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/polyfill-intl-grapheme",
- "version": "v1.33.0",
+ "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
- "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70"
+ "reference": "e9247d281d694a5120554d9afaf54e070e88a603"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70",
- "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603",
+ "reference": "e9247d281d694a5120554d9afaf54e070e88a603",
"shasum": ""
},
"require": {
@@ -6177,7 +6868,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1"
},
"funding": [
{
@@ -6197,20 +6888,20 @@
"type": "tidelift"
}
],
- "time": "2025-06-27T09:58:17+00:00"
+ "time": "2026-05-26T05:58:03+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
- "version": "v1.33.0",
+ "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
- "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
+ "reference": "dc21118016c039a66235cf93d96b435ffb282412"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
- "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412",
+ "reference": "dc21118016c039a66235cf93d96b435ffb282412",
"shasum": ""
},
"require": {
@@ -6264,7 +6955,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1"
},
"funding": [
{
@@ -6284,20 +6975,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-10T14:38:51+00:00"
+ "time": "2026-05-25T15:22:23+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
- "version": "v1.33.0",
+ "version": "v1.38.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
- "reference": "3833d7255cc303546435cb650316bff708a1c75c"
+ "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
- "reference": "3833d7255cc303546435cb650316bff708a1c75c",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b",
+ "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b",
"shasum": ""
},
"require": {
@@ -6349,7 +7040,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0"
},
"funding": [
{
@@ -6369,20 +7060,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-05-25T13:48:31+00:00"
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.33.0",
+ "version": "v1.38.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
- "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
+ "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
- "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
+ "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
"shasum": ""
},
"require": {
@@ -6434,7 +7125,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2"
},
"funding": [
{
@@ -6454,20 +7145,20 @@
"type": "tidelift"
}
],
- "time": "2024-12-23T08:48:59+00:00"
+ "time": "2026-05-27T06:59:30+00:00"
},
{
"name": "symfony/polyfill-php80",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
- "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608"
+ "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
- "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
+ "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
+ "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
"shasum": ""
},
"require": {
@@ -6518,7 +7209,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
},
"funding": [
{
@@ -6538,20 +7229,20 @@
"type": "tidelift"
}
],
- "time": "2025-01-02T08:10:11+00:00"
+ "time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/polyfill-php83",
- "version": "v1.33.0",
+ "version": "v1.38.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php83.git",
- "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5"
+ "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5",
- "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5",
+ "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8",
+ "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8",
"shasum": ""
},
"require": {
@@ -6598,7 +7289,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2"
},
"funding": [
{
@@ -6618,20 +7309,20 @@
"type": "tidelift"
}
],
- "time": "2025-07-08T02:45:35+00:00"
+ "time": "2026-05-27T06:51:48+00:00"
},
{
"name": "symfony/polyfill-php84",
- "version": "v1.33.0",
+ "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php84.git",
- "reference": "d8ced4d875142b6a7426000426b8abc631d6b191"
+ "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191",
- "reference": "d8ced4d875142b6a7426000426b8abc631d6b191",
+ "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa",
+ "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa",
"shasum": ""
},
"require": {
@@ -6678,7 +7369,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1"
},
"funding": [
{
@@ -6698,20 +7389,20 @@
"type": "tidelift"
}
],
- "time": "2025-06-24T13:30:11+00:00"
+ "time": "2026-05-26T12:51:13+00:00"
},
{
"name": "symfony/polyfill-php85",
- "version": "v1.33.0",
+ "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php85.git",
- "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91"
+ "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91",
- "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91",
+ "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1",
+ "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1",
"shasum": ""
},
"require": {
@@ -6758,7 +7449,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1"
},
"funding": [
{
@@ -6778,20 +7469,20 @@
"type": "tidelift"
}
],
- "time": "2025-06-23T16:12:55+00:00"
+ "time": "2026-05-26T02:25:22+00:00"
},
{
"name": "symfony/polyfill-uuid",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-uuid.git",
- "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2"
+ "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2",
- "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2",
+ "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94",
+ "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94",
"shasum": ""
},
"require": {
@@ -6841,7 +7532,7 @@
"uuid"
],
"support": {
- "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0"
},
"funding": [
{
@@ -6861,20 +7552,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/process",
- "version": "v7.3.3",
+ "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "32241012d521e2e8a9d713adb0812bb773b907f1"
+ "reference": "f5804be144caceb570f6747519999636b664f24c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/32241012d521e2e8a9d713adb0812bb773b907f1",
- "reference": "32241012d521e2e8a9d713adb0812bb773b907f1",
+ "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c",
+ "reference": "f5804be144caceb570f6747519999636b664f24c",
"shasum": ""
},
"require": {
@@ -6906,7 +7597,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/process/tree/v7.3.3"
+ "source": "https://github.com/symfony/process/tree/v7.4.13"
},
"funding": [
{
@@ -6926,20 +7617,187 @@
"type": "tidelift"
}
],
- "time": "2025-08-18T09:42:54+00:00"
+ "time": "2026-05-23T16:05:06+00:00"
},
{
- "name": "symfony/routing",
- "version": "v7.3.2",
+ "name": "symfony/property-access",
+ "version": "v8.1.0",
"source": {
"type": "git",
- "url": "https://github.com/symfony/routing.git",
- "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4"
+ "url": "https://github.com/symfony/property-access.git",
+ "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/routing/zipball/7614b8ca5fa89b9cd233e21b627bfc5774f586e4",
- "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4",
+ "url": "https://api.github.com/repos/symfony/property-access/zipball/9261ef060f26cc7b728f67f141ba19b98a6209a9",
+ "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4.1",
+ "symfony/property-info": "^7.4.4|^8.0.4"
+ },
+ "require-dev": {
+ "symfony/cache": "^7.4|^8.0",
+ "symfony/var-exporter": "^7.4|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\PropertyAccess\\": ""
+ },
+ "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 functions to read and write from/to an object or array using a simple string notation",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "access",
+ "array",
+ "extraction",
+ "index",
+ "injection",
+ "object",
+ "property",
+ "property-path",
+ "reflection"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/property-access/tree/v8.1.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-29T05:06:50+00:00"
+ },
+ {
+ "name": "symfony/property-info",
+ "version": "v8.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/property-info.git",
+ "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/property-info/zipball/4721e8c56d0cd2378e0ef9a9899f810008b859f7",
+ "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4.1",
+ "symfony/string": "^7.4|^8.0",
+ "symfony/type-info": "^7.4.7|^8.0.7"
+ },
+ "conflict": {
+ "phpdocumentor/reflection-docblock": "<5.2|>=7",
+ "phpdocumentor/type-resolver": "<1.5.1"
+ },
+ "require-dev": {
+ "phpdocumentor/reflection-docblock": "^5.2|^6.0",
+ "phpstan/phpdoc-parser": "^1.0|^2.0",
+ "symfony/cache": "^7.4|^8.0",
+ "symfony/dependency-injection": "^7.4|^8.0",
+ "symfony/serializer": "^7.4|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\PropertyInfo\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Kévin Dunglas",
+ "email": "dunglas@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Extracts information about PHP class' properties using metadata of popular sources",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "doctrine",
+ "phpdoc",
+ "property",
+ "symfony",
+ "type",
+ "validator"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/property-info/tree/v8.1.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-29T05:06:50+00:00"
+ },
+ {
+ "name": "symfony/routing",
+ "version": "v7.4.13",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/routing.git",
+ "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d",
+ "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d",
"shasum": ""
},
"require": {
@@ -6953,11 +7811,11 @@
},
"require-dev": {
"psr/log": "^1|^2|^3",
- "symfony/config": "^6.4|^7.0",
- "symfony/dependency-injection": "^6.4|^7.0",
- "symfony/expression-language": "^6.4|^7.0",
- "symfony/http-foundation": "^6.4|^7.0",
- "symfony/yaml": "^6.4|^7.0"
+ "symfony/config": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/expression-language": "^6.4|^7.0|^8.0",
+ "symfony/http-foundation": "^6.4|^7.0|^8.0",
+ "symfony/yaml": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -6991,7 +7849,7 @@
"url"
],
"support": {
- "source": "https://github.com/symfony/routing/tree/v7.3.2"
+ "source": "https://github.com/symfony/routing/tree/v7.4.13"
},
"funding": [
{
@@ -7011,20 +7869,119 @@
"type": "tidelift"
}
],
- "time": "2025-07-15T11:36:08+00:00"
+ "time": "2026-05-24T11:20:33+00:00"
},
{
- "name": "symfony/service-contracts",
- "version": "v3.6.0",
+ "name": "symfony/serializer",
+ "version": "v8.1.0",
"source": {
"type": "git",
- "url": "https://github.com/symfony/service-contracts.git",
- "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4"
+ "url": "https://github.com/symfony/serializer.git",
+ "reference": "d101886195c5f772cf7033641fe9c40c3e3969e1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4",
- "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4",
+ "url": "https://api.github.com/repos/symfony/serializer/zipball/d101886195c5f772cf7033641fe9c40c3e3969e1",
+ "reference": "d101886195c5f772cf7033641fe9c40c3e3969e1",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4.1",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/polyfill-ctype": "^1.8"
+ },
+ "conflict": {
+ "phpdocumentor/reflection-docblock": "<5.2|>=7",
+ "phpdocumentor/type-resolver": "<1.5.1",
+ "symfony/property-access": "<8.1",
+ "symfony/property-info": "<7.4",
+ "symfony/type-info": "<7.4"
+ },
+ "require-dev": {
+ "phpdocumentor/reflection-docblock": "^5.2|^6.0",
+ "phpstan/phpdoc-parser": "^1.0|^2.0",
+ "seld/jsonlint": "^1.10",
+ "symfony/cache": "^7.4|^8.0",
+ "symfony/config": "^7.4|^8.0",
+ "symfony/console": "^7.4|^8.0",
+ "symfony/dependency-injection": "^7.4|^8.0",
+ "symfony/error-handler": "^7.4|^8.0",
+ "symfony/filesystem": "^7.4|^8.0",
+ "symfony/form": "^7.4|^8.0",
+ "symfony/http-foundation": "^7.4|^8.0",
+ "symfony/http-kernel": "^7.4|^8.0",
+ "symfony/messenger": "^7.4|^8.0",
+ "symfony/mime": "^7.4|^8.0",
+ "symfony/property-access": "^8.1",
+ "symfony/property-info": "^7.4|^8.0",
+ "symfony/translation-contracts": "^2.5|^3",
+ "symfony/type-info": "^7.4|^8.0",
+ "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",
+ "symfony/yaml": "^7.4|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Serializer\\": ""
+ },
+ "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": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/serializer/tree/v8.1.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-29T05:06:50+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": {
@@ -7042,7 +7999,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -7078,7 +8035,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/service-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/service-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -7089,44 +8046,47 @@
"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": "2025-04-25T09:37:31+00:00"
+ "time": "2026-03-28T09:44:51+00:00"
},
{
"name": "symfony/string",
- "version": "v7.3.3",
+ "version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
- "reference": "17a426cce5fd1f0901fefa9b2a490d0038fd3c9c"
+ "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/string/zipball/17a426cce5fd1f0901fefa9b2a490d0038fd3c9c",
- "reference": "17a426cce5fd1f0901fefa9b2a490d0038fd3c9c",
+ "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9",
+ "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9",
"shasum": ""
},
"require": {
- "php": ">=8.2",
- "symfony/polyfill-ctype": "~1.8",
- "symfony/polyfill-intl-grapheme": "~1.0",
- "symfony/polyfill-intl-normalizer": "~1.0",
- "symfony/polyfill-mbstring": "~1.0"
+ "php": ">=8.4.1",
+ "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.1",
- "symfony/error-handler": "^6.4|^7.0",
- "symfony/http-client": "^6.4|^7.0",
- "symfony/intl": "^6.4|^7.0",
+ "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": "^6.4|^7.0"
+ "symfony/var-exporter": "^7.4|^8.0"
},
"type": "library",
"autoload": {
@@ -7165,7 +8125,7 @@
"utf8"
],
"support": {
- "source": "https://github.com/symfony/string/tree/v7.3.3"
+ "source": "https://github.com/symfony/string/tree/v8.1.0"
},
"funding": [
{
@@ -7185,38 +8145,31 @@
"type": "tidelift"
}
],
- "time": "2025-08-25T06:35:40+00:00"
+ "time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/translation",
- "version": "v7.3.3",
+ "version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation.git",
- "reference": "e0837b4cbcef63c754d89a4806575cada743a38d"
+ "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/translation/zipball/e0837b4cbcef63c754d89a4806575cada743a38d",
- "reference": "e0837b4cbcef63c754d89a4806575cada743a38d",
+ "url": "https://api.github.com/repos/symfony/translation/zipball/b2bd012ca28c4acae830ee1206a5b6e35dd99693",
+ "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693",
"shasum": ""
},
"require": {
- "php": ">=8.2",
- "symfony/deprecation-contracts": "^2.5|^3",
- "symfony/polyfill-mbstring": "~1.0",
- "symfony/translation-contracts": "^2.5|^3.0"
+ "php": ">=8.4.1",
+ "symfony/polyfill-mbstring": "^1.0",
+ "symfony/translation-contracts": "^3.6.1"
},
"conflict": {
"nikic/php-parser": "<5.0",
- "symfony/config": "<6.4",
- "symfony/console": "<6.4",
- "symfony/dependency-injection": "<6.4",
"symfony/http-client-contracts": "<2.5",
- "symfony/http-kernel": "<6.4",
- "symfony/service-contracts": "<2.5",
- "symfony/twig-bundle": "<6.4",
- "symfony/yaml": "<6.4"
+ "symfony/service-contracts": "<2.5"
},
"provide": {
"symfony/translation-implementation": "2.3|3.0"
@@ -7224,17 +8177,17 @@
"require-dev": {
"nikic/php-parser": "^5.0",
"psr/log": "^1|^2|^3",
- "symfony/config": "^6.4|^7.0",
- "symfony/console": "^6.4|^7.0",
- "symfony/dependency-injection": "^6.4|^7.0",
- "symfony/finder": "^6.4|^7.0",
+ "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": "^6.4|^7.0",
- "symfony/intl": "^6.4|^7.0",
+ "symfony/http-kernel": "^7.4|^8.0",
+ "symfony/intl": "^7.4|^8.0",
"symfony/polyfill-intl-icu": "^1.21",
- "symfony/routing": "^6.4|^7.0",
+ "symfony/routing": "^7.4|^8.0",
"symfony/service-contracts": "^2.5|^3",
- "symfony/yaml": "^6.4|^7.0"
+ "symfony/yaml": "^7.4|^8.0"
},
"type": "library",
"autoload": {
@@ -7265,7 +8218,7 @@
"description": "Provides tools to internationalize your application",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/translation/tree/v7.3.3"
+ "source": "https://github.com/symfony/translation/tree/v8.1.0"
},
"funding": [
{
@@ -7285,20 +8238,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-01T21:02:37+00:00"
+ "time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/translation-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation-contracts.git",
- "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d"
+ "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d",
- "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d",
+ "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d",
+ "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d",
"shasum": ""
},
"require": {
@@ -7311,7 +8264,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -7347,7 +8300,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -7358,25 +8311,111 @@
"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": "2024-09-27T08:32:26+00:00"
+ "time": "2026-01-05T13:30:16+00:00"
},
{
- "name": "symfony/uid",
- "version": "v7.3.1",
+ "name": "symfony/type-info",
+ "version": "v8.1.0",
"source": {
"type": "git",
- "url": "https://github.com/symfony/uid.git",
- "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb"
+ "url": "https://github.com/symfony/type-info.git",
+ "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/uid/zipball/a69f69f3159b852651a6bf45a9fdd149520525bb",
- "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb",
+ "url": "https://api.github.com/repos/symfony/type-info/zipball/9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7",
+ "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4.1",
+ "psr/container": "^1.1|^2.0"
+ },
+ "conflict": {
+ "phpstan/phpdoc-parser": "<1.30"
+ },
+ "require-dev": {
+ "phpstan/phpdoc-parser": "^1.30|^2.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\TypeInfo\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mathias Arlaud",
+ "email": "mathias.arlaud@gmail.com"
+ },
+ {
+ "name": "Baptiste LEDUC",
+ "email": "baptiste.leduc@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Extracts PHP types information.",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "PHPStan",
+ "phpdoc",
+ "symfony",
+ "type"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/type-info/tree/v8.1.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-29T05:06:50+00:00"
+ },
+ {
+ "name": "symfony/uid",
+ "version": "v7.4.9",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/uid.git",
+ "reference": "2676b524340abcfe4d6151ec698463cebafee439"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439",
+ "reference": "2676b524340abcfe4d6151ec698463cebafee439",
"shasum": ""
},
"require": {
@@ -7384,7 +8423,7 @@
"symfony/polyfill-uuid": "^1.15"
},
"require-dev": {
- "symfony/console": "^6.4|^7.0"
+ "symfony/console": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -7421,7 +8460,7 @@
"uuid"
],
"support": {
- "source": "https://github.com/symfony/uid/tree/v7.3.1"
+ "source": "https://github.com/symfony/uid/tree/v7.4.9"
},
"funding": [
{
@@ -7432,25 +8471,29 @@
"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": "2025-06-27T19:55:54+00:00"
+ "time": "2026-04-30T15:19:22+00:00"
},
{
"name": "symfony/var-dumper",
- "version": "v7.3.3",
+ "version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-dumper.git",
- "reference": "34d8d4c4b9597347306d1ec8eb4e1319b1e6986f"
+ "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/var-dumper/zipball/34d8d4c4b9597347306d1ec8eb4e1319b1e6986f",
- "reference": "34d8d4c4b9597347306d1ec8eb4e1319b1e6986f",
+ "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd",
+ "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd",
"shasum": ""
},
"require": {
@@ -7462,10 +8505,10 @@
"symfony/console": "<6.4"
},
"require-dev": {
- "symfony/console": "^6.4|^7.0",
- "symfony/http-kernel": "^6.4|^7.0",
- "symfony/process": "^6.4|^7.0",
- "symfony/uid": "^6.4|^7.0",
+ "symfony/console": "^6.4|^7.0|^8.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/process": "^6.4|^7.0|^8.0",
+ "symfony/uid": "^6.4|^7.0|^8.0",
"twig/twig": "^3.12"
},
"bin": [
@@ -7504,7 +8547,7 @@
"dump"
],
"support": {
- "source": "https://github.com/symfony/var-dumper/tree/v7.3.3"
+ "source": "https://github.com/symfony/var-dumper/tree/v7.4.8"
},
"funding": [
{
@@ -7524,27 +8567,170 @@
"type": "tidelift"
}
],
- "time": "2025-08-13T11:49:31+00:00"
+ "time": "2026-03-30T13:44:50+00:00"
},
{
- "name": "tijsverkoyen/css-to-inline-styles",
- "version": "v2.3.0",
+ "name": "thecodingmachine/safe",
+ "version": "v3.4.0",
"source": {
"type": "git",
- "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git",
- "reference": "0d72ac1c00084279c1816675284073c5a337c20d"
+ "url": "https://github.com/thecodingmachine/safe.git",
+ "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d",
- "reference": "0d72ac1c00084279c1816675284073c5a337c20d",
+ "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
+ "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "php-parallel-lint/php-parallel-lint": "^1.4",
+ "phpstan/phpstan": "^2",
+ "phpunit/phpunit": "^10",
+ "squizlabs/php_codesniffer": "^3.2"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "lib/special_cases.php",
+ "generated/apache.php",
+ "generated/apcu.php",
+ "generated/array.php",
+ "generated/bzip2.php",
+ "generated/calendar.php",
+ "generated/classobj.php",
+ "generated/com.php",
+ "generated/cubrid.php",
+ "generated/curl.php",
+ "generated/datetime.php",
+ "generated/dir.php",
+ "generated/eio.php",
+ "generated/errorfunc.php",
+ "generated/exec.php",
+ "generated/fileinfo.php",
+ "generated/filesystem.php",
+ "generated/filter.php",
+ "generated/fpm.php",
+ "generated/ftp.php",
+ "generated/funchand.php",
+ "generated/gettext.php",
+ "generated/gmp.php",
+ "generated/gnupg.php",
+ "generated/hash.php",
+ "generated/ibase.php",
+ "generated/ibmDb2.php",
+ "generated/iconv.php",
+ "generated/image.php",
+ "generated/imap.php",
+ "generated/info.php",
+ "generated/inotify.php",
+ "generated/json.php",
+ "generated/ldap.php",
+ "generated/libxml.php",
+ "generated/lzf.php",
+ "generated/mailparse.php",
+ "generated/mbstring.php",
+ "generated/misc.php",
+ "generated/mysql.php",
+ "generated/mysqli.php",
+ "generated/network.php",
+ "generated/oci8.php",
+ "generated/opcache.php",
+ "generated/openssl.php",
+ "generated/outcontrol.php",
+ "generated/pcntl.php",
+ "generated/pcre.php",
+ "generated/pgsql.php",
+ "generated/posix.php",
+ "generated/ps.php",
+ "generated/pspell.php",
+ "generated/readline.php",
+ "generated/rnp.php",
+ "generated/rpminfo.php",
+ "generated/rrd.php",
+ "generated/sem.php",
+ "generated/session.php",
+ "generated/shmop.php",
+ "generated/sockets.php",
+ "generated/sodium.php",
+ "generated/solr.php",
+ "generated/spl.php",
+ "generated/sqlsrv.php",
+ "generated/ssdeep.php",
+ "generated/ssh2.php",
+ "generated/stream.php",
+ "generated/strings.php",
+ "generated/swoole.php",
+ "generated/uodbc.php",
+ "generated/uopz.php",
+ "generated/url.php",
+ "generated/var.php",
+ "generated/xdiff.php",
+ "generated/xml.php",
+ "generated/xmlrpc.php",
+ "generated/yaml.php",
+ "generated/yaz.php",
+ "generated/zip.php",
+ "generated/zlib.php"
+ ],
+ "classmap": [
+ "lib/DateTime.php",
+ "lib/DateTimeImmutable.php",
+ "lib/Exceptions/",
+ "generated/Exceptions/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHP core functions that throw exceptions instead of returning FALSE on error",
+ "support": {
+ "issues": "https://github.com/thecodingmachine/safe/issues",
+ "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/OskarStark",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/shish",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/silasjoisten",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/staabm",
+ "type": "github"
+ }
+ ],
+ "time": "2026-02-04T18:08:13+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"
+ "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^2.0",
@@ -7577,32 +8763,32 @@
"homepage": "https://github.com/tijsverkoyen/CssToInlineStyles",
"support": {
"issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues",
- "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0"
+ "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0"
},
- "time": "2024-12-21T16:25:41+00:00"
+ "time": "2025-12-02T11:56:42+00:00"
},
{
"name": "vlucas/phpdotenv",
- "version": "v5.6.2",
+ "version": "v5.6.3",
"source": {
"type": "git",
"url": "https://github.com/vlucas/phpdotenv.git",
- "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af"
+ "reference": "955e7815d677a3eaa7075231212f2110983adecc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af",
- "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af",
+ "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc",
+ "reference": "955e7815d677a3eaa7075231212f2110983adecc",
"shasum": ""
},
"require": {
"ext-pcre": "*",
- "graham-campbell/result-type": "^1.1.3",
+ "graham-campbell/result-type": "^1.1.4",
"php": "^7.2.5 || ^8.0",
- "phpoption/phpoption": "^1.9.3",
- "symfony/polyfill-ctype": "^1.24",
- "symfony/polyfill-mbstring": "^1.24",
- "symfony/polyfill-php80": "^1.24"
+ "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",
@@ -7651,7 +8837,7 @@
],
"support": {
"issues": "https://github.com/vlucas/phpdotenv/issues",
- "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2"
+ "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3"
},
"funding": [
{
@@ -7663,27 +8849,27 @@
"type": "tidelift"
}
],
- "time": "2025-04-30T23:37:27+00:00"
+ "time": "2025-12-27T19:49:13+00:00"
},
{
"name": "voku/portable-ascii",
- "version": "2.0.3",
+ "version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/voku/portable-ascii.git",
- "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d"
+ "reference": "8e1051fe39379367aecf014f41744ce7539a856f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d",
- "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d",
+ "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f",
+ "reference": "8e1051fe39379367aecf014f41744ce7539a856f",
"shasum": ""
},
"require": {
- "php": ">=7.0.0"
+ "php": ">=7.1.0"
},
"require-dev": {
- "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0"
+ "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5"
},
"suggest": {
"ext-intl": "Use Intl for transliterator_transliterate() support"
@@ -7713,7 +8899,7 @@
],
"support": {
"issues": "https://github.com/voku/portable-ascii/issues",
- "source": "https://github.com/voku/portable-ascii/tree/2.0.3"
+ "source": "https://github.com/voku/portable-ascii/tree/2.1.1"
},
"funding": [
{
@@ -7737,37 +8923,198 @@
"type": "tidelift"
}
],
- "time": "2024-11-21T01:49:47+00:00"
+ "time": "2026-04-26T05:33:54+00:00"
},
{
- "name": "webmozart/assert",
- "version": "1.11.0",
+ "name": "web-auth/cose-lib",
+ "version": "4.5.2",
"source": {
"type": "git",
- "url": "https://github.com/webmozarts/assert.git",
- "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991"
+ "url": "https://github.com/web-auth/cose-lib.git",
+ "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991",
- "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991",
+ "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/5b38660f90070a8e45f3dbc9528ade3b608dd77d",
+ "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d",
+ "shasum": ""
+ },
+ "require": {
+ "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
+ "ext-json": "*",
+ "ext-openssl": "*",
+ "php": ">=8.1",
+ "spomky-labs/pki-framework": "^1.0"
+ },
+ "require-dev": {
+ "spomky-labs/cbor-php": "^3.2.2"
+ },
+ "suggest": {
+ "ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension",
+ "ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension",
+ "spomky-labs/cbor-php": "For COSE Signature support"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Cose\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Florent Morselli",
+ "homepage": "https://github.com/Spomky"
+ },
+ {
+ "name": "All contributors",
+ "homepage": "https://github.com/web-auth/cose/contributors"
+ }
+ ],
+ "description": "CBOR Object Signing and Encryption (COSE) For PHP",
+ "homepage": "https://github.com/web-auth",
+ "keywords": [
+ "COSE",
+ "RFC8152"
+ ],
+ "support": {
+ "issues": "https://github.com/web-auth/cose-lib/issues",
+ "source": "https://github.com/web-auth/cose-lib/tree/4.5.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Spomky",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/FlorentMorselli",
+ "type": "patreon"
+ }
+ ],
+ "time": "2026-05-03T09:49:50+00:00"
+ },
+ {
+ "name": "web-auth/webauthn-lib",
+ "version": "5.3.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/web-auth/webauthn-lib.git",
+ "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f",
+ "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-openssl": "*",
+ "paragonie/constant_time_encoding": "^2.6|^3.0",
+ "php": ">=8.2",
+ "phpdocumentor/reflection-docblock": "^5.3|^6.0",
+ "psr/clock": "^1.0",
+ "psr/event-dispatcher": "^1.0",
+ "psr/log": "^1.0|^2.0|^3.0",
+ "spomky-labs/cbor-php": "^3.0",
+ "spomky-labs/pki-framework": "^1.0",
+ "symfony/clock": "^6.4|^7.0|^8.0",
+ "symfony/deprecation-contracts": "^3.2",
+ "symfony/property-access": "^6.4|^7.0|^8.0",
+ "symfony/property-info": "^6.4|^7.0|^8.0",
+ "symfony/serializer": "^6.4|^7.0|^8.0",
+ "symfony/uid": "^6.4|^7.0|^8.0",
+ "web-auth/cose-lib": "^4.2.3"
+ },
+ "suggest": {
+ "psr/log-implementation": "Recommended to receive logs from the library",
+ "symfony/event-dispatcher": "Recommended to use dispatched events",
+ "web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/web-auth/webauthn-framework",
+ "name": "web-auth/webauthn-framework"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Webauthn\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Florent Morselli",
+ "homepage": "https://github.com/Spomky"
+ },
+ {
+ "name": "All contributors",
+ "homepage": "https://github.com/web-auth/webauthn-library/contributors"
+ }
+ ],
+ "description": "FIDO2/Webauthn Support For PHP",
+ "homepage": "https://github.com/web-auth",
+ "keywords": [
+ "FIDO2",
+ "fido",
+ "webauthn"
+ ],
+ "support": {
+ "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Spomky",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/FlorentMorselli",
+ "type": "patreon"
+ }
+ ],
+ "time": "2026-05-31T15:00:08+00:00"
+ },
+ {
+ "name": "webmozart/assert",
+ "version": "2.4.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/webmozarts/assert.git",
+ "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70",
+ "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70",
"shasum": ""
},
"require": {
"ext-ctype": "*",
- "php": "^7.2 || ^8.0"
+ "ext-date": "*",
+ "ext-filter": "*",
+ "php": "^8.2"
},
- "conflict": {
- "phpstan/phpstan": "<0.12.20",
- "vimeo/psalm": "<4.6.1 || 4.6.2"
- },
- "require-dev": {
- "phpunit/phpunit": "^8.5.13"
+ "suggest": {
+ "ext-intl": "",
+ "ext-simplexml": "",
+ "ext-spl": ""
},
"type": "library",
"extra": {
+ "psalm": {
+ "pluginClass": "Webmozart\\Assert\\PsalmPlugin"
+ },
"branch-alias": {
- "dev-master": "1.10-dev"
+ "dev-master": "2.0-dev",
+ "dev-feature/2-0": "2.0-dev"
}
},
"autoload": {
@@ -7783,6 +9130,10 @@
{
"name": "Bernhard Schussek",
"email": "bschussek@gmail.com"
+ },
+ {
+ "name": "Woody Gilk",
+ "email": "woody.gilk@gmail.com"
}
],
"description": "Assertions to validate method input/output with nice error messages.",
@@ -7793,9 +9144,9 @@
],
"support": {
"issues": "https://github.com/webmozarts/assert/issues",
- "source": "https://github.com/webmozarts/assert/tree/1.11.0"
+ "source": "https://github.com/webmozarts/assert/tree/2.4.1"
},
- "time": "2022-06-03T18:03:27+00:00"
+ "time": "2026-06-15T15:31:57+00:00"
},
{
"name": "wikimedia/composer-merge-plugin",
@@ -8042,37 +9393,38 @@
},
{
"name": "laravel/pail",
- "version": "v1.2.3",
+ "version": "v1.2.7",
"source": {
"type": "git",
"url": "https://github.com/laravel/pail.git",
- "reference": "8cc3d575c1f0e57eeb923f366a37528c50d2385a"
+ "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/pail/zipball/8cc3d575c1f0e57eeb923f366a37528c50d2385a",
- "reference": "8cc3d575c1f0e57eeb923f366a37528c50d2385a",
+ "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa",
+ "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
- "illuminate/console": "^10.24|^11.0|^12.0",
- "illuminate/contracts": "^10.24|^11.0|^12.0",
- "illuminate/log": "^10.24|^11.0|^12.0",
- "illuminate/process": "^10.24|^11.0|^12.0",
- "illuminate/support": "^10.24|^11.0|^12.0",
+ "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"
+ "symfony/console": "^6.0|^7.0|^8.0"
},
"require-dev": {
- "laravel/framework": "^10.24|^11.0|^12.0",
+ "laravel/framework": "^10.24|^11.0|^12.0|^13.0",
"laravel/pint": "^1.13",
- "orchestra/testbench-core": "^8.13|^9.0|^10.0",
- "pestphp/pest": "^2.20|^3.0",
- "pestphp/pest-plugin-type-coverage": "^2.3|^3.0",
+ "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"
+ "symfony/var-dumper": "^6.3|^7.0|^8.0",
+ "symfony/yaml": "^6.3|^7.0|^8.0"
},
"type": "library",
"extra": {
@@ -8117,20 +9469,20 @@
"issues": "https://github.com/laravel/pail/issues",
"source": "https://github.com/laravel/pail"
},
- "time": "2025-06-05T13:55:57+00:00"
+ "time": "2026-05-20T22:24:57+00:00"
},
{
"name": "laravel/pint",
- "version": "v1.25.1",
+ "version": "v1.29.3",
"source": {
"type": "git",
"url": "https://github.com/laravel/pint.git",
- "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9"
+ "reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/pint/zipball/5016e263f95d97670d71b9a987bd8996ade6d8d9",
- "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9",
+ "url": "https://api.github.com/repos/laravel/pint/zipball/da1d1111a6aa2e082d2a388b194afe1ba0a05d14",
+ "reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14",
"shasum": ""
},
"require": {
@@ -8141,13 +9493,14 @@
"php": "^8.2.0"
},
"require-dev": {
- "friendsofphp/php-cs-fixer": "^3.87.2",
- "illuminate/view": "^11.46.0",
- "larastan/larastan": "^3.7.1",
- "laravel-zero/framework": "^11.45.0",
+ "friendsofphp/php-cs-fixer": "^3.95.8",
+ "illuminate/view": "^12.62.0",
+ "larastan/larastan": "^3.10.0",
+ "laravel-zero/framework": "^12.1.0",
+ "laravel/agent-detector": "^2.0.2",
"mockery/mockery": "^1.6.12",
- "nunomaduro/termwind": "^2.3.1",
- "pestphp/pest": "^2.36.0"
+ "nunomaduro/termwind": "^2.4.0",
+ "pestphp/pest": "^3.8.6"
},
"bin": [
"builds/pint"
@@ -8173,6 +9526,7 @@
"description": "An opinionated code formatter for PHP.",
"homepage": "https://laravel.com",
"keywords": [
+ "dev",
"format",
"formatter",
"lint",
@@ -8183,33 +9537,33 @@
"issues": "https://github.com/laravel/pint/issues",
"source": "https://github.com/laravel/pint"
},
- "time": "2025-09-19T02:57:12+00:00"
+ "time": "2026-06-16T15:34:04+00:00"
},
{
"name": "laravel/sail",
- "version": "v1.45.0",
+ "version": "v1.63.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/sail.git",
- "reference": "019a2933ff4a9199f098d4259713f9bc266a874e"
+ "reference": "51bbce3f803c1d386cabbb44e618c955a12ff5fc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/sail/zipball/019a2933ff4a9199f098d4259713f9bc266a874e",
- "reference": "019a2933ff4a9199f098d4259713f9bc266a874e",
+ "url": "https://api.github.com/repos/laravel/sail/zipball/51bbce3f803c1d386cabbb44e618c955a12ff5fc",
+ "reference": "51bbce3f803c1d386cabbb44e618c955a12ff5fc",
"shasum": ""
},
"require": {
- "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0",
- "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0",
- "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0",
+ "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0",
+ "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0",
+ "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0",
"php": "^8.0",
- "symfony/console": "^6.0|^7.0",
- "symfony/yaml": "^6.0|^7.0"
+ "symfony/console": "^6.0|^7.0|^8.0",
+ "symfony/yaml": "^6.0|^7.0|^8.0"
},
"require-dev": {
- "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0",
- "phpstan/phpstan": "^1.10"
+ "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0",
+ "phpstan/phpstan": "^2.0"
},
"bin": [
"bin/sail"
@@ -8246,36 +9600,36 @@
"issues": "https://github.com/laravel/sail/issues",
"source": "https://github.com/laravel/sail"
},
- "time": "2025-08-25T19:28:31+00:00"
+ "time": "2026-06-18T08:54:14+00:00"
},
{
"name": "laravel/telescope",
- "version": "v5.11.4",
+ "version": "v5.20.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/telescope.git",
- "reference": "8b7bd77857d6b1b8c9362560cde74911cee68ab8"
+ "reference": "38ec6e6006a67e05e0c476c5f8ef3550b72e43d8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/telescope/zipball/8b7bd77857d6b1b8c9362560cde74911cee68ab8",
- "reference": "8b7bd77857d6b1b8c9362560cde74911cee68ab8",
+ "url": "https://api.github.com/repos/laravel/telescope/zipball/38ec6e6006a67e05e0c476c5f8ef3550b72e43d8",
+ "reference": "38ec6e6006a67e05e0c476c5f8ef3550b72e43d8",
"shasum": ""
},
"require": {
"ext-json": "*",
- "laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0",
+ "laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0",
+ "laravel/sentinel": "^1.0",
"php": "^8.0",
- "symfony/console": "^5.3|^6.0|^7.0",
- "symfony/var-dumper": "^5.0|^6.0|^7.0"
+ "symfony/console": "^5.3|^6.0|^7.0|^8.0",
+ "symfony/var-dumper": "^5.0|^6.0|^7.0|^8.0"
},
"require-dev": {
"ext-gd": "*",
"guzzlehttp/guzzle": "^6.0|^7.0",
- "laravel/octane": "^1.4|^2.0|dev-develop",
- "orchestra/testbench": "^6.40|^7.37|^8.17|^9.0|^10.0",
- "phpstan/phpstan": "^1.10",
- "phpunit/phpunit": "^9.0|^10.5|^11.5"
+ "laravel/octane": "^1.4|^2.0",
+ "orchestra/testbench": "^6.47.1|^7.55|^8.36|^9.15|^10.8|^11.0",
+ "phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
@@ -8313,9 +9667,9 @@
],
"support": {
"issues": "https://github.com/laravel/telescope/issues",
- "source": "https://github.com/laravel/telescope/tree/v5.11.4"
+ "source": "https://github.com/laravel/telescope/tree/v5.20.0"
},
- "time": "2025-09-12T14:36:07+00:00"
+ "time": "2026-04-06T12:52:26+00:00"
},
{
"name": "mockery/mockery",
@@ -8462,39 +9816,36 @@
},
{
"name": "nunomaduro/collision",
- "version": "v8.8.2",
+ "version": "v8.9.4",
"source": {
"type": "git",
"url": "https://github.com/nunomaduro/collision.git",
- "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb"
+ "reference": "716af8f95a470e9094cfca09ed897b023be191a5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nunomaduro/collision/zipball/60207965f9b7b7a4ce15a0f75d57f9dadb105bdb",
- "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb",
+ "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5",
+ "reference": "716af8f95a470e9094cfca09ed897b023be191a5",
"shasum": ""
},
"require": {
- "filp/whoops": "^2.18.1",
- "nunomaduro/termwind": "^2.3.1",
+ "filp/whoops": "^2.18.4",
+ "nunomaduro/termwind": "^2.4.0",
"php": "^8.2.0",
- "symfony/console": "^7.3.0"
+ "symfony/console": "^7.4.8 || ^8.0.8"
},
"conflict": {
- "laravel/framework": "<11.44.2 || >=13.0.0",
- "phpunit/phpunit": "<11.5.15 || >=13.0.0"
+ "laravel/framework": "<11.48.0 || >=14.0.0",
+ "phpunit/phpunit": "<11.5.50 || >=14.0.0"
},
"require-dev": {
- "brianium/paratest": "^7.8.3",
- "larastan/larastan": "^3.4.2",
- "laravel/framework": "^11.44.2 || ^12.18",
- "laravel/pint": "^1.22.1",
- "laravel/sail": "^1.43.1",
- "laravel/sanctum": "^4.1.1",
- "laravel/tinker": "^2.10.1",
- "orchestra/testbench-core": "^9.12.0 || ^10.4",
- "pestphp/pest": "^3.8.2",
- "sebastian/environment": "^7.2.1 || ^8.0"
+ "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": {
@@ -8557,7 +9908,7 @@
"type": "patreon"
}
],
- "time": "2025-06-25T02:12:12+00:00"
+ "time": "2026-04-21T14:04:20+00:00"
},
{
"name": "phar-io/manifest",
@@ -8679,35 +10030,35 @@
},
{
"name": "phpunit/php-code-coverage",
- "version": "11.0.11",
+ "version": "11.0.12",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
- "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4"
+ "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4",
- "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56",
+ "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-libxml": "*",
"ext-xmlwriter": "*",
- "nikic/php-parser": "^5.4.0",
+ "nikic/php-parser": "^5.7.0",
"php": ">=8.2",
"phpunit/php-file-iterator": "^5.1.0",
"phpunit/php-text-template": "^4.0.1",
"sebastian/code-unit-reverse-lookup": "^4.0.1",
"sebastian/complexity": "^4.0.1",
- "sebastian/environment": "^7.2.0",
+ "sebastian/environment": "^7.2.1",
"sebastian/lines-of-code": "^3.0.1",
"sebastian/version": "^5.0.2",
- "theseer/tokenizer": "^1.2.3"
+ "theseer/tokenizer": "^1.3.1"
},
"require-dev": {
- "phpunit/phpunit": "^11.5.2"
+ "phpunit/phpunit": "^11.5.46"
},
"suggest": {
"ext-pcov": "PHP extension that provides line coverage",
@@ -8745,7 +10096,7 @@
"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/11.0.11"
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12"
},
"funding": [
{
@@ -8765,32 +10116,32 @@
"type": "tidelift"
}
],
- "time": "2025-08-27T14:37:49+00:00"
+ "time": "2025-12-24T07:01:01+00:00"
},
{
"name": "phpunit/php-file-iterator",
- "version": "5.1.0",
+ "version": "5.1.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-file-iterator.git",
- "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6"
+ "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6",
- "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903",
+ "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903",
"shasum": ""
},
"require": {
"php": ">=8.2"
},
"require-dev": {
- "phpunit/phpunit": "^11.0"
+ "phpunit/phpunit": "^11.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "5.0-dev"
+ "dev-main": "5.1-dev"
}
},
"autoload": {
@@ -8818,15 +10169,27 @@
"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/5.1.0"
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.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": "2024-08-27T05:02:59+00:00"
+ "time": "2026-02-02T13:52:54+00:00"
},
{
"name": "phpunit/php-invoker",
@@ -9014,16 +10377,16 @@
},
{
"name": "phpunit/phpunit",
- "version": "11.5.39",
+ "version": "11.5.55",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "ad5597f79d8489d2870073ac0bc0dd0ad1fa9931"
+ "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ad5597f79d8489d2870073ac0bc0dd0ad1fa9931",
- "reference": "ad5597f79d8489d2870073ac0bc0dd0ad1fa9931",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00",
+ "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00",
"shasum": ""
},
"require": {
@@ -9037,19 +10400,20 @@
"phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1",
"php": ">=8.2",
- "phpunit/php-code-coverage": "^11.0.11",
- "phpunit/php-file-iterator": "^5.1.0",
+ "phpunit/php-code-coverage": "^11.0.12",
+ "phpunit/php-file-iterator": "^5.1.1",
"phpunit/php-invoker": "^5.0.1",
"phpunit/php-text-template": "^4.0.1",
"phpunit/php-timer": "^7.0.1",
"sebastian/cli-parser": "^3.0.2",
"sebastian/code-unit": "^3.0.3",
- "sebastian/comparator": "^6.3.2",
+ "sebastian/comparator": "^6.3.3",
"sebastian/diff": "^6.0.2",
"sebastian/environment": "^7.2.1",
- "sebastian/exporter": "^6.3.0",
+ "sebastian/exporter": "^6.3.2",
"sebastian/global-state": "^7.0.2",
"sebastian/object-enumerator": "^6.0.1",
+ "sebastian/recursion-context": "^6.0.3",
"sebastian/type": "^5.1.3",
"sebastian/version": "^5.0.2",
"staabm/side-effects-detector": "^1.0.5"
@@ -9095,7 +10459,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.39"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55"
},
"funding": [
{
@@ -9119,7 +10483,7 @@
"type": "tidelift"
}
],
- "time": "2025-09-14T06:20:41+00:00"
+ "time": "2026-02-18T12:37:06+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -9293,16 +10657,16 @@
},
{
"name": "sebastian/comparator",
- "version": "6.3.2",
+ "version": "6.3.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
- "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8"
+ "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8",
- "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9",
+ "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9",
"shasum": ""
},
"require": {
@@ -9361,7 +10725,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues",
"security": "https://github.com/sebastianbergmann/comparator/security/policy",
- "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2"
+ "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3"
},
"funding": [
{
@@ -9381,7 +10745,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-10T08:07:46+00:00"
+ "time": "2026-01-24T09:26:40+00:00"
},
{
"name": "sebastian/complexity",
@@ -9586,16 +10950,16 @@
},
{
"name": "sebastian/exporter",
- "version": "6.3.0",
+ "version": "6.3.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/exporter.git",
- "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3"
+ "reference": "70a298763b40b213ec087c51c739efcaa90bcd74"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/3473f61172093b2da7de1fb5782e1f24cc036dc3",
- "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74",
+ "reference": "70a298763b40b213ec087c51c739efcaa90bcd74",
"shasum": ""
},
"require": {
@@ -9609,7 +10973,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "6.1-dev"
+ "dev-main": "6.3-dev"
}
},
"autoload": {
@@ -9652,15 +11016,27 @@
"support": {
"issues": "https://github.com/sebastianbergmann/exporter/issues",
"security": "https://github.com/sebastianbergmann/exporter/security/policy",
- "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.0"
+ "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.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/exporter",
+ "type": "tidelift"
}
],
- "time": "2024-12-05T09:17:50+00:00"
+ "time": "2025-09-24T06:12:51+00:00"
},
{
"name": "sebastian/global-state",
@@ -10149,28 +11525,28 @@
},
{
"name": "symfony/yaml",
- "version": "v7.3.3",
+ "version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
- "reference": "d4f4a66866fe2451f61296924767280ab5732d9d"
+ "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/yaml/zipball/d4f4a66866fe2451f61296924767280ab5732d9d",
- "reference": "d4f4a66866fe2451f61296924767280ab5732d9d",
+ "url": "https://api.github.com/repos/symfony/yaml/zipball/efb42bd2c6f4f3ccfd4683583449938b5fc146b0",
+ "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0",
"shasum": ""
},
"require": {
- "php": ">=8.2",
- "symfony/deprecation-contracts": "^2.5|^3.0",
+ "php": ">=8.4.1",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
- "symfony/console": "<6.4"
+ "symfony/console": "<7.4"
},
"require-dev": {
- "symfony/console": "^6.4|^7.0"
+ "symfony/console": "^7.4|^8.0",
+ "yaml/yaml-test-suite": "*"
},
"bin": [
"Resources/bin/yaml-lint"
@@ -10201,7 +11577,7 @@
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/yaml/tree/v7.3.3"
+ "source": "https://github.com/symfony/yaml/tree/v8.1.0"
},
"funding": [
{
@@ -10221,20 +11597,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-27T11:34:33+00:00"
+ "time": "2026-05-29T05:06:50+00:00"
},
{
"name": "theseer/tokenizer",
- "version": "1.2.3",
+ "version": "1.3.1",
"source": {
"type": "git",
"url": "https://github.com/theseer/tokenizer.git",
- "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2"
+ "reference": "b7489ce515e168639d17feec34b8847c326b0b3c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
- "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c",
+ "reference": "b7489ce515e168639d17feec34b8847c326b0b3c",
"shasum": ""
},
"require": {
@@ -10263,7 +11639,7 @@
"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/1.2.3"
+ "source": "https://github.com/theseer/tokenizer/tree/1.3.1"
},
"funding": [
{
@@ -10271,7 +11647,7 @@
"type": "github"
}
],
- "time": "2024-03-03T12:36:25+00:00"
+ "time": "2025-11-17T20:03:58+00:00"
}
],
"aliases": [],
@@ -10283,5 +11659,5 @@
"php": "^8.2"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/be/config/auth.php b/be/config/auth.php
index 3f1a624..59abb17 100644
--- a/be/config/auth.php
+++ b/be/config/auth.php
@@ -122,4 +122,15 @@ return [
'max_attempts' => (int) env('EMAIL_VERIFICATION_OTP_MAX_ATTEMPTS', 5),
],
+ /*
+ |--------------------------------------------------------------------------
+ | Password Reset OTP
+ |--------------------------------------------------------------------------
+ */
+
+ 'password_reset' => [
+ 'expiry_minutes' => (int) env('PASSWORD_RESET_OTP_EXPIRY_MINUTES', 10),
+ 'max_attempts' => (int) env('PASSWORD_RESET_OTP_MAX_ATTEMPTS', 5),
+ ],
+
];
diff --git a/be/config/browsershot.php b/be/config/browsershot.php
new file mode 100644
index 0000000..ecf85f0
--- /dev/null
+++ b/be/config/browsershot.php
@@ -0,0 +1,36 @@
+ env('BROWSERSHOT_NODE_BINARY'),
+
+ 'npm_binary' => env('BROWSERSHOT_NPM_BINARY'),
+
+ 'node_module_path' => env('BROWSERSHOT_NODE_MODULE_PATH', base_path()),
+
+ 'chrome_path' => env('BROWSERSHOT_CHROME_PATH'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Chrome sandbox
+ |--------------------------------------------------------------------------
+ |
+ | Disable sandbox in Docker/Linux containers where Chrome cannot run sandboxed.
+ |
+ */
+
+ 'no_sandbox' => env('BROWSERSHOT_NO_SANDBOX', false),
+
+ 'timeout' => (int) env('BROWSERSHOT_TIMEOUT', 60),
+
+];
diff --git a/be/config/cors.php b/be/config/cors.php
index 28c3045..715d028 100644
--- a/be/config/cors.php
+++ b/be/config/cors.php
@@ -20,13 +20,15 @@ return [
'allowed_methods' => ['*'],
'allowed_origins' => [
+ 'http://localhost',
+ 'http://127.0.0.1',
'http://localhost:5173',
'http://127.0.0.1:5173',
'http://20.11.32.49',
'http://20.11.32.50',
],
- 'allowed_origins_patterns' => ['*'],
+ 'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
diff --git a/be/config/letter.php b/be/config/letter.php
new file mode 100644
index 0000000..dad463b
--- /dev/null
+++ b/be/config/letter.php
@@ -0,0 +1,85 @@
+ [
+ 'format' => 'A4',
+ 'width_mm' => 210,
+ 'height_mm' => 297,
+ 'margin_top_mm' => 20,
+ 'margin_right_mm' => 10,
+ 'margin_bottom_mm' => 1,
+ 'margin_left_mm' => 10,
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Content
+ |--------------------------------------------------------------------------
+ */
+
+ 'content' => [
+ 'padding_x_mm' => 10,
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Typography
+ |--------------------------------------------------------------------------
+ */
+
+ 'typography' => [
+ 'font_family' => 'Arial, Helvetica, sans-serif',
+ 'font_size' => '11pt',
+ 'line_height' => 1.5,
+ 'body_color' => '#1a1a1a',
+ 'signatory_title_size' => '10pt',
+ 'signatory_title_color' => '#333333',
+ 'footer_font_size' => '10pt',
+ 'footer_line_height' => 1.45,
+ 'letterhead_registration_size' => '9pt',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Spacing (pixels in CSS)
+ |--------------------------------------------------------------------------
+ */
+
+ 'spacing' => [
+ 'letterhead_margin_bottom' => 20,
+ 'meta_margin_bottom' => 20,
+ 'recipient_margin_bottom' => 16,
+ 'subject_margin_bottom' => 16,
+ 'body_margin_bottom' => 20,
+ 'body_paragraph_margin_bottom' => 10,
+ 'signature_block_margin_bottom' => 20,
+ 'signature_company_margin_bottom' => 16,
+ 'signature_space_height' => 48,
+ 'logo_height' => 80,
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Brand colours
+ |--------------------------------------------------------------------------
+ */
+
+ 'colors' => [
+ 'blue' => '#0e579b',
+ 'orange' => '#d35400',
+ 'white' => '#ffffff',
+ 'black' => '#000000',
+ ],
+
+];
diff --git a/be/config/mail.php b/be/config/mail.php
index 522b284..c875aaf 100644
--- a/be/config/mail.php
+++ b/be/config/mail.php
@@ -115,4 +115,15 @@ return [
'name' => env('MAIL_FROM_NAME', 'Example'),
],
+ /*
+ |--------------------------------------------------------------------------
+ | Email Logo
+ |--------------------------------------------------------------------------
+ |
+ | Path to the logo embedded in transactional email templates.
+ |
+ */
+
+ 'logo_path' => env('MAIL_LOGO_PATH', public_path('images/logo/logo-kopkb-letterhead.svg')),
+
];
diff --git a/be/database/migrations/2025_08_02_061334_create_documents_table.php b/be/database/migrations/2026_06_14_061334_create_documents_table.php
similarity index 78%
rename from be/database/migrations/2025_08_02_061334_create_documents_table.php
rename to be/database/migrations/2026_06_14_061334_create_documents_table.php
index 7530ea0..58a8006 100644
--- a/be/database/migrations/2025_08_02_061334_create_documents_table.php
+++ b/be/database/migrations/2026_06_14_061334_create_documents_table.php
@@ -13,19 +13,16 @@ return new class extends Migration
{
Schema::create('documents', function (Blueprint $table) {
$table->uuid('id')->primary();
- $table->string('documentable_type');
- $table->unsignedBigInteger('documentable_id');
+ $table->uuidMorphs('documentable');
$table->string('name');
$table->string('path', 500);
$table->unsignedBigInteger('file_size')->nullable();
$table->string('mime_type', 100)->nullable();
$table->string('type', 100)->nullable()->default('general')->index();
$table->text('description')->nullable();
- $table->uuid('uploaded_by')->foreign('users')->onDelete('restrict');
+ $table->foreignUuid('uploaded_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->softDeletes();
-
- $table->index(['documentable_type', 'documentable_id']);
});
}
diff --git a/be/database/migrations/2026_06_25_120000_create_password_reset_otps_table.php b/be/database/migrations/2026_06_25_120000_create_password_reset_otps_table.php
new file mode 100644
index 0000000..3a7409a
--- /dev/null
+++ b/be/database/migrations/2026_06_25_120000_create_password_reset_otps_table.php
@@ -0,0 +1,33 @@
+uuid('id')->primary();
+ $table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete();
+ $table->string('code');
+ $table->timestamp('expires_at');
+ $table->unsignedTinyInteger('attempts')->default(0);
+ $table->timestamps();
+
+ $table->index(['user_id', 'expires_at']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('password_reset_otps');
+ }
+};
diff --git a/be/docker/common/unified/Dockerfile b/be/docker/common/unified/Dockerfile
index c586227..8a506eb 100644
--- a/be/docker/common/unified/Dockerfile
+++ b/be/docker/common/unified/Dockerfile
@@ -4,15 +4,15 @@ FROM node:22 AS frontend-builder
WORKDIR /app/frontend
# Copy frontend package files
-COPY SUTERA-frontend/package*.json ./
-COPY SUTERA-frontend/pnpm-lock.yaml* ./
+COPY fe/package*.json ./
+COPY fe/pnpm-lock.yaml* ./
# Install pnpm and frontend dependencies (including dev dependencies for build)
RUN npm install -g pnpm
RUN pnpm install --frozen-lockfile
# Copy frontend source code
-COPY SUTERA-frontend/ ./
+COPY fe/ ./
# Build TWO frontend bundles (path-based):
# - Production: served at /
@@ -67,7 +67,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /var/www
# Copy the entire Laravel application code into the container
-COPY SUTERA-backend/ /var/www
+COPY be/ /var/www
# Copy Composer from official image (avoids flaky getcomposer.org in CI)
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
@@ -115,11 +115,11 @@ RUN curl -o /usr/local/bin/php-fpm-healthcheck \
&& chmod +x /usr/local/bin/php-fpm-healthcheck
# Copy the initialization script
-COPY SUTERA-backend/docker/common/unified/entrypoint.sh /usr/local/bin/entrypoint.sh
+COPY be/docker/common/unified/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Copy supervisor configuration files
-COPY SUTERA-backend/docker/common/unified/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
+COPY be/docker/common/unified/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
# Create supervisor conf.d directory
RUN mkdir -p /etc/supervisor/conf.d
@@ -129,11 +129,11 @@ RUN mkdir -p /etc/supervisor/conf.d
# by mounting files into:
# - /etc/nginx/nginx.conf
# - /etc/supervisor/conf.d/
-COPY SUTERA-backend/docker/common/unified/supervisor/unified.conf /etc/supervisor/conf.d/unified.conf
-COPY SUTERA-backend/docker/common/unified/nginx/nginx.conf /etc/nginx/nginx.conf
+COPY be/docker/common/unified/supervisor/unified.conf /etc/supervisor/conf.d/unified.conf
+COPY be/docker/common/unified/nginx/nginx.conf /etc/nginx/nginx.conf
# Copy the initial storage structure
-COPY SUTERA-backend/storage /var/www/storage-init
+COPY be/storage /var/www/storage-init
# Copy PHP extensions and libraries from the builder stage
COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/
diff --git a/be/docker/production/docker-compose.production.yml b/be/docker/production/docker-compose.production.yml
index 7bc1e8a..242719d 100644
--- a/be/docker/production/docker-compose.production.yml
+++ b/be/docker/production/docker-compose.production.yml
@@ -2,7 +2,7 @@ services:
app:
build:
context: .
- dockerfile: ./SUTERA-backend/docker/common/unified/Dockerfile
+ dockerfile: ./be/docker/common/unified/Dockerfile
platforms:
- linux/amd64
- linux/arm64
@@ -23,7 +23,7 @@ services:
- app-storage-production:/var/www/storage
- app-logs-production:/var/www/storage/logs
- ./.env.production:/var/www/.env:ro
- - ./SUTERA-backend/docker/production/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
+ - ./be/docker/production/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
networks:
- sutera-production-network
depends_on:
diff --git a/be/docker/staging/docker-compose.staging.yml b/be/docker/staging/docker-compose.staging.yml
index 4c13517..3217647 100644
--- a/be/docker/staging/docker-compose.staging.yml
+++ b/be/docker/staging/docker-compose.staging.yml
@@ -2,7 +2,7 @@ services:
app:
build:
context: .
- dockerfile: ./SUTERA-backend/docker/common/unified/Dockerfile
+ dockerfile: ./be/docker/common/unified/Dockerfile
args:
ENV: staging
platforms:
@@ -87,7 +87,7 @@ services:
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- postgres-data-staging:/var/lib/postgres
- - ./SUTERA-backend/docker/postgres/conf.d:/etc/postgres/conf.d:ro
+ - ./be/docker/postgres/conf.d:/etc/postgres/conf.d:ro
networks:
- sutera-staging-network
healthcheck:
diff --git a/be/docker/training/docker-compose.training.yml b/be/docker/training/docker-compose.training.yml
index 16efeae..030aae7 100644
--- a/be/docker/training/docker-compose.training.yml
+++ b/be/docker/training/docker-compose.training.yml
@@ -2,7 +2,7 @@ services:
app:
build:
context: .
- dockerfile: ./SUTERA-backend/docker/common/unified/Dockerfile
+ dockerfile: ./be/docker/common/unified/Dockerfile
platforms:
- linux/amd64
- linux/arm64
@@ -28,7 +28,7 @@ services:
- app-storage-training:/var/www/storage
- app-logs-training:/var/www/storage/logs
- ./.env.training:/var/www/.env:ro
- - ./SUTERA-backend/docker/training/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
+ - ./be/docker/training/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
networks:
- sutera-training-network
depends_on:
@@ -84,7 +84,7 @@ services:
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- postgres-data-training:/var/lib/postgres
- - ./SUTERA-backend/docker/postgres/conf.d:/etc/postgres/conf.d:ro
+ - ./be/docker/postgres/conf.d:/etc/postgres/conf.d:ro
networks:
- sutera-training-network
healthcheck:
diff --git a/be/modules_statuses.json b/be/modules_statuses.json
index c645c10..c835c83 100644
--- a/be/modules_statuses.json
+++ b/be/modules_statuses.json
@@ -1,29 +1,11 @@
{
"Auth": true,
- "KJCVariant": true,
- "Rank": true,
- "Position": true,
- "Unit": true,
- "Formation": true,
- "Camp": true,
"User": true,
- "KJC": true,
- "KJCAssetEntitlement": true,
- "KJCAssetHolding": true,
- "KJCSparePart": true,
- "KJCRepair": true,
- "KJCRepairDirective": true,
- "PKJAssetEntitlement": true,
- "PKJVariant": true,
- "PKJAssetHolding": true,
"ActivityLog": true,
"Role": true,
- "KJCReport": true,
"Dashboard": true,
- "PKJReport": true,
"ExternalAPI": true,
- "KJCTemporaryLoan": true,
"Notification": true,
- "KJCRepairTool": true,
- "Feedback": true
+ "MembershipApplication": true,
+ "Activity": true
}
\ No newline at end of file
diff --git a/be/package-lock.json b/be/package-lock.json
new file mode 100644
index 0000000..10bc260
--- /dev/null
+++ b/be/package-lock.json
@@ -0,0 +1,2761 @@
+{
+ "name": "html",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "puppeteer": "^25.2.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.0.0",
+ "axios": "^1.11.0",
+ "concurrently": "^9.0.1",
+ "laravel-vite-plugin": "^2.0.0",
+ "tailwindcss": "^4.0.0",
+ "vite": "^7.0.4"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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/@puppeteer/browsers": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.5.tgz",
+ "integrity": "sha512-xYXNuEQmHNIPWWcbL/skf2KF7seyp7c1xmKFRk3wmdFx7VwBsKVrtOLKs8ecaezsKPsWeF1YsgwIiElAscaryA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "modern-tar": "^0.7.6",
+ "yargs": "^18.0.0"
+ },
+ "bin": {
+ "browsers": "lib/main-cli.js"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ },
+ "peerDependencies": {
+ "proxy-agent": ">=8.0.1"
+ },
+ "peerDependenciesMeta": {
+ "proxy-agent": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@puppeteer/browsers/node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/@puppeteer/browsers/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@puppeteer/browsers/node_modules/cliui": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
+ "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^7.2.0",
+ "strip-ansi": "^7.1.0",
+ "wrap-ansi": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@puppeteer/browsers/node_modules/emoji-regex": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+ "license": "MIT"
+ },
+ "node_modules/@puppeteer/browsers/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@puppeteer/browsers/node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/@puppeteer/browsers/node_modules/wrap-ansi": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+ "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "string-width": "^7.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/@puppeteer/browsers/node_modules/yargs": {
+ "version": "18.0.0",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
+ "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^9.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "string-width": "^7.2.0",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^22.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/@puppeteer/browsers/node_modules/yargs-parser": {
+ "version": "22.0.0",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
+ "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
+ "license": "ISC",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
+ "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "5.21.6",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz",
+ "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.1",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.1",
+ "@tailwindcss/oxide-darwin-x64": "4.3.1",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.1",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.1",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.1",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.1",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.1",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz",
+ "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz",
+ "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz",
+ "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz",
+ "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz",
+ "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz",
+ "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz",
+ "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz",
+ "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz",
+ "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz",
+ "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==",
+ "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.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz",
+ "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz",
+ "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz",
+ "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.3.1",
+ "@tailwindcss/oxide": "4.3.1",
+ "tailwindcss": "4.3.1"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "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/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.18.1",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
+ "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.16.0",
+ "form-data": "^4.0.5",
+ "https-proxy-agent": "^5.0.1",
+ "proxy-from-env": "^2.1.0"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "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/chromium-bidi": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz",
+ "integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "mitt": "^3.0.1",
+ "zod": "^3.24.1"
+ },
+ "engines": {
+ "node": ">=20.19.0 <22.0.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "devtools-protocol": "*"
+ }
+ },
+ "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/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/concurrently": {
+ "version": "9.2.3",
+ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.3.tgz",
+ "integrity": "sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "4.1.2",
+ "rxjs": "7.8.2",
+ "shell-quote": "1.8.4",
+ "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/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "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/devtools-protocol": {
+ "version": "0.0.1638949",
+ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1638949.tgz",
+ "integrity": "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "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.21.6",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
+ "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "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/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.7",
+ "@esbuild/android-arm": "0.27.7",
+ "@esbuild/android-arm64": "0.27.7",
+ "@esbuild/android-x64": "0.27.7",
+ "@esbuild/darwin-arm64": "0.27.7",
+ "@esbuild/darwin-x64": "0.27.7",
+ "@esbuild/freebsd-arm64": "0.27.7",
+ "@esbuild/freebsd-x64": "0.27.7",
+ "@esbuild/linux-arm": "0.27.7",
+ "@esbuild/linux-arm64": "0.27.7",
+ "@esbuild/linux-ia32": "0.27.7",
+ "@esbuild/linux-loong64": "0.27.7",
+ "@esbuild/linux-mips64el": "0.27.7",
+ "@esbuild/linux-ppc64": "0.27.7",
+ "@esbuild/linux-riscv64": "0.27.7",
+ "@esbuild/linux-s390x": "0.27.7",
+ "@esbuild/linux-x64": "0.27.7",
+ "@esbuild/netbsd-arm64": "0.27.7",
+ "@esbuild/netbsd-x64": "0.27.7",
+ "@esbuild/openbsd-arm64": "0.27.7",
+ "@esbuild/openbsd-x64": "0.27.7",
+ "@esbuild/openharmony-arm64": "0.27.7",
+ "@esbuild/sunos-x64": "0.27.7",
+ "@esbuild/win32-arm64": "0.27.7",
+ "@esbuild/win32-ia32": "0.27.7",
+ "@esbuild/win32-x64": "0.27.7"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "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/follow-redirects": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
+ "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.4",
+ "mime-types": "^2.1.35"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "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==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-east-asian-width": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
+ "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "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/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "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/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/laravel-vite-plugin": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.1.0.tgz",
+ "integrity": "sha512-z+ck2BSV6KWtYcoIzk9Y5+p4NEjqM+Y4i8/H+VZRLq0OgNjW2DqyADquwYu5j8qRvaXwzNmfCWl1KrMlV1zpsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picocolors": "^1.0.0",
+ "vite-plugin-full-reload": "^1.1.0"
+ },
+ "bin": {
+ "clean-orphaned-assets": "bin/clean.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^7.0.0"
+ }
+ },
+ "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,
+ "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,
+ "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,
+ "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,
+ "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==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "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/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mitt": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
+ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
+ "license": "MIT"
+ },
+ "node_modules/modern-tar": {
+ "version": "0.7.6",
+ "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz",
+ "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.15",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
+ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
+ "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/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": "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/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/proxy-from-env": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/puppeteer": {
+ "version": "25.2.0",
+ "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.2.0.tgz",
+ "integrity": "sha512-JPMPd/2+lgdkLhEyPqH895oR3ccMt1wSra6oewgjjTuLmo2s9zPZpKXQTFEIiA/fMKpiL01kjU3+2zPEReRWNg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@puppeteer/browsers": "3.0.5",
+ "chromium-bidi": "16.0.1",
+ "devtools-protocol": "0.0.1638949",
+ "lilconfig": "^3.1.3",
+ "puppeteer-core": "25.2.0",
+ "typed-query-selector": "^2.12.2"
+ },
+ "bin": {
+ "puppeteer": "lib/puppeteer/node/cli.js"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/puppeteer-core": {
+ "version": "25.2.0",
+ "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.2.0.tgz",
+ "integrity": "sha512-jGhuGAlkgOcbyGRc0Cm9b/y4vvqoxhyAyl6a1diVe8F3sHsgTaQ60QQT5F3rGegTZV3prysgHVc+0LsvPZo3GA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@puppeteer/browsers": "3.0.5",
+ "chromium-bidi": "16.0.1",
+ "devtools-protocol": "0.0.1638949",
+ "typed-query-selector": "^2.12.2",
+ "webdriver-bidi-protocol": "0.4.2",
+ "ws": "^8.21.0"
+ },
+ "engines": {
+ "node": ">=22.12.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/rollup": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.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.4",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
+ "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
+ "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/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/tailwindcss": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
+ "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "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/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/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/typed-query-selector": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
+ "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "7.3.5",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
+ "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "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",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.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
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "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-plugin-full-reload/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/webdriver-bidi-protocol": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz",
+ "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==",
+ "license": "Apache-2.0"
+ },
+ "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/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "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"
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/be/package.json b/be/package.json
index a5707d8..63e1661 100644
--- a/be/package.json
+++ b/be/package.json
@@ -13,5 +13,8 @@
"laravel-vite-plugin": "^2.0.0",
"tailwindcss": "^4.0.0",
"vite": "^7.0.4"
+ },
+ "dependencies": {
+ "puppeteer": "^25.2.0"
}
}
diff --git a/be/public/images/logo/Logo-RAJD.png b/be/public/images/logo/Logo-RAJD.png
deleted file mode 100644
index 37e434c..0000000
Binary files a/be/public/images/logo/Logo-RAJD.png and /dev/null differ
diff --git a/be/public/images/logo/logo-kopkb-letterhead.svg b/be/public/images/logo/logo-kopkb-letterhead.svg
new file mode 100644
index 0000000..5c3ea85
--- /dev/null
+++ b/be/public/images/logo/logo-kopkb-letterhead.svg
@@ -0,0 +1,161 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/be/public/images/logo/logo-kopkb.svg b/be/public/images/logo/logo-kopkb.svg
new file mode 100644
index 0000000..ef88ccb
--- /dev/null
+++ b/be/public/images/logo/logo-kopkb.svg
@@ -0,0 +1,418 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/be/resources/views/emails/partials/footer.blade.php b/be/resources/views/emails/partials/footer.blade.php
new file mode 100644
index 0000000..7e275d9
--- /dev/null
+++ b/be/resources/views/emails/partials/footer.blade.php
@@ -0,0 +1,2 @@
+Terima kasih,
+{{ config('app.name') }}
diff --git a/be/resources/views/emails/partials/header.blade.php b/be/resources/views/emails/partials/header.blade.php
new file mode 100644
index 0000000..628f5a8
--- /dev/null
+++ b/be/resources/views/emails/partials/header.blade.php
@@ -0,0 +1,9 @@
+@php
+ $logoPath = $logoPath ?? config('mail.logo_path');
+@endphp
+
+@if (! empty($logoPath) && file_exists($logoPath) && isset($message))
+
+
+
+@endif
diff --git a/be/resources/views/pdf/layouts/letter.blade.php b/be/resources/views/pdf/layouts/letter.blade.php
new file mode 100644
index 0000000..e649a3a
--- /dev/null
+++ b/be/resources/views/pdf/layouts/letter.blade.php
@@ -0,0 +1,380 @@
+{{--
+ Reusable base layout for official PDF letters.
+
+ Extend this layout and fill the sections below:
+
+ @extends('pdf.layouts.letter')
+
+ @section('letter-reference') Ruj: APP-2026-00001 @endsection
+ @section('letter-date') {{ now()->format('d F Y') }} @endsection
+ @section('letter-recipient') ... @endsection
+ @section('letter-subject') Perkara: ... @endsection
+ @section('letter-salutation') Assalamualaikum dan Salam Sejahtera, @endsection
+ @section('letter-body') ... @endsection
+ @section('letter-closing') Yang benar, @endsection
+ @section('letter-signature') ... @endsection
+
+ Optional overrides via view data:
+ - logoPath, organizationAddress, organizationContact
+ - showReference, showDate, showSubject (bool, default true)
+--}}
+@php
+ $showReference = $showReference ?? true;
+ $showDate = $showDate ?? true;
+ $showSubject = $showSubject ?? true;
+ $letterDate = trim($__env->yieldContent('letter-date'));
+ $css = ($letterLayout ?? \App\Support\LetterLayout::resolve())['css'];
+@endphp
+
+
+
+
+ @yield('letter-title', trim($__env->yieldContent('letter-subject', 'Surat Rasmi')))
+
+ @stack('letter-styles')
+
+
+
+ @include('pdf.partials.letterhead')
+
+
+
+
+ @hasSection('letter-recipient')
+
+
@yield('letter-recipient')
+
+ @endif
+
+ @if ($showSubject && trim($__env->yieldContent('letter-subject')) !== '')
+
+ @yield('letter-subject')
+
+ @endif
+
+ @hasSection('letter-salutation')
+
@yield('letter-salutation')
+ @endif
+
+
+ @yield('letter-body')
+
+
+ @hasSection('letter-closing')
+
@yield('letter-closing')
+ @endif
+
+
+
+ @hasSection('letter-signature')
+
@yield('letter-signature')
+ @endif
+
+
+
+
+
+ @stack('letter-scripts')
+
+
diff --git a/be/resources/views/pdf/partials/footer-icon.blade.php b/be/resources/views/pdf/partials/footer-icon.blade.php
new file mode 100644
index 0000000..cc9dd0d
--- /dev/null
+++ b/be/resources/views/pdf/partials/footer-icon.blade.php
@@ -0,0 +1,30 @@
+@php
+ $color = $color ?? '#ffffff';
+@endphp
+@switch($icon)
+ @case('phone')
+
+
+
+ @break
+ @case('fax')
+
+
+
+ @break
+ @case('email')
+
+
+
+ @break
+ @case('web')
+
+
+
+ @break
+ @case('location')
+
+
+
+ @break
+@endswitch
diff --git a/be/resources/views/pdf/partials/letterfooter.blade.php b/be/resources/views/pdf/partials/letterfooter.blade.php
new file mode 100644
index 0000000..de80b00
--- /dev/null
+++ b/be/resources/views/pdf/partials/letterfooter.blade.php
@@ -0,0 +1,38 @@
+
diff --git a/be/resources/views/pdf/partials/letterhead.blade.php b/be/resources/views/pdf/partials/letterhead.blade.php
new file mode 100644
index 0000000..e27b900
--- /dev/null
+++ b/be/resources/views/pdf/partials/letterhead.blade.php
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+ @if (! empty($logoUrl))
+
+
+
+ @endif
+
+ KOPERASI PERMODALAN
+ KELANTAN BERHAD
+ D-5-0228
+
+
+
+
+
+
diff --git a/be/resources/views/pdf/preview-approved-membership.blade.php b/be/resources/views/pdf/preview-approved-membership.blade.php
new file mode 100644
index 0000000..89c7937
--- /dev/null
+++ b/be/resources/views/pdf/preview-approved-membership.blade.php
@@ -0,0 +1,72 @@
+@extends('pdf.layouts.letter')
+
+@section('letter-title', 'Surat')
+
+@section('letter-reference', 'APP-2026-00001')
+
+@section('letter-date', now()->translatedFormat('d F Y'))
+
+@section('letter-recipient')
+ Ahmad bin Abdullah
+ No. 12, Jalan Contoh,
+ 43000 Kajang, Selangor
+@endsection
+
+@section('letter-subject')
+ KELULUSAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD
+@endsection
+
+{{-- @section('letter-salutation')
+ Assalamualaikum dan Salam Sejahtera,
+@endsection --}}
+
+@section('letter-body')
+
+ Dengan segala hormatnya saya merujuk kepada perkara di atas.
+
+
+ 2. Sukacita dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi
+ Permodalan Kelantan Berhad(KoPKB) telah DILULUSKAN dalam Mesyuarat Lembaga
+ Koperasi Bil. 4-2025/2026 pada 24 Jun 2026.
+
+
+ 3. Pihak Koperasi akan membuat potongan gaji melalui majikan tuan sebagaiman ketetapan berikut:-
+
+ Saham
+
+ Potongan sebanyak RM 84.00 (Mei 2026 - Jun 2026)
+ Potongan sebanyak RM 50.00 sehingga Syer Maksima
+
+ Yuran
+
+ Potongan sebanyak RM 50.00 setiap bulan
+
+ Fi Masuk
+
+ Potongan sebanyak RM 10.00 (Pembayaran Pertama)
+
+
+
+
+ 4. Untuk makluman tuan, kelulusan keanggotaan ini adalah bersyarat di mana tuan dikehendaki
+ menjelaskan modal syer minimum sebanyak RM500.00. Bayaran tersebut
+ perlu dibuat secara ansuran melalui potongan gaji dalam tempoh enam (6) bulan.
+ Pemenuhan syarat ini adalah bagi melayakkan tuan memperoleh hak, kewajipan dan
+ liabiliti sebagai anggota KoPKB sebagaimana terkandung di dalam Undang-Undang Kecil (UUK),
+ Perkara 59 yang telah didaftarkan pada 11 September 2024. Manakala had maksima modal syer adalah
+ sebanyak RM5,000.00.
+
+@endsection
+
+@section('letter-closing')
+ Sekian, terima kasih.
+@endsection
+
+@section('letter-signature')
+
+ KOPERASI PERMODALAN KELANTAN BERHAD
+
+ MUHAMMAD NAFIS BIN ZAINUDDIN
+ Pengurus Besar
+ b/p Setiausaha
+@endsection
diff --git a/be/resources/views/pdf/preview.blade.php b/be/resources/views/pdf/preview.blade.php
new file mode 100644
index 0000000..e81357a
--- /dev/null
+++ b/be/resources/views/pdf/preview.blade.php
@@ -0,0 +1,43 @@
+@extends('pdf.layouts.letter')
+
+@section('letter-title', 'Pratonton Surat')
+
+@section('letter-reference', 'APP-2026-00001')
+
+@section('letter-date', now()->translatedFormat('d F Y'))
+
+@section('letter-recipient')
+ Ahmad bin Abdullah
+ No. 12, Jalan Contoh,
+ 43000 Kajang, Selangor
+@endsection
+
+@section('letter-subject')
+ PERKARA: PRATONTON REKA BENTUK SURAT RASMI
+@endsection
+
+@section('letter-salutation')
+ Assalamualaikum dan Salam Sejahtera,
+@endsection
+
+@section('letter-body')
+
+ Dengan hormatnya perkara di atas adalah dirujuk.
+
+
+ 1. Ini adalah kandungan contoh untuk pratonton reka bentuk surat. Gunakan halaman ini
+ untuk menguji susun atur, fon, jarak, dan elemen letterhead sebelum menghasilkan PDF sebenar.
+
+
+ 2. Teks ini boleh diganti apabila templat surat muktamad disediakan.
+
+@endsection
+
+@section('letter-closing')
+ Sekian, terima kasih.
+@endsection
+
+@section('letter-signature')
+ NAMA PENGARAH
+ Jawatan / {{ config('app.name') }}
+@endsection
diff --git a/be/routes/api.php b/be/routes/api.php
index 0c60ae2..b16345e 100644
--- a/be/routes/api.php
+++ b/be/routes/api.php
@@ -1,32 +1,3 @@
middleware(['auth:sanctum', 'single.session']);
-
-// Social Media - Admin endpoints
-Route::middleware(['auth:sanctum', 'single.session'])->prefix('social-media')->group(function () {
- Route::get('/settings', [SocialMediaController::class, 'getSocialMediaSettings']);
- Route::post('/settings', [SocialMediaController::class, 'updateSocialMedia']);
- Route::post('/', [SocialMediaController::class, 'addSocialMedia']);
- Route::put('/{id}', [SocialMediaController::class, 'updateSocialMediaPlatform']);
- Route::delete('/{id}', [SocialMediaController::class, 'deleteSocialMediaPlatform']);
- Route::patch('/{id}/toggle', [SocialMediaController::class, 'toggleSocialMediaPlatform']);
-});
-
-// Contact Settings - Admin endpoints
-Route::middleware(['auth:sanctum', 'single.session'])->prefix('contact')->group(function () {
- Route::get('/settings', [ContactController::class, 'getContactSettings']);
- Route::post('/settings', [ContactController::class, 'updateContacts']);
- Route::post('/', [ContactController::class, 'addContact']);
- Route::put('/{id}', [ContactController::class, 'updateContact']);
- Route::delete('/{id}', [ContactController::class, 'deleteContact']);
- Route::patch('/{id}/toggle', [ContactController::class, 'toggleContact']);
-});
-
+use Illuminate\Support\Facades\Route;
\ No newline at end of file
diff --git a/be/routes/web.php b/be/routes/web.php
index bfe8a20..a4def31 100644
--- a/be/routes/web.php
+++ b/be/routes/web.php
@@ -1,7 +1,14 @@
name('letters.preview');
+
+Route::get('/letters/preview-approved-membership', [LetterPreviewController::class, 'showMembershipApprovedLetter'])
+ ->name('letters.preview-membership-approved-membership');
\ No newline at end of file
diff --git a/docs/SENARAI DAFTAR ANGGOTA 2026.xls b/docs/SENARAI DAFTAR ANGGOTA 2026.xls
deleted file mode 100644
index 8011f86..0000000
Binary files a/docs/SENARAI DAFTAR ANGGOTA 2026.xls and /dev/null differ
diff --git a/docs/membership_application-db_design.md b/docs/membership_application-db_design.md
new file mode 100644
index 0000000..240423f
--- /dev/null
+++ b/docs/membership_application-db_design.md
@@ -0,0 +1,283 @@
+The differences are actually quite small, but they're important because they reflect the **real business process** more accurately.
+
+## Earlier Design
+
+I initially assumed:
+
+```text
+Management
+ |
+Approve/Reject
+ |
+Board
+ |
+Pass/Fail
+ |
+General Manager
+ |
+Approve/Reject
+```
+
+Therefore I proposed:
+
+```text
+application_management_reviews
+application_board_evaluations
+application_gm_decisions
+```
+
+or later:
+
+```text
+application_reviews
+ - MANAGEMENT
+ - BOARD
+ - GM
+```
+
+because I thought the GM was making a decision.
+
+---
+
+## Current Design
+
+After your clarification:
+
+> The outcome is already known after board evaluation.
+>
+> The GM only prints the result letter and notifies the applicant.
+
+The workflow is actually:
+
+```text
+Management
+ |
+Approve/Reject
+ |
+Board
+ |
+Pass/Fail
+ |
+General Manager
+(Print letter)
+(Send email)
+```
+
+The GM is no longer a reviewer.
+
+Therefore:
+
+### Removed
+
+```text
+application_gm_decisions
+```
+
+because there is no GM decision to store.
+
+---
+
+### Added
+
+```text
+application_notifications
+```
+
+because the GM's responsibility is administrative:
+
+* Generate acceptance/rejection letter
+* Send email
+* Mark notification completed
+
+Example:
+
+| application_id | letter_generated_at | email_sent_at |
+| -------------- | ------------------- | ---------------- |
+| 1001 | 2026-06-01 10:00 | 2026-06-01 10:05 |
+
+---
+
+## Status Changes
+
+### Earlier
+
+```text
+MANAGEMENT_REJECTED
+GM_REJECTED
+APPROVED
+ACCOUNT_CREATED
+```
+
+### Current
+
+```text
+MANAGEMENT_REJECTED
+BOARD_FAILED
+BOARD_PASSED
+NOTIFICATION_PENDING
+COMPLETED
+```
+
+because the board determines the outcome.
+
+---
+
+## Review Tables
+
+### Earlier
+
+I proposed three tables:
+
+```text
+application_management_reviews
+application_board_evaluations
+application_gm_decisions
+```
+
+Then simplified to:
+
+```text
+application_reviews
+```
+
+with:
+
+```text
+MANAGEMENT
+BOARD
+GM
+```
+
+### Current
+
+Still:
+
+```text
+application_reviews
+```
+
+but only:
+
+```text
+MANAGEMENT
+BOARD
+```
+
+No GM stage.
+
+---
+
+## What I Would Simplify Even Further
+
+Looking at your process, I'd actually make it even leaner.
+
+### membership_applications
+
+Add outcome fields directly:
+
+```sql
+membership_applications
+-----------------------
+id
+
+status
+
+management_result
+board_result
+
+notification_sent_at
+
+submitted_at
+completed_at
+```
+
+Where:
+
+```text
+management_result
+-----------------
+PENDING
+APPROVED
+REJECTED
+
+board_result
+------------
+PENDING
+PASS
+FAIL
+```
+
+Then you might not even need `application_reviews` if:
+
+* One management review only
+* One board evaluation only
+
+You could store everything directly on the application record.
+
+Example:
+
+| application | mgmt | board |
+| ----------- | -------- | ----- |
+| APP001 | APPROVED | PASS |
+| APP002 | APPROVED | FAIL |
+| APP003 | REJECTED | NULL |
+
+---
+
+## My Recommendation
+
+For your described workflow, I'd use:
+
+```text
+membership_applications
+application_applicants
+application_heirs
+application_documents
+application_references
+application_reviews
+application_notifications
+users
+members
+```
+
+and remove:
+
+```text
+application_management_reviews
+application_board_evaluations
+application_gm_decisions
+application_workflow_logs
+```
+
+unless you have a compliance/audit requirement.
+
+### Why remove `application_workflow_logs`?
+
+If your workflow is fixed and simple:
+
+* Management reviews once.
+* Board evaluates once.
+* GM sends notification once.
+
+Then:
+
+```text
+application_reviews
+```
+
+already tells you who reviewed and when.
+
+A workflow log becomes useful only when you need things like:
+
+```text
+Reopened
+Returned for correction
+Resubmitted
+Reassigned
+Escalated
+Withdrawn
+```
+
+If those scenarios don't exist, it's additional complexity without much benefit.
+
+So the "latest" design is mainly different because **the GM is no longer treated as a decision-maker**, only as a notification handler. That removes an entire approval layer from the data model and simplifies the workflow considerably.
diff --git a/fe/.DS_Store b/fe/.DS_Store
index 531073e..bea6f35 100644
Binary files a/fe/.DS_Store and b/fe/.DS_Store differ
diff --git a/fe/public/favicon.ico b/fe/public/favicon.ico
index 84fbd8a..4aea46d 100644
Binary files a/fe/public/favicon.ico and b/fe/public/favicon.ico differ
diff --git a/fe/src/.DS_Store b/fe/src/.DS_Store
new file mode 100644
index 0000000..0c5a35f
Binary files /dev/null and b/fe/src/.DS_Store differ
diff --git a/fe/src/assets/.DS_Store b/fe/src/assets/.DS_Store
new file mode 100644
index 0000000..6b03fd7
Binary files /dev/null and b/fe/src/assets/.DS_Store differ
diff --git a/fe/src/components/daily-notes/DailyNotes.vue b/fe/src/components/daily-notes/DailyNotes.vue
index 0b5be3e..e10bc45 100644
--- a/fe/src/components/daily-notes/DailyNotes.vue
+++ b/fe/src/components/daily-notes/DailyNotes.vue
@@ -21,7 +21,7 @@ import {
TableRow,
TableCell,
} from '@/components/ui/table'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
CarouselRoot,
@@ -95,12 +95,7 @@ const sport = ref(false)
-
+
{{ faker['news'][0]!['title'] }}
@@ -113,12 +108,7 @@ const sport = ref(false)
View Notes
-
+
Dismiss
diff --git a/fe/src/components/notification-dropdown/NotificationDropdown.vue b/fe/src/components/notification-dropdown/NotificationDropdown.vue
index da6e3d6..6f77938 100644
--- a/fe/src/components/notification-dropdown/NotificationDropdown.vue
+++ b/fe/src/components/notification-dropdown/NotificationDropdown.vue
@@ -1,9 +1,12 @@
@@ -25,29 +82,65 @@ const props = defineProps()
:class="`before:shadow-foreground/5 z-50 flex w-96 flex-col gap-2.5 px-6 py-5 before:rounded-2xl before:shadow-xl before:backdrop-blur after:rounded-2xl ${props.boxClass ?? ''}`"
>
-
Notifications
-
View More
-
-
+
-
- PA
-
-
-
-
-
{{ faker['users'][0]!['name'] }}
-
{{ faker['times'][0] }}
+ Tandakan semua dibaca
+
+
+
+
+ Memuatkan notifikasi...
+
+
+
+ {{ error }}
+
+
+
+
+ Tiada notifikasi buat masa ini.
+
+
+
+
+
+
+
+
+
+
{{ notification.title }}
+
{{ notification.time_ago }}
-
- {{ faker['news'][0]!['shortContent'] }}
+
+ {{ notification.message }}
+
diff --git a/fe/src/components/official-stores/OfficialStores.vue b/fe/src/components/official-stores/OfficialStores.vue
index d98bc95..cdec6a8 100644
--- a/fe/src/components/official-stores/OfficialStores.vue
+++ b/fe/src/components/official-stores/OfficialStores.vue
@@ -21,7 +21,7 @@ import {
TableRow,
TableCell,
} from '@/components/ui/table'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
CarouselRoot,
@@ -77,75 +77,67 @@ const sport = ref(false)
250 Official stores in 21 countries, click the marker to see location details.
-
+
diff --git a/fe/src/components/recent-activities/RecentActivities.vue b/fe/src/components/recent-activities/RecentActivities.vue
index e68e89b..e77ccf2 100644
--- a/fe/src/components/recent-activities/RecentActivities.vue
+++ b/fe/src/components/recent-activities/RecentActivities.vue
@@ -21,7 +21,7 @@ import {
TableRow,
TableCell,
} from '@/components/ui/table'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
CarouselRoot,
@@ -79,12 +79,9 @@ const sport = ref(false)
Show More
+ class="before:bg-foreground/10 relative mt-5 before:absolute before:ms-5 before:mt-5 before:block before:h-[85%] before:w-px">
-
+
PA
@@ -101,9 +98,7 @@ const sport = ref(false)
-
+
PA
@@ -137,9 +132,7 @@ const sport = ref(false)
12 November
-
+
PA
@@ -162,9 +155,7 @@ const sport = ref(false)
-
+
PA
diff --git a/fe/src/components/schedules/Schedules.vue b/fe/src/components/schedules/Schedules.vue
index d015de6..2a1e600 100644
--- a/fe/src/components/schedules/Schedules.vue
+++ b/fe/src/components/schedules/Schedules.vue
@@ -21,7 +21,7 @@ import {
TableRow,
TableCell,
} from '@/components/ui/table'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
CarouselRoot,
@@ -141,22 +141,22 @@ const sport = ref(false)
+ class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-pending)]">
+
UI/UX Workshop
23th
+ class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-primary)]">
+
VueJs Frontend Development
10th
+ class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-warning)]">
+
Laravel Rest API
31th
diff --git a/fe/src/components/transactions/Transactions.vue b/fe/src/components/transactions/Transactions.vue
index c0957b0..b582b7e 100644
--- a/fe/src/components/transactions/Transactions.vue
+++ b/fe/src/components/transactions/Transactions.vue
@@ -21,7 +21,7 @@ import {
TableRow,
TableCell,
} from '@/components/ui/table'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
CarouselRoot,
@@ -78,10 +78,7 @@ const sport = ref(false)
Transactions
-
+
PA
@@ -92,12 +89,10 @@ const sport = ref(false)
{{ faker['dates'][0] }}
-
+
{{ faker['trueFalse'][0] ? '+' : '-' }}${{ faker['totals'][0] }}
diff --git a/fe/src/components/ui/datepicker/DatePickerMonthSelect.vue b/fe/src/components/ui/datepicker/DatePickerMonthSelect.vue
index 1b1ffb3..89cd30d 100644
--- a/fe/src/components/ui/datepicker/DatePickerMonthSelect.vue
+++ b/fe/src/components/ui/datepicker/DatePickerMonthSelect.vue
@@ -1,10 +1,6 @@
-
-
-
-
-
-
diff --git a/fe/src/components/ui/native-select/NativeSelectOption.vue b/fe/src/components/ui/native-select/NativeSelectOption.vue
deleted file mode 100644
index 1d79dfe..0000000
--- a/fe/src/components/ui/native-select/NativeSelectOption.vue
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
diff --git a/fe/src/components/ui/native-select/NativeSelectOptionGroup.vue b/fe/src/components/ui/native-select/NativeSelectOptionGroup.vue
deleted file mode 100644
index a365051..0000000
--- a/fe/src/components/ui/native-select/NativeSelectOptionGroup.vue
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
diff --git a/fe/src/components/ui/native-select/index.ts b/fe/src/components/ui/native-select/index.ts
deleted file mode 100644
index b4b5fef..0000000
--- a/fe/src/components/ui/native-select/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export { default as NativeSelect } from "./NativeSelect.vue";
-export { default as NativeSelectOption } from "./NativeSelectOption.vue";
-export { default as NativeSelectOptionGroup } from "./NativeSelectOptionGroup.vue";
diff --git a/fe/src/components/ui/textarea/Textarea.vue b/fe/src/components/ui/textarea/Textarea.vue
index b594852..9aa563d 100644
--- a/fe/src/components/ui/textarea/Textarea.vue
+++ b/fe/src/components/ui/textarea/Textarea.vue
@@ -2,11 +2,13 @@
import { cn } from "@mykopkb/core/utils/cn";
import { textarea } from "@mykopkb/core/styles/textarea.styles";
-const { class: className, ...props } = defineProps<{
+const modelValue = defineModel
();
+
+const { class: className } = defineProps<{
class?: string;
}>();
-
+
diff --git a/fe/src/components/ui/usage/DataTable.vue b/fe/src/components/ui/usage/DataTable.vue
index 0361a81..3db6a24 100644
--- a/fe/src/components/ui/usage/DataTable.vue
+++ b/fe/src/components/ui/usage/DataTable.vue
@@ -43,10 +43,17 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
+import * as select from '@zag-js/select';
import {
- NativeSelect,
- NativeSelectOption,
-} from '@/components/ui/native-select';
+ SelectRoot,
+ SelectControl,
+ SelectTrigger,
+ SelectValueText,
+ SelectContent,
+ SelectItemGroup,
+ SelectItem,
+ SelectItemText,
+} from '@/components/ui/select';
// Type definitions
type ColumnType = 'index' | 'select' | 'action' | 'status' | 'id' | 'date' | 'email' | 'phone' | 'priority' | 'category' | 'registration' | 'model' | 'unit' | 'text';
@@ -129,6 +136,22 @@ const resizingColumn = ref(null);
const startX = ref(0);
const perPageOptions = [10, 25, 50, 100, 250, 500, 1000];
+type PerPageSelectOption = { label: string; value: number };
+
+const perPageSelectOptions: PerPageSelectOption[] = perPageOptions.map((option) => ({
+ label: String(option),
+ value: option,
+}));
+
+const perPageCollection = select.collection({
+ items: perPageSelectOptions,
+ itemToValue: (item) => item.label,
+});
+
+const perPageInitial = computed(() => [
+ String(itemsPerPageModel.value ?? props.pagination?.per_page ?? props.itemsPerPage ?? 10),
+]);
+
const startWidth = ref(0);
// Helper function to detect column type from key and title
@@ -136,9 +159,10 @@ const detectColumnType = (header: TableHeader): ColumnType => {
const key = header.key.toLowerCase();
const title = header.title.toLowerCase();
- // Index/Number columns
- if (key === '#' || key === 'index' || key === 'bil' || key === 'no' ||
- title.includes('bil') || title.includes('no') || title.includes('#')) {
+ // Index/Number columns (Bil./# row counters — not field labels like "No. Permohonan")
+ if (key === '#' || key === 'index' || key === 'bil' ||
+ title.includes('bil') || title === '#' ||
+ /^no\.?\s*$/i.test(title.trim())) {
return 'index';
}
@@ -416,8 +440,9 @@ const headerAlignClass = (align?: TableHeader['align']) => {
return 'text-left';
};
-const handleItemsPerPageChange = (event: Event) => {
- const value = Number((event.target as HTMLSelectElement).value);
+const setItemsPerPageValue = (details: { value: string[] }) => {
+ const value = Number(details.value[0]);
+ if (Number.isNaN(value)) return;
itemsPerPageModel.value = value;
emit('update:items-per-page', value);
};
@@ -791,12 +816,30 @@ watch(sortBy, (newSort) => {
class="flex flex-wrap items-center justify-between gap-4 border-t border-foreground/10 bg-foreground/2 px-4 py-3">
Bilangan rekod per halaman:
-
-
- {{ option }}
-
-
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
diff --git a/fe/src/components/ui/usage/NativeSelect.vue b/fe/src/components/ui/usage/NativeSelect.vue
deleted file mode 100644
index d44f5da..0000000
--- a/fe/src/components/ui/usage/NativeSelect.vue
+++ /dev/null
@@ -1,72 +0,0 @@
-
-
-
-
-
-
-
- Select status
- Todo
- In Progress
- Done
- Cancelled
-
-
-
-
- Select department
-
- Frontend
- Backend
- DevOps
-
-
- Sales Rep
-
- Account Manager
-
-
- Sales Director
-
-
-
-
- Customer Support
-
-
- Product Manager
-
-
- Operations Manager
-
-
-
-
-
-
- Select status
- Todo
- In Progress
- Done
- Cancelled
-
-
-
-
-
diff --git a/fe/src/components/weekly-best-sellers/WeeklyBestSellers.vue b/fe/src/components/weekly-best-sellers/WeeklyBestSellers.vue
index 40ed000..d89b80c 100644
--- a/fe/src/components/weekly-best-sellers/WeeklyBestSellers.vue
+++ b/fe/src/components/weekly-best-sellers/WeeklyBestSellers.vue
@@ -21,7 +21,7 @@ import {
TableRow,
TableCell,
} from '@/components/ui/table'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
CarouselRoot,
@@ -78,10 +78,7 @@ const sport = ref(false)
Weekly Best Sellers
-
+
PA
diff --git a/fe/src/components/weekly-top-products/WeeklyTopProducts.vue b/fe/src/components/weekly-top-products/WeeklyTopProducts.vue
index 4f010dc..7364c66 100644
--- a/fe/src/components/weekly-top-products/WeeklyTopProducts.vue
+++ b/fe/src/components/weekly-top-products/WeeklyTopProducts.vue
@@ -21,7 +21,6 @@ import {
TableRow,
TableCell,
} from '@/components/ui/table'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
CarouselRoot,
@@ -147,12 +146,7 @@ const sport = ref(false)
-
+
Previous
diff --git a/fe/src/composables/useFilteredMenu.ts b/fe/src/composables/useFilteredMenu.ts
new file mode 100644
index 0000000..f7c55b8
--- /dev/null
+++ b/fe/src/composables/useFilteredMenu.ts
@@ -0,0 +1,14 @@
+import { computed, type ComputedRef } from 'vue'
+import type { MenuList } from '@/core/utils/filterMenuByPermission'
+import { filterMenuByPermission } from '@/core/utils/filterMenuByPermission'
+import { usePermissions } from './usePermissions'
+
+export function useFilteredMenu(menu: MenuList): ComputedRef {
+ const { activeRolePermissions, hasPermission } = usePermissions()
+
+ return computed(() => {
+ void activeRolePermissions.value
+
+ return filterMenuByPermission(menu, hasPermission)
+ })
+}
diff --git a/fe/src/composables/usePermissions.ts b/fe/src/composables/usePermissions.ts
new file mode 100644
index 0000000..6810772
--- /dev/null
+++ b/fe/src/composables/usePermissions.ts
@@ -0,0 +1,45 @@
+import { computed } from 'vue'
+import {
+ getActiveRolePermissions,
+ hasActiveRolePermission,
+ hasAnyActiveRolePermission,
+} from '@/core/utils/activeRolePermission'
+import { useAuthStore } from '@/stores/auth'
+import type { AuthPermission } from '@/modules/auth/types/auth.types'
+
+const DEVELOPER_ROLE = 'DEVELOPER'
+
+export function usePermissions() {
+ const authStore = useAuthStore()
+
+ const activeRolePermissions = computed(() =>
+ getActiveRolePermissions(authStore.user, authStore.activeRole),
+ )
+
+ const isDeveloper = computed(
+ () => authStore.activeRole?.name === DEVELOPER_ROLE,
+ )
+
+ function hasPermission(permission: string): boolean {
+ return hasActiveRolePermission(
+ authStore.user,
+ authStore.activeRole,
+ permission,
+ )
+ }
+
+ function hasAnyPermission(permissions: string[]): boolean {
+ return hasAnyActiveRolePermission(
+ authStore.user,
+ authStore.activeRole,
+ permissions,
+ )
+ }
+
+ return {
+ activeRolePermissions,
+ hasPermission,
+ hasAnyPermission,
+ isDeveloper,
+ }
+}
diff --git a/fe/src/composables/useRoleSwitcher.ts b/fe/src/composables/useRoleSwitcher.ts
index 2f72185..65b6fde 100644
--- a/fe/src/composables/useRoleSwitcher.ts
+++ b/fe/src/composables/useRoleSwitcher.ts
@@ -59,28 +59,9 @@ export function useRoleSwitcher() {
return
}
- const nextRoleLabel = availableRoles.value.find((r) => r.id === nextRoleId)?.name
- const currentRoleLabel = authStore.activeRole?.name
-
- const result = await Swal.fire({
- title: 'Tukar peranan?',
- text: `Anda akan bertukar daripada "${currentRoleLabel}" kepada "${nextRoleLabel}".`,
- icon: 'question',
- showCancelButton: true,
- confirmButtonText: 'Ya, bertukar',
- cancelButtonText: 'Batal',
- reverseButtons: true,
- })
-
- if (!result.isConfirmed) {
- roleComboValue.value = authStore.activeRole?.id ? [authStore.activeRole.id] : ['']
- selectedRoleId.value = authStore.activeRole?.id ?? ''
- return
- }
-
roleComboValue.value = [nextRoleId]
selectedRoleId.value = nextRoleId
- await onSwitchRole()
+ await onSwitchRole(nextRoleId)
}
const onRoleInputValueChange = ({ inputValue }: { inputValue: string }) => {
@@ -94,12 +75,12 @@ export function useRoleSwitcher() {
roleOptions.value = filtered.length > 0 ? filtered : all
}
- const onSwitchRole = async () => {
- if (!selectedRoleId.value || selectedRoleId.value === authStore.activeRole?.id) return
+ const onSwitchRole = async (roleId: string) => {
+ if (!roleId || roleId === authStore.activeRole?.id) return
switchingRole.value = true
try {
- const res = await authStore.switchRole(selectedRoleId.value)
+ const res = await authStore.switchRole(roleId)
await Swal.fire({
toast: true,
diff --git a/fe/src/core/types/menu.ts b/fe/src/core/types/menu.ts
index 248b51b..d9f8ec0 100644
--- a/fe/src/core/types/menu.ts
+++ b/fe/src/core/types/menu.ts
@@ -6,5 +6,6 @@ export interface Menu {
route_name?: string
params?: Record
badge?: number
+ permission?: string
sub_menu?: Menu[]
}
diff --git a/fe/src/core/utils/activeRolePermission.ts b/fe/src/core/utils/activeRolePermission.ts
new file mode 100644
index 0000000..4386c26
--- /dev/null
+++ b/fe/src/core/utils/activeRolePermission.ts
@@ -0,0 +1,38 @@
+import type { AuthPermission, AuthRole, AuthUser } from '@/modules/auth/types/auth.types'
+
+const DEVELOPER_ROLE = 'DEVELOPER'
+
+export function getActiveRolePermissions(
+ user: AuthUser | null | undefined,
+ activeRole: AuthRole | null | undefined,
+): AuthPermission[] {
+ if (!user?.roles?.length || !activeRole) {
+ return []
+ }
+
+ return user.roles.find((role) => role.id === activeRole.id)?.permissions ?? []
+}
+
+export function hasActiveRolePermission(
+ user: AuthUser | null | undefined,
+ activeRole: AuthRole | null | undefined,
+ permission: string,
+): boolean {
+ if (activeRole?.name === DEVELOPER_ROLE) {
+ return true
+ }
+
+ return getActiveRolePermissions(user, activeRole).some(
+ (item) => item.name === permission,
+ )
+}
+
+export function hasAnyActiveRolePermission(
+ user: AuthUser | null | undefined,
+ activeRole: AuthRole | null | undefined,
+ permissions: string[],
+): boolean {
+ return permissions.some((permission) =>
+ hasActiveRolePermission(user, activeRole, permission),
+ )
+}
diff --git a/fe/src/core/utils/filterMenuByPermission.ts b/fe/src/core/utils/filterMenuByPermission.ts
new file mode 100644
index 0000000..a21d153
--- /dev/null
+++ b/fe/src/core/utils/filterMenuByPermission.ts
@@ -0,0 +1,75 @@
+import type { Menu } from '@/core/types/menu'
+
+export type MenuList = (string | Menu)[]
+
+function isMenuVisible(menu: Menu, hasPermission: (permission: string) => boolean): boolean {
+ if (menu.sub_menu?.length) {
+ return menu.sub_menu.some((item) => isMenuVisible(item, hasPermission))
+ }
+
+ if (!menu.permission) {
+ return true
+ }
+
+ return hasPermission(menu.permission)
+}
+
+function filterMenuItem(menu: Menu, hasPermission: (permission: string) => boolean): Menu | null {
+ if (menu.sub_menu?.length) {
+ const subMenu = menu.sub_menu
+ .map((item) => filterMenuItem(item, hasPermission))
+ .filter((item): item is Menu => item !== null)
+
+ if (!subMenu.length) {
+ return null
+ }
+
+ return { ...menu, sub_menu: subMenu }
+ }
+
+ return isMenuVisible(menu, hasPermission) ? menu : null
+}
+
+function sectionHasVisibleItems(
+ menu: MenuList,
+ startIndex: number,
+ hasPermission: (permission: string) => boolean,
+): boolean {
+ for (let index = startIndex + 1; index < menu.length; index += 1) {
+ const entry = menu[index]
+
+ if (typeof entry === 'string') {
+ break
+ }
+
+ if (entry && isMenuVisible(entry, hasPermission)) {
+ return true
+ }
+ }
+
+ return false
+}
+
+export function filterMenuByPermission(
+ menu: MenuList,
+ hasPermission: (permission: string) => boolean,
+): MenuList {
+ const filtered: MenuList = []
+
+ menu.forEach((entry, index) => {
+ if (typeof entry === 'string') {
+ if (sectionHasVisibleItems(menu, index, hasPermission)) {
+ filtered.push(entry)
+ }
+
+ return
+ }
+
+ const visibleItem = filterMenuItem(entry, hasPermission)
+ if (visibleItem) {
+ filtered.push(visibleItem)
+ }
+ })
+
+ return filtered
+}
diff --git a/fe/src/core/utils/getApiErrorMessage.ts b/fe/src/core/utils/getApiErrorMessage.ts
index 185460d..b1c1b88 100644
--- a/fe/src/core/utils/getApiErrorMessage.ts
+++ b/fe/src/core/utils/getApiErrorMessage.ts
@@ -5,6 +5,18 @@ type ApiErrorBody = {
errors?: Record
}
+export function getApiValidationErrors(error: unknown): Record | null {
+ if (axios.isAxiosError(error)) {
+ const data = error.response?.data as ApiErrorBody | undefined
+
+ if (data?.errors && Object.keys(data.errors).length > 0) {
+ return data.errors
+ }
+ }
+
+ return null
+}
+
export function getApiErrorMessage(
error: unknown,
fallback = 'Something went wrong. Please try again.',
diff --git a/fe/src/core/utils/routePermission.ts b/fe/src/core/utils/routePermission.ts
new file mode 100644
index 0000000..0e6e467
--- /dev/null
+++ b/fe/src/core/utils/routePermission.ts
@@ -0,0 +1,17 @@
+import type { RouteLocationNormalized } from 'vue-router'
+
+export function getRouteRequiredPermissions(
+ route: RouteLocationNormalized,
+): string[] {
+ for (const record of [...route.matched].reverse()) {
+ if (typeof record.meta.permission === 'string') {
+ return [record.meta.permission]
+ }
+
+ if (Array.isArray(record.meta.permissions) && record.meta.permissions.length) {
+ return record.meta.permissions
+ }
+ }
+
+ return []
+}
diff --git a/fe/src/docs/pages/accordion.vue b/fe/src/docs/pages/accordion.vue
index e2b345b..2ed3e06 100644
--- a/fe/src/docs/pages/accordion.vue
+++ b/fe/src/docs/pages/accordion.vue
@@ -126,361 +126,4 @@ import { Box } from "@/components/ui/box";
-
-
Installation
-
Install the following dependencies:
-
add @zag-js/vue @zag-js/accordion
-
- Copy and paste the following code into your project.
-
-
- {{ `
-
-
-
-
-
-
-
-
-
-
- ` }}
-
-
- {{ `
-
-
-
-
-
-
-
-
-
-
- ` }}
-
-
- {{`
-
-
-
-
-
-
-
-
-
-
- ` }}
-
-
- {{ `
-
-
-
-
-
-
-
-
-
-
-
-
-
- ` }}
-
-
- {{ `
- export { default as AccordionRoot } from "./AccordionRoot.vue";
- export { default as AccordionItem } from "./AccordionItem.vue";
- export { default as AccordionTrigger } from "./AccordionTrigger.vue";
- export { default as AccordionContent } from "./AccordionContent.vue";
- ` }}
-
-
- Update the import paths to match your project setup.
-
-
-
-
Usage
-
- {{ `
- import {
- AccordionRoot,
- AccordionItem,
- AccordionTrigger,
- AccordionContent,
- } from "@/components/ui/accordion";
- import { Box } from "@/components/ui/box";
- ` }}
-
-
- {{ `
-
-
-
- Product Information
-
-
- Our flagship product combines cutting-edge technology with
- sleek design. Built with premium materials, it offers
- unparalleled performance and reliability.
-
-
- Key features include advanced processing capabilities, and an
- intuitive user interface designed for both beginners and
- experts.
-
-
-
-
- Shipping Details
-
-
- We offer worldwide shipping through trusted courier partners.
- Standard delivery takes 3-5 business days, while express
- shipping ensures delivery within 1-2 business days.
-
-
- All orders are carefully packaged and fully insured. Track
- your shipment in real-time through our dedicated tracking
- portal.
-
-
-
-
- Return Policy
-
-
- We stand behind our products with a comprehensive 30-day
- return policy. If you're not completely satisfied, simply
- return the item in its original condition.
-
-
- Our hassle-free return process includes free return shipping
- and full refunds processed within 48 hours of receiving the
- returned item.
-
-
-
-
-
- ` }}
-
-
-
-
Variants
-
A collection of components you can use.
-
-
-
-
- Product Information
-
-
- Our flagship product combines cutting-edge technology with sleek
- design. Built with premium materials, it offers unparalleled
- performance and reliability.
-
-
- Key features include advanced processing capabilities, and an
- intuitive user interface designed for both beginners and
- experts.
-
-
-
-
- Shipping Details
-
-
- We offer worldwide shipping through trusted courier partners.
- Standard delivery takes 3-5 business days, while express
- shipping ensures delivery within 1-2 business days.
-
-
- All orders are carefully packaged and fully insured. Track your
- shipment in real-time through our dedicated tracking portal.
-
-
-
-
- Return Policy
-
-
- We stand behind our products with a comprehensive 30-day return
- policy. If you're not completely satisfied, simply return the
- item in its original condition.
-
-
- Our hassle-free return process includes free return shipping and
- full refunds processed within 48 hours of receiving the returned
- item.
-
-
-
-
-
-
-
- {{ `
-
-
- Product Information
-
-
- Our flagship product combines cutting-edge technology with sleek
- design. Built with premium materials, it offers unparalleled
- performance and reliability.
-
-
- Key features include advanced processing capabilities, and an
- intuitive user interface designed for both beginners and
- experts.
-
-
-
-
- Shipping Details
-
-
- We offer worldwide shipping through trusted courier partners.
- Standard delivery takes 3-5 business days, while express
- shipping ensures delivery within 1-2 business days.
-
-
- All orders are carefully packaged and fully insured. Track your
- shipment in real-time through our dedicated tracking portal.
-
-
-
-
- Return Policy
-
-
- We stand behind our products with a comprehensive 30-day return
- policy. If you're not completely satisfied, simply return the
- item in its original condition.
-
-
- Our hassle-free return process includes free return shipping and
- full refunds processed within 48 hours of receiving the returned
- item.
-
-
-
-
- ` }}
-
-
-
-
diff --git a/fe/src/docs/pages/data-table.vue b/fe/src/docs/pages/data-table.vue
deleted file mode 100644
index 6b43719..0000000
--- a/fe/src/docs/pages/data-table.vue
+++ /dev/null
@@ -1,197 +0,0 @@
-
-
-
-
-
-
-
-
-
-
{{ error }}
-
-
-
- {{ item.name }}
-
-
-
- {{ item.email }}
-
-
-
- {{item.roles?.map((role: UserRole) => role.name).join(", ") || "-"}}
-
-
-
-
- {{ item.status }}
-
-
-
-
-
-
- Installation
- Install the following dependencies:
-
- add xlsx jspdf jspdf-autotable file-saver
-
-
-
diff --git a/fe/src/docs/pages/datepicker.vue b/fe/src/docs/pages/datepicker.vue
index ff0eb08..3798624 100644
--- a/fe/src/docs/pages/datepicker.vue
+++ b/fe/src/docs/pages/datepicker.vue
@@ -100,7 +100,7 @@ import {
{{
month.label
- }}
+ }}
@@ -124,7 +124,7 @@ import {
{{
year.label
- }}
+ }}
@@ -522,10 +522,7 @@ const api = inject("datepickerApi");
{{ `
-
-
-
-
-
-
- Select status
- Todo
- In Progress
- Done
- Cancelled
-
-
-
-
- {{ `
-
- Select status
- Todo
- In Progress
- Done
- Cancelled
-
- ` }}
-
-
-
-
-
-
Installation
-
- Copy and paste the following code into your project.
-
-
- {{ `
-
-
-
-
-
-
-
- ` }}
-
-
- {{ `
-
-
-
-
-
-
-
- ` }}
-
-
- {{ `
-
-
-
-
-
-
-
- ` }}
-
-
- {{ `
- export { default as NativeSelect } from "./NativeSelect.vue";
- export { default as NativeSelectOption } from "./NativeSelectOption.vue";
- export { default as NativeSelectOptionGroup } from "./NativeSelectOptionGroup.vue";
- ` }}
-
-
- Update the import paths to match your project setup.
-
-
-
-
Usage
-
- {{ `
- import {
- NativeSelect,
- NativeSelectOption,
- NativeSelectOptionGroup,
- } from "@/components/ui/native-select";
- ` }}
-
-
- {{ `
-
- Select status
- Todo
- In Progress
- Done
- Cancelled
-
- ` }}
-
-
-
-
Variants
-
A collection of components you can use.
-
-
-
- Select department
-
- Frontend
- Backend
- DevOps
-
-
- Sales Rep
- Account Manager
- Sales Director
-
-
- Customer Support
- Product Manager
- Operations Manager
-
-
-
-
-
- {{ `
-
- Select department
-
- Frontend
- Backend
- DevOps
-
-
- Sales Rep
- Account Manager
- Sales Director
-
-
- Customer Support
- Product Manager
- Operations Manager
-
-
- ` }}
-
-
-
-
-
-
- Select status
- Todo
- In Progress
- Done
- Cancelled
-
-
-
-
- {{ `
-
- Select status
- Todo
- In Progress
- Done
- Cancelled
-
- ` }}
-
-
-
-
-
diff --git a/fe/src/main/side-menu.ts b/fe/src/main/side-menu.ts
index 8edbd9a..a7916e0 100644
--- a/fe/src/main/side-menu.ts
+++ b/fe/src/main/side-menu.ts
@@ -1,10 +1,20 @@
import type { Menu } from '@/core/types/menu'
import { authMenu } from '@/modules/auth'
import { userMenu } from '@/modules/user'
+import { roleMenu } from '@/modules/role'
+import { membershipApplicationMenu } from '@/modules/membership-application'
+import { activityMenu } from '@/modules/activity'
export type { Menu }
const mainMenu: (string | Menu)[] = [
+ 'Umum',
+ ...activityMenu,
+ 'Teknologi Maklumat',
+ ...roleMenu,
+ 'Pentadbiran',
+ ...membershipApplicationMenu,
+ ...userMenu,
'GENERAL REPORTS',
{
icon: 'CircleGauge',
@@ -88,8 +98,6 @@ const mainMenu: (string | Menu)[] = [
},
],
},
- 'Admin',
- ...userMenu,
'APPS',
{
icon: 'CircleGauge',
@@ -411,11 +419,6 @@ const mainMenu: (string | Menu)[] = [
route_name: 'table',
title: 'Table',
},
- {
- icon: 'CircleGauge',
- route_name: 'data-table',
- title: 'Data Table',
- },
],
},
{
diff --git a/fe/src/modules/activity/components/ActivityReportsSection.vue b/fe/src/modules/activity/components/ActivityReportsSection.vue
new file mode 100644
index 0000000..8ac7c90
--- /dev/null
+++ b/fe/src/modules/activity/components/ActivityReportsSection.vue
@@ -0,0 +1,328 @@
+
+
+
+
+
+
+
Laporan Aktiviti
+
{{ reports.length }} laporan
+
+
+
+
+ Tambah Laporan
+
+
+
+
+
+ Tiada laporan aktiviti.
+
+
+
+
+
+
+ {{ report.prepared_by_user?.name ?? 'Pengguna' }}
+
+
+ {{ report.prepared_by_user.email }}
+
+
+
+
+ {{ formatDateTime(report.created_at) }}
+
+
+
+
+
+
+
+
+
+
+ {{ report.report_text }}
+
+
+
+
+
+
+
+ {{ reportModalTitle }}
+ {{ reportModalDescription }}
+
+
+
+
+
+
+
+
+
+
Padam Laporan Aktiviti?
+
+ Adakah anda benar-benar mahu memadam laporan ini?
+
+ Proses ini tidak boleh dibatalkan.
+
+
+ {{ deleteError }}
+
+
+
+
+ Batal
+
+
+ {{ deletingReport ? 'Memadam...' : 'Padam' }}
+
+
+
+
+
diff --git a/fe/src/modules/activity/components/ActivityTypeSelectField.vue b/fe/src/modules/activity/components/ActivityTypeSelectField.vue
new file mode 100644
index 0000000..89b2010
--- /dev/null
+++ b/fe/src/modules/activity/components/ActivityTypeSelectField.vue
@@ -0,0 +1,296 @@
+
+
+
+
+ Jenis Aktiviti
+
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Tambah Jenis Aktiviti
+
+ Daftar jenis aktiviti baharu untuk digunakan dalam borang ini.
+
+
+
+
+ Nama
+
+ {{ typeNameError }}
+
+
+
+ Kod
+
+ {{ typeCodeError }}
+
+
+
+
+ Batal
+
+
+ {{ savingType ? 'Menyimpan...' : 'Simpan' }}
+
+
+
+
+
+
diff --git a/fe/src/modules/activity/composables/useActivityDetail.ts b/fe/src/modules/activity/composables/useActivityDetail.ts
new file mode 100644
index 0000000..a25a9f3
--- /dev/null
+++ b/fe/src/modules/activity/composables/useActivityDetail.ts
@@ -0,0 +1,49 @@
+import { computed, onMounted, ref, watch } from 'vue'
+import { useRoute } from 'vue-router'
+import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
+import { getActivity } from '../services/activity.service'
+import type { ActivityDetail } from '../types/activity.types'
+
+export function useActivityDetail() {
+ const route = useRoute()
+ const activity = ref(null)
+ const loading = ref(false)
+ const error = ref(null)
+
+ const activityId = computed(() => String(route.params.id ?? ''))
+
+ async function fetchActivity() {
+ if (!activityId.value) {
+ error.value = 'ID aktiviti tidak sah.'
+ return
+ }
+
+ loading.value = true
+ error.value = null
+
+ try {
+ activity.value = await getActivity(activityId.value)
+ } catch (err) {
+ error.value = getApiErrorMessage(err, 'Gagal memuatkan butiran aktiviti.')
+ activity.value = null
+ } finally {
+ loading.value = false
+ }
+ }
+
+ watch(activityId, () => {
+ fetchActivity()
+ })
+
+ onMounted(() => {
+ fetchActivity()
+ })
+
+ return {
+ activity,
+ activityId,
+ loading,
+ error,
+ fetchActivity,
+ }
+}
diff --git a/fe/src/modules/activity/composables/useActivityList.ts b/fe/src/modules/activity/composables/useActivityList.ts
new file mode 100644
index 0000000..e98feae
--- /dev/null
+++ b/fe/src/modules/activity/composables/useActivityList.ts
@@ -0,0 +1,84 @@
+import { onMounted, ref, watch } from 'vue'
+import debounce from 'lodash/debounce'
+import { useApiPagination } from '@/composables/useApiPagination'
+import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
+import { listActivities } from '../services/activity.service'
+import type { ActivityListItem } from '../types/activity.types'
+
+export function useActivityList() {
+ const activities = ref([])
+ const loading = ref(false)
+ const error = ref(null)
+ const search = ref('')
+ const page = ref(1)
+ const itemsPerPage = ref(10)
+ const sortBy = ref('start_datetime')
+ const sortOrder = ref<'asc' | 'desc'>('desc')
+
+ const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
+
+ async function fetchActivities(requestPage = page.value) {
+ loading.value = true
+ error.value = null
+
+ try {
+ const data = await listActivities({
+ page: requestPage,
+ per_page: itemsPerPage.value,
+ sort_by: sortBy.value,
+ sort_order: sortOrder.value,
+ search: search.value.trim() || undefined,
+ })
+
+ activities.value = data.data
+ applyPagination(data.pagination)
+ page.value = data.pagination.current_page
+ } catch (err) {
+ error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai aktiviti.')
+ activities.value = []
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const debouncedSearch = debounce(() => {
+ fetchActivities(1)
+ }, 400)
+
+ watch(search, () => {
+ debouncedSearch()
+ })
+
+ watch([sortBy, sortOrder], () => {
+ fetchActivities(1)
+ })
+
+ watch(page, (nextPage, previousPage) => {
+ if (nextPage !== previousPage) {
+ fetchActivities(nextPage)
+ }
+ })
+
+ watch(itemsPerPage, (nextValue, previousValue) => {
+ if (nextValue !== previousValue) {
+ fetchActivities(1)
+ }
+ })
+
+ onMounted(() => {
+ fetchActivities(1)
+ })
+
+ return {
+ activities,
+ loading,
+ error,
+ search,
+ page,
+ itemsPerPage,
+ sortBy,
+ sortOrder,
+ pagination,
+ fetchActivities,
+ }
+}
diff --git a/fe/src/modules/activity/index.ts b/fe/src/modules/activity/index.ts
new file mode 100644
index 0000000..4384c91
--- /dev/null
+++ b/fe/src/modules/activity/index.ts
@@ -0,0 +1,2 @@
+export { activityLayoutRoutes } from './routes'
+export { activityMenu } from './menu'
diff --git a/fe/src/modules/activity/menu.ts b/fe/src/modules/activity/menu.ts
new file mode 100644
index 0000000..6783387
--- /dev/null
+++ b/fe/src/modules/activity/menu.ts
@@ -0,0 +1,10 @@
+import type { Menu } from '@/core/types/menu'
+
+export const activityMenu: Menu[] = [
+ {
+ icon: 'CalendarDays',
+ route_name: 'list-activities',
+ title: 'Aktiviti',
+ permission: 'lihat aktiviti',
+ },
+]
diff --git a/fe/src/modules/activity/pages/ActivityCreate.vue b/fe/src/modules/activity/pages/ActivityCreate.vue
new file mode 100644
index 0000000..68d88db
--- /dev/null
+++ b/fe/src/modules/activity/pages/ActivityCreate.vue
@@ -0,0 +1,405 @@
+
+
+
+
+
+
Daftar Aktiviti
+
+ Kembali
+
+
+
+
+
+ Berjaya
+ {{ successMessage }}
+
+
+
+
+
+ Ralat
+ {{ error }}
+
+
+
+
+
+
+
+
+
+ Galeri Foto
+
+ Pilihan. Tambah imej beberapa kali sebelum mendaftar. Imej dimuat naik semasa pendaftaran.
+
+
+
+
+
+
+
+
+
+
+
+
+ Tambah Imej Galeri
+
+
+
+
+
+ Dokumen Lampiran
+
+ Pilihan. Tambah dokumen beberapa kali sebelum mendaftar. Dokumen dimuat naik semasa pendaftaran.
+
+
+
+
+
+
{{ document.file.name }}
+
{{ document.file.type || 'Dokumen' }}
+
+
+
+ Padam
+
+
+
+
+
+ Tambah Dokumen
+
+
+
+
+
+
+ Batal
+
+
+ {{ saving ? 'Menyimpan...' : 'Daftar Aktiviti' }}
+
+
+
+
+
diff --git a/fe/src/modules/activity/pages/ActivityDetail.vue b/fe/src/modules/activity/pages/ActivityDetail.vue
new file mode 100644
index 0000000..5d4e9ef
--- /dev/null
+++ b/fe/src/modules/activity/pages/ActivityDetail.vue
@@ -0,0 +1,547 @@
+
+
+
+
+
+
+
Butiran Aktiviti
+
+ {{ activity.reference_number ?? activity.id }} · {{ activity.title }}
+
+
+
+ Kembali
+
+
+
+ Kemaskini
+
+
+
+ Hapus
+
+
+
+
+
+ Ralat
+ {{ error }}
+
+
+
+
Memuatkan butiran aktiviti...
+
+
+
+
+
+
Tajuk Aktiviti
+
{{ activity.title }}
+
+
+
+ {{ activity.is_active ? 'Aktif' : 'Tidak Aktif' }}
+
+
+ {{ activity.activity_type.name }}
+
+
+
+
+
+
+ No. Rujukan:
+ {{ activity.reference_number ?? '-' }}
+
+
+ Penganjur:
+ {{ activity.organizer ?? '-' }}
+
+
+ Lokasi:
+ {{ activity.location ?? '-' }}
+
+
+ Tarikh Mula:
+ {{ formatDateTime(activity.start_datetime) }}
+
+
+ Tarikh Tamat:
+ {{ formatDateTime(activity.end_datetime) }}
+
+
+ Dicipta:
+ {{ formatDateTime(activity.created_at) }}
+
+
+ Dikemaskini:
+ {{ formatDateTime(activity.updated_at) }}
+
+
+
+
+
Penerangan
+
{{ activity.description }}
+
+
+
+
+
+ Tiada imej galeri dimuat naik.
+
+
+
+
+
+
+
Galeri Foto
+
{{ galleryImages.length }} imej
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ image.name }}
+
+ {{ image.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Dokumen Lampiran
+
+ {{ activity.documents.length }} dokumen · muat turun atau lihat
+
+
+
+
+
+
+ Tiada dokumen lampiran.
+
+
+
+
+
+
+
+
{{ documentLabel(document) }}
+
+
+ {{ document.name }} · {{ formatFileSize(document.file_size) }}
+
+
+ {{ document.description }}
+
+
+
+
+
+ {{
+ documentPreviewLoading && previewDocument?.id === document.id
+ ? 'Memuatkan...'
+ : 'Lihat'
+ }}
+
+
+
+ {{
+ downloadingDocumentId === document.id ? 'Memuat turun...' : 'Muat Turun'
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ previewDocument ? documentLabel(previewDocument) : 'Pratonton Dokumen' }}
+
+
+ {{ previewDocument.name }} · {{ formatFileSize(previewDocument.file_size) }}
+
+
+
+
+
+ Memuatkan dokumen...
+
+
+
+
+
+
+
+
+
+ Pratonton tidak tersedia untuk jenis fail ini. Sila muat turun dokumen.
+
+
+
+
+
+
+ Muat Turun
+
+
+ Tutup
+
+
+
+
+
+
+
+
+
+
+
Padam Aktiviti?
+
+ Adakah anda benar-benar mahu memadam
+ {{ activity.title }} ?
+
+ Semua dokumen, galeri, dan laporan berkaitan turut akan dipadam.
+
+
+ {{ deleteError }}
+
+
+
+
+ Batal
+
+
+ {{ deletingActivity ? 'Memadam...' : 'Hapus' }}
+
+
+
+
+
diff --git a/fe/src/modules/activity/pages/ActivityEdit.vue b/fe/src/modules/activity/pages/ActivityEdit.vue
new file mode 100644
index 0000000..6eb56d2
--- /dev/null
+++ b/fe/src/modules/activity/pages/ActivityEdit.vue
@@ -0,0 +1,504 @@
+
+
+
+
+
+
Kemaskini Aktiviti
+
+ Kembali
+
+
+
+
+
+ Berjaya
+ {{ successMessage }}
+
+
+
+
+
+ Ralat
+ {{ error }}
+
+
+
+
Memuatkan maklumat aktiviti...
+
+
+
+
+
+
+
+ Galeri Foto
+
+ Imej dimuat naik serta-merta apabila dipilih. Padam imej tanpa perlu simpan borang.
+
+
+
+
+
+
+
+
+
+
+
+
+ Tambah Imej Galeri
+
+ Memuat naik imej...
+
+
+
+
+ Dokumen Lampiran
+
+ Dokumen dimuat naik serta-merta apabila dipilih. Padam dokumen tanpa perlu simpan borang.
+
+
+
+
+
+
{{ document.name }}
+
{{ document.mime_type }}
+
+
+
+ Padam
+
+
+
+
+
+ Tambah Dokumen
+
+ Memuat naik dokumen...
+
+
+
+
+
+
+
+ Batal
+
+
+ {{ saving ? 'Menyimpan...' : 'Simpan Perubahan' }}
+
+
+
+
+
diff --git a/fe/src/modules/activity/pages/ActivityList.vue b/fe/src/modules/activity/pages/ActivityList.vue
new file mode 100644
index 0000000..1673fe4
--- /dev/null
+++ b/fe/src/modules/activity/pages/ActivityList.vue
@@ -0,0 +1,369 @@
+
+
+
+
+
+
+
Senarai Aktiviti
+
Urus dan lihat aktiviti koperasi.
+
+
+
+ Tambah Aktiviti
+
+
+
+
+
+ Ralat
+ {{ error }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ activity.activity_type?.name ?? 'Aktiviti' }}
+
+
+
+ {{ activity.reference_number }}
+
+ •
+ {{ formatDateTime(activity.start_datetime) }}
+
+
+
+ {{ activity.is_active ? 'Aktif' : 'Tidak Aktif' }}
+
+
+
+
+
+
+
+
+ Tiada imej
+
+
+
+
+ {{ activity.title }}
+
+
+ {{ truncateText(activity.description) }}
+
+
+
+
+ {{ activity.organizer }}
+
+
+
+ {{ activity.location }}
+
+
+
+
+
+
+
+ {{ activity.gallery_image_count }} imej
+
+
+
+ {{ activity.documents_count }} dokumen
+
+
+
+
+
+
+
+
+
+
+
+
+ Lihat
+
+
+ Edit
+
+
+
+
+
+
+
+
+
+
+ Tiada aktiviti ditemui.
+
+
+
+
+
+
+
+
+
+
+ {{ pageItem.value }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+
diff --git a/fe/src/modules/activity/routes.ts b/fe/src/modules/activity/routes.ts
new file mode 100644
index 0000000..1ba1b23
--- /dev/null
+++ b/fe/src/modules/activity/routes.ts
@@ -0,0 +1,44 @@
+import type { RouteRecordRaw } from 'vue-router'
+
+export const activityLayoutRoutes: RouteRecordRaw[] = [
+ {
+ path: 'activities',
+ name: 'list-activities',
+ component: () => import('./pages/ActivityList.vue'),
+ meta: {
+ title: 'Senarai Aktiviti',
+ module: 'activity',
+ permission: 'lihat aktiviti',
+ },
+ },
+ {
+ path: 'activities/create',
+ name: 'create-activity',
+ component: () => import('./pages/ActivityCreate.vue'),
+ meta: {
+ title: 'Daftar Aktiviti',
+ module: 'activity',
+ permission: 'tambah aktiviti',
+ },
+ },
+ {
+ path: 'activities/:id',
+ name: 'view-activity',
+ component: () => import('./pages/ActivityDetail.vue'),
+ meta: {
+ title: 'Butiran Aktiviti',
+ module: 'activity',
+ permission: 'lihat aktiviti',
+ },
+ },
+ {
+ path: 'activities/:id/edit',
+ name: 'edit-activity',
+ component: () => import('./pages/ActivityEdit.vue'),
+ meta: {
+ title: 'Kemaskini Aktiviti',
+ module: 'activity',
+ permission: 'kemaskini aktiviti',
+ },
+ },
+]
diff --git a/fe/src/modules/activity/services/activity.service.ts b/fe/src/modules/activity/services/activity.service.ts
new file mode 100644
index 0000000..8999ab9
--- /dev/null
+++ b/fe/src/modules/activity/services/activity.service.ts
@@ -0,0 +1,337 @@
+import { api } from '@/core/services/api'
+import type { PaginatedApiResponse } from '@/core/types/api'
+import type {
+ ActivityDetail,
+ ActivityListItem,
+ ActivityReport,
+ ActivityType,
+ CreateActivityReportPayload,
+ CreateActivityPayload,
+ CreateActivityTypePayload,
+ ListActivitiesParams,
+ UpdateActivityFiles,
+ UpdateActivityPayload,
+ UpdateActivityReportPayload,
+} from '../types/activity.types'
+
+type ActivityApiResponse = {
+ success: boolean
+ data: ActivityDetail
+ message?: string
+}
+
+type ActivityReportApiResponse = {
+ success: boolean
+ data: ActivityReport
+ message?: string
+}
+
+function appendIfPresent(formData: FormData, key: string, value: string | null | undefined) {
+ if (value !== null && value !== undefined && value !== '') {
+ formData.append(key, value)
+ }
+}
+
+export function buildActivityFormData(
+ payload: UpdateActivityPayload,
+ files: UpdateActivityFiles = {},
+ options: { method?: 'PUT' } = {},
+): FormData {
+ const formData = new FormData()
+
+ formData.append('activity_type_id', payload.activity_type_id)
+ formData.append('title', payload.title)
+ formData.append('is_active', payload.is_active ? '1' : '0')
+ appendIfPresent(formData, 'reference_number', payload.reference_number ?? undefined)
+ appendIfPresent(formData, 'description', payload.description ?? undefined)
+ appendIfPresent(formData, 'start_datetime', payload.start_datetime ?? undefined)
+ appendIfPresent(formData, 'end_datetime', payload.end_datetime ?? undefined)
+ appendIfPresent(formData, 'organizer', payload.organizer ?? undefined)
+ appendIfPresent(formData, 'location', payload.location ?? undefined)
+
+ files.documents?.forEach((file, index) => {
+ formData.append(`documents[${index}]`, file)
+ })
+
+ files.gallery?.forEach((file, index) => {
+ formData.append(`gallery[${index}]`, file)
+ })
+
+ if (options.method === 'PUT') {
+ formData.append('_method', 'PUT')
+ }
+
+ return formData
+}
+
+export async function listActivities(
+ params: ListActivitiesParams,
+): Promise> {
+ const { data } = await api.get>('/v1/activities', {
+ params,
+ })
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal memuatkan senarai aktiviti.')
+ }
+
+ return data
+}
+
+export async function listActivityTypes(): Promise {
+ const { data } = await api.get>('/v1/activity-types', {
+ params: { per_page: 1000 },
+ })
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal memuatkan jenis aktiviti.')
+ }
+
+ return data.data
+}
+
+type ActivityTypeApiResponse = {
+ success: boolean
+ data: ActivityType
+ message?: string
+}
+
+export async function createActivityType(
+ payload: CreateActivityTypePayload,
+): Promise {
+ const { data } = await api.post('/v1/activity-types', payload)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal menambah jenis aktiviti.')
+ }
+
+ return data.data
+}
+
+export async function getActivity(id: string): Promise {
+ const { data } = await api.get(`/v1/activities/${id}`)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal memuatkan butiran aktiviti.')
+ }
+
+ return data.data
+}
+
+export async function updateActivity(
+ id: string,
+ payload: UpdateActivityPayload,
+ files: UpdateActivityFiles = {},
+): Promise {
+ const formData = buildActivityFormData(payload, files, { method: 'PUT' })
+
+ const { data } = await api.post(`/v1/activities/${id}`, formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ })
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal mengemaskini aktiviti.')
+ }
+
+ return data.data
+}
+
+export async function createActivity(
+ payload: CreateActivityPayload,
+ files: UpdateActivityFiles = {},
+): Promise {
+ const formData = buildActivityFormData(payload, files)
+
+ const { data } = await api.post('/v1/activities', formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ })
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal mendaftar aktiviti.')
+ }
+
+ return data.data
+}
+
+export async function uploadActivityGalleryImage(
+ activityId: string,
+ file: File,
+): Promise {
+ const formData = new FormData()
+ formData.append('file', file)
+
+ const { data } = await api.post(
+ `/v1/activities/${activityId}/gallery`,
+ formData,
+ {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ },
+ )
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal memuat naik imej galeri.')
+ }
+
+ return data.data
+}
+
+export async function uploadActivityDocument(
+ activityId: string,
+ file: File,
+): Promise {
+ const formData = new FormData()
+ formData.append('file', file)
+
+ const { data } = await api.post(
+ `/v1/activities/${activityId}/documents`,
+ formData,
+ {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ },
+ )
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal memuat naik dokumen.')
+ }
+
+ return data.data
+}
+
+export async function deleteActivityGalleryImage(
+ activityId: string,
+ imageId: string,
+): Promise {
+ const { data } = await api.delete(
+ `/v1/activities/${activityId}/gallery/${imageId}`,
+ )
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal memadam imej galeri.')
+ }
+
+ return data.data
+}
+
+export async function deleteActivityDocument(
+ activityId: string,
+ documentId: string,
+): Promise {
+ const { data } = await api.delete(
+ `/v1/activities/${activityId}/documents/${documentId}`,
+ )
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal memadam dokumen.')
+ }
+
+ return data.data
+}
+
+export async function fetchActivityGalleryImage(
+ activityId: string,
+ imageId: string,
+ mimeType?: string | null,
+): Promise {
+ const response = await api.get(`/v1/activities/${activityId}/gallery/${imageId}`, {
+ responseType: 'blob',
+ })
+
+ const contentType =
+ mimeType ||
+ (typeof response.headers['content-type'] === 'string' ? response.headers['content-type'] : null) ||
+ 'application/octet-stream'
+
+ return new Blob([response.data], { type: contentType })
+}
+
+export async function fetchActivityDocument(
+ activityId: string,
+ documentId: string,
+ mimeType?: string | null,
+): Promise {
+ const response = await api.get(
+ `/v1/activities/${activityId}/documents/${documentId}/download`,
+ { responseType: 'blob' },
+ )
+
+ const contentType =
+ mimeType ||
+ (typeof response.headers['content-type'] === 'string' ? response.headers['content-type'] : null) ||
+ 'application/octet-stream'
+
+ return new Blob([response.data], { type: contentType })
+}
+
+export async function downloadActivityDocument(
+ activityId: string,
+ documentId: string,
+ fileName: string,
+ mimeType?: string | null,
+): Promise {
+ const blob = await fetchActivityDocument(activityId, documentId, mimeType)
+ const url = window.URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = fileName
+ link.click()
+ window.URL.revokeObjectURL(url)
+}
+
+export async function createActivityReport(
+ payload: CreateActivityReportPayload,
+): Promise {
+ const { data } = await api.post('/v1/activity-reports', payload)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal menambah laporan aktiviti.')
+ }
+
+ return data.data
+}
+
+export async function updateActivityReport(
+ id: string,
+ payload: UpdateActivityReportPayload,
+): Promise {
+ const { data } = await api.put(`/v1/activity-reports/${id}`, payload)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal mengemaskini laporan aktiviti.')
+ }
+
+ return data.data
+}
+
+type ActivityDeleteResponse = {
+ success: boolean
+ message?: string
+}
+
+export async function deleteActivity(id: string): Promise {
+ const { data } = await api.delete(`/v1/activities/${id}`)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal memadam aktiviti.')
+ }
+}
+
+type ActivityReportDeleteResponse = {
+ success: boolean
+ message?: string
+}
+
+export async function deleteActivityReport(id: string): Promise {
+ const { data } = await api.delete(`/v1/activity-reports/${id}`)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal memadam laporan aktiviti.')
+ }
+}
diff --git a/fe/src/modules/activity/types/activity.types.ts b/fe/src/modules/activity/types/activity.types.ts
new file mode 100644
index 0000000..c35f696
--- /dev/null
+++ b/fe/src/modules/activity/types/activity.types.ts
@@ -0,0 +1,126 @@
+export type ActivityType = {
+ id: string
+ name: string
+ code: string | null
+ created_at: string
+ updated_at: string
+}
+
+export type CreateActivityTypePayload = {
+ name: string
+ code?: string | null
+}
+
+export type ActivityGalleryImage = {
+ id: string
+ activity_id: string
+ name: string
+ description: string | null
+ mime_type: string
+ file_size: number
+ uploaded_by: string
+ url: string
+ created_at: string
+}
+
+export type ActivityDocument = {
+ id: string
+ activity_id: string
+ name: string
+ type: string | null
+ description: string | null
+ mime_type: string
+ file_size: number
+ uploaded_by: string
+ url: string
+ created_at: string
+}
+
+export type ActivityReport = {
+ id: string
+ activity_id: string
+ report_text: string
+ prepared_by: string
+ prepared_by_user?: {
+ id: string
+ name: string
+ email: string
+ } | null
+ created_at: string
+ updated_at: string
+}
+
+export type ActivityDetail = {
+ id: string
+ activity_type_id: string
+ activity_type: ActivityType | null
+ title: string
+ reference_number: string | null
+ description: string | null
+ start_datetime: string | null
+ end_datetime: string | null
+ organizer: string | null
+ location: string | null
+ is_active: boolean
+ documents: ActivityDocument[]
+ gallery_images: ActivityGalleryImage[]
+ reports: ActivityReport[]
+ created_at: string
+ updated_at: string
+}
+
+export type ActivityListItem = {
+ id: string
+ activity_type_id: string
+ activity_type: ActivityType | null
+ title: string
+ reference_number: string | null
+ description: string | null
+ start_datetime: string | null
+ end_datetime: string | null
+ organizer: string | null
+ location: string | null
+ is_active: boolean
+ gallery_image_count: number
+ documents_count: number
+ cover_image: ActivityGalleryImage | null
+ created_at: string
+ updated_at: string
+}
+
+export type ListActivitiesParams = {
+ page?: number
+ per_page?: number
+ search?: string
+ sort_by?: string
+ sort_order?: 'asc' | 'desc'
+}
+
+export type UpdateActivityPayload = {
+ activity_type_id: string
+ title: string
+ reference_number?: string | null
+ description?: string | null
+ start_datetime?: string | null
+ end_datetime?: string | null
+ organizer?: string | null
+ location?: string | null
+ is_active: boolean
+}
+
+export type CreateActivityPayload = UpdateActivityPayload
+
+export type UpdateActivityFiles = {
+ documents?: File[]
+ gallery?: File[]
+}
+
+export type CreateActivityReportPayload = {
+ activity_id: string
+ report_text: string
+}
+
+export type UpdateActivityReportPayload = {
+ activity_id: string
+ report_text: string
+}
diff --git a/fe/src/modules/auth/index.ts b/fe/src/modules/auth/index.ts
index 06c07d7..7174ddf 100644
--- a/fe/src/modules/auth/index.ts
+++ b/fe/src/modules/auth/index.ts
@@ -6,10 +6,14 @@ export {
register,
verifyEmail,
resendVerificationEmail,
+ requestForgotPassword,
+ resetPassword,
fetchCurrentUser,
getAuthErrorMessage,
getRegisterErrorMessage,
getVerifyEmailErrorMessage,
+ getForgotPasswordErrorMessage,
+ getResetPasswordErrorMessage,
resolvePostLoginRoute,
resolvePostAuthRoute,
isAccountPending,
@@ -22,6 +26,10 @@ export type {
RegisterResponse,
VerifyEmailPayload,
VerifyEmailResponse,
+ ForgotPasswordPayload,
+ ForgotPasswordResponse,
+ ResetPasswordPayload,
+ ResetPasswordResponse,
SessionResponse,
AuthUser,
AuthRole,
diff --git a/fe/src/modules/auth/pages/ForgotPassword.vue b/fe/src/modules/auth/pages/ForgotPassword.vue
new file mode 100644
index 0000000..6f90612
--- /dev/null
+++ b/fe/src/modules/auth/pages/ForgotPassword.vue
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+ Lupa Kata Laluan
+
+
+ Kod OTP akan dihantar jika e-mel wujud dalam sistem.
+
+
+
+
+
+
+
+ Lupa Kata Laluan
+
+
+ Masukkan e-mel akaun anda. Kod OTP akan dihantar jika e-mel wujud dalam sistem.
+
+
+
+ Ralat
+ {{ errorMessage }}
+
+
+
+ {{ successMessage }}
+
+
+
+
+
+
+
+ {{ loading ? 'Menghantar...' : 'Hantar Kod OTP' }}
+
+
+ Kembali ke Log Masuk
+
+
+
+
+
+
+
+
+
+
diff --git a/fe/src/modules/auth/pages/Login.vue b/fe/src/modules/auth/pages/Login.vue
index a66af56..1e4f0d5 100644
--- a/fe/src/modules/auth/pages/Login.vue
+++ b/fe/src/modules/auth/pages/Login.vue
@@ -1,6 +1,6 @@
+
+
+
+
+
+
+
+
+
+
+ Tetapkan Semula Kata Laluan
+
+
+ Masukkan kod OTP dan kata laluan baharu anda.
+
+
+
+
+
+
+
+
+
+
diff --git a/fe/src/modules/auth/routes.ts b/fe/src/modules/auth/routes.ts
index 1149f99..31906f2 100644
--- a/fe/src/modules/auth/routes.ts
+++ b/fe/src/modules/auth/routes.ts
@@ -19,6 +19,18 @@ export const authPublicRoutes: RouteRecordRaw[] = [
component: () => import('./pages/VerifyEmail.vue'),
meta: { module: 'auth' },
},
+ {
+ path: '/forgot-password',
+ name: 'forgot-password',
+ component: () => import('./pages/ForgotPassword.vue'),
+ meta: { module: 'auth' },
+ },
+ {
+ path: '/reset-password',
+ name: 'reset-password',
+ component: () => import('./pages/ResetPassword.vue'),
+ meta: { module: 'auth' },
+ },
{
path: '/account-pending',
name: 'account-pending',
diff --git a/fe/src/modules/auth/services/auth.service.ts b/fe/src/modules/auth/services/auth.service.ts
index a478687..06391b2 100644
--- a/fe/src/modules/auth/services/auth.service.ts
+++ b/fe/src/modules/auth/services/auth.service.ts
@@ -1,12 +1,16 @@
import { api } from '@/core/services/api'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import type {
+ ForgotPasswordPayload,
+ ForgotPasswordResponse,
LoginCredentials,
LoginResponse,
LoginVerificationRequiredData,
RegisterCredentials,
RegisterResponse,
ResendVerificationResponse,
+ ResetPasswordPayload,
+ ResetPasswordResponse,
SessionResponse,
SwitchRoleResponse,
VerifyEmailPayload,
@@ -33,6 +37,20 @@ export async function resendVerificationEmail(email: string): Promise {
+ const { data } = await api.post('/forgot-password', payload)
+ return data
+}
+
+export async function resetPassword(
+ payload: ResetPasswordPayload,
+): Promise {
+ const { data } = await api.post('/reset-password', payload)
+ return data
+}
+
export async function fetchCurrentUser(): Promise {
const { data } = await api.get('/v1/me')
return data
@@ -59,6 +77,14 @@ export function getVerifyEmailErrorMessage(error: unknown): string {
return getApiErrorMessage(error, 'Email verification failed. Please try again.')
}
+export function getForgotPasswordErrorMessage(error: unknown): string {
+ return getApiErrorMessage(error, 'Gagal menghantar kod OTP. Sila cuba lagi.')
+}
+
+export function getResetPasswordErrorMessage(error: unknown): string {
+ return getApiErrorMessage(error, 'Gagal menetapkan semula kata laluan. Sila cuba lagi.')
+}
+
export function isAccountPending(user: { status: string } | null | undefined): boolean {
return user?.status === 'pending'
}
diff --git a/fe/src/modules/auth/types/auth.types.ts b/fe/src/modules/auth/types/auth.types.ts
index c759a0b..d985f1c 100644
--- a/fe/src/modules/auth/types/auth.types.ts
+++ b/fe/src/modules/auth/types/auth.types.ts
@@ -35,7 +35,14 @@ export interface AuthUser {
position: string | null
phone_number: string | null
image_url: string | null
+ member_number: number | null
+ member_type: string | null
status: string
+ gender: string | null
+ marriage_status: string | null
+ join_date: string | null
+ birth_date: string | null
+ birth_place: string | null
roles?: Array
}
@@ -91,3 +98,24 @@ export interface ResendVerificationResponse {
success: boolean
message: string
}
+
+export interface ForgotPasswordPayload {
+ email: string
+}
+
+export interface ForgotPasswordResponse {
+ success: boolean
+ message: string
+}
+
+export interface ResetPasswordPayload {
+ email: string
+ otp: string
+ password: string
+ password_confirmation: string
+}
+
+export interface ResetPasswordResponse {
+ success: boolean
+ message: string
+}
diff --git a/fe/src/modules/membership-application/composables/useMembershipApplicationList.ts b/fe/src/modules/membership-application/composables/useMembershipApplicationList.ts
new file mode 100644
index 0000000..9312dc9
--- /dev/null
+++ b/fe/src/modules/membership-application/composables/useMembershipApplicationList.ts
@@ -0,0 +1,93 @@
+import { onMounted, ref, watch } from 'vue'
+import debounce from 'lodash/debounce'
+import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
+import { useApiPagination } from '@/composables/useApiPagination'
+import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
+import { listMembershipApplications } from '../services/membership-application.service'
+import type { MembershipApplicationListItem } from '../types/membership-application.types'
+
+export function useMembershipApplicationList() {
+ const applications = ref([])
+ const loading = ref(false)
+ const error = ref(null)
+ const search = ref('')
+ const statusFilter = ref('')
+ const sortBy = ref([{ key: 'submitted_at', order: 'desc' }])
+ const page = ref(1)
+ const itemsPerPage = ref(10)
+
+ const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
+
+ async function fetchApplications(requestPage = page.value) {
+ loading.value = true
+ error.value = null
+
+ try {
+ const activeSort = sortBy.value[0]
+ const data = await listMembershipApplications({
+ page: requestPage,
+ per_page: itemsPerPage.value,
+ sort_by: activeSort?.key ?? 'submitted_at',
+ sort_order: activeSort?.order ?? 'desc',
+ search: search.value.trim() || undefined,
+ status: statusFilter.value.trim() || undefined,
+ })
+
+ applications.value = data.data
+ applyPagination(data.pagination)
+ page.value = data.pagination.current_page
+ } catch (err) {
+ error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai permohonan keahlian.')
+ applications.value = []
+ } finally {
+ loading.value = false
+ }
+ }
+
+ function handleSortUpdate(value: SortConfig[]) {
+ sortBy.value = value
+ fetchApplications(1)
+ }
+
+ const debouncedSearch = debounce(() => {
+ fetchApplications(1)
+ }, 400)
+
+ watch(search, () => {
+ debouncedSearch()
+ })
+
+ watch(statusFilter, () => {
+ fetchApplications(1)
+ })
+
+ watch(page, (nextPage, previousPage) => {
+ if (nextPage !== previousPage) {
+ fetchApplications(nextPage)
+ }
+ })
+
+ watch(itemsPerPage, (nextValue, previousValue) => {
+ if (nextValue !== previousValue) {
+ fetchApplications(1)
+ }
+ })
+
+ onMounted(() => {
+ fetchApplications(1)
+ })
+
+ return {
+ applications,
+ loading,
+ error,
+ search,
+ statusFilter,
+ sortBy,
+ page,
+ itemsPerPage,
+ pagination,
+ handleSortUpdate,
+ fetchApplications,
+ }
+}
diff --git a/fe/src/modules/membership-application/index.ts b/fe/src/modules/membership-application/index.ts
new file mode 100644
index 0000000..8789c9f
--- /dev/null
+++ b/fe/src/modules/membership-application/index.ts
@@ -0,0 +1,10 @@
+export { membershipApplicationPublicRoutes, membershipApplicationLayoutRoutes } from './routes'
+export { membershipApplicationMenu } from './menu'
+export { submitMembershipApplication, listMembershipApplications, getMembershipApplication, downloadMembershipApplicationDocument } from './services/membership-application.service'
+export type {
+ MembershipApplicationFormState,
+ MembershipApplicationSubmitResponse,
+ MembershipApplicationListItem,
+ MembershipApplicationDetail,
+ MembershipApplicationStatus,
+} from './types/membership-application.types'
diff --git a/fe/src/modules/membership-application/menu.ts b/fe/src/modules/membership-application/menu.ts
new file mode 100644
index 0000000..85f39e2
--- /dev/null
+++ b/fe/src/modules/membership-application/menu.ts
@@ -0,0 +1,10 @@
+import type { Menu } from '@/core/types/menu'
+
+export const membershipApplicationMenu: Menu[] = [
+ {
+ icon: 'ClipboardList',
+ route_name: 'list-membership-applications',
+ title: 'Permohonan Keahlian',
+ permission: 'lihat permohonan keahlian',
+ },
+]
diff --git a/fe/src/modules/membership-application/pages/MembershipApplication.vue b/fe/src/modules/membership-application/pages/MembershipApplication.vue
new file mode 100644
index 0000000..f6f76cc
--- /dev/null
+++ b/fe/src/modules/membership-application/pages/MembershipApplication.vue
@@ -0,0 +1,854 @@
+
+
+
+
+
+
+
+
+
+
Permohonan Keahlian
+
Borang permohonan ahli koperasi
+
+
+
+ Log Masuk
+
+
+
+
+
+
+
+
+
+ Permohonan Berjaya Dihantar
+
+ Terima kasih. Permohonan keahlian anda telah diterima.
+
+
+
No. Permohonan
+
{{ applicationNumber }}
+
+
+ E-mel permohonan anda telah dihantar. Anda akan menerima emel
+ apabila keputusan permohonan tersedia.
+
+
+ Kembali ke Log Masuk
+
+
+
+
+
+
+
+
+
+ {{ step.id }}
+
+
+ {{ step.label }}
+
+
+
+
+
+
+
{{ stepTitle }}
+
{{ stepDescription }}
+
+
+
+ Ralat
+ {{ errorMessage }}
+
+
+
+
+
+
+
+
diff --git a/fe/src/modules/membership-application/pages/MembershipApplicationDetail.vue b/fe/src/modules/membership-application/pages/MembershipApplicationDetail.vue
new file mode 100644
index 0000000..67ea0ea
--- /dev/null
+++ b/fe/src/modules/membership-application/pages/MembershipApplicationDetail.vue
@@ -0,0 +1,935 @@
+
+
+
+
+
+
+
Butiran Permohonan Keahlian
+
+ {{ application.application_number }} · {{ application.applicant?.name ?? '-' }}
+
+
+
+ Kembali
+
+
+
+ Kemaskini
+
+
+
+
+
+ Berjaya
+ {{ successMessage }}
+
+
+
+
+
+ Ralat
+ {{ error }}
+
+
+
+
Memuatkan butiran permohonan...
+
+
+
+
+
+
No. Permohonan
+
{{ application.application_number }}
+
+
+
+ {{ statusLabel(application.status) }}
+
+
+ Lembaga: {{ application.board_result === 'PASS' ? 'Lulus' : 'Gagal' }}
+
+
+
+
+
+
+ Tarikh Hantar:
+ {{ formatDateTime(application.submitted_at) }}
+
+
+ Tarikh Selesai:
+ {{ formatDateTime(application.completed_at) }}
+
+
+ Emel Pemohon:
+ {{ displayValue(application.applicant?.email) }}
+
+
+ No. IC:
+ {{ displayValue(application.applicant?.ic_number) }}
+
+
+
+
+
+
+
+
+
+ {{ step.id }}
+
+
+ {{ step.label }}
+
+
+
+
+
+
+ Semakan Pentadbiran
+
+ Luluskan atau tolak permohonan pada peringkat pentadbiran.
+
+
+ Catatan
+
+
+
+
+ Luluskan
+
+
+ Tolak
+
+
+
+
+
+ Semakan Lembaga
+
+ Luluskan atau gagalkan permohonan pada peringkat lembaga.
+
+
+ Catatan
+
+
+
+
+ Lulus
+
+
+ Gagal
+
+
+
+
+
+ Makluman Keputusan
+
+ Hantar e-mel keputusan kepada pemohon
+ dan cipta akaun ahli .
+
+
+
+ Selesaikan & Hantar Makluman
+
+
+
+
+
+
+
+
+
+ Maklumat Peribadi
+
+
+ Hubungan & Alamat
+
+
+ Pekerjaan & Caruman
+
+
+ Waris
+
+
+ Pencadang & Penyokong
+
+
+ Dokumen
+
+
+ Sejarah Semakan
+
+
+
+
+
+
+ Tiada maklumat pemohon.
+
+
+ Nama Penuh
+
+
+
+ Emel
+
+
+
+ No. Kad Pengenalan
+
+
+
+ Tarikh Lahir
+
+
+
+ Tempat Lahir
+
+
+
+ Jantina
+
+
+
+ Status Perkahwinan
+
+
+
+
+
+
+ Tiada maklumat pemohon.
+
+
+ Alamat
+
+
+
+ No. Telefon
+
+
+
+ No. Pejabat
+
+
+
+ Poskod
+
+
+
+
+
+
+ Tiada maklumat pemohon.
+
+
+ Nama Majikan
+
+
+
+ Jawatan Semasa
+
+
+
+ Alamat Majikan
+
+
+
+ Tarikh Mula Berkhidmat
+
+
+
+ Caruman Saham (RM)
+
+
+
+ Caruman Yuran (RM)
+
+
+
+
+
+
+ Tiada maklumat waris.
+
+
+
+
+
+
+
Pencadang
+
+
+ Nama:
+ {{ displayValue(getReference('PROPOSER')?.member?.name) }}
+
+
+ No. IC:
+ {{ displayValue(getReference('PROPOSER')?.member?.ic_number) }}
+
+
+
+
+
Penyokong
+
+
+ Nama:
+ {{ displayValue(getReference('SUPPORTER')?.member?.name) }}
+
+
+ No. IC:
+ {{ displayValue(getReference('SUPPORTER')?.member?.ic_number) }}
+
+
+
+
+
+
+
+ Tiada dokumen dimuat naik.
+
+
+
+
{{ documentLabel(document.type, document.name) }}
+
+ {{ document.name }} · {{ formatFileSize(document.file_size) }}
+
+
+
+
+
+ {{ previewLoading && previewDocument?.id === document.id ? 'Memuatkan...' : 'Lihat' }}
+
+
+
+ {{ downloadingDocumentId === document.id ? 'Memuat turun...' : 'Muat Turun' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ reviewStageLabel(review.stage) }}
+
+ {{ formatDateTime(review.reviewed_at) }}
+
+
+
+ {{ reviewDecisionLabel(review.decision, review.stage) }}
+
+
+
+
+
+ Disemak oleh:
+ {{ displayValue(review.reviewer?.name) }}
+
+
+ Catatan:
+ {{ review.remarks }}
+
+
+
+
+
+
+
+
+
+
{ confirmDialogOpen = details.open; if (!details.open) pendingAction = null }">
+
+
+
{{ confirmDialogTitle }}
+
{{ confirmDialogDescription }}
+
+
+
+ Batal
+
+
+ {{ reviewSubmitting || completeSubmitting ? 'Memproses...' : 'Sahkan' }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ previewDocument ? documentLabel(previewDocument.type, previewDocument.name) : 'Pratonton Dokumen' }}
+
+
+ {{ previewDocument.name }} · {{ formatFileSize(previewDocument.file_size) }}
+
+
+
+
+
+ Memuatkan dokumen...
+
+
+
+
+
+
+
+
+
+ Pratonton tidak tersedia untuk jenis fail ini. Sila muat turun dokumen.
+
+
+
+
+
+ Tutup
+
+
+
+ Muat Turun
+
+
+
+
+
+
+
diff --git a/fe/src/modules/membership-application/pages/MembershipApplicationEdit.vue b/fe/src/modules/membership-application/pages/MembershipApplicationEdit.vue
new file mode 100644
index 0000000..75a4e39
--- /dev/null
+++ b/fe/src/modules/membership-application/pages/MembershipApplicationEdit.vue
@@ -0,0 +1,1104 @@
+
+
+
+
+
+
+
Kemaskini Permohonan Keahlian
+
+ {{ application.application_number }} · {{ application.applicant?.name ?? '-' }}
+
+
+
+ Lihat
+
+
+ Kembali
+
+
+
+
+ Tidak Boleh Dikemaskini
+
+ Permohonan yang telah selesai tidak boleh dikemaskini.
+
+
+
+
+
+ Ralat
+ {{ error }}
+
+
+
+
Memuatkan permohonan...
+
+
+
+
+
+
No. Permohonan
+
{{ application.application_number }}
+
+
+
+ {{ statusLabel(application.status) }}
+
+
+ Lembaga: {{ application.board_result === 'PASS' ? 'Lulus' : 'Gagal' }}
+
+
+
+
+
+
+ Tarikh Hantar:
+ {{ formatDateTime(application.submitted_at) }}
+
+
+ Tarikh Selesai:
+ {{ formatDateTime(application.completed_at) }}
+
+
+ Emel Pemohon:
+ {{ displayValue(application.applicant?.email) }}
+
+
+ No. IC:
+ {{ displayValue(application.applicant?.ic_number) }}
+
+
+
+
+
+
+
+
+
+ {{ step.id }}
+
+
+ {{ step.label }}
+
+
+
+
+
+
+
+
+
+
+ Maklumat Peribadi
+
+
+ Hubungan & Alamat
+
+
+ Pekerjaan & Caruman
+
+
+ Waris
+
+
+ Pencadang & Penyokong
+
+
+ Dokumen
+
+
+ Sejarah Semakan
+
+
+
+
+
+
+
+
+ Nama Penuh
+
+ {{ fieldErrors['applicant.name'] }}
+
+
+ Emel
+
+ {{ fieldErrors['applicant.email'] }}
+
+
+ No. Kad Pengenalan
+
+ {{ fieldErrors['applicant.ic_number'] }}
+
+
+ Tarikh Lahir
+
+ {{ fieldErrors['applicant.birth_date'] }}
+
+
+ Tempat Lahir
+
+ {{ fieldErrors['applicant.birth_place'] }}
+
+
+ Jantina
+
+
+
+
+
+
+
+
+ Jantina
+
+ {{ item.label }}
+
+
+
+
+ {{ fieldErrors['applicant.gender'] }}
+
+
+ Status Perkahwinan
+
+
+
+
+
+
+
+
+ Status Perkahwinan
+
+ {{ item.label }}
+
+
+
+
+ {{ fieldErrors['applicant.marriage_status'] }}
+
+
+
+
+
+
+
+ Alamat
+
+ {{ fieldErrors['applicant.address'] }}
+
+
+ No. Telefon
+
+ {{ fieldErrors['applicant.phone_number'] }}
+
+
+ No. Pejabat
+
+ {{ fieldErrors['applicant.office_number'] }}
+
+
+ Poskod
+
+ {{ fieldErrors['applicant.postcode'] }}
+
+
+
+
+
+
+
+ Nama Majikan
+
+ {{ fieldErrors['applicant.employer_name'] }}
+
+
+ Jawatan Semasa
+
+ {{ fieldErrors['applicant.current_position'] }}
+
+
+ Alamat Majikan
+
+ {{ fieldErrors['applicant.employer_address'] }}
+
+
+ Tarikh Mula Berkhidmat
+
+ {{ fieldErrors['applicant.start_work_date'] }}
+
+
+ Caruman Saham (RM)
+
+ {{ fieldErrors['applicant.stock_monthly_contribution'] }}
+
+
+ Caruman Yuran (RM)
+
+ {{ fieldErrors['applicant.fee_monthly_contribution'] }}
+
+
+
+
+
+
+
+
+
+
+ Masukkan no. KP ahli sedia ada. Sistem akan mengesahkan dan mengisi nama secara automatik.
+
+
+
+
+
+
+ Saiz maksimum setiap fail: {{ MAX_FILE_SIZE_MB }}MB .
+ Muat naik fail baharu untuk menggantikan dokumen sedia ada.
+
+
+
+
+
+
{{ DOCUMENT_TYPE_LABELS[type] }}
+
+ {{ documentsByType[type]?.name }} · {{ formatFileSize(documentsByType[type]?.file_size) }}
+
+
Tiada dokumen dimuat naik.
+
+
+
+
+ Lihat
+
+
+
+ Muat Turun
+
+
+
+
+
+ {{ documentsByType[type] ? 'Ganti Dokumen' : 'Muat Naik Dokumen' }}
+
+
+ {{ fieldErrors[`documents.${type}`] }}
+ Memuat naik...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ reviewStageLabel(review.stage) }}
+
+ {{ formatDateTime(review.reviewed_at) }}
+
+
+
+ {{ reviewDecisionLabel(review.decision, review.stage) }}
+
+
+
+
+
+ Disemak oleh:
+ {{ displayValue(review.reviewer?.name) }}
+
+
+ Catatan:
+ {{ review.remarks }}
+
+
+
+
+
+
+
+
+
+
+
+ Berjaya
+ {{ successMessage }}
+
+
+
+
+
+ Ralat
+ {{ error }}
+
+
+
+
+
+ {{ saving ? 'Menyimpan...' : 'Simpan' }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ previewDocument ? documentLabel(previewDocument.type, previewDocument.name) : 'Pratonton Dokumen' }}
+
+
+ {{ previewDocument.name }} · {{ formatFileSize(previewDocument.file_size) }}
+
+
+
+
+
+ Memuatkan dokumen...
+
+
+
+
+
+
+
+
+
+ Pratonton tidak tersedia untuk jenis fail ini. Sila muat turun dokumen.
+
+
+
+
+
+ Tutup
+
+
+
+ Muat Turun
+
+
+
+
+
+
+
diff --git a/fe/src/modules/membership-application/pages/MembershipApplicationList.vue b/fe/src/modules/membership-application/pages/MembershipApplicationList.vue
new file mode 100644
index 0000000..bb3a89d
--- /dev/null
+++ b/fe/src/modules/membership-application/pages/MembershipApplicationList.vue
@@ -0,0 +1,502 @@
+
+
+
+
+
+
Senarai Permohonan Keahlian
+
Urus dan semak permohonan keahlian koperasi.
+
+
+
+
+ Berjaya
+ {{ batchSuccessMessage }}
+
+
+
+
+
+ Sebahagian Permohonan Gagal
+
+
+
+ {{ item.application_number ?? item.id }}: {{ item.message }}
+
+
+
+
+
+
+
+
+ Ralat
+ {{ error }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Status
+
+ {{ item.label }}
+
+
+
+
+
+
+
+ {{ allSelectableSelected ? 'Nyahpilih Halaman' : 'Pilih Halaman' }}
+
+
+ Selesaikan Terpilih ({{ selectedIds.length }})
+
+
+
+
+
+
+ toggleSelection((item as MembershipApplicationListItem).id)"
+ >
+
+
+
+
+
+ {{ (item as MembershipApplicationListItem).applicant?.name ?? '-' }}
+
+
+
+ {{ (item as MembershipApplicationListItem).applicant?.email ?? '-' }}
+
+
+
+ {{ (item as MembershipApplicationListItem).applicant?.ic_number ?? '-' }}
+
+
+
+
+ {{ statusLabel((item as MembershipApplicationListItem).status) }}
+
+
+
+
+
+ {{ boardResultLabel((item as MembershipApplicationListItem).board_result) }}
+
+ -
+
+
+
+ {{ formatSubmittedAt((item as MembershipApplicationListItem).submitted_at) }}
+
+
+
+
+
+
+
+
{ batchConfirmOpen = details.open }"
+ >
+
+
+
Selesaikan Permohonan Terpilih?
+
+ {{ selectedIds.length }} permohonan akan diselesaikan dan e-mel keputusan dihantar.
+
+
+
+
+ Batal
+
+
+ {{ batchSubmitting ? 'Memproses...' : 'Sahkan' }}
+
+
+
+
+
+
diff --git a/fe/src/modules/membership-application/routes.ts b/fe/src/modules/membership-application/routes.ts
new file mode 100644
index 0000000..108f9c2
--- /dev/null
+++ b/fe/src/modules/membership-application/routes.ts
@@ -0,0 +1,43 @@
+import type { RouteRecordRaw } from 'vue-router'
+
+export const membershipApplicationPublicRoutes: RouteRecordRaw[] = [
+ {
+ path: '/apply-membership',
+ name: 'membership-application-apply',
+ component: () => import('./pages/MembershipApplication.vue'),
+ meta: { public: true, module: 'membership-application' },
+ },
+]
+
+export const membershipApplicationLayoutRoutes: RouteRecordRaw[] = [
+ {
+ path: 'membership-applications',
+ name: 'list-membership-applications',
+ component: () => import('./pages/MembershipApplicationList.vue'),
+ meta: {
+ title: 'Senarai Permohonan Keahlian',
+ module: 'membership-application',
+ permission: 'lihat permohonan keahlian',
+ },
+ },
+ {
+ path: 'membership-applications/:id',
+ name: 'view-membership-application',
+ component: () => import('./pages/MembershipApplicationDetail.vue'),
+ meta: {
+ title: 'Butiran Permohonan Keahlian',
+ module: 'membership-application',
+ permission: 'lihat permohonan keahlian',
+ },
+ },
+ {
+ path: 'membership-applications/:id/edit',
+ name: 'edit-membership-application',
+ component: () => import('./pages/MembershipApplicationEdit.vue'),
+ meta: {
+ title: 'Kemaskini Permohonan Keahlian',
+ module: 'membership-application',
+ permission: 'kemaskini permohonan keahlian',
+ },
+ },
+]
diff --git a/fe/src/modules/membership-application/services/membership-application.service.ts b/fe/src/modules/membership-application/services/membership-application.service.ts
new file mode 100644
index 0000000..1d1cc14
--- /dev/null
+++ b/fe/src/modules/membership-application/services/membership-application.service.ts
@@ -0,0 +1,251 @@
+import { api } from '@/core/services/api'
+import type { PaginatedApiResponse } from '@/core/types/api'
+import type {
+ DocumentUploadType,
+ ListMembershipApplicationsParams,
+ MembershipApplicationApiResponse,
+ MembershipApplicationFormState,
+ MembershipApplicationListItem,
+ MembershipApplicationReviewPayload,
+ MembershipApplicationReviewResponse,
+ MembershipApplicationSubmitResponse,
+ BatchCompleteResponse,
+ MembershipApplicationUpdateResponse,
+ MemberLookupResponse,
+ UpdateMembershipApplicationPayload,
+} from '../types/membership-application.types'
+
+function appendFormData(formData: FormData, key: string, value: string | number | File) {
+ formData.append(key, String(value))
+}
+
+// Build form data for membership application
+export function buildMembershipApplicationFormData(
+ form: MembershipApplicationFormState,
+): FormData {
+ const formData = new FormData()
+
+ Object.entries(form.applicant).forEach(([key, value]) => {
+ if (value !== '') {
+ appendFormData(formData, `applicant[${key}]`, value)
+ }
+ })
+
+ form.heirs.forEach((heir, index) => {
+ Object.entries(heir).forEach(([key, value]) => {
+ if (value !== '') {
+ appendFormData(formData, `heirs[${index}][${key}]`, value)
+ }
+ })
+ })
+
+ if (form.references.proposer.ic_number.trim()) {
+ appendFormData(formData, 'references[proposer_ic_number]', form.references.proposer.ic_number.trim())
+ }
+
+ if (form.references.supporter.ic_number.trim()) {
+ appendFormData(formData, 'references[supporter_ic_number]', form.references.supporter.ic_number.trim())
+ }
+
+ Object.entries(form.documents).forEach(([key, file]) => {
+ if (file instanceof File) {
+ formData.append(`documents[${key}]`, file)
+ }
+ })
+
+ return formData
+}
+
+// Submit membership application
+export async function submitMembershipApplication(
+ form: MembershipApplicationFormState,
+): Promise {
+ const formData = buildMembershipApplicationFormData(form)
+
+ const { data } = await api.post(
+ '/v1/public/membership-applications',
+ formData,
+ {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ },
+ )
+
+ return data
+}
+
+// Lookup member by IC number
+export async function lookupMemberByIcNumber(icNumber: string): Promise {
+ const { data } = await api.get('/v1/public/membership-applications/member-lookup', {
+ params: { ic_number: icNumber.trim() },
+ })
+
+ return data
+}
+
+// List membership applications
+export async function listMembershipApplications(
+ params: ListMembershipApplicationsParams,
+): Promise> {
+ const { data } = await api.get>(
+ '/v1/membership-applications',
+ { params },
+ )
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Failed to load membership applications')
+ }
+
+ return data
+}
+
+// Get membership application
+export async function getMembershipApplication(id: string): Promise {
+ const { data } = await api.get(`/v1/membership-applications/${id}`)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Failed to load membership application')
+ }
+
+ return data
+}
+
+// Update membership application
+export async function updateMembershipApplication(
+ id: string,
+ payload: UpdateMembershipApplicationPayload,
+): Promise {
+ const { data } = await api.patch(
+ `/v1/membership-applications/${id}`,
+ payload,
+ )
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Failed to update membership application')
+ }
+
+ return data
+}
+
+// Upload membership application document
+export async function uploadMembershipApplicationDocument(
+ id: string,
+ type: DocumentUploadType,
+ file: File,
+): Promise {
+ const formData = new FormData()
+ formData.append('type', type)
+ formData.append('file', file)
+
+ const { data } = await api.post(
+ `/v1/membership-applications/${id}/documents`,
+ formData,
+ {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ },
+ )
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Failed to upload document')
+ }
+
+ return data
+}
+
+// Submit management review
+export async function submitManagementReview(
+ id: string,
+ payload: MembershipApplicationReviewPayload,
+): Promise {
+ const { data } = await api.post(
+ `/v1/membership-applications/${id}/management-review`,
+ payload,
+ )
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Failed to submit management review')
+ }
+
+ return data
+}
+
+// Submit board review
+export async function submitBoardReview(
+ id: string,
+ payload: MembershipApplicationReviewPayload,
+): Promise {
+ const { data } = await api.post(
+ `/v1/membership-applications/${id}/board-review`,
+ payload,
+ )
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Failed to submit board review')
+ }
+
+ return data
+}
+
+// Complete membership application
+export async function completeMembershipApplication(
+ id: string,
+): Promise {
+ const { data } = await api.post(
+ `/v1/membership-applications/${id}/complete`,
+ )
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Failed to complete membership application')
+ }
+
+ return data
+}
+
+export async function batchCompleteMembershipApplications(
+ applicationIds: string[],
+): Promise {
+ const { data } = await api.post(
+ '/v1/membership-applications/batch-complete',
+ { application_ids: applicationIds },
+ )
+
+ return data
+}
+
+// Fetch membership application document/ view in modal
+export async function fetchMembershipApplicationDocument(
+ applicationId: string,
+ documentId: string,
+ mimeType?: string | null,
+): Promise {
+ const response = await api.get(
+ `/v1/membership-applications/${applicationId}/documents/${documentId}/download`,
+ { responseType: 'blob' },
+ )
+
+ const contentType =
+ mimeType ||
+ (typeof response.headers['content-type'] === 'string' ? response.headers['content-type'] : null) ||
+ 'application/octet-stream'
+
+ return new Blob([response.data], { type: contentType })
+}
+
+// Download membership application document
+export async function downloadMembershipApplicationDocument(
+ applicationId: string,
+ documentId: string,
+ fileName: string,
+ mimeType?: string | null,
+): Promise {
+ const blob = await fetchMembershipApplicationDocument(applicationId, documentId, mimeType)
+ const url = window.URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = fileName
+ link.click()
+ window.URL.revokeObjectURL(url)
+}
diff --git a/fe/src/modules/membership-application/types/membership-application.types.ts b/fe/src/modules/membership-application/types/membership-application.types.ts
new file mode 100644
index 0000000..d015a3c
--- /dev/null
+++ b/fe/src/modules/membership-application/types/membership-application.types.ts
@@ -0,0 +1,234 @@
+export interface MembershipApplicationApplicantForm {
+ name: string
+ email: string
+ ic_number: string
+ birth_date: string
+ birth_place: string
+ gender: string
+ marriage_status: string
+ address: string
+ phone_number: string
+ office_number: string
+ postcode: string
+ employer_name: string
+ employer_address: string
+ current_position: string
+ start_work_date: string
+ stock_monthly_contribution: string
+ fee_monthly_contribution: string
+}
+
+export interface MembershipApplicationHeirForm {
+ name: string
+ ic_number: string
+ relationship: string
+ phone_number: string
+}
+
+export interface MembershipApplicationReferenceForm {
+ ic_number: string
+ user_id: string
+ name: string
+}
+
+export interface MembershipApplicationReferencesForm {
+ proposer: MembershipApplicationReferenceForm
+ supporter: MembershipApplicationReferenceForm
+}
+
+export interface MembershipApplicationDocumentsForm {
+ ic_copy: File | null
+ photo: File | null
+ salary_slip: File | null
+ employer_letter: File | null
+}
+
+export interface MembershipApplicationFormState {
+ applicant: MembershipApplicationApplicantForm
+ heirs: MembershipApplicationHeirForm[]
+ references: MembershipApplicationReferencesForm
+ documents: MembershipApplicationDocumentsForm
+}
+
+export interface MemberLookupResponse {
+ success: boolean
+ message: string
+ data: {
+ id: string
+ name: string
+ ic_number: string
+ } | null
+}
+
+export interface MembershipApplicationSubmitResponse {
+ success: boolean
+ message: string
+ data: {
+ id: string
+ application_number: string
+ status: string
+ submitted_at: string
+ }
+}
+
+export type MembershipApplicationStatus =
+ | 'SUBMITTED'
+ | 'PENDING_BOARD'
+ | 'MANAGEMENT_REJECTED'
+ | 'PENDING_NOTIFICATION'
+ | 'COMPLETED'
+
+export type MembershipApplicationBoardResult = 'PASS' | 'FAIL'
+
+export interface MembershipApplicationApplicantSummary {
+ name: string
+ email: string
+ ic_number: string
+}
+
+export interface MembershipApplicationListItem {
+ id: string
+ application_number: string
+ status: MembershipApplicationStatus
+ board_result: MembershipApplicationBoardResult | null
+ submitted_at: string | null
+ applicant: MembershipApplicationApplicantSummary | null
+ created_at: string | null
+}
+
+export interface ListMembershipApplicationsParams {
+ page?: number
+ per_page?: number
+ sort_by?: string
+ sort_order?: 'asc' | 'desc'
+ search?: string
+ status?: string
+}
+
+export interface MembershipApplicationApplicantDetail {
+ id: string
+ name: string
+ email: string
+ ic_number: string
+ birth_date: string | null
+ birth_place: string | null
+ gender: string | null
+ marriage_status: string | null
+ address: string | null
+ phone_number: string | null
+ office_number: string | null
+ postcode: string | null
+ employer_name: string | null
+ employer_address: string | null
+ current_position: string | null
+ start_work_date: string | null
+ stock_monthly_contribution: string | number | null
+ fee_monthly_contribution: string | number | null
+}
+
+export interface MembershipApplicationHeirDetail {
+ id: string
+ name: string
+ ic_number: string
+ relationship: string
+ phone_number: string
+}
+
+export interface MembershipApplicationReferenceMember {
+ id: string
+ name: string
+ ic_number: string
+}
+
+export interface MembershipApplicationReferenceDetail {
+ id: string
+ reference_type: 'PROPOSER' | 'SUPPORTER'
+ user_id: string | null
+ member: MembershipApplicationReferenceMember | null
+ assigned_by: string | null
+ assigned_at: string | null
+}
+
+export interface MembershipApplicationDocumentDetail {
+ id: string
+ name: string
+ type: string
+ mime_type: string | null
+ file_size: number | null
+}
+
+export interface MembershipApplicationReviewDetail {
+ id: string
+ stage: 'MANAGEMENT' | 'BOARD'
+ decision: string | null
+ remarks: string | null
+ reviewer: { id: string; name: string } | null
+ reviewed_at: string | null
+}
+
+export interface MembershipApplicationDetail {
+ id: string
+ application_number: string
+ status: MembershipApplicationStatus
+ board_result: MembershipApplicationBoardResult | null
+ submitted_at: string | null
+ completed_at: string | null
+ applicant: MembershipApplicationApplicantDetail | null
+ heirs: MembershipApplicationHeirDetail[]
+ references: MembershipApplicationReferenceDetail[]
+ documents: MembershipApplicationDocumentDetail[]
+ reviews: MembershipApplicationReviewDetail[]
+ created_at: string | null
+ updated_at: string | null
+}
+
+export type MembershipApplicationApiResponse = {
+ success: boolean
+ data: MembershipApplicationDetail
+ message?: string
+}
+
+export interface UpdateMembershipApplicationPayload {
+ applicant: Omit & {
+ stock_monthly_contribution: number
+ fee_monthly_contribution: number
+ }
+ heirs: MembershipApplicationHeirForm[]
+ references: {
+ proposer_ic_number: string
+ supporter_ic_number: string
+ }
+}
+
+export type MembershipApplicationUpdateResponse = MembershipApplicationApiResponse & {
+ message: string
+}
+
+export type DocumentUploadType = 'ic_copy' | 'photo' | 'salary_slip' | 'employer_letter'
+
+export type ManagementReviewDecision = 'APPROVED' | 'REJECTED'
+export type BoardReviewDecision = 'PASS' | 'FAIL'
+
+export interface MembershipApplicationReviewPayload {
+ decision: ManagementReviewDecision | BoardReviewDecision
+ remarks?: string | null
+}
+
+export type MembershipApplicationReviewResponse = MembershipApplicationApiResponse & {
+ message: string
+}
+
+export interface BatchCompleteFailedItem {
+ id: string
+ application_number: string | null
+ message: string
+}
+
+export type BatchCompleteResponse = {
+ success: boolean
+ message: string
+ data: {
+ succeeded: MembershipApplicationDetail[]
+ failed: BatchCompleteFailedItem[]
+ }
+}
diff --git a/fe/src/modules/membership-application/utils/membership-application-form.utils.ts b/fe/src/modules/membership-application/utils/membership-application-form.utils.ts
new file mode 100644
index 0000000..18c61c3
--- /dev/null
+++ b/fe/src/modules/membership-application/utils/membership-application-form.utils.ts
@@ -0,0 +1,204 @@
+import dayjs from 'dayjs'
+import * as select from '@zag-js/select'
+import type {
+ MembershipApplicationDetail,
+ MembershipApplicationDocumentsForm,
+ MembershipApplicationFormState,
+ MembershipApplicationHeirForm,
+ MembershipApplicationReferenceForm,
+ UpdateMembershipApplicationPayload,
+} from '../types/membership-application.types'
+
+export type SelectOption = { label: string; value: string }
+
+export const GENDER_OPTIONS: SelectOption[] = [
+ { label: 'Lelaki', value: 'Lelaki' },
+ { label: 'Perempuan', value: 'Perempuan' },
+]
+
+export const MARRIAGE_STATUS_OPTIONS: SelectOption[] = [
+ { label: 'Belum Berkahwin', value: 'Belum Berkahwin' },
+ { label: 'Berkahwin', value: 'Berkahwin' },
+ { label: 'Bercerai', value: 'Bercerai' },
+ { label: 'Balu', value: 'Balu' },
+ { label: 'Duda', value: 'Duda' },
+]
+
+export const RELATIONSHIP_OPTIONS: SelectOption[] = [
+ { label: 'Isteri', value: 'Isteri' },
+ { label: 'Suami', value: 'Suami' },
+ { label: 'Anak', value: 'Anak' },
+ { label: 'Bapa', value: 'Bapa' },
+ { label: 'Ibu', value: 'Ibu' },
+ { label: 'Orang Tua', value: 'Orang Tua' },
+ { label: 'Saudara', value: 'Saudara' },
+ { label: 'Lain-lain', value: 'Lain-lain' },
+]
+
+export const DOCUMENT_TYPE_LABELS: Record = {
+ ic_copy: 'Salinan Kad Pengenalan',
+ photo: 'Gambar Passport',
+ salary_slip: 'Slip Gaji',
+ employer_letter: 'Surat Pengesahan Majikan',
+}
+
+export const DOCUMENT_UPLOAD_TYPES = ['ic_copy', 'photo', 'salary_slip', 'employer_letter'] as const
+export type DocumentUploadType = (typeof DOCUMENT_UPLOAD_TYPES)[number]
+
+export function createSelectCollection(options: SelectOption[]) {
+ return select.collection({
+ items: options,
+ itemToValue: (item) => item.label,
+ })
+}
+
+export function labelToApiValue(options: SelectOption[], label: string | undefined): string {
+ if (!label) return ''
+ return options.find((option) => option.label === label)?.value ?? ''
+}
+
+export function apiValueToLabel(options: SelectOption[], value: string | undefined): string[] {
+ if (!value) return []
+ const option = options.find((item) => item.value === value)
+ return option ? [option.label] : [value]
+}
+
+export function createEmptyReference(): MembershipApplicationReferenceForm {
+ return {
+ ic_number: '',
+ user_id: '',
+ name: '',
+ }
+}
+
+export function createEmptyHeir(): MembershipApplicationHeirForm {
+ return {
+ name: '',
+ ic_number: '',
+ relationship: '',
+ phone_number: '',
+ }
+}
+
+function toDateInputValue(value: string | null | undefined): string {
+ if (!value) return ''
+ return dayjs(value).format('YYYY-MM-DD')
+}
+
+function toFormString(value: string | number | null | undefined): string {
+ if (value === null || value === undefined) return ''
+ return String(value)
+}
+
+export function createEmptyFormState(): MembershipApplicationFormState {
+ return {
+ applicant: {
+ name: '',
+ email: '',
+ ic_number: '',
+ birth_date: '',
+ birth_place: '',
+ gender: '',
+ marriage_status: '',
+ address: '',
+ phone_number: '',
+ office_number: '',
+ postcode: '',
+ employer_name: '',
+ employer_address: '',
+ current_position: '',
+ start_work_date: '',
+ stock_monthly_contribution: '',
+ fee_monthly_contribution: '',
+ },
+ heirs: [createEmptyHeir()],
+ references: {
+ proposer: createEmptyReference(),
+ supporter: createEmptyReference(),
+ },
+ documents: {
+ ic_copy: null,
+ photo: null,
+ salary_slip: null,
+ employer_letter: null,
+ },
+ }
+}
+
+export function detailToFormState(detail: MembershipApplicationDetail): MembershipApplicationFormState {
+ const applicant = detail.applicant
+ const proposer = detail.references.find((reference) => reference.reference_type === 'PROPOSER')
+ const supporter = detail.references.find((reference) => reference.reference_type === 'SUPPORTER')
+
+ return {
+ applicant: {
+ name: toFormString(applicant?.name),
+ email: toFormString(applicant?.email),
+ ic_number: toFormString(applicant?.ic_number),
+ birth_date: toDateInputValue(applicant?.birth_date),
+ birth_place: toFormString(applicant?.birth_place),
+ gender: toFormString(applicant?.gender),
+ marriage_status: toFormString(applicant?.marriage_status),
+ address: toFormString(applicant?.address),
+ phone_number: toFormString(applicant?.phone_number),
+ office_number: toFormString(applicant?.office_number),
+ postcode: toFormString(applicant?.postcode),
+ employer_name: toFormString(applicant?.employer_name),
+ employer_address: toFormString(applicant?.employer_address),
+ current_position: toFormString(applicant?.current_position),
+ start_work_date: toDateInputValue(applicant?.start_work_date),
+ stock_monthly_contribution: toFormString(applicant?.stock_monthly_contribution),
+ fee_monthly_contribution: toFormString(applicant?.fee_monthly_contribution),
+ },
+ heirs: detail.heirs.length
+ ? detail.heirs.map((heir) => ({
+ name: toFormString(heir.name),
+ ic_number: toFormString(heir.ic_number),
+ relationship: toFormString(heir.relationship),
+ phone_number: toFormString(heir.phone_number),
+ }))
+ : [createEmptyHeir()],
+ references: {
+ proposer: {
+ ic_number: toFormString(proposer?.member?.ic_number),
+ user_id: toFormString(proposer?.user_id),
+ name: toFormString(proposer?.member?.name),
+ },
+ supporter: {
+ ic_number: toFormString(supporter?.member?.ic_number),
+ user_id: toFormString(supporter?.user_id),
+ name: toFormString(supporter?.member?.name),
+ },
+ },
+ documents: {
+ ic_copy: null,
+ photo: null,
+ salary_slip: null,
+ employer_letter: null,
+ },
+ }
+}
+
+export function buildUpdatePayload(form: MembershipApplicationFormState): UpdateMembershipApplicationPayload {
+ return {
+ applicant: {
+ ...form.applicant,
+ stock_monthly_contribution: Number(form.applicant.stock_monthly_contribution || 0),
+ fee_monthly_contribution: Number(form.applicant.fee_monthly_contribution || 0),
+ },
+ heirs: form.heirs,
+ references: {
+ proposer_ic_number: form.references.proposer.ic_number.trim(),
+ supporter_ic_number: form.references.supporter.ic_number.trim(),
+ },
+ }
+}
+
+export function emptyDocumentsForm(): MembershipApplicationDocumentsForm {
+ return {
+ ic_copy: null,
+ photo: null,
+ salary_slip: null,
+ employer_letter: null,
+ }
+}
diff --git a/fe/src/modules/notification/composables/useNotifications.ts b/fe/src/modules/notification/composables/useNotifications.ts
new file mode 100644
index 0000000..c6780f2
--- /dev/null
+++ b/fe/src/modules/notification/composables/useNotifications.ts
@@ -0,0 +1,72 @@
+import { ref } from 'vue'
+import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
+import {
+ listNotifications,
+ markAllNotificationsAsRead,
+ markNotificationAsRead,
+} from '../services/notification.service'
+import type { NotificationItem } from '../types/notification.types'
+
+const notifications = ref([])
+const unreadCount = ref(0)
+const loading = ref(false)
+const error = ref(null)
+
+export function useNotifications() {
+ async function fetchNotifications(perPage = 5) {
+ loading.value = true
+ error.value = null
+
+ try {
+ const response = await listNotifications({ per_page: perPage, page: 1 })
+ notifications.value = response.data
+ unreadCount.value = response.unread_count
+ } catch (fetchError) {
+ error.value = getApiErrorMessage(fetchError, 'Gagal memuatkan notifikasi.')
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function markAsRead(id: string) {
+ const notification = notifications.value.find((item) => item.id === id)
+ if (!notification || notification.is_read) {
+ return
+ }
+
+ try {
+ await markNotificationAsRead(id)
+ notification.is_read = true
+ unreadCount.value = Math.max(0, unreadCount.value - 1)
+ } catch (markError) {
+ error.value = getApiErrorMessage(markError, 'Gagal menandakan notifikasi sebagai dibaca.')
+ }
+ }
+
+ async function markAllAsRead() {
+ if (unreadCount.value === 0) {
+ return
+ }
+
+ try {
+ await markAllNotificationsAsRead()
+ notifications.value = notifications.value.map((notification) => ({
+ ...notification,
+ is_read: true,
+ }))
+ unreadCount.value = 0
+ } catch (markError) {
+ error.value = getApiErrorMessage(markError, 'Gagal menandakan semua notifikasi sebagai dibaca.')
+ }
+ }
+
+ return {
+ notifications,
+ unreadCount,
+ loading,
+ error,
+ fetchNotifications,
+ markAsRead,
+ markAllAsRead,
+ }
+}
diff --git a/fe/src/modules/notification/index.ts b/fe/src/modules/notification/index.ts
new file mode 100644
index 0000000..7e5e89a
--- /dev/null
+++ b/fe/src/modules/notification/index.ts
@@ -0,0 +1,3 @@
+export { useNotifications } from './composables/useNotifications'
+export { resolveNotificationRoute } from './utils/notification-navigation'
+export type { NotificationItem } from './types/notification.types'
diff --git a/fe/src/modules/notification/services/notification.service.ts b/fe/src/modules/notification/services/notification.service.ts
new file mode 100644
index 0000000..3d46436
--- /dev/null
+++ b/fe/src/modules/notification/services/notification.service.ts
@@ -0,0 +1,51 @@
+import { api } from '@/core/services/api'
+import type {
+ ListNotificationsParams,
+ NotificationActionApiResponse,
+ NotificationCountApiResponse,
+ NotificationsApiResponse,
+} from '../types/notification.types'
+
+export async function listNotifications(
+ params: ListNotificationsParams = {},
+): Promise {
+ const { data } = await api.get('/v1/notifications', {
+ params,
+ })
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal memuatkan notifikasi.')
+ }
+
+ return data
+}
+
+export async function getNotificationCount(): Promise {
+ const { data } = await api.get('/v1/notifications/count')
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal memuatkan bilangan notifikasi.')
+ }
+
+ return data
+}
+
+export async function markNotificationAsRead(id: string): Promise {
+ const { data } = await api.patch(`/v1/notifications/${id}/read`)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal menandakan notifikasi sebagai dibaca.')
+ }
+
+ return data
+}
+
+export async function markAllNotificationsAsRead(): Promise {
+ const { data } = await api.patch('/v1/notifications/mark-all-read')
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal menandakan semua notifikasi sebagai dibaca.')
+ }
+
+ return data
+}
diff --git a/fe/src/modules/notification/types/notification.types.ts b/fe/src/modules/notification/types/notification.types.ts
new file mode 100644
index 0000000..ae14bce
--- /dev/null
+++ b/fe/src/modules/notification/types/notification.types.ts
@@ -0,0 +1,62 @@
+export type NotificationNavigation = {
+ route: string
+ params: Record
+ query: Record
+}
+
+export type NotificationData = {
+ user_id?: string
+ sender_id?: string | null
+ type?: string
+ message?: string
+ user_name?: string
+ user_email?: string
+ user_status?: string
+}
+
+export type NotificationItem = {
+ id: string
+ type: string
+ title: string
+ message: string
+ is_read: boolean
+ created_at: string
+ time_ago: string
+ icon: string
+ color: string
+ navigation: NotificationNavigation
+ data: NotificationData
+}
+
+export type NotificationPagination = {
+ current_page: number
+ last_page: number
+ per_page: number
+ total: number
+ has_more: boolean
+}
+
+export type NotificationsApiResponse = {
+ success: boolean
+ data: NotificationItem[]
+ pagination: NotificationPagination
+ unread_count: number
+ message?: string
+}
+
+export type NotificationCountApiResponse = {
+ success: boolean
+ unread_count: number
+ message?: string
+}
+
+export type NotificationActionApiResponse = {
+ success: boolean
+ message?: string
+}
+
+export type ListNotificationsParams = {
+ page?: number
+ per_page?: number
+ filter?: 'all' | 'unread' | 'read'
+}
diff --git a/fe/src/modules/notification/utils/notification-navigation.ts b/fe/src/modules/notification/utils/notification-navigation.ts
new file mode 100644
index 0000000..4d90eb7
--- /dev/null
+++ b/fe/src/modules/notification/utils/notification-navigation.ts
@@ -0,0 +1,30 @@
+import type { RouteLocationRaw } from 'vue-router'
+import type { NotificationItem } from '../types/notification.types'
+
+export function resolveNotificationRoute(notification: NotificationItem): RouteLocationRaw | null {
+ const userId = notification.data.user_id
+
+ if (notification.type === 'user_activation_required') {
+ if (userId) {
+ return { name: 'edit-user', params: { id: String(userId) } }
+ }
+
+ return { name: 'list-users' }
+ }
+
+ const route = notification.navigation.route
+
+ if (route === '/users') {
+ if (userId) {
+ return { name: 'edit-user', params: { id: String(userId) } }
+ }
+
+ return { name: 'list-users' }
+ }
+
+ if (route === '/dashboard') {
+ return { name: 'dashboard-overview-1' }
+ }
+
+ return null
+}
diff --git a/fe/src/modules/profile/pages/BankDetailTab.vue b/fe/src/modules/profile/pages/BankDetailTab.vue
new file mode 100644
index 0000000..85f83ea
--- /dev/null
+++ b/fe/src/modules/profile/pages/BankDetailTab.vue
@@ -0,0 +1,548 @@
+
+
+
+
+
+
+
+
+
Akaun Bank
+
+ Urus maklumat akaun bank anda.
+
+
+
+
+
+ Memuatkan akaun bank...
+
+
+
+
+
+
+
+ {{ bankNameById[bankDetail.bank_id] ?? bankDetail.bank_id }}
+
+
+ {{ accountTypeLabel[bankDetail.account_type] ?? bankDetail.account_type }}
+
+
+
{{ bankDetail.account_name }}
+
{{ bankDetail.account_number }}
+
+
+
+
+ Kemaskini
+
+
+
+ Padam
+
+
+
+
+
+
+ Tiada akaun bank direkodkan.
+
+
+
+
+
+
+ {{ isEditingBankDetail ? 'Kemaskini Akaun Bank' : 'Tambah Akaun Bank' }}
+
+
+ {{
+ isEditingBankDetail
+ ? 'Kemas kini maklumat akaun bank yang dipilih.'
+ : 'Tambah akaun bank baharu ke profil anda.'
+ }}
+
+
+
+
+ Batal
+
+
+ {{ savingBankDetail ? 'Menyimpan...' : isEditingBankDetail ? 'Kemaskini' : 'Tambah' }}
+
+
+
+
+
+
+
+ Bank
+
+
+
+
+
+
+
+
+ Bank
+
+ {{ item.label }}
+
+
+
+
+ {{ bankDetailErrors.bank_id }}
+
+
+ Jenis Akaun
+
+
+
+
+
+
+
+
+ Jenis Akaun
+
+ {{ item.label }}
+
+
+
+
+
+ {{ bankDetailErrors.account_type }}
+
+
+
+ Nama Akaun
+
+
+ {{ bankDetailErrors.account_name }}
+
+
+
+ Nombor Akaun
+
+
+ {{ bankDetailErrors.account_number }}
+
+
+
+
+
+
+
+
+
diff --git a/fe/src/modules/profile/pages/ChangePassword.vue b/fe/src/modules/profile/pages/ChangePasswordTab.vue
similarity index 100%
rename from fe/src/modules/profile/pages/ChangePassword.vue
rename to fe/src/modules/profile/pages/ChangePasswordTab.vue
diff --git a/fe/src/modules/profile/pages/EmploymentTab.vue b/fe/src/modules/profile/pages/EmploymentTab.vue
new file mode 100644
index 0000000..4d5e959
--- /dev/null
+++ b/fe/src/modules/profile/pages/EmploymentTab.vue
@@ -0,0 +1,541 @@
+
+
+
+
+
+
+
+
+
Pekerjaan
+
+ Urus sejarah dan maklumat pekerjaan anda.
+
+
+
+
+
+ Memuatkan pekerjaan...
+
+
+
+
+
+
+ {{ employment.company_name }}
+ Semasa
+
+ {{ employmentTypeLabel[employment.employment_type] ?? employment.employment_type }}
+
+
+
{{ employment.job_title }}
+
{{ formatEmploymentPeriod(employment) }}
+
{{ formatSalary(employment.salary) }}
+
+
+
+
+ Kemaskini
+
+
+
+ Padam
+
+
+
+
+
+
+ Tiada pekerjaan direkodkan.
+
+
+
+
+
+
+ {{ isEditingEmployment ? 'Kemaskini Pekerjaan' : 'Tambah Pekerjaan' }}
+
+
+ {{
+ isEditingEmployment
+ ? 'Kemas kini maklumat pekerjaan yang dipilih.'
+ : 'Tambah rekod pekerjaan baharu ke profil anda.'
+ }}
+
+
+
+
+ Batal
+
+
+ {{ savingEmployment ? 'Menyimpan...' : isEditingEmployment ? 'Kemaskini' : 'Tambah' }}
+
+
+
+
+
+
+
+ Nama Syarikat
+
+ {{ employmentErrors.company_name }}
+
+
+ Jawatan
+
+ {{ employmentErrors.job_title }}
+
+
+ Jenis Kerja
+
+
+
+
+
+
+
+
+ Jenis Kerja
+
+ {{ item.label }}
+
+
+
+
+
+ {{ employmentErrors.employment_type }}
+
+
+
+ Gaji (RM)
+
+ {{ employmentErrors.salary }}
+
+
+ Tarikh Mula
+
+ {{ employmentErrors.start_date }}
+
+
+ Tarikh Tamat
+
+ {{ employmentErrors.end_date }}
+
+
+ (employmentForm.is_current = checked === true)">
+
+ Pekerjaan semasa
+
+
+
+
+
+
+
+
+
diff --git a/fe/src/modules/profile/pages/HeirTab.vue b/fe/src/modules/profile/pages/HeirTab.vue
new file mode 100644
index 0000000..f545a3c
--- /dev/null
+++ b/fe/src/modules/profile/pages/HeirTab.vue
@@ -0,0 +1,516 @@
+
+
+
+
+
+
+
+
+
Pewaris
+
+ Urus maklumat pewaris anda.
+
+
+
+
+
+ Memuatkan pewaris...
+
+
+
+
+
+
+ {{ heir.name }}
+ Utama
+ {{ heir.relationship }}
+
+
{{ heir.ic_number }}
+
{{ heir.phone_number }}
+
{{ heir.address }}
+
+
+
+
+ Kemaskini
+
+
+
+ Padam
+
+
+
+
+
+
+ Tiada pewaris direkodkan.
+
+
+
+
+
+
+ {{ isEditingHeir ? 'Kemaskini Pewaris' : 'Tambah Pewaris' }}
+
+
+ {{
+ isEditingHeir
+ ? 'Kemas kini maklumat pewaris yang dipilih.'
+ : 'Tambah pewaris baharu ke profil anda.'
+ }}
+
+
+
+
+ Batal
+
+
+ {{ savingHeir ? 'Menyimpan...' : isEditingHeir ? 'Kemaskini' : 'Tambah' }}
+
+
+
+
+
+
+
+ Nama
+
+ {{ heirErrors.name }}
+
+
+ No. Kad Pengenalan
+
+ {{ heirErrors.ic_number }}
+
+
+ Hubungan
+
+
+
+
+
+
+
+
+ Hubungan
+
+ {{ item.label }}
+
+
+
+
+ {{ heirErrors.relationship }}
+
+
+ No. Telefon
+
+ {{ heirErrors.phone_number }}
+
+
+ Alamat
+
+ {{ heirErrors.address }}
+
+
+ (heirForm.is_primary = checked === true)"
+ >
+
+ Pewaris utama
+
+
+
+
+
+
+
+
+
diff --git a/fe/src/modules/profile/pages/ProfileOverview2.vue b/fe/src/modules/profile/pages/ProfileOverview2.vue
index 20f2d98..6c55ebf 100644
--- a/fe/src/modules/profile/pages/ProfileOverview2.vue
+++ b/fe/src/modules/profile/pages/ProfileOverview2.vue
@@ -1,9 +1,8 @@
@@ -191,7 +133,6 @@ onMounted(async () => {
{{ authStore.userName || '-' }}
- {{ authStore.userActiveRole }}
{
-
-
-
{{ displayValue(authStore.activeRole?.name) }}
-
Peranan
-
-
-
{{ statusLabel }}
-
Status
-
-
-
{{ displayValue(authStore.activeRole?.context) }}
-
Konteks
+
+
+
+
+
+
+
+
+
+
+ Kad Digital
+
+
+
+
+
No. Anggota
+
+ {{ displayValue(authStore.userMemberNumber) }}
+
+
+
+
+
+
{{ authStore.userName || '-' }}
+
Nama
+
+
+
{{ displayValue(authStore.userMemberType) }}
+
Jenis Anggota
+
+
-
+
Profil
+
+ Pekerjaan
+
- Maklumat Bank
+ Bank
+
+
+ Pewaris
Kata Laluan
- Perkhidmatan
+ Lain-lain
-
-
-
-
-
Maklumat Peribadi
-
- Kemas kini maklumat peribadi anda.
-
-
-
- {{ saving ? 'Menyimpan...' : 'Simpan' }}
-
-
-
-
-
-
- Nama
-
-
-
- E-mel
-
-
-
- No. Kad Pengenalan
-
-
-
- No. Telefon
-
-
-
- Jawatan
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
+
-
+
+
+
+
+
@@ -627,7 +571,7 @@ onMounted(async () => {
class="flex w-full items-center justify-center border-b border-foreground/15 pb-5 sm:w-auto sm:justify-start sm:border-b-0 sm:pb-0">
{{
faker['dates'][0]
- }}
+ }}
Date of Release
diff --git a/fe/src/modules/profile/pages/ProfileTab.vue b/fe/src/modules/profile/pages/ProfileTab.vue
new file mode 100644
index 0000000..89a0841
--- /dev/null
+++ b/fe/src/modules/profile/pages/ProfileTab.vue
@@ -0,0 +1,821 @@
+
+
+
+
+
diff --git a/fe/src/modules/profile/pages/UpdateProfile.vue b/fe/src/modules/profile/pages/UpdateProfile.vue
deleted file mode 100644
index b35b3fa..0000000
--- a/fe/src/modules/profile/pages/UpdateProfile.vue
+++ /dev/null
@@ -1,353 +0,0 @@
-
-
-
-
-
Update Profile
-
-
-
-
-
-
-
- PA
-
-
-
-
- {{ fakers[0]!['users'][0]!['name'] }}
-
-
{{ fakers[0]!['jobs'][0] }}
-
-
-
-
-
-
-
- Export Options
-
-
-
- English
-
-
-
- Indonesia
- 10
-
-
-
- English
-
-
-
- Indonesia
-
-
-
-
- Settings
-
-
- View Profile
-
-
-
-
-
-
-
-
-
-
- New Group
-
-
- New Quick Link
-
-
-
-
-
-
-
-
-
-
Display Information
-
-
-
-
-
-
-
-
- Display Name
-
-
-
- Nearest MRT Station
-
- Admiralty
- Aljunied
- Ang Mo Kio
- Bartley
- Beauty World
-
-
-
-
-
- Postal Code
-
- 018906 - 1 STRAITS BOULEVARD SINGA...
- 018910 - 5A MARINA GARDENS DRIVE...
- 018915 - 100A CENTRAL BOULEVARD...
- 018925 - 21 PARK STREET MARINA...
- 018926 - 23 PARK STREET MARINA...
-
-
-
- Phone Number
-
-
-
-
-
- Address
-
-
-
-
- Save
-
-
-
-
-
-
-
-
-
-
-
-
-
- Remove this profile photo?
-
-
-
-
- Change Photo
-
-
-
-
-
-
-
-
-
-
-
-
Personal Information
-
-
-
-
-
- Save
-
- Delete Account
-
-
-
-
-
-
-
-
-
diff --git a/fe/src/modules/profile/routes.ts b/fe/src/modules/profile/routes.ts
index 88d85d7..22e4a07 100644
--- a/fe/src/modules/profile/routes.ts
+++ b/fe/src/modules/profile/routes.ts
@@ -7,16 +7,4 @@ export const profileLayoutRoutes: RouteRecordRaw[] = [
component: () => import('./pages/ProfileOverview2.vue'),
meta: { title: 'Profil', module: 'profile' },
},
- {
- path: 'update-profile',
- name: 'update-profile',
- component: () => import('./pages/UpdateProfile.vue'),
- meta: { title: 'Update Profile', module: 'profile' },
- },
- {
- path: 'change-password',
- name: 'change-password',
- component: () => import('./pages/ChangePassword.vue'),
- meta: { title: 'Change Password', module: 'profile' },
- },
]
diff --git a/fe/src/modules/profile/services/address.service.ts b/fe/src/modules/profile/services/address.service.ts
new file mode 100644
index 0000000..91cf126
--- /dev/null
+++ b/fe/src/modules/profile/services/address.service.ts
@@ -0,0 +1,51 @@
+import { api } from '@/core/services/api'
+import type {
+ AddressApiResponse,
+ AddressPayload,
+ AddressesApiResponse,
+} from '../types/address.types'
+
+export async function listAddresses(perPage = 100): Promise
{
+ const { data } = await api.get('/v1/addresses', {
+ params: { per_page: perPage },
+ })
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal memuatkan alamat.')
+ }
+
+ return data
+}
+
+export async function createAddress(payload: AddressPayload): Promise {
+ const { data } = await api.post('/v1/addresses', payload)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal menambah alamat.')
+ }
+
+ return data
+}
+
+export async function updateAddress(
+ id: string,
+ payload: AddressPayload,
+): Promise {
+ const { data } = await api.put(`/v1/addresses/${id}`, payload)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal mengemas kini alamat.')
+ }
+
+ return data
+}
+
+export async function deleteAddress(id: string): Promise {
+ const { data } = await api.delete(`/v1/addresses/${id}`)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal memadam alamat.')
+ }
+
+ return data
+}
diff --git a/fe/src/modules/profile/services/bank.service.ts b/fe/src/modules/profile/services/bank.service.ts
new file mode 100644
index 0000000..2bdde3d
--- /dev/null
+++ b/fe/src/modules/profile/services/bank.service.ts
@@ -0,0 +1,12 @@
+import { api } from '@/core/services/api'
+import type { BanksApiResponse } from '../types/bank.types'
+
+export async function listActiveBanks(): Promise {
+ const { data } = await api.get('/v1/banks/active')
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal memuatkan senarai bank.')
+ }
+
+ return data
+}
diff --git a/fe/src/modules/profile/services/bankDetail.service.ts b/fe/src/modules/profile/services/bankDetail.service.ts
new file mode 100644
index 0000000..318be62
--- /dev/null
+++ b/fe/src/modules/profile/services/bankDetail.service.ts
@@ -0,0 +1,51 @@
+import { api } from '@/core/services/api'
+import type {
+ BankDetailApiResponse,
+ BankDetailPayload,
+ BankDetailsApiResponse,
+} from '../types/bankDetail.types'
+
+export async function listBankDetails(perPage = 100): Promise {
+ const { data } = await api.get('/v1/bank_details', {
+ params: { per_page: perPage },
+ })
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal memuatkan akaun bank.')
+ }
+
+ return data
+}
+
+export async function createBankDetail(payload: BankDetailPayload): Promise {
+ const { data } = await api.post('/v1/bank_details', payload)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal menambah akaun bank.')
+ }
+
+ return data
+}
+
+export async function updateBankDetail(
+ id: string,
+ payload: BankDetailPayload,
+): Promise {
+ const { data } = await api.put(`/v1/bank_details/${id}`, payload)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal mengemas kini akaun bank.')
+ }
+
+ return data
+}
+
+export async function deleteBankDetail(id: string): Promise {
+ const { data } = await api.delete(`/v1/bank_details/${id}`)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal memadam akaun bank.')
+ }
+
+ return data
+}
diff --git a/fe/src/modules/profile/services/employment.service.ts b/fe/src/modules/profile/services/employment.service.ts
new file mode 100644
index 0000000..7d1f905
--- /dev/null
+++ b/fe/src/modules/profile/services/employment.service.ts
@@ -0,0 +1,51 @@
+import { api } from '@/core/services/api'
+import type {
+ EmploymentApiResponse,
+ EmploymentPayload,
+ EmploymentsApiResponse,
+} from '../types/employment.types'
+
+export async function listEmployments(perPage = 100): Promise {
+ const { data } = await api.get('/v1/employments', {
+ params: { per_page: perPage },
+ })
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal memuatkan pekerjaan.')
+ }
+
+ return data
+}
+
+export async function createEmployment(payload: EmploymentPayload): Promise {
+ const { data } = await api.post('/v1/employments', payload)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal menambah pekerjaan.')
+ }
+
+ return data
+}
+
+export async function updateEmployment(
+ id: string,
+ payload: EmploymentPayload,
+): Promise {
+ const { data } = await api.put(`/v1/employments/${id}`, payload)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal mengemas kini pekerjaan.')
+ }
+
+ return data
+}
+
+export async function deleteEmployment(id: string): Promise {
+ const { data } = await api.delete(`/v1/employments/${id}`)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal memadam pekerjaan.')
+ }
+
+ return data
+}
diff --git a/fe/src/modules/profile/services/heir.service.ts b/fe/src/modules/profile/services/heir.service.ts
new file mode 100644
index 0000000..d110350
--- /dev/null
+++ b/fe/src/modules/profile/services/heir.service.ts
@@ -0,0 +1,44 @@
+import { api } from '@/core/services/api'
+import type { HeirApiResponse, HeirPayload, HeirsApiResponse } from '../types/heir.types'
+
+export async function listHeirs(perPage = 100): Promise {
+ const { data } = await api.get('/v1/heirs', {
+ params: { per_page: perPage },
+ })
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal memuatkan pewaris.')
+ }
+
+ return data
+}
+
+export async function createHeir(payload: HeirPayload): Promise {
+ const { data } = await api.post('/v1/heirs', payload)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal menambah pewaris.')
+ }
+
+ return data
+}
+
+export async function updateHeir(id: string, payload: HeirPayload): Promise {
+ const { data } = await api.put(`/v1/heirs/${id}`, payload)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal mengemas kini pewaris.')
+ }
+
+ return data
+}
+
+export async function deleteHeir(id: string): Promise {
+ const { data } = await api.delete(`/v1/heirs/${id}`)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Gagal memadam pewaris.')
+ }
+
+ return data
+}
diff --git a/fe/src/modules/profile/services/profile.service.ts b/fe/src/modules/profile/services/profile.service.ts
index 711b967..9ef0f69 100644
--- a/fe/src/modules/profile/services/profile.service.ts
+++ b/fe/src/modules/profile/services/profile.service.ts
@@ -31,6 +31,10 @@ export async function updateProfile(payload: UpdateProfilePayload): Promise('/v1/profile', formData, {
@@ -46,6 +50,10 @@ export async function updateProfile(payload: UpdateProfilePayload): Promise('/v1/profile', body)
return data
diff --git a/fe/src/modules/profile/types/address.types.ts b/fe/src/modules/profile/types/address.types.ts
new file mode 100644
index 0000000..8cf9ace
--- /dev/null
+++ b/fe/src/modules/profile/types/address.types.ts
@@ -0,0 +1,41 @@
+export interface Address {
+ id: string
+ user_id: string
+ address_type: string
+ address_line_1: string
+ address_line_2: string | null
+ city: string
+ state: string
+ postcode: string
+ country: string
+ created_at: string | null
+ updated_at: string | null
+}
+
+export interface AddressPayload {
+ address_type: string
+ address_line_1: string
+ address_line_2?: string
+ city: string
+ state: string
+ postcode: string
+ country: string
+}
+
+export interface AddressesApiResponse {
+ success: boolean
+ data: Address[]
+ pagination?: {
+ current_page: number
+ per_page: number
+ total: number
+ last_page: number
+ }
+ message?: string
+}
+
+export interface AddressApiResponse {
+ success: boolean
+ data: Address
+ message?: string
+}
diff --git a/fe/src/modules/profile/types/bank.types.ts b/fe/src/modules/profile/types/bank.types.ts
new file mode 100644
index 0000000..80d1535
--- /dev/null
+++ b/fe/src/modules/profile/types/bank.types.ts
@@ -0,0 +1,15 @@
+export interface Bank {
+ id: string
+ name: string
+ code: string
+ swift_code: string | null
+ is_active: boolean
+ created_at: string | null
+ updated_at: string | null
+}
+
+export interface BanksApiResponse {
+ success: boolean
+ data: Bank[]
+ message?: string
+}
diff --git a/fe/src/modules/profile/types/bankDetail.types.ts b/fe/src/modules/profile/types/bankDetail.types.ts
new file mode 100644
index 0000000..1bcf2e9
--- /dev/null
+++ b/fe/src/modules/profile/types/bankDetail.types.ts
@@ -0,0 +1,35 @@
+export interface BankDetail {
+ id: string
+ user_id: string
+ bank_id: string
+ account_name: string
+ account_number: string
+ account_type: string
+ created_at: string | null
+ updated_at: string | null
+}
+
+export interface BankDetailPayload {
+ bank_id: string
+ account_name: string
+ account_number: string
+ account_type: string
+}
+
+export interface BankDetailsApiResponse {
+ success: boolean
+ data: BankDetail[]
+ pagination?: {
+ current_page: number
+ per_page: number
+ total: number
+ last_page: number
+ }
+ message?: string
+}
+
+export interface BankDetailApiResponse {
+ success: boolean
+ data: BankDetail
+ message?: string
+}
diff --git a/fe/src/modules/profile/types/employment.types.ts b/fe/src/modules/profile/types/employment.types.ts
new file mode 100644
index 0000000..882e210
--- /dev/null
+++ b/fe/src/modules/profile/types/employment.types.ts
@@ -0,0 +1,41 @@
+export interface Employment {
+ id: string
+ user_id: string
+ company_name: string
+ job_title: string
+ employment_type: string
+ salary: number | string
+ start_date: string
+ end_date: string | null
+ is_current: boolean
+ created_at: string | null
+ updated_at: string | null
+}
+
+export interface EmploymentPayload {
+ company_name: string
+ job_title: string
+ employment_type: string
+ salary: number
+ start_date: string
+ end_date?: string | null
+ is_current: boolean
+}
+
+export interface EmploymentsApiResponse {
+ success: boolean
+ data: Employment[]
+ pagination?: {
+ current_page: number
+ per_page: number
+ total: number
+ last_page: number
+ }
+ message?: string
+}
+
+export interface EmploymentApiResponse {
+ success: boolean
+ data: Employment
+ message?: string
+}
diff --git a/fe/src/modules/profile/types/heir.types.ts b/fe/src/modules/profile/types/heir.types.ts
new file mode 100644
index 0000000..ccacd18
--- /dev/null
+++ b/fe/src/modules/profile/types/heir.types.ts
@@ -0,0 +1,39 @@
+export interface Heir {
+ id: string
+ user_id: string
+ name: string
+ ic_number: string
+ relationship: string
+ phone_number: string
+ address: string
+ is_primary: boolean
+ created_at: string | null
+ updated_at: string | null
+}
+
+export interface HeirPayload {
+ name: string
+ ic_number: string
+ relationship: string
+ phone_number: string
+ address: string
+ is_primary: boolean
+}
+
+export interface HeirsApiResponse {
+ success: boolean
+ data: Heir[]
+ pagination?: {
+ current_page: number
+ per_page: number
+ total: number
+ last_page: number
+ }
+ message?: string
+}
+
+export interface HeirApiResponse {
+ success: boolean
+ data: Heir
+ message?: string
+}
diff --git a/fe/src/modules/profile/types/profile.types.ts b/fe/src/modules/profile/types/profile.types.ts
index 7d93127..69fdfe7 100644
--- a/fe/src/modules/profile/types/profile.types.ts
+++ b/fe/src/modules/profile/types/profile.types.ts
@@ -5,6 +5,10 @@ export interface UpdateProfilePayload {
ic_number?: string
position?: string
phone_number?: string
+ gender?: string
+ marriage_status?: string
+ birth_date?: string
+ birth_place?: string
image?: File
}
diff --git a/fe/src/modules/role/components/RolePermissionsPicker.vue b/fe/src/modules/role/components/RolePermissionsPicker.vue
new file mode 100644
index 0000000..496a5ff
--- /dev/null
+++ b/fe/src/modules/role/components/RolePermissionsPicker.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
+ Tiada permissions ditemui
+
+
+
+
+ {{ group.label }}
+
+
+ setPermissionChecked(permission.id, checked === true)">
+
+
+ {{ permission.name }}
+
+
+
+
+
+
+
+
diff --git a/fe/src/modules/role/composables/useRoleList.ts b/fe/src/modules/role/composables/useRoleList.ts
new file mode 100644
index 0000000..ef52ec6
--- /dev/null
+++ b/fe/src/modules/role/composables/useRoleList.ts
@@ -0,0 +1,126 @@
+import { computed, onMounted, ref, watch } from 'vue'
+import debounce from 'lodash/debounce'
+import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
+import { useApiPagination } from '@/composables/useApiPagination'
+import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
+import { listRoles } from '../services/role.service'
+import type { RoleListItem } from '../types/role.types'
+
+function getSortValue(item: RoleListItem, key: string): string {
+ if (key === 'permissions') {
+ return item.permissions?.map((permission) => permission.name).join(', ') ?? ''
+ }
+
+ const value = item[key as keyof RoleListItem]
+ return value == null ? '' : String(value)
+}
+
+export function useRoleList() {
+ const allRoles = ref([])
+ const loading = ref(false)
+ const error = ref(null)
+ const search = ref('')
+ const sortBy = ref([{ key: 'name', order: 'asc' }])
+ const page = ref(1)
+ const itemsPerPage = ref(10)
+
+ const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
+
+ const sortedRoles = computed(() => {
+ const activeSort = sortBy.value[0]
+ const sorted = [...allRoles.value]
+
+ if (!activeSort?.key) {
+ return sorted
+ }
+
+ return sorted.sort((left, right) => {
+ const comparison = getSortValue(left, activeSort.key).localeCompare(
+ getSortValue(right, activeSort.key),
+ )
+
+ return activeSort.order === 'desc' ? -comparison : comparison
+ })
+ })
+
+ const roles = computed(() => {
+ const start = (page.value - 1) * itemsPerPage.value
+ return sortedRoles.value.slice(start, start + itemsPerPage.value)
+ })
+
+ function updatePagination() {
+ const total = sortedRoles.value.length
+ const lastPage = Math.max(1, Math.ceil(total / itemsPerPage.value))
+ const currentPage = Math.min(page.value, lastPage)
+ const from = total === 0 ? null : (currentPage - 1) * itemsPerPage.value + 1
+ const to = total === 0 ? null : Math.min(currentPage * itemsPerPage.value, total)
+
+ applyPagination({
+ current_page: currentPage,
+ per_page: itemsPerPage.value,
+ total,
+ last_page: lastPage,
+ from,
+ to,
+ has_more_pages: currentPage < lastPage,
+ })
+ }
+
+ async function fetchRoles() {
+ loading.value = true
+ error.value = null
+
+ try {
+ const response = await listRoles({
+ search: search.value.trim() || undefined,
+ })
+ allRoles.value = response.data
+ } catch (err) {
+ error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai peranan.')
+ allRoles.value = []
+ } finally {
+ loading.value = false
+ updatePagination()
+ }
+ }
+
+ function handleSortUpdate(value: SortConfig[]) {
+ sortBy.value = value
+ page.value = 1
+ updatePagination()
+ }
+
+ const debouncedSearch = debounce(() => {
+ page.value = 1
+ fetchRoles()
+ }, 400)
+
+ watch(search, () => {
+ debouncedSearch()
+ })
+
+ watch([sortedRoles, page, itemsPerPage], () => {
+ updatePagination()
+ })
+
+ watch(itemsPerPage, () => {
+ page.value = 1
+ })
+
+ onMounted(() => {
+ fetchRoles()
+ })
+
+ return {
+ roles,
+ loading,
+ error,
+ search,
+ sortBy,
+ page,
+ itemsPerPage,
+ pagination,
+ handleSortUpdate,
+ fetchRoles,
+ }
+}
diff --git a/fe/src/modules/role/composables/useRoleSelectFields.ts b/fe/src/modules/role/composables/useRoleSelectFields.ts
new file mode 100644
index 0000000..3de58c5
--- /dev/null
+++ b/fe/src/modules/role/composables/useRoleSelectFields.ts
@@ -0,0 +1,73 @@
+import { ref } from 'vue'
+import * as select from '@zag-js/select'
+
+type SelectOption = { label: string; value: string }
+
+export const GUARD_OPTIONS: SelectOption[] = [
+ { label: 'api', value: 'api' },
+ { label: 'web', value: 'web' },
+]
+
+export const CONTEXT_OPTIONS: SelectOption[] = [
+ { label: 'member', value: 'member' },
+ { label: 'admin', value: 'admin' },
+]
+
+function createSelectCollection(options: SelectOption[]) {
+ return select.collection({
+ items: options,
+ itemToValue: (item) => item.label,
+ })
+}
+
+export function useRoleSelectFields(
+ initialGuard: 'api' | 'web' = 'api',
+ initialContext: 'member' | 'admin' = 'member',
+) {
+ const guardCollection = createSelectCollection(GUARD_OPTIONS)
+ const contextCollection = createSelectCollection(CONTEXT_OPTIONS)
+
+ const guardValue = ref([initialGuard])
+ const contextValue = ref([initialContext])
+ const guardInitial = ref([initialGuard])
+ const contextInitial = ref([initialContext])
+
+ function setGuardValue(details: { value: string[] }) {
+ guardValue.value = details.value
+ }
+
+ function setContextValue(details: { value: string[] }) {
+ contextValue.value = details.value
+ }
+
+ function syncFromRole(guard: string, roleContext: string) {
+ const guardName = guard === 'web' ? 'web' : 'api'
+ const contextName = roleContext === 'admin' ? 'admin' : 'member'
+ guardValue.value = [guardName]
+ contextValue.value = [contextName]
+ guardInitial.value = [guardName]
+ contextInitial.value = [contextName]
+ }
+
+ function getGuardName(): 'api' | 'web' {
+ return guardValue.value[0] === 'web' ? 'web' : 'api'
+ }
+
+ function getContext(): 'member' | 'admin' {
+ return contextValue.value[0] === 'admin' ? 'admin' : 'member'
+ }
+
+ return {
+ guardCollection,
+ contextCollection,
+ guardValue,
+ contextValue,
+ guardInitial,
+ contextInitial,
+ setGuardValue,
+ setContextValue,
+ syncFromRole,
+ getGuardName,
+ getContext,
+ }
+}
diff --git a/fe/src/modules/role/index.ts b/fe/src/modules/role/index.ts
new file mode 100644
index 0000000..038209b
--- /dev/null
+++ b/fe/src/modules/role/index.ts
@@ -0,0 +1,2 @@
+export { roleLayoutRoutes } from './routes'
+export { roleMenu } from './menu'
diff --git a/fe/src/modules/role/menu.ts b/fe/src/modules/role/menu.ts
new file mode 100644
index 0000000..3b18dea
--- /dev/null
+++ b/fe/src/modules/role/menu.ts
@@ -0,0 +1,10 @@
+import type { Menu } from '@/core/types/menu'
+
+export const roleMenu: Menu[] = [
+ {
+ icon: 'ShieldCheck',
+ route_name: 'list-roles',
+ title: 'Senarai Peranan',
+ permission: 'lihat peranan',
+ },
+]
diff --git a/fe/src/modules/role/pages/RoleCreate.vue b/fe/src/modules/role/pages/RoleCreate.vue
new file mode 100644
index 0000000..45b34bc
--- /dev/null
+++ b/fe/src/modules/role/pages/RoleCreate.vue
@@ -0,0 +1,196 @@
+
+
+
+
+
+
Daftar Peranan
+
+ Kembali
+
+
+
+
+ Ralat
+ {{ error }}
+
+
+
+ Berjaya
+ {{ successMessage }}
+
+
+
+
+
+ Nama Peranan
+
+
+
+
+ Nama Penuh Peranan
+
+
+
+
+
+ Guard
+
+
+
+
+
+
+
+
+ Guard
+
+ {{ item.label }}
+
+
+
+
+
+
+
+ Konteks
+
+
+
+
+
+
+
+
+ Konteks
+
+ {{ item.label }}
+
+
+
+
+
+
+
+
+
+
Permissions
+
{{ selectedCount }} dipilih
+
+
+
+
+
+
+
+ {{ saving ? 'Mendaftar...' : 'Daftar' }}
+
+
+
+
+
+
diff --git a/fe/src/modules/role/pages/RoleEdit.vue b/fe/src/modules/role/pages/RoleEdit.vue
new file mode 100644
index 0000000..aca1b5f
--- /dev/null
+++ b/fe/src/modules/role/pages/RoleEdit.vue
@@ -0,0 +1,210 @@
+
+
+
+
+
+
Kemaskini Peranan
+
+ Kembali
+
+
+
+
+ Ralat
+ {{ error }}
+
+
+
+ Berjaya
+ {{ successMessage }}
+
+
+
+
+
+ Nama Peranan
+
+
+
+
+ Nama Penuh Peranan
+
+
+
+
+
+ Guard
+
+
+
+
+
+
+
+
+ Guard
+
+ {{ item.label }}
+
+
+
+
+
+
+
+ Konteks
+
+
+
+
+
+
+
+
+ Konteks
+
+ {{ item.label }}
+
+
+
+
+
+
+
+
+
+
Permissions
+
{{ selectedCount }} dipilih
+
+
+
+
+
+
+
+ {{ saving ? 'Menyimpan...' : 'Simpan' }}
+
+
+
+
+
+
diff --git a/fe/src/modules/role/pages/RoleList.vue b/fe/src/modules/role/pages/RoleList.vue
new file mode 100644
index 0000000..3fb6738
--- /dev/null
+++ b/fe/src/modules/role/pages/RoleList.vue
@@ -0,0 +1,195 @@
+
+
+
+
+
Senarai Peranan
+
+
+ Ralat
+ {{ error }}
+
+
+
+
+
+
+
+
+
+ Tambah Peranan
+
+
+
+
+
+
+
+
+
+
+ {{ role.name }}
+
+
+ {{ role.fullname || '-' }}
+
+
+
+ {{ role.context }}
+
+
+
+
+
+
+ Guard: {{ role.guard_name }}
+
+
+
+ Permissions: {{ role.permissions?.length ?? 0 }}
+
+
+
+
+
+
+
+
+ Tiada rekod ditemui
+
+
+
+
+ Previous
+
+
+
+ {{ page.value }}
+
+
+
+
+ Next
+
+
+
+
+
+
(deleteConfirmationOpen = details.open)">
+
+
+
+
Adakah anda yakin?
+
+ Adakah anda benar-benar mahu menghapus rekod ini?
+ Proses ini tidak boleh dibatalkan.
+
+
+ {{ deleteError }}
+
+
+
+ Cancel
+
+ {{ deleting ? 'Menghapus...' : 'Hapus' }}
+
+
+
+
+
+
+
diff --git a/fe/src/modules/role/routes.ts b/fe/src/modules/role/routes.ts
new file mode 100644
index 0000000..77be2de
--- /dev/null
+++ b/fe/src/modules/role/routes.ts
@@ -0,0 +1,22 @@
+import type { RouteRecordRaw } from 'vue-router'
+
+export const roleLayoutRoutes: RouteRecordRaw[] = [
+ {
+ path: 'list-roles',
+ name: 'list-roles',
+ component: () => import('./pages/RoleList.vue'),
+ meta: { title: 'List Roles', module: 'role', permission: 'lihat peranan' },
+ },
+ {
+ path: 'roles/create',
+ name: 'create-role',
+ component: () => import('./pages/RoleCreate.vue'),
+ meta: { title: 'Create Role', module: 'role', permission: 'tambah peranan' },
+ },
+ {
+ path: 'roles/:id/edit',
+ name: 'edit-role',
+ component: () => import('./pages/RoleEdit.vue'),
+ meta: { title: 'Edit Role', module: 'role', permission: 'kemaskini peranan' },
+ },
+]
diff --git a/fe/src/modules/role/services/role.service.ts b/fe/src/modules/role/services/role.service.ts
new file mode 100644
index 0000000..4049ca3
--- /dev/null
+++ b/fe/src/modules/role/services/role.service.ts
@@ -0,0 +1,85 @@
+import { api } from '@/core/services/api'
+import type { CreateRolePayload, ListRolesParams, RoleListItem, RolePermission } from '../types/role.types'
+
+type RolesApiResponse = {
+ success: boolean
+ data: RoleListItem[]
+ message?: string
+}
+
+type RoleApiResponse = {
+ success: boolean
+ data: RoleListItem
+ message?: string
+}
+
+type PermissionsApiResponse = {
+ success: boolean
+ data: RolePermission[]
+ message?: string
+}
+
+export async function listRoles(params?: ListRolesParams): Promise {
+ const { data } = await api.get('/v1/roles', {
+ params,
+ })
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Failed to load roles')
+ }
+
+ return data
+}
+
+export async function getRole(id: string): Promise {
+ const { data } = await api.get(`/v1/roles/${id}`)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Failed to load role')
+ }
+
+ return data
+}
+
+export async function createRole(payload: CreateRolePayload): Promise {
+ const { data } = await api.post('/v1/roles', payload)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Failed to create role')
+ }
+
+ return data
+}
+
+export async function updateRole(
+ id: string,
+ payload: CreateRolePayload,
+): Promise {
+ const { data } = await api.put(`/v1/roles/${id}`, payload)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Failed to update role')
+ }
+
+ return data
+}
+
+export async function deleteRole(id: string): Promise {
+ const { data } = await api.delete(`/v1/roles/${id}`)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Failed to delete role')
+ }
+
+ return data
+}
+
+export async function listPermissions(): Promise {
+ const { data } = await api.get('/v1/permissions')
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Failed to load permissions')
+ }
+
+ return data
+}
diff --git a/fe/src/modules/role/types/role.types.ts b/fe/src/modules/role/types/role.types.ts
new file mode 100644
index 0000000..cc7e929
--- /dev/null
+++ b/fe/src/modules/role/types/role.types.ts
@@ -0,0 +1,40 @@
+export interface RolePermission {
+ id: string
+ name: string
+ guard_name: string
+ route_name: string | null
+}
+
+export interface Role {
+ id: string
+ name: string
+ guard_name: string
+ fullname: string | null
+ context: string
+ permissions?: RolePermission[]
+ created_at?: string
+ updated_at?: string
+}
+
+export interface RoleListItem {
+ id: string
+ name: string
+ guard_name: string
+ fullname: string | null
+ context: string
+ permissions?: RolePermission[]
+ created_at?: string
+ updated_at?: string
+}
+
+export interface ListRolesParams {
+ search?: string
+}
+
+export interface CreateRolePayload {
+ name: string
+ fullname: string
+ guard_name: 'api' | 'web'
+ context: 'member' | 'admin'
+ permissions?: string[]
+}
diff --git a/fe/src/modules/role/utils/groupPermissions.ts b/fe/src/modules/role/utils/groupPermissions.ts
new file mode 100644
index 0000000..7c649c6
--- /dev/null
+++ b/fe/src/modules/role/utils/groupPermissions.ts
@@ -0,0 +1,57 @@
+import type { RolePermission } from '../types/role.types'
+
+export interface PermissionGroup {
+ key: string
+ label: string
+ permissions: RolePermission[]
+}
+
+function toGroupKey(routeName: string): string {
+ return routeName
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+}
+
+export function groupPermissionsByRoute(
+ permissions: RolePermission[],
+): PermissionGroup[] {
+ const groups = new Map()
+
+ for (const permission of permissions) {
+ const label = permission.route_name?.trim() || 'Lain-lain'
+ const bucket = groups.get(label) ?? []
+ bucket.push(permission)
+ groups.set(label, bucket)
+ }
+
+ return Array.from(groups.entries())
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([label, items]) => ({
+ key: toGroupKey(label) || 'lain-lain',
+ label,
+ permissions: [...items].sort((left, right) =>
+ left.name.localeCompare(right.name),
+ ),
+ }))
+}
+
+export function resolveDefaultOpenGroups(
+ groups: PermissionGroup[],
+ selectedPermissionIds: Set,
+): string[] {
+ const openGroups = groups
+ .filter((group) =>
+ group.permissions.some((permission) =>
+ selectedPermissionIds.has(permission.id),
+ ),
+ )
+ .map((group) => group.key)
+
+ if (openGroups.length) {
+ return openGroups
+ }
+
+ return groups[0] ? [groups[0].key] : []
+}
diff --git a/fe/src/modules/user/composables/useDeletedUserList.ts b/fe/src/modules/user/composables/useDeletedUserList.ts
new file mode 100644
index 0000000..e1fcd37
--- /dev/null
+++ b/fe/src/modules/user/composables/useDeletedUserList.ts
@@ -0,0 +1,99 @@
+import { onMounted, ref, watch } from 'vue'
+import debounce from 'lodash/debounce'
+import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
+import { useApiPagination } from '@/composables/useApiPagination'
+import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
+import { listDeletedUsers } from '../services/user.service'
+import type { UserListItem } from '../types/user.types'
+
+export function useDeletedUserList(options?: { autoWatchFilters?: boolean; autoFetchOnMount?: boolean }) {
+ const autoWatchFilters = options?.autoWatchFilters ?? true
+ const autoFetchOnMount = options?.autoFetchOnMount ?? true
+ const users = ref([])
+ const loading = ref(false)
+ const error = ref(null)
+ const search = ref('')
+ const statusFilter = ref('')
+ const sortBy = ref([{ key: 'deleted_at', order: 'desc' }])
+ const page = ref(1)
+ const itemsPerPage = ref(10)
+
+ const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
+
+ async function fetchUsers(requestPage = page.value) {
+ loading.value = true
+ error.value = null
+
+ try {
+ const activeSort = sortBy.value[0]
+ const data = await listDeletedUsers({
+ page: requestPage,
+ per_page: itemsPerPage.value,
+ sort_by: activeSort?.key ?? 'deleted_at',
+ sort_order: activeSort?.order ?? 'desc',
+ search: search.value.trim() || undefined,
+ status: statusFilter.value.trim() || undefined,
+ })
+
+ users.value = data.data
+ applyPagination(data.pagination)
+ page.value = data.pagination.current_page
+ } catch (err) {
+ error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai pengguna yang dipadam.')
+ users.value = []
+ } finally {
+ loading.value = false
+ }
+ }
+
+ function handleSortUpdate(value: SortConfig[]) {
+ sortBy.value = value
+ fetchUsers(1)
+ }
+
+ const debouncedSearch = debounce(() => {
+ fetchUsers(1)
+ }, 400)
+
+ if (autoWatchFilters) {
+ watch(search, () => {
+ debouncedSearch()
+ })
+
+ watch(statusFilter, () => {
+ fetchUsers(1)
+ })
+ }
+
+ watch(page, (nextPage, previousPage) => {
+ if (nextPage !== previousPage) {
+ fetchUsers(nextPage)
+ }
+ })
+
+ watch(itemsPerPage, (nextValue, previousValue) => {
+ if (nextValue !== previousValue) {
+ fetchUsers(1)
+ }
+ })
+
+ if (autoFetchOnMount) {
+ onMounted(() => {
+ fetchUsers(1)
+ })
+ }
+
+ return {
+ users,
+ loading,
+ error,
+ search,
+ statusFilter,
+ sortBy,
+ page,
+ itemsPerPage,
+ pagination,
+ handleSortUpdate,
+ fetchUsers,
+ }
+}
diff --git a/fe/src/modules/user/composables/useImpersonate.ts b/fe/src/modules/user/composables/useImpersonate.ts
index 91bdcbe..dd806ad 100644
--- a/fe/src/modules/user/composables/useImpersonate.ts
+++ b/fe/src/modules/user/composables/useImpersonate.ts
@@ -28,7 +28,7 @@ function userCanImpersonate(): boolean {
)
}
-function targetCanBeImpersonated(target: UserListItem, currentUserId?: string): boolean {
+function isImpersonateTargetVisible(target: UserListItem, currentUserId?: string): boolean {
if (!currentUserId || target.id === currentUserId) {
return false
}
@@ -36,6 +36,14 @@ function targetCanBeImpersonated(target: UserListItem, currentUserId?: string):
return !target.roles?.some((role) => role.name === DEVELOPER_ROLE)
}
+function targetCanBeImpersonated(target: UserListItem, currentUserId?: string): boolean {
+ if (!isImpersonateTargetVisible(target, currentUserId)) {
+ return false
+ }
+
+ return target.status === 'active'
+}
+
export function useImpersonate() {
const authStore = useAuthStore()
const router = useRouter()
@@ -44,10 +52,26 @@ export function useImpersonate() {
const impersonating = computed(() => authStore.isImpersonating)
const canImpersonate = computed(() => userCanImpersonate())
+ function showImpersonateButton(target: UserListItem): boolean {
+ return canImpersonate.value && isImpersonateTargetVisible(target, authStore.user?.id)
+ }
+
function canImpersonateUser(target: UserListItem): boolean {
return canImpersonate.value && targetCanBeImpersonated(target, authStore.user?.id)
}
+ function impersonateButtonTitle(target: UserListItem): string {
+ if (impersonating.value) {
+ return 'Anda sedang menyamar pengguna'
+ }
+
+ if (target.status !== 'active') {
+ return 'Hanya pengguna aktif boleh disamar'
+ }
+
+ return 'Menyamar sebagai pengguna'
+ }
+
async function refreshImpersonationStatus() {
await authStore.refreshImpersonationStatus()
}
@@ -136,7 +160,9 @@ export function useImpersonate() {
return {
canImpersonate,
+ showImpersonateButton,
canImpersonateUser,
+ impersonateButtonTitle,
impersonating,
loading,
refreshImpersonationStatus,
diff --git a/fe/src/modules/user/composables/useUserList.ts b/fe/src/modules/user/composables/useUserList.ts
index 175d356..f19daf2 100644
--- a/fe/src/modules/user/composables/useUserList.ts
+++ b/fe/src/modules/user/composables/useUserList.ts
@@ -85,5 +85,6 @@ export function useUserList() {
itemsPerPage,
pagination,
handleSortUpdate,
+ fetchUsers,
}
}
diff --git a/fe/src/modules/user/menu.ts b/fe/src/modules/user/menu.ts
index 43076b3..9fba37a 100644
--- a/fe/src/modules/user/menu.ts
+++ b/fe/src/modules/user/menu.ts
@@ -5,5 +5,6 @@ export const userMenu: Menu[] = [
icon: 'Users',
route_name: 'list-users',
title: 'Senarai Pengguna',
+ permission: 'lihat pengguna',
},
]
diff --git a/fe/src/modules/user/pages/UserBankDetailTab.vue b/fe/src/modules/user/pages/UserBankDetailTab.vue
new file mode 100644
index 0000000..4703098
--- /dev/null
+++ b/fe/src/modules/user/pages/UserBankDetailTab.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
Akaun Bank
+
Senarai akaun bank pengguna.
+
+
+
+
+
+ {{ bankLabel(bankDetail) }}
+
+ {{ ACCOUNT_TYPE_LABEL[bankDetail.account_type] ?? bankDetail.account_type }}
+
+
+
{{ bankDetail.account_name }}
+
{{ bankDetail.account_number }}
+
+
+
+
+ Tiada akaun bank direkodkan.
+
+
+
+
diff --git a/fe/src/modules/user/pages/UserCreate.vue b/fe/src/modules/user/pages/UserCreate.vue
new file mode 100644
index 0000000..f488b72
--- /dev/null
+++ b/fe/src/modules/user/pages/UserCreate.vue
@@ -0,0 +1,441 @@
+
+
+
+
+
diff --git a/fe/src/modules/user/pages/UserEdit.vue b/fe/src/modules/user/pages/UserEdit.vue
new file mode 100644
index 0000000..0808321
--- /dev/null
+++ b/fe/src/modules/user/pages/UserEdit.vue
@@ -0,0 +1,374 @@
+
+
+
+
+
diff --git a/fe/src/modules/user/pages/UserEmploymentTab.vue b/fe/src/modules/user/pages/UserEmploymentTab.vue
new file mode 100644
index 0000000..957a6fa
--- /dev/null
+++ b/fe/src/modules/user/pages/UserEmploymentTab.vue
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
Pekerjaan
+
Senarai pekerjaan pengguna.
+
+
+
+
+
+ {{ employment.company_name }}
+ Semasa
+
+ {{ EMPLOYMENT_TYPE_LABEL[employment.employment_type] ?? employment.employment_type }}
+
+
+
{{ employment.job_title }}
+
{{ formatEmploymentPeriod(employment) }}
+
{{ formatSalary(employment.salary) }}
+
+
+
+
+ Tiada pekerjaan direkodkan.
+
+
+
+
diff --git a/fe/src/modules/user/pages/UserHeirTab.vue b/fe/src/modules/user/pages/UserHeirTab.vue
new file mode 100644
index 0000000..37cdce6
--- /dev/null
+++ b/fe/src/modules/user/pages/UserHeirTab.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
Pewaris
+
Senarai pewaris pengguna.
+
+
+
+
+
+ {{ heir.name }}
+ Utama
+ {{ heir.relationship }}
+
+
{{ heir.ic_number }}
+
{{ heir.phone_number }}
+
{{ heir.address }}
+
+
+
+
+ Tiada pewaris direkodkan.
+
+
+
+
diff --git a/fe/src/modules/user/pages/UserList.vue b/fe/src/modules/user/pages/UserList.vue
index a26f93e..ba1783a 100644
--- a/fe/src/modules/user/pages/UserList.vue
+++ b/fe/src/modules/user/pages/UserList.vue
@@ -1,15 +1,78 @@
+
+
Senarai Pengguna
+
Urus dan semak pengguna koperasi.
+
Error
{{ error }}
@@ -72,16 +318,50 @@ onMounted(() => {
show-pagination exportable export-file-name="users" v-model:page="page" v-model:items-per-page="itemsPerPage"
@update:sort-by="handleSortUpdate">
-
-
-
+
+
+
+
+
+
+
+ Daftar Pengguna
+
+
+
+ Status:
+
+ {{ chip.label }}
+
+
- {{ item.name }}
+
+
+
+
+ {{ item.name.charAt(0) }}
+
+
+
+
{{ item.name }}
+
@@ -92,20 +372,227 @@ onMounted(() => {
{{ formatUserRoles(item.roles) }}
+
+ {{ item.member_number }}
+
+
+
-
+
{{ item.status }}
-
-
-
+
+ Urus Peranan
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Pengguna Dipadam
+
Pengguna yang telah dipadam dan boleh dipulihkan.
+
+
+
+ Error
+ {{ deletedError }}
+
+
+
+
+
+
+
+ {{ item.name.charAt(0) }}
+
+
{{ item.name }}
+
+
+
+
+ {{ item.email }}
+
+
+
+ {{ formatUserRoles(item.roles) }}
+
+
+
+ {{ item.member_number ?? '-' }}
+
+
+
+
+ {{ item.status }}
+
+
+
+
+ {{ formatDeletedAt(item.deleted_at) }}
+
+
+
+
+
+
+ Pulihkan
+
+
+
+
+
+
+
(assignRolesOpen = details.open)">
+
+
+
Tetapkan Peranan
+
+ Pilih peranan untuk
+ {{ userToAssignRoles.name }}
+
+
+
+ {{ assignRolesError }}
+
+
+
+
+ Memuatkan peranan...
+
+
+ Tiada peranan ditemui
+
+
+ setRoleChecked(role.id, checked === true)">
+
+
+ {{ role.name }}
+ ({{ role.fullname }})
+
+
+
+
+
+
+
+ Batal
+
+
+ {{ savingRoles ? 'Menyimpan...' : 'Simpan' }}
+
+
+
+
+
+
(deleteConfirmationOpen = details.open)">
+
+
+
+
Adakah anda yakin?
+
+ Adakah anda benar-benar mahu menghapus
+ {{ userToDelete.name }} ?
+
+ Proses ini tidak boleh dibatalkan.
+
+
+ {{ deleteError }}
+
+
+
+
+ Batal
+
+
+ {{ deleting ? 'Menghapus...' : 'Hapus' }}
+
+
+
+
+
+
(restoreConfirmationOpen = details.open)">
+
+
+
+
Pulihkan pengguna?
+
+ Adakah anda mahu memulihkan
+ {{ userToRestore.name }} ?
+
+
+ {{ restoreError }}
+
+
+
+
+ Batal
+
+
+ {{ restoring ? 'Memulihkan...' : 'Pulihkan' }}
+
+
+
+
diff --git a/fe/src/modules/user/pages/UserProfileTab.vue b/fe/src/modules/user/pages/UserProfileTab.vue
new file mode 100644
index 0000000..3ce50dd
--- /dev/null
+++ b/fe/src/modules/user/pages/UserProfileTab.vue
@@ -0,0 +1,153 @@
+
+
+
+
+
diff --git a/fe/src/modules/user/pages/UserProfileView.vue b/fe/src/modules/user/pages/UserProfileView.vue
new file mode 100644
index 0000000..fcf3b61
--- /dev/null
+++ b/fe/src/modules/user/pages/UserProfileView.vue
@@ -0,0 +1,169 @@
+
+
+
+
+
+
Profil {{ user?.name }} - {{ user?.member_number }}
+
+ Kembali
+
+
+
+
+ Ralat
+ {{ error }}
+
+
+
Memuatkan profil...
+
+
+
+
+
+
+ {{ avatarFallback }}
+
+
+
+
+ {{ displayValue(user.name) }}
+
+
{{ roleNames }}
+
+
+
+
Maklumat Hubungan
+
+
+
+ {{ displayValue(user.email) }}
+
+
+
+ {{ displayValue(user.phone_number) }}
+
+
+
+ {{ displayValue(user.ic_number) }}
+
+
+
+
+
+
+
{{ user.roles?.length ?? 0 }}
+
Peranan
+
+
+
{{ statusLabel }}
+
Status
+
+
+
+ {{ displayValue(user.member_type) }}
+
+
Jenis Anggota
+
+
+
+
+
+
+
+
+ Profil
+
+
+ Pekerjaan
+
+
+ Bank
+
+
+ Pewaris
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fe/src/modules/user/routes.ts b/fe/src/modules/user/routes.ts
index fd8ab5c..90c905d 100644
--- a/fe/src/modules/user/routes.ts
+++ b/fe/src/modules/user/routes.ts
@@ -5,6 +5,24 @@ export const userLayoutRoutes: RouteRecordRaw[] = [
path: 'list-users',
name: 'list-users',
component: () => import('./pages/UserList.vue'),
- meta: { title: 'List Users', module: 'user' },
+ meta: { title: 'List Users', module: 'user', permission: 'lihat pengguna' },
+ },
+ {
+ path: 'users/create',
+ name: 'create-user',
+ component: () => import('./pages/UserCreate.vue'),
+ meta: { title: 'Create User', module: 'user', permission: 'daftar pengguna baru' },
+ },
+ {
+ path: 'users/:id/edit',
+ name: 'edit-user',
+ component: () => import('./pages/UserEdit.vue'),
+ meta: { title: 'Edit User', module: 'user', permission: 'kemaskini pengguna' },
+ },
+ {
+ path: 'users/:id/profile',
+ name: 'view-user',
+ component: () => import('./pages/UserProfileView.vue'),
+ meta: { title: 'View User Profile', module: 'user', permission: 'lihat pengguna' },
},
]
diff --git a/fe/src/modules/user/services/user.service.ts b/fe/src/modules/user/services/user.service.ts
index 2ab407c..d53ed17 100644
--- a/fe/src/modules/user/services/user.service.ts
+++ b/fe/src/modules/user/services/user.service.ts
@@ -1,6 +1,19 @@
import { api } from '@/core/services/api'
import type { PaginatedApiResponse } from '@/core/types/api'
-import type { ListUsersParams, UserListItem } from '../types/user.types'
+import type {
+ CreateUserPayload,
+ ListDeletedUsersParams,
+ ListUsersParams,
+ UpdateUserPayload,
+ UserDetail,
+ UserListItem,
+} from '../types/user.types'
+
+type UserApiResponse = {
+ success: boolean
+ data: UserDetail
+ message?: string
+}
export async function listUsers(
params: ListUsersParams,
@@ -15,3 +28,85 @@ export async function listUsers(
return data
}
+
+export async function listDeletedUsers(
+ params: ListDeletedUsersParams,
+): Promise> {
+ const { data } = await api.get>('/v1/users/deleted', {
+ params,
+ })
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Failed to load deleted users')
+ }
+
+ return data
+}
+
+export async function restoreUser(id: string): Promise {
+ const { data } = await api.post(`/v1/users/${id}/restore`)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Failed to restore user')
+ }
+
+ return data
+}
+
+export async function getUser(id: string): Promise {
+ const { data } = await api.get(`/v1/users/${id}`)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Failed to load user')
+ }
+
+ return data
+}
+
+export async function createUser(payload: CreateUserPayload): Promise {
+ const { data } = await api.post('/v1/users', payload)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Failed to create user')
+ }
+
+ return data
+}
+
+export async function updateUser(
+ id: string,
+ payload: UpdateUserPayload,
+): Promise {
+ const { data } = await api.patch(`/v1/users/${id}`, payload)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Failed to update user')
+ }
+
+ return data
+}
+
+export async function deleteUser(id: string): Promise {
+ const { data } = await api.delete(`/v1/users/${id}`)
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Failed to delete user')
+ }
+
+ return data
+}
+
+export async function assignUserRoles(
+ id: string,
+ roleIds: string[],
+): Promise {
+ const { data } = await api.post(`/v1/users/${id}/roles`, {
+ roles: roleIds,
+ })
+
+ if (!data.success) {
+ throw new Error(data.message ?? 'Failed to assign roles')
+ }
+
+ return data
+}
diff --git a/fe/src/modules/user/types/user.types.ts b/fe/src/modules/user/types/user.types.ts
index aa4e629..321c226 100644
--- a/fe/src/modules/user/types/user.types.ts
+++ b/fe/src/modules/user/types/user.types.ts
@@ -1,9 +1,19 @@
+import type { Address } from '@/modules/profile/types/address.types'
+import type { Bank } from '@/modules/profile/types/bank.types'
+import type { BankDetail } from '@/modules/profile/types/bankDetail.types'
+import type { Employment } from '@/modules/profile/types/employment.types'
+import type { Heir } from '@/modules/profile/types/heir.types'
+
export interface UserRole {
id: string
name: string
guard_name: string
}
+export interface UserBankDetail extends BankDetail {
+ bank?: Bank | null
+}
+
export interface UserListItem {
id: string
name: string
@@ -13,7 +23,63 @@ export interface UserListItem {
phone_number: string
image_url: string | null
status: string
+ deleted_at?: string | null
roles: UserRole[]
+ member_number?: number | null
+}
+
+export interface UserDetail {
+ id: string
+ name: string
+ email: string
+ ic_number: string
+ position: string
+ phone_number: string | null
+ image_url: string | null
+ status: string
+ gender: string | null
+ marriage_status: string | null
+ member_number: number | null
+ member_type: string | null
+ join_date: string | null
+ birth_date: string | null
+ birth_place: string | null
+ roles: UserRole[]
+ addresses?: Address[]
+ employments?: Employment[]
+ bank_details?: UserBankDetail[]
+ heirs?: Heir[]
+}
+
+export interface UpdateUserPayload {
+ name: string
+ ic_number: string
+ position: string
+ phone_number?: string | null
+ status?: string | null
+ gender?: string | null
+ marriage_status?: string | null
+ member_number?: number | null
+ member_type?: string | null
+ join_date?: string | null
+ birth_date?: string | null
+ birth_place?: string | null
+}
+
+export interface CreateUserPayload {
+ name: string
+ email: string
+ ic_number: string
+ position: string
+ phone_number?: string | null
+ status: string
+ gender: string
+ marriage_status: string
+ member_number: number
+ member_type: string
+ join_date: string
+ birth_date: string
+ birth_place: string
}
export interface ListUsersParams {
@@ -24,3 +90,12 @@ export interface ListUsersParams {
search?: string
status?: string
}
+
+export interface ListDeletedUsersParams {
+ page: number
+ per_page: number
+ sort_by: string
+ sort_order: string
+ search?: string
+ status?: string
+}
diff --git a/fe/src/router.d.ts b/fe/src/router.d.ts
new file mode 100644
index 0000000..2ef3621
--- /dev/null
+++ b/fe/src/router.d.ts
@@ -0,0 +1,14 @@
+import 'vue-router'
+
+declare module 'vue-router' {
+ interface RouteMeta {
+ title?: string
+ module?: string
+ /** Single permission required to access this route (active role). */
+ permission?: string
+ /** Any one of these permissions grants access (active role). */
+ permissions?: string[]
+ }
+}
+
+export {}
diff --git a/fe/src/router/index.ts b/fe/src/router/index.ts
index 09d1b5e..3b1143e 100644
--- a/fe/src/router/index.ts
+++ b/fe/src/router/index.ts
@@ -1,11 +1,16 @@
import { createRouter, createWebHistory } from 'vue-router'
import Layout from '@/themes'
import DocsLayout from '@/docs/DocsLayout.vue'
+import { hasAnyActiveRolePermission } from '@/core/utils/activeRolePermission'
+import { getRouteRequiredPermissions } from '@/core/utils/routePermission'
import { authPublicRoutes, resolvePostAuthRoute } from '@/modules/auth'
+import { membershipApplicationPublicRoutes, membershipApplicationLayoutRoutes } from '@/modules/membership-application'
import { profileLayoutRoutes } from '@/modules/profile'
import { pinia } from '@/pinia'
import { useAuthStore } from '@/stores/auth'
import { userLayoutRoutes } from '@/modules/user'
+import { roleLayoutRoutes } from '@/modules/role'
+import { activityLayoutRoutes } from '@/modules/activity'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -182,6 +187,9 @@ const router = createRouter({
},
...profileLayoutRoutes,
...userLayoutRoutes,
+ ...roleLayoutRoutes,
+ ...activityLayoutRoutes,
+ ...membershipApplicationLayoutRoutes,
// Docs Layout
{
path: '/',
@@ -243,11 +251,6 @@ const router = createRouter({
name: "combobox",
component: () => import("../docs/pages/combobox.vue"),
},
- {
- path: "data-table",
- name: "data-table",
- component: () => import("../docs/pages/data-table.vue"),
- },
{
path: "datepicker",
name: "datepicker",
@@ -278,11 +281,6 @@ const router = createRouter({
name: "menu",
component: () => import("../docs/pages/menu.vue"),
},
- {
- path: "native-select",
- name: "native-select",
- component: () => import("../docs/pages/native-select.vue"),
- },
{
path: "pagination",
name: "pagination",
@@ -368,15 +366,21 @@ const router = createRouter({
],
},
...authPublicRoutes,
+ ...membershipApplicationPublicRoutes,
{
path: '/error-page',
name: 'error-page',
component: () => import('../views/ErrorPage.vue'),
},
+ {
+ path: '/forbidden',
+ name: 'forbidden',
+ component: () => import('../views/ForbiddenPage.vue'),
+ },
],
})
-const PUBLIC_ROUTE_NAMES = new Set(['login', 'register', 'verify-email'])
+const PUBLIC_ROUTE_NAMES = new Set(['login', 'register', 'verify-email', 'membership-application-apply'])
router.beforeEach(async (to) => {
const authStore = useAuthStore(pinia)
@@ -405,12 +409,16 @@ router.beforeEach(async (to) => {
}
// Redirect authenticated users away from login/register/verify-email.
- if (PUBLIC_ROUTE_NAMES.has(routeName) && authStore.isAuthenticated) {
+ if (
+ authStore.isAuthenticated &&
+ PUBLIC_ROUTE_NAMES.has(routeName) &&
+ routeName !== 'membership-application-apply'
+ ) {
return resolvePostAuthRoute(authStore.user)
}
- // Other auth pages (privacy, terms, etc.).
- if (to.meta?.module === 'auth' || PUBLIC_ROUTE_NAMES.has(routeName)) {
+ // Public routes (auth pages, membership application, privacy, terms, etc.).
+ if (to.meta?.public === true || to.meta?.module === 'auth' || PUBLIC_ROUTE_NAMES.has(routeName)) {
return true
}
@@ -430,6 +438,19 @@ router.beforeEach(async (to) => {
return { name: 'account-pending' }
}
+ const requiredPermissions = getRouteRequiredPermissions(to)
+
+ if (
+ requiredPermissions.length &&
+ !hasAnyActiveRolePermission(
+ authStore.user,
+ authStore.activeRole,
+ requiredPermissions,
+ )
+ ) {
+ return { name: 'forbidden' }
+ }
+
return true
})
diff --git a/fe/src/stores/auth.ts b/fe/src/stores/auth.ts
index eb53386..5e241c4 100644
--- a/fe/src/stores/auth.ts
+++ b/fe/src/stores/auth.ts
@@ -25,6 +25,14 @@ export const useAuthStore = defineStore('auth', {
userActiveRole: (state) => state.activeRole?.name ?? '',
userPosition: (state) => state.user?.position ?? '',
userImageUrl: (state) => state.user?.image_url ?? null,
+ userMemberNumber: (state) => state.user?.member_number ?? null,
+ userMemberType: (state) => state.user?.member_type ?? null,
+ userStatus: (state) => state.user?.status ?? null,
+ userGender: (state) => state.user?.gender ?? null,
+ userMarriageStatus: (state) => state.user?.marriage_status ?? null,
+ userJoinDate: (state) => state.user?.join_date ?? null,
+ userBirthDate: (state) => state.user?.birth_date ?? null,
+ userBirthPlace: (state) => state.user?.birth_place ?? null,
isAuthenticated: (state) => state.user !== null,
isAccountPending: (state) => state.user?.status === 'pending',
isAccountActive: (state) => state.user?.status === 'active',
diff --git a/fe/src/themes/Enigma/SideMenu/SideMenu.vue b/fe/src/themes/Enigma/SideMenu/SideMenu.vue
index f379821..bc6599d 100644
--- a/fe/src/themes/Enigma/SideMenu/SideMenu.vue
+++ b/fe/src/themes/Enigma/SideMenu/SideMenu.vue
@@ -1,6 +1,7 @@
@@ -101,7 +106,7 @@ onMounted(() => {
'[-webkit-mask-composite:destination-in]',
].join(' ')">
-
+
@@ -140,8 +145,19 @@ onMounted(() => {
-
-
+
+
+
+ {{ unreadCount > 9 ? '9+' : unreadCount }}
+
diff --git a/fe/src/views/AddProduct.vue b/fe/src/views/AddProduct.vue
index 1bfb36e..21274ba 100644
--- a/fe/src/views/AddProduct.vue
+++ b/fe/src/views/AddProduct.vue
@@ -9,7 +9,6 @@ import { Field, FieldDescription } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Lucide } from '@/components/ui/lucide'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import {
RadioGroupRoot,
RadioGroupItem,
@@ -45,11 +44,8 @@ import {
Starting May 10, 2024, there will be changes to the Terms & Conditions regarding the
number of products that may be added by the Seller.
-
+
Learn More
@@ -73,11 +69,9 @@ import {
Avoid selling counterfeit products / violating Intellectual Property Rights, so
that your products are not deleted.
-
+ target="blank">
Learn More
@@ -104,21 +98,14 @@ import {
-
-
+
+
+ class="bg-(--color)/80 border-(--color) absolute right-0 top-0 -mr-2 -mt-2 flex size-5 items-center justify-center rounded-full text-white [--color:var(--color-danger)]">
@@ -128,17 +115,11 @@ import {
-
@@ -186,11 +167,8 @@ import {
-
+
{{ faker['categories'][0]!['name'] }}
@@ -209,11 +187,8 @@ import {
-
+
{{ faker['categories'][0]!['name'] }}
@@ -284,11 +259,9 @@ import {
Add a video so that buyers are more interested in your product.
-
+ target="blank">
Learn more.
@@ -355,8 +328,7 @@ import {
+ class="bg-(--color)/[.03] border-(--color)/5 relative rounded-lg border py-10 pl-5 pr-5 [--color:var(--color-foreground)] xl:pr-10">
@@ -368,14 +340,9 @@ import {
@@ -387,20 +354,12 @@ import {
Options
-
+
@@ -445,8 +404,7 @@ import {
+ class="bg-(--color)/[.03] border-(--color)/5 relative rounded-lg border py-10 pl-5 pr-5 [--color:var(--color-foreground)] xl:pr-10">
@@ -458,14 +416,9 @@ import {
@@ -477,20 +430,12 @@ import {
Options
-
+
@@ -543,8 +488,7 @@ import {
- Apply To All
+ Apply To All
@@ -590,13 +532,11 @@ import {
Price
@@ -605,33 +545,19 @@ import {
-
-
- {{ size }}
+
+
+ {{ size }}
+
{{ color }}
@@ -673,9 +599,7 @@ import {
{{ rowIndex + 1 }}.
- Wholesale Price {{ rowIndex + 1 }}
+ Wholesale Price {{ rowIndex + 1 }}
@@ -685,15 +609,10 @@ import {
@@ -734,9 +653,7 @@ import {
- Active
+ Active
@@ -799,12 +716,7 @@ import {
-
+
@@ -812,11 +724,9 @@ import {
Pay close attention to the weight of the product so that there is no difference
in data with the shipping courier.
-
+ target="blank">
Learn More
@@ -842,8 +752,7 @@ import {
@@ -952,51 +861,39 @@ import {
+ class="before:bg-foreground/10 relative before:absolute before:left-0 before:z-[-1] before:h-full before:w-[2px] before:content-['']">
+ class="[&.active]:border-primary [&.active]:text-primary active mb-4 border-l-2 border-transparent py-1 pl-5 opacity-70 [&.active]:font-medium [&.active]:opacity-100">
Upload Product
+ class="[&.active]:border-primary [&.active]:text-primary mb-4 border-l-2 border-transparent py-1 pl-5 opacity-70 [&.active]:font-medium [&.active]:opacity-100">
Product Information
+ class="[&.active]:border-primary [&.active]:text-primary mb-4 border-l-2 border-transparent py-1 pl-5 opacity-70 [&.active]:font-medium [&.active]:opacity-100">
Product Detail
+ class="[&.active]:border-primary [&.active]:text-primary mb-4 border-l-2 border-transparent py-1 pl-5 opacity-70 [&.active]:font-medium [&.active]:opacity-100">
Product Variant
+ class="[&.active]:border-primary [&.active]:text-primary mb-4 border-l-2 border-transparent py-1 pl-5 opacity-70 [&.active]:font-medium [&.active]:opacity-100">
Product Variant (Details)
+ class="[&.active]:border-primary [&.active]:text-primary mb-4 border-l-2 border-transparent py-1 pl-5 opacity-70 [&.active]:font-medium [&.active]:opacity-100">
Product Management
+ class="[&.active]:border-primary [&.active]:text-primary mb-4 border-l-2 border-transparent py-1 pl-5 opacity-70 [&.active]:font-medium [&.active]:opacity-100">
Weight & Shipping
-
+ class="bg-(--color)/10 border-(--color)/20 box text-(--color) box relative mt-10 border p-5 [--color:var(--color-warning)] rounded-(--radius)">
+
Tips
Price
diff --git a/fe/src/views/BlogLayout1.vue b/fe/src/views/BlogLayout1.vue
index 47fee18..5ae6291 100644
--- a/fe/src/views/BlogLayout1.vue
+++ b/fe/src/views/BlogLayout1.vue
@@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Lucide } from '@/components/ui/lucide'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
import {
PaginationContext,
PaginationRoot,
@@ -92,11 +92,7 @@ import {
@@ -202,12 +179,7 @@ import {
-
+
diff --git a/fe/src/views/BlogLayout2.vue b/fe/src/views/BlogLayout2.vue
index dfb61fc..70bb156 100644
--- a/fe/src/views/BlogLayout2.vue
+++ b/fe/src/views/BlogLayout2.vue
@@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Lucide } from '@/components/ui/lucide'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
import {
PaginationContext,
PaginationRoot,
@@ -57,13 +57,8 @@ import {
-
+ class="image-fit h-[320px] before:absolute before:left-0 before:top-0 before:z-10 before:block before:h-full before:w-full before:rounded-t-xl before:bg-gradient-to-t before:from-black/90 before:to-black/10">
+
PA
@@ -79,10 +74,7 @@ import {
-
+
@@ -113,10 +105,8 @@ import {
@@ -206,12 +179,7 @@ import {
-
+
diff --git a/fe/src/views/Categories.vue b/fe/src/views/Categories.vue
index b3e34c7..5deff22 100644
--- a/fe/src/views/Categories.vue
+++ b/fe/src/views/Categories.vue
@@ -8,7 +8,7 @@ import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/d
import { Input } from '@/components/ui/input'
import { Lucide } from '@/components/ui/lucide'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
import {
PaginationContext,
PaginationRoot,
@@ -85,24 +85,15 @@ const deleteConfirmationOpen = ref(false)
@@ -121,10 +112,8 @@ const deleteConfirmationOpen = ref(false)
-
+
{{ faker['trueFalse'][0] ? 'Active' : 'Inactive' }}
@@ -135,11 +124,7 @@ const deleteConfirmationOpen = ref(false)
Edit
-
+
Delete
@@ -152,12 +137,7 @@ const deleteConfirmationOpen = ref(false)
-
+
Previous
@@ -179,10 +159,7 @@ const deleteConfirmationOpen = ref(false)
-
(deleteConfirmationOpen = details.open)"
- >
+ (deleteConfirmationOpen = details.open)">
diff --git a/fe/src/views/CrudDataList.vue b/fe/src/views/CrudDataList.vue
index 8c079a5..404017e 100644
--- a/fe/src/views/CrudDataList.vue
+++ b/fe/src/views/CrudDataList.vue
@@ -8,7 +8,7 @@ import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/d
import { Input } from '@/components/ui/input'
import { Lucide } from '@/components/ui/lucide'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
import {
PaginationContext,
PaginationRoot,
@@ -85,24 +85,15 @@ const deleteConfirmationOpen = ref(false)
@@ -118,10 +109,8 @@ const deleteConfirmationOpen = ref(false)
{{ faker['stocks'][0] }}
-
+
{{ faker['trueFalse'][0] ? 'Active' : 'Inactive' }}
@@ -132,11 +121,7 @@ const deleteConfirmationOpen = ref(false)
Edit
-
+
Delete
@@ -149,12 +134,7 @@ const deleteConfirmationOpen = ref(false)
-
+
Previous
@@ -176,10 +156,7 @@ const deleteConfirmationOpen = ref(false)
-
(deleteConfirmationOpen = details.open)"
- >
+ (deleteConfirmationOpen = details.open)">
diff --git a/fe/src/views/DashboardOverview1.vue b/fe/src/views/DashboardOverview1.vue
index c59f55b..d04c855 100644
--- a/fe/src/views/DashboardOverview1.vue
+++ b/fe/src/views/DashboardOverview1.vue
@@ -46,7 +46,6 @@ import {
MenuContent,
MenuCheckboxItem,
} from '@/components/ui/menu'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
import { Transactions } from '@/components/transactions'
import { RecentActivities } from '@/components/recent-activities'
@@ -85,10 +84,7 @@ const salesReportDate = ref
([
-
+
12%
@@ -103,10 +99,7 @@ const salesReportDate = ref([
-
+
9%
@@ -156,12 +149,8 @@ const salesReportDate = ref([
Sales Report
-
(salesReportDate = details.value)"
- >
+ (salesReportDate = details.value)">
@@ -180,21 +169,14 @@ const salesReportDate = ref([
-
+
{{ weekDay.short }}
-
+
{{ day.day }}
@@ -207,27 +189,17 @@ const salesReportDate = ref([
-
+
{{ weekDay.short }}
-
-
+
+
{{ day.day }}
@@ -249,9 +221,7 @@ const salesReportDate = ref([
$24,100,21
This Month
-
+
$21,023,01
Last Month
@@ -261,39 +231,23 @@ const salesReportDate = ref
([
Filter by Category
-
+
PC & Laptop
-
+
Smartphone
-
+
Electronic
-
+
Photography
-
+
Sport
@@ -317,22 +271,22 @@ const salesReportDate = ref([
+ class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-primary)]">
+
17 - 30 Years old
62%
+ class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-pending)]">
+
31 - 50 Years old
33%
+ class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-warning)]">
+
>= 50 Years old
10%
@@ -353,22 +307,22 @@ const salesReportDate = ref([
+ class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-primary)]">
+
17 - 30 Years old
62%
+ class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-pending)]">
+
31 - 50 Years old
33%
+ class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-warning)]">
+
>= 50 Years old
10%
@@ -397,9 +351,7 @@ const salesReportDate = ref([
-
@@ -410,9 +362,7 @@ const salesReportDate = ref
([
Social Media
-
137 Sales
+
137 Sales
@@ -428,9 +378,7 @@ const salesReportDate = ref
([
-
@@ -441,9 +389,7 @@ const salesReportDate = ref
([
Posted Ads
-
180 Campaign
+
180 Campaign
@@ -474,15 +420,13 @@ const salesReportDate = ref
([
+ class="col-span-12 mt-3 md:col-span-6 xl:col-span-12 xl:col-start-1 xl:row-start-1 2xl:col-start-auto 2xl:row-start-auto">
+ class="col-span-12 mt-3 md:col-span-6 xl:col-span-4 xl:col-start-1 xl:row-start-2 2xl:col-span-12 2xl:col-start-auto 2xl:row-start-auto">
@@ -491,10 +435,7 @@ const salesReportDate = ref([
- (onboardingDialog = details.open)"
- >
+ (onboardingDialog = details.open)">
@@ -504,13 +445,9 @@ const salesReportDate = ref
([
-
+ class="w-full bg-primary/[.05] mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
+
Welcome to Midone Admin!
@@ -521,16 +458,13 @@ const salesReportDate = ref
([
-
+
Skip Intro
-
- Next
+
+ Next
+
@@ -538,13 +472,9 @@ const salesReportDate = ref([
-
+ class="w-full bg-primary/[.05] mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
+
Example Request Information
@@ -572,9 +502,7 @@ const salesReportDate = ref
([
-
+
Previous
diff --git a/fe/src/views/ErrorPage.vue b/fe/src/views/ErrorPage.vue
index 472080a..1091cac 100644
--- a/fe/src/views/ErrorPage.vue
+++ b/fe/src/views/ErrorPage.vue
@@ -4,24 +4,17 @@ import { Button } from '@/components/ui/button'
-
+
-
+
-
+
404
@@ -29,10 +22,7 @@ import { Button } from '@/components/ui/button'
You may have mistyped the address or the page may have moved.
-
+
Back to Home
diff --git a/fe/src/views/FileManager.vue b/fe/src/views/FileManager.vue
index 4ef9dad..13c2974 100644
--- a/fe/src/views/FileManager.vue
+++ b/fe/src/views/FileManager.vue
@@ -9,7 +9,7 @@ import { FileIcon } from '@/components/ui/file-icon'
import { Input } from '@/components/ui/input'
import { Lucide } from '@/components/ui/lucide'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
import {
PaginationContext,
PaginationRoot,
@@ -33,68 +33,58 @@ import {
Custom Work
+ class="bg-(--color)/20 border-(--color)/60 me-3 mr-3 h-2 w-2 rounded-full border [--color:var(--color-success)]">
+
Important Meetings
+ class="bg-(--color)/20 border-(--color)/60 me-3 mr-3 h-2 w-2 rounded-full border [--color:var(--color-warning)]">
+
Work
+ class="bg-(--color)/20 border-(--color)/60 me-3 mr-3 h-2 w-2 rounded-full border [--color:var(--color-pending)]">
+
Design
+ class="bg-(--color)/20 border-(--color)/60 me-3 mr-3 h-2 w-2 rounded-full border [--color:var(--color-danger)]">
+
Next Week
@@ -108,10 +98,7 @@ import {
-
-
-
+
+
+
{{ faker.files[0]!.fileName.split('/').pop() }}
@@ -235,12 +210,7 @@ import {
-
+
Previous
diff --git a/fe/src/views/ForbiddenPage.vue b/fe/src/views/ForbiddenPage.vue
new file mode 100644
index 0000000..3c8bd8e
--- /dev/null
+++ b/fe/src/views/ForbiddenPage.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
403
+
Access denied.
+
+ You do not have permission to access this page.
+
+
+ Back to Home
+
+
+
+
+
+
diff --git a/fe/src/views/Inbox.vue b/fe/src/views/Inbox.vue
index 6558cf9..3fb0b1a 100644
--- a/fe/src/views/Inbox.vue
+++ b/fe/src/views/Inbox.vue
@@ -9,7 +9,7 @@ import { Field, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Lucide } from '@/components/ui/lucide'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
import {
PopoverRoot,
PopoverTrigger,
@@ -28,68 +28,58 @@ import {
Compose
Custom Work
+ class="bg-(--color)/20 border-(--color)/60 me-3 mr-3 h-2 w-2 rounded-full border [--color:var(--color-success)]">
+
Important Meetings
+ class="bg-(--color)/20 border-(--color)/60 me-3 mr-3 h-2 w-2 rounded-full border [--color:var(--color-warning)]">
+
Work
+ class="bg-(--color)/20 border-(--color)/60 me-3 mr-3 h-2 w-2 rounded-full border [--color:var(--color-pending)]">
+
Design
+ class="bg-(--color)/20 border-(--color)/60 me-3 mr-3 h-2 w-2 rounded-full border [--color:var(--color-danger)]">
+
Next Week
@@ -103,10 +93,7 @@ import {
-
+
@@ -185,12 +172,9 @@ import {
-
+
+ class="-mx-5 mt-3 flex items-center border-t border-foreground/15 px-5 pt-5 sm:mx-0 sm:mt-0 sm:border-0 sm:px-0 sm:pt-0">
@@ -232,30 +216,20 @@ import {
-
+
-
+
-
+
@@ -272,20 +246,16 @@ import {
{{ faker['news'][0]!['shortContent'] }}
-
+
{{ faker['times'][0] }}
-
+
4.41 GB (25%) of 17 GB used Manage
Last account activity: 36 minutes ago
diff --git a/fe/src/views/PointOfSale.vue b/fe/src/views/PointOfSale.vue
index 4947f09..f526674 100644
--- a/fe/src/views/PointOfSale.vue
+++ b/fe/src/views/PointOfSale.vue
@@ -14,7 +14,7 @@ import { Field, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Lucide } from '@/components/ui/lucide'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
@@ -27,14 +27,8 @@ const addItemDialogOpen = ref(false)
Point of Sale
-
+
New Order
@@ -71,15 +65,8 @@ const addItemDialogOpen = ref(false)
-
+
Soup
5 Items
-
+
Pancake & Toast
8 Items
-
+
Pasta
4 Items
-
+
Waffle
3 Items
-
+
Snacks
8 Items
-
+
Deserts
8 Items
-
+
Beverage
9 Items
-
+
-
+
- (newOrderDialogOpen = details.open)"
- >
+ (newOrderDialogOpen = details.open)">
New Order
@@ -291,10 +249,7 @@ const addItemDialogOpen = ref(false)
- (addItemDialogOpen = details.open)"
- >
+ (addItemDialogOpen = details.open)">
{{ fakers[0]!['foods'][0]!['name'] }}
@@ -306,13 +261,7 @@ const addItemDialogOpen = ref(false)
Quantity
-
-
+
+
diff --git a/fe/src/views/ProductGrid.vue b/fe/src/views/ProductGrid.vue
index 79eb6a0..8ca5028 100644
--- a/fe/src/views/ProductGrid.vue
+++ b/fe/src/views/ProductGrid.vue
@@ -6,7 +6,6 @@ import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Box } from '@/components/ui/box'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import {
PaginationContext,
PaginationRoot,
@@ -60,27 +59,14 @@ const deleteConfirmationOpen = ref(false)
-
+
-
-
+ class="image-fit h-40 overflow-hidden rounded-lg before:absolute before:left-0 before:top-0 before:z-10 before:block before:h-full before:w-full before:bg-gradient-to-t before:from-black before:to-black/10 2xl:h-56">
+
+
Featured
@@ -107,20 +93,14 @@ const deleteConfirmationOpen = ref(false)
-
+
@@ -129,12 +109,7 @@ const deleteConfirmationOpen = ref(false)
-
+
Previous
@@ -156,10 +131,7 @@ const deleteConfirmationOpen = ref(false)
-
(deleteConfirmationOpen = details.open)"
- >
+ (deleteConfirmationOpen = details.open)">
diff --git a/fe/src/views/ProductList.vue b/fe/src/views/ProductList.vue
index 5624863..34df7ce 100644
--- a/fe/src/views/ProductList.vue
+++ b/fe/src/views/ProductList.vue
@@ -13,7 +13,6 @@ import {
TableRow,
TableCell,
} from '@/components/ui/table'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
PaginationContext,
@@ -86,24 +85,15 @@ const deleteConfirmationOpen = ref(false)
@@ -120,10 +110,8 @@ const deleteConfirmationOpen = ref(false)
${{ faker['totals'][0] }}
-
+
{{ faker['trueFalse'][0] ? 'Active' : 'Inactive' }}
@@ -134,11 +122,7 @@ const deleteConfirmationOpen = ref(false)
Edit
-
+
Delete
@@ -151,12 +135,7 @@ const deleteConfirmationOpen = ref(false)
-
+
Previous
@@ -178,10 +157,7 @@ const deleteConfirmationOpen = ref(false)
-
(deleteConfirmationOpen = details.open)"
- >
+ (deleteConfirmationOpen = details.open)">
diff --git a/fe/src/views/Reviews.vue b/fe/src/views/Reviews.vue
index 200f902..0642cd0 100644
--- a/fe/src/views/Reviews.vue
+++ b/fe/src/views/Reviews.vue
@@ -12,7 +12,7 @@ import {
TableRow,
TableCell,
} from '@/components/ui/table'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
import { CheckboxRoot, CheckboxControl } from '@/components/ui/checkbox'
import {
PaginationContext,
@@ -115,11 +115,8 @@ import { Lucide } from '@/components/ui/lucide'
-
+
{{ 'Uploaded at ' + faker['dates'][0] }}
@@ -139,12 +136,8 @@ import { Lucide } from '@/components/ui/lucide'
-
+
(4.5+)
@@ -154,10 +147,8 @@ import { Lucide } from '@/components/ui/lucide'
{{ faker['formattedTimes'][0] }}
-
+
{{ faker['trueFalse'][0] ? 'Active' : 'Inactive' }}
@@ -177,12 +168,7 @@ import { Lucide } from '@/components/ui/lucide'
-
+
Previous
diff --git a/fe/src/views/SellerDetail.vue b/fe/src/views/SellerDetail.vue
index ce2dfd3..3a5bec8 100644
--- a/fe/src/views/SellerDetail.vue
+++ b/fe/src/views/SellerDetail.vue
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Box } from '@/components/ui/box'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
import {
PaginationContext,
PaginationRoot,
@@ -166,27 +166,15 @@ const deleteConfirmationOpen = ref(false)
-
+
-
-
+ class="image-fit h-40 overflow-hidden rounded-xl before:absolute before:left-0 before:top-0 before:z-10 before:block before:h-full before:w-full before:bg-gradient-to-t before:from-black before:to-black/10 2xl:h-56">
+
+
Featured
@@ -212,20 +200,14 @@ const deleteConfirmationOpen = ref(false)
-
+
@@ -234,12 +216,7 @@ const deleteConfirmationOpen = ref(false)
-
+
Previous
@@ -263,10 +240,7 @@ const deleteConfirmationOpen = ref(false)
- (deleteConfirmationOpen = details.open)"
- >
+ (deleteConfirmationOpen = details.open)">
diff --git a/fe/src/views/SellerList.vue b/fe/src/views/SellerList.vue
index 9fb4650..536249b 100644
--- a/fe/src/views/SellerList.vue
+++ b/fe/src/views/SellerList.vue
@@ -13,7 +13,7 @@ import {
TableRow,
TableCell,
} from '@/components/ui/table'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
import { CheckboxRoot, CheckboxControl } from '@/components/ui/checkbox'
import {
PaginationContext,
@@ -112,11 +112,8 @@ const stores = ['Themeforest', 'Codecanyon', 'Graphicriver']
-
+
{{ 'Uploaded at ' + faker['dates'][0] }}
@@ -142,10 +139,8 @@ const stores = ['Themeforest', 'Codecanyon', 'Graphicriver']
{{ faker['users'][0]!['gender'] }}
-
+
{{ faker['trueFalse'][0] ? 'Active' : 'Inactive' }}
@@ -157,11 +152,7 @@ const stores = ['Themeforest', 'Codecanyon', 'Graphicriver']
Edit
-
+
Delete
@@ -173,12 +164,7 @@ const stores = ['Themeforest', 'Codecanyon', 'Graphicriver']
-
+
Previous
@@ -200,10 +186,7 @@ const stores = ['Themeforest', 'Codecanyon', 'Graphicriver']
- (deleteConfirmationOpen = details.open)"
- >
+ (deleteConfirmationOpen = details.open)">
diff --git a/fe/src/views/TransactionList.vue b/fe/src/views/TransactionList.vue
index 7a08e5f..204415e 100644
--- a/fe/src/views/TransactionList.vue
+++ b/fe/src/views/TransactionList.vue
@@ -13,7 +13,7 @@ import {
TableRow,
TableCell,
} from '@/components/ui/table'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
import { CheckboxRoot, CheckboxControl } from '@/components/ui/checkbox'
import {
PaginationContext,
@@ -127,10 +127,8 @@ const deleteConfirmationOpen = ref(false)
-
+
{{ faker['trueFalse'][0] ? 'Active' : 'Inactive' }}
@@ -152,11 +150,8 @@ const deleteConfirmationOpen = ref(false)
View Details
-
+
Change Status
@@ -169,12 +164,7 @@ const deleteConfirmationOpen = ref(false)
-
+
Previous
@@ -196,10 +186,7 @@ const deleteConfirmationOpen = ref(false)
-
(deleteConfirmationOpen = details.open)"
- >
+ (deleteConfirmationOpen = details.open)">
diff --git a/fe/src/views/UsersLayout1.vue b/fe/src/views/UsersLayout1.vue
index f31a555..019d4da 100644
--- a/fe/src/views/UsersLayout1.vue
+++ b/fe/src/views/UsersLayout1.vue
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Box } from '@/components/ui/box'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
PaginationContext,
@@ -59,11 +59,7 @@ import { Lucide } from '@/components/ui/lucide'
-
+
@@ -81,10 +77,8 @@ import { Lucide } from '@/components/ui/lucide'
-
+
@@ -94,10 +88,8 @@ import { Lucide } from '@/components/ui/lucide'
-
+
@@ -107,10 +99,8 @@ import { Lucide } from '@/components/ui/lucide'
-
+
@@ -138,12 +128,7 @@ import { Lucide } from '@/components/ui/lucide'
-
+
Previous
diff --git a/fe/src/views/UsersLayout2.vue b/fe/src/views/UsersLayout2.vue
index f52f2cb..442552e 100644
--- a/fe/src/views/UsersLayout2.vue
+++ b/fe/src/views/UsersLayout2.vue
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Box } from '@/components/ui/box'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
PaginationContext,
@@ -78,12 +78,7 @@ import { Lucide } from '@/components/ui/lucide'
-
+
Previous
diff --git a/fe/src/views/UsersLayout3.vue b/fe/src/views/UsersLayout3.vue
index f1bbe6d..1393694 100644
--- a/fe/src/views/UsersLayout3.vue
+++ b/fe/src/views/UsersLayout3.vue
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Box } from '@/components/ui/box'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
PaginationContext,
@@ -53,11 +53,7 @@ import { Lucide } from '@/components/ui/lucide'
-
+
@@ -112,12 +108,7 @@ import { Lucide } from '@/components/ui/lucide'
-
+
Previous
diff --git a/fe/src/views/WizardLayout1.vue b/fe/src/views/WizardLayout1.vue
index c4faaac..e0ca24a 100644
--- a/fe/src/views/WizardLayout1.vue
+++ b/fe/src/views/WizardLayout1.vue
@@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Box } from '@/components/ui/box'
import { Field, FieldLabel } from '@/components/ui/field'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
@@ -14,16 +14,10 @@ import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
1
-
+
2
-
+
3
diff --git a/fe/src/views/WizardLayout2.vue b/fe/src/views/WizardLayout2.vue
index 35c9b0c..09e9646 100644
--- a/fe/src/views/WizardLayout2.vue
+++ b/fe/src/views/WizardLayout2.vue
@@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Box } from '@/components/ui/box'
import { Field, FieldLabel } from '@/components/ui/field'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
@@ -18,37 +18,25 @@ import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
Create New Account
-
+
3
Setup Your Business Details
-
+
4
Setup Billing Account
-
+
5
Finalize your purchase
diff --git a/fe/src/views/WizardLayout3.vue b/fe/src/views/WizardLayout3.vue
index b3c6df6..61180b8 100644
--- a/fe/src/views/WizardLayout3.vue
+++ b/fe/src/views/WizardLayout3.vue
@@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Box } from '@/components/ui/box'
import { Field, FieldLabel } from '@/components/ui/field'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+
@@ -13,8 +13,7 @@ import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
+ class="before:bg-foreground/10 relative flex flex-col justify-center px-5 before:absolute before:bottom-0 before:top-0 before:mt-6 before:hidden before:h-0.5 before:w-[69%] sm:px-20 lg:flex-row before:lg:block">
1
@@ -22,37 +21,25 @@ import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
-
+
3
Setup Your Business Details
-
+
4
Setup Billing Account
-
+
5
Finalize your purchase