From 52ffd3393a3d5dbc2a411dbfe1f7a85dfd457633 Mon Sep 17 00:00:00 2001 From: ISMAIL MASSERAN Date: Tue, 14 Jul 2026 12:02:37 +0800 Subject: [PATCH] DONE: feedback module, use permission name for notification --- .../Auth/Actions/Fortify/CreateNewUser.php | 34 +- be/Modules/Feedback/Actions/.gitkeep | 0 be/Modules/Feedback/Config/.gitkeep | 0 be/Modules/Feedback/Config/config.php | 5 + be/Modules/Feedback/Console/.gitkeep | 0 .../Feedback/Database/Factories/.gitkeep | 0 .../Feedback/Database/Migrations/.gitkeep | 0 ...26_07_13_042006_create_feedbacks_table.php | 54 ++ be/Modules/Feedback/Database/Seeders/.gitkeep | 0 .../Seeders/FeedbackDatabaseSeeder.php | 16 + be/Modules/Feedback/Emails/.gitkeep | 0 be/Modules/Feedback/Entities/.gitkeep | 0 be/Modules/Feedback/Entities/Feedback.php | 119 +++++ be/Modules/Feedback/Helpers/.gitkeep | 0 be/Modules/Feedback/Http/Controllers/.gitkeep | 0 .../Http/Controllers/FeedbackController.php | 339 ++++++++++++ be/Modules/Feedback/Http/Requests/.gitkeep | 0 .../Http/Requests/FeedbackRequest.php | 83 +++ be/Modules/Feedback/Jobs/.gitkeep | 0 be/Modules/Feedback/Notifications/.gitkeep | 0 .../Notifications/FeedbackNotification.php | 66 +++ be/Modules/Feedback/Policies/.gitkeep | 0 .../Feedback/Policies/FeedbackPolicy.php | 59 +++ be/Modules/Feedback/Providers/.gitkeep | 0 .../Providers/EventServiceProvider.php | 27 + .../Providers/FeedbackServiceProvider.php | 160 ++++++ .../Providers/RouteServiceProvider.php | 39 ++ be/Modules/Feedback/Repositories/.gitkeep | 0 .../Feedback/Repositories/Contracts/.gitkeep | 0 .../Contracts/FeedbackRepositoryInterface.php | 44 ++ .../Repositories/FeedbackRepository.php | 149 ++++++ be/Modules/Feedback/Routes/.gitkeep | 0 be/Modules/Feedback/Routes/api.php | 20 + be/Modules/Feedback/Services/.gitkeep | 0 be/Modules/Feedback/Tests/Feature/.gitkeep | 0 be/Modules/Feedback/Tests/Unit/.gitkeep | 0 be/Modules/Feedback/Transformers/.gitkeep | 0 .../Transformers/FeedbackResource.php | 79 +++ be/Modules/Feedback/composer.json | 30 ++ be/Modules/Feedback/module.json | 12 + be/Modules/Feedback/package.json | 15 + be/Modules/User/Policies/UserPolicy.php | 22 +- be/app/Traits/NotifiesAdmins.php | 10 +- be/modules_statuses.json | 3 +- fe/src/main/side-menu.ts | 2 + fe/src/modules/auth/pages/Login.vue | 2 + fe/src/modules/auth/pages/Register.vue | 2 + .../feedback/components/HelpdeskFab.vue | 54 ++ .../feedback/composables/useFeedbackList.ts | 104 ++++ fe/src/modules/feedback/index.ts | 22 + fe/src/modules/feedback/menu.ts | 10 + .../modules/feedback/pages/FeedbackDetail.vue | 486 ++++++++++++++++++ .../modules/feedback/pages/FeedbackList.vue | 289 +++++++++++ .../modules/feedback/pages/FeedbackSubmit.vue | 347 +++++++++++++ fe/src/modules/feedback/routes.ts | 33 ++ .../feedback/services/feedback.service.ts | 170 ++++++ .../modules/feedback/types/feedback.types.ts | 180 +++++++ .../pages/MembershipApplication.vue | 2 + fe/src/router/index.ts | 8 +- fe/src/themes/Layout.vue | 13 +- 60 files changed, 3083 insertions(+), 26 deletions(-) create mode 100644 be/Modules/Feedback/Actions/.gitkeep create mode 100644 be/Modules/Feedback/Config/.gitkeep create mode 100644 be/Modules/Feedback/Config/config.php create mode 100644 be/Modules/Feedback/Console/.gitkeep create mode 100644 be/Modules/Feedback/Database/Factories/.gitkeep create mode 100644 be/Modules/Feedback/Database/Migrations/.gitkeep create mode 100644 be/Modules/Feedback/Database/Migrations/2026_07_13_042006_create_feedbacks_table.php create mode 100644 be/Modules/Feedback/Database/Seeders/.gitkeep create mode 100644 be/Modules/Feedback/Database/Seeders/FeedbackDatabaseSeeder.php create mode 100644 be/Modules/Feedback/Emails/.gitkeep create mode 100644 be/Modules/Feedback/Entities/.gitkeep create mode 100644 be/Modules/Feedback/Entities/Feedback.php create mode 100644 be/Modules/Feedback/Helpers/.gitkeep create mode 100644 be/Modules/Feedback/Http/Controllers/.gitkeep create mode 100644 be/Modules/Feedback/Http/Controllers/FeedbackController.php create mode 100644 be/Modules/Feedback/Http/Requests/.gitkeep create mode 100644 be/Modules/Feedback/Http/Requests/FeedbackRequest.php create mode 100644 be/Modules/Feedback/Jobs/.gitkeep create mode 100644 be/Modules/Feedback/Notifications/.gitkeep create mode 100644 be/Modules/Feedback/Notifications/FeedbackNotification.php create mode 100644 be/Modules/Feedback/Policies/.gitkeep create mode 100644 be/Modules/Feedback/Policies/FeedbackPolicy.php create mode 100644 be/Modules/Feedback/Providers/.gitkeep create mode 100644 be/Modules/Feedback/Providers/EventServiceProvider.php create mode 100644 be/Modules/Feedback/Providers/FeedbackServiceProvider.php create mode 100644 be/Modules/Feedback/Providers/RouteServiceProvider.php create mode 100644 be/Modules/Feedback/Repositories/.gitkeep create mode 100644 be/Modules/Feedback/Repositories/Contracts/.gitkeep create mode 100644 be/Modules/Feedback/Repositories/Contracts/FeedbackRepositoryInterface.php create mode 100644 be/Modules/Feedback/Repositories/FeedbackRepository.php create mode 100644 be/Modules/Feedback/Routes/.gitkeep create mode 100644 be/Modules/Feedback/Routes/api.php create mode 100644 be/Modules/Feedback/Services/.gitkeep create mode 100644 be/Modules/Feedback/Tests/Feature/.gitkeep create mode 100644 be/Modules/Feedback/Tests/Unit/.gitkeep create mode 100644 be/Modules/Feedback/Transformers/.gitkeep create mode 100644 be/Modules/Feedback/Transformers/FeedbackResource.php create mode 100644 be/Modules/Feedback/composer.json create mode 100644 be/Modules/Feedback/module.json create mode 100644 be/Modules/Feedback/package.json create mode 100644 fe/src/modules/feedback/components/HelpdeskFab.vue create mode 100644 fe/src/modules/feedback/composables/useFeedbackList.ts create mode 100644 fe/src/modules/feedback/index.ts create mode 100644 fe/src/modules/feedback/menu.ts create mode 100644 fe/src/modules/feedback/pages/FeedbackDetail.vue create mode 100644 fe/src/modules/feedback/pages/FeedbackList.vue create mode 100644 fe/src/modules/feedback/pages/FeedbackSubmit.vue create mode 100644 fe/src/modules/feedback/routes.ts create mode 100644 fe/src/modules/feedback/services/feedback.service.ts create mode 100644 fe/src/modules/feedback/types/feedback.types.ts diff --git a/be/Modules/Auth/Actions/Fortify/CreateNewUser.php b/be/Modules/Auth/Actions/Fortify/CreateNewUser.php index fb27230..52ea091 100644 --- a/be/Modules/Auth/Actions/Fortify/CreateNewUser.php +++ b/be/Modules/Auth/Actions/Fortify/CreateNewUser.php @@ -13,6 +13,7 @@ use Modules\Auth\Entities\User; use Modules\Auth\Services\PhoneVerificationOtpService; use Modules\Role\Entities\Role; use Modules\User\Notifications\UserActivationNotification; +use Modules\User\Policies\UserPolicy; use Exception; class CreateNewUser implements CreatesNewUsers @@ -23,11 +24,6 @@ class CreateNewUser implements CreatesNewUsers protected PhoneVerificationOtpService $phoneVerificationOtpService, ) {} - /** - * Validate and create a newly registered user. - * - * @param array $input - */ public function create(array $input): User { $phoneNumber = $this->phoneVerificationOtpService->normalizePhoneNumber($input['phone_number'] ?? ''); @@ -77,6 +73,34 @@ class CreateNewUser implements CreatesNewUsers $user->assignRole($role); } + if ($user->status === 'pending') { + $this->notifyAdminsForActivation($user); + } + return $user; } + + /** + * Notify users who can kemaskini pengguna about new user requiring activation. + */ + private function notifyAdminsForActivation(User $newUser): void + { + try { + $recipients = $this->getUsersWithPermission(UserPolicy::PERMISSION_UPDATE) + ->where('id', '!=', $newUser->id); + + $sender = auth()->user() ?? $newUser; + + foreach ($recipients as $recipient) { + try { + $recipient->notify(new UserActivationNotification($newUser, $sender)); + } catch (Exception $e) { + Log::error('Failed to send user activation notification: '.$e->getMessage()); + } + } + } catch (Exception $e) { + Log::error('Failed to notify admins for user activation: '.$e->getMessage()); + } + } + } diff --git a/be/Modules/Feedback/Actions/.gitkeep b/be/Modules/Feedback/Actions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Config/.gitkeep b/be/Modules/Feedback/Config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Config/config.php b/be/Modules/Feedback/Config/config.php new file mode 100644 index 0000000..cf0c8c1 --- /dev/null +++ b/be/Modules/Feedback/Config/config.php @@ -0,0 +1,5 @@ + 'Feedback', +]; diff --git a/be/Modules/Feedback/Console/.gitkeep b/be/Modules/Feedback/Console/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Database/Factories/.gitkeep b/be/Modules/Feedback/Database/Factories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Database/Migrations/.gitkeep b/be/Modules/Feedback/Database/Migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Database/Migrations/2026_07_13_042006_create_feedbacks_table.php b/be/Modules/Feedback/Database/Migrations/2026_07_13_042006_create_feedbacks_table.php new file mode 100644 index 0000000..e54961e --- /dev/null +++ b/be/Modules/Feedback/Database/Migrations/2026_07_13_042006_create_feedbacks_table.php @@ -0,0 +1,54 @@ +uuid('id')->primary(); + $table->foreignUuid('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('title'); + $table->text('description'); + $table->string('page_url')->nullable(); // URL where the issue occurred + $table->json('browser_info')->nullable(); // Browser, OS, screen resolution etc. + $table->text('steps_to_reproduce')->nullable(); // Steps to reproduce the issue + $table->text('expected_behavior')->nullable(); // What should happen + $table->text('actual_behavior')->nullable(); // What actually happened + $table->text('additional_notes')->nullable(); // Any additional information + $table->foreignUuid('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $table->text('admin_notes')->nullable(); // Internal notes for admins + $table->timestamp('resolved_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + // Add ENUM columns + DB::statement("ALTER TABLE feedback ADD COLUMN type feedback_type_enum NOT NULL DEFAULT 'general_feedback'"); + DB::statement("ALTER TABLE feedback ADD COLUMN priority feedback_priority_enum NOT NULL DEFAULT 'medium'"); + DB::statement("ALTER TABLE feedback ADD COLUMN status feedback_status_enum NOT NULL DEFAULT 'open'"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('feedback'); + DB::statement("DROP TYPE IF EXISTS feedback_type_enum"); + DB::statement("DROP TYPE IF EXISTS feedback_priority_enum"); + DB::statement("DROP TYPE IF EXISTS feedback_status_enum"); + } +}; diff --git a/be/Modules/Feedback/Database/Seeders/.gitkeep b/be/Modules/Feedback/Database/Seeders/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Database/Seeders/FeedbackDatabaseSeeder.php b/be/Modules/Feedback/Database/Seeders/FeedbackDatabaseSeeder.php new file mode 100644 index 0000000..47557c6 --- /dev/null +++ b/be/Modules/Feedback/Database/Seeders/FeedbackDatabaseSeeder.php @@ -0,0 +1,16 @@ +call([]); + } +} diff --git a/be/Modules/Feedback/Emails/.gitkeep b/be/Modules/Feedback/Emails/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Entities/.gitkeep b/be/Modules/Feedback/Entities/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Entities/Feedback.php b/be/Modules/Feedback/Entities/Feedback.php new file mode 100644 index 0000000..5323a6c --- /dev/null +++ b/be/Modules/Feedback/Entities/Feedback.php @@ -0,0 +1,119 @@ + 'array', + 'resolved_at' => 'datetime', + ]; + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logAll() + ->logOnlyDirty(); + } + + /** + * Get the user who submitted the feedback + */ + public function user() + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * Get the admin assigned to handle this feedback + */ + public function assignedUser() + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function images(): MorphMany + { + return $this->documents()->where('type', self::IMAGE_DOCUMENT_TYPE); + } + + public function videos(): MorphMany + { + return $this->documents()->where('type', self::VIDEO_DOCUMENT_TYPE); + } + + /** + * Scope for filtering by type + */ + public function scopeOfType($query, $type) + { + return $query->where('type', $type); + } + + /** + * Scope for filtering by status + */ + public function scopeOfStatus($query, $status) + { + return $query->where('status', $status); + } + + /** + * Scope for filtering by priority + */ + public function scopeOfPriority($query, $priority) + { + return $query->where('priority', $priority); + } + + /** + * Scope for open feedback + */ + public function scopeOpen($query) + { + return $query->whereIn('status', ['open', 'in_progress']); + } + + /** + * Scope for resolved feedback + */ + public function scopeResolved($query) + { + return $query->whereIn('status', ['resolved', 'closed']); + } +} diff --git a/be/Modules/Feedback/Helpers/.gitkeep b/be/Modules/Feedback/Helpers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Http/Controllers/.gitkeep b/be/Modules/Feedback/Http/Controllers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Http/Controllers/FeedbackController.php b/be/Modules/Feedback/Http/Controllers/FeedbackController.php new file mode 100644 index 0000000..adfaa1f --- /dev/null +++ b/be/Modules/Feedback/Http/Controllers/FeedbackController.php @@ -0,0 +1,339 @@ + $request->get('type'), + 'status' => $request->get('status'), + 'priority' => $request->get('priority'), + ]; + + // Always try paginated methods first when perPage is specified + if ($perPage > 0) { + if (method_exists($this->repository, 'getAllWithRelationsPaginated')) { + return $this->repository->getAllWithRelationsPaginated($perPage, $search, $sortBy, $sortOrder, $filters); + } + + if (method_exists($this->repository, 'getAllPaginated')) { + return $this->repository->getAllPaginated($perPage, $search, $sortBy, $sortOrder, $filters); + } + } + + // Fallback to non-paginated methods + if (method_exists($this->repository, 'getAllWithRelations')) { + return $this->repository->getAllWithRelations($search, $sortBy, $sortOrder); + } + + return $this->repository->all($search, $sortBy, $sortOrder); + } + + /** + * Store a newly created resource in storage (public access) + */ + public function store(Request $request): JsonResponse + { + // Skip authorization for public feedback submission + try { + $validated = $this->validateRequest($request); + $data = $this->prepareStoreData($validated); + + $item = $this->repository->create($data); + + $this->uploadAttachments($item, $request); + + // Load relationships + $item->load(['user', 'documents']); + + // Send notification to admins + $this->notifyAdminsForNewFeedback($item); + + return response()->json([ + 'success' => true, + 'data' => new $this->resourceClass($item), + 'message' => 'Maklum balas berjaya dihantar. Terima kasih!', + ], 201); + + } catch (Exception $e) { + Log::error("Error creating {$this->resourceName}: ".$e->getMessage()); + + return $this->errorResponse($this->getErrorMessage('store').': '.$e->getMessage(), 500); + } + } + + /** + * Prepare data for store method + */ + protected function prepareStoreData(array $validated): array + { + $data = $validated; + + // Check if user is authenticated by looking for the Authorization header + $authHeader = request()->header('Authorization'); + if ($authHeader && str_starts_with($authHeader, 'Bearer ')) { + // Try to authenticate the user manually + $token = str_replace('Bearer ', '', $authHeader); + $personalAccessToken = PersonalAccessToken::findToken($token); + + if ($personalAccessToken) { + $data['user_id'] = $personalAccessToken->tokenable_id; + } else { + $data['user_id'] = null; + } + } else { + // User is not authenticated + $data['user_id'] = null; + } + + $data['browser_info'] = $this->getBrowserInfo(request()); + + unset($data['images'], $data['videos']); + + return $data; + } + + /** + * Upload image and video attachments via HasDocuments. + */ + protected function uploadAttachments(Feedback $feedback, Request $request): void + { + if ($request->hasFile('images')) { + foreach ($request->file('images') as $image) { + $feedback->uploadDocument($image, Feedback::IMAGE_DOCUMENT_TYPE); + } + } + + if ($request->hasFile('videos')) { + foreach ($request->file('videos') as $video) { + $feedback->uploadDocument($video, Feedback::VIDEO_DOCUMENT_TYPE); + } + } + } + + /** + * Prepare data for update method + */ + protected function prepareUpdateData(array $validated, $feedback): array + { + $data = $validated; + + unset($data['images'], $data['videos']); + + // Set resolved_at timestamp when status changes to resolved + if (isset($data['status']) && $data['status'] === 'resolved') { + $data['resolved_at'] = now(); + } elseif (isset($data['status']) && $data['status'] !== 'resolved') { + $data['resolved_at'] = null; + } + + return $data; + } + + /** + * Update the specified resource in storage. + */ + public function update(Request $request, string $id): JsonResponse + { + $this->authorize('update', $this->modelClass); + + try { + $item = $this->repository->findById($id); + + if (! $item) { + return $this->errorResponse($this->getNotFoundMessage(), 404); + } + + $validated = $this->validateRequest($request); + $data = $this->prepareUpdateData($validated, $item); + + $item->update($data); + $item->load(['user', 'assignedUser', 'documents']); + + return response()->json([ + 'success' => true, + 'data' => new $this->resourceClass($item), + 'message' => $this->getSuccessMessage('update'), + ]); + } catch (Exception $e) { + Log::error("Error updating {$this->resourceName}: ".$e->getMessage()); + + return $this->errorResponse($this->getErrorMessage('update').': '.$e->getMessage(), 500); + } + } + + /** + * Load relations for show method + */ + protected function loadShowRelations($item) + { + return $item->load(['user', 'assignedUser', 'documents']); + } + + /** + * Check dependencies before deletion + */ + protected function checkDependencies($feedback): ?\Illuminate\Http\JsonResponse + { + foreach ($feedback->documents as $document) { + $feedback->deleteDocument($document->id); + } + + return null; + } + + /** + * Stream a feedback attachment inline (image/video preview). + */ + public function serveDocument(string $id, string $documentId): BinaryFileResponse + { + $this->authorize('view', $this->modelClass); + + $feedback = Feedback::findOrFail($id); + $document = $feedback->documents()->findOrFail($documentId); + + $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.'"', + ]); + } + + /** + * Get feedback statistics + */ + public function statistics(): JsonResponse + { + $stats = [ + 'total' => Feedback::count(), + 'open' => Feedback::open()->count(), + 'resolved' => Feedback::resolved()->count(), + 'by_type' => Feedback::selectRaw('type, COUNT(*) as count') + ->groupBy('type') + ->pluck('count', 'type'), + 'by_priority' => Feedback::selectRaw('priority, COUNT(*) as count') + ->groupBy('priority') + ->pluck('count', 'priority'), + 'by_status' => Feedback::selectRaw('status, COUNT(*) as count') + ->groupBy('status') + ->pluck('count', 'status'), + ]; + + return response()->json([ + 'success' => true, + 'data' => $stats + ]); + } + + /** + * Get user's own feedback + */ + public function myFeedback(Request $request): JsonResponse + { + $query = Feedback::where('user_id', auth()->id()) + ->with(['assignedUser', 'documents']) + ->orderBy('created_at', 'desc'); + + // Apply filters + if ($request->has('status') && $request->status) { + $query->ofStatus($request->status); + } + + if ($request->has('type') && $request->type) { + $query->ofType($request->type); + } + + $perPage = $request->get('per_page', 15); + $feedback = $query->paginate($perPage); + + return response()->json([ + 'success' => true, + 'data' => FeedbackResource::collection($feedback->items()), + 'meta' => [ + 'current_page' => $feedback->currentPage(), + 'last_page' => $feedback->lastPage(), + 'per_page' => $feedback->perPage(), + 'total' => $feedback->total(), + ] + ]); + } + + /** + * Get browser information from request + */ + private function getBrowserInfo(Request $request): array + { + return [ + 'user_agent' => $request->userAgent(), + 'ip_address' => $request->ip(), + 'referer' => $request->header('referer'), + 'accept_language' => $request->header('accept-language'), + 'screen_resolution' => $request->input('screen_resolution'), + 'viewport_size' => $request->input('viewport_size'), + 'timezone' => $request->input('timezone'), + ]; + } + + /** + * Notify admins about new feedback submission + */ + private function notifyAdminsForNewFeedback(Feedback $feedback): void + { + try { + $adminRoles = ['PENTADBIR', 'PS 2 KJC', 'PS 2 ALAT']; + + // Get users with specific roles plus admins (PENTADBIR and DEVELOPER) + $adminUsers = $this->getUsersWithRolesAndAdmins($adminRoles); + + $sender = auth()->user() ?? $feedback->user; // Use current user as sender, or feedback submitter if no auth + + foreach ($adminUsers as $admin) { + try { + $admin->notify(new FeedbackNotification($feedback, $sender)); + } catch (Exception $e) { + Log::error('Failed to send feedback notification: '.$e->getMessage()); + } + } + } catch (Exception $e) { + Log::error('Failed to notify admins for new feedback: '.$e->getMessage()); + } + } +} \ No newline at end of file diff --git a/be/Modules/Feedback/Http/Requests/.gitkeep b/be/Modules/Feedback/Http/Requests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Http/Requests/FeedbackRequest.php b/be/Modules/Feedback/Http/Requests/FeedbackRequest.php new file mode 100644 index 0000000..543092c --- /dev/null +++ b/be/Modules/Feedback/Http/Requests/FeedbackRequest.php @@ -0,0 +1,83 @@ +|string> + */ + public function rules(): array + { + $rules = [ + 'type' => 'required|in:bug,feature_request,general_feedback,ui_issue,performance_issue', + 'title' => 'required|string|max:255', + 'description' => 'required|string', + 'priority' => 'required|in:low,medium,high,critical', + 'page_url' => 'nullable|url', + 'steps_to_reproduce' => 'nullable|string', + 'expected_behavior' => 'nullable|string', + 'actual_behavior' => 'nullable|string', + 'additional_notes' => 'nullable|string', + 'images' => 'nullable|array', + 'images.*' => 'file|mimes:jpeg,png,jpg,gif,webp|max:10240', // 10MB max + 'videos' => 'nullable|array', + 'videos.*' => 'file|mimes:mp4,mov,webm|max:51200', // 50MB max + ]; + + // For update operations, add admin-specific fields + if ($this->isMethod('PUT') || $this->isMethod('PATCH')) { + $rules = [ + 'type' => 'sometimes|in:bug,feature_request,general_feedback,ui_issue,performance_issue', + 'title' => 'sometimes|string|max:255', + 'description' => 'sometimes|string', + 'priority' => 'sometimes|in:low,medium,high,critical', + 'page_url' => 'nullable|url', + 'steps_to_reproduce' => 'nullable|string', + 'expected_behavior' => 'nullable|string', + 'actual_behavior' => 'nullable|string', + 'additional_notes' => 'nullable|string', + 'status' => 'sometimes|in:open,in_progress,resolved,closed,rejected', + 'assigned_to' => 'nullable|exists:users,id', + 'admin_notes' => 'nullable|string', + ]; + } + + return $rules; + } + + /** + * Get custom messages for validator errors. + */ + public function messages(): array + { + return [ + 'type.required' => 'Jenis maklum balas diperlukan.', + 'type.in' => 'Jenis maklum balas tidak sah.', + 'title.required' => 'Tajuk diperlukan.', + 'title.max' => 'Tajuk tidak boleh melebihi 255 aksara.', + 'description.required' => 'Penerangan diperlukan.', + 'priority.required' => 'Keutamaan diperlukan.', + 'priority.in' => 'Keutamaan tidak sah.', + 'page_url.url' => 'URL halaman tidak sah.', + 'images.*.mimes' => 'Format imej mestilah jpeg, png, jpg, gif atau webp.', + 'images.*.max' => 'Saiz imej tidak boleh melebihi 10MB.', + 'videos.*.mimes' => 'Format video mestilah mp4, mov atau webm.', + 'videos.*.max' => 'Saiz video tidak boleh melebihi 50MB.', + 'status.in' => 'Status tidak sah.', + 'assigned_to.exists' => 'Pengguna yang ditugaskan tidak wujud.', + ]; + } +} diff --git a/be/Modules/Feedback/Jobs/.gitkeep b/be/Modules/Feedback/Jobs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Notifications/.gitkeep b/be/Modules/Feedback/Notifications/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Notifications/FeedbackNotification.php b/be/Modules/Feedback/Notifications/FeedbackNotification.php new file mode 100644 index 0000000..bb083d2 --- /dev/null +++ b/be/Modules/Feedback/Notifications/FeedbackNotification.php @@ -0,0 +1,66 @@ +feedback = $feedback; + $this->sender = $sender; + } + + /** + * Get the notification's delivery channels. + */ + public function via($notifiable): array + { + return ['database']; + } + + /** + * Get the array representation of the notification. + */ + public function toArray($notifiable): array + { + return [ + 'feedback_id' => $this->feedback->id, + 'sender_id' => $this->sender ? $this->sender->id : null, + 'type' => 'feedback_submitted', + 'message' => $this->getNotificationMessage(), + 'feedback_title' => $this->feedback->title ?? 'Unknown', + 'feedback_type' => $this->feedback->type ?? 'Unknown', + 'feedback_priority' => $this->feedback->priority ?? 'normal', + 'user_name' => $this->feedback->user ? $this->feedback->user->name : 'Anonymous', + 'user_email' => $this->feedback->user ? $this->feedback->user->email : null, + ]; + } + + /** + * Get notification message + */ + private function getNotificationMessage(): string + { + $userName = $this->feedback->user ? $this->feedback->user->name : 'Pengguna tanpa nama'; + $feedbackTitle = $this->feedback->title ?? 'Unknown'; + $feedbackType = $this->feedback->type ?? 'Unknown'; + $feedbackPriority = $this->feedback->priority ?? 'normal'; + $feedbackId = $this->feedback->id; + + return "Maklum balas baru telah diterima. ID: {$feedbackId}, Tajuk: {$feedbackTitle}, Jenis: {$feedbackType}, Keutamaan: {$feedbackPriority}, Pengguna: {$userName}"; + } +} + diff --git a/be/Modules/Feedback/Policies/.gitkeep b/be/Modules/Feedback/Policies/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Policies/FeedbackPolicy.php b/be/Modules/Feedback/Policies/FeedbackPolicy.php new file mode 100644 index 0000000..f6fa555 --- /dev/null +++ b/be/Modules/Feedback/Policies/FeedbackPolicy.php @@ -0,0 +1,59 @@ +hasPermissionTo('lihat maklum balas'); // Adjust based on your authorization logic + } + + /** + * Determine whether the user can view the model. + */ + public function view($user, ?Feedback $feedback = null): bool + { + return $user->hasPermissionTo('lihat maklum balas'); // Adjust based on your authorization logic + } + + /** + * Determine whether the user can create models. + */ + public function create($user): bool + { + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update($user, ?Feedback $feedback = null): bool + { + return $user->hasPermissionTo('kemaskini maklum balas'); // Adjust based on your authorization logic + } + + /** + * Determine whether the user can delete any model. + */ + public function deleteAny($user): bool + { + return $user->hasPermissionTo('padam maklum balas'); // Adjust based on your authorization logic + } + + /** + * Determine whether the user can delete the model. + */ + public function delete($user, ?Feedback $feedback = null): bool + { + return $user->hasPermissionTo('padam maklum balas'); // Adjust based on your authorization logic + } +} diff --git a/be/Modules/Feedback/Providers/.gitkeep b/be/Modules/Feedback/Providers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Providers/EventServiceProvider.php b/be/Modules/Feedback/Providers/EventServiceProvider.php new file mode 100644 index 0000000..52d943b --- /dev/null +++ b/be/Modules/Feedback/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/Feedback/Providers/FeedbackServiceProvider.php b/be/Modules/Feedback/Providers/FeedbackServiceProvider.php new file mode 100644 index 0000000..f3373ba --- /dev/null +++ b/be/Modules/Feedback/Providers/FeedbackServiceProvider.php @@ -0,0 +1,160 @@ +registerCommands(); + $this->registerCommandSchedules(); + $this->registerTranslations(); + $this->registerConfig(); + $this->registerViews(); + $this->loadMigrationsFrom(module_path($this->name, 'Database/Migrations')); + } + + /** + * 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\Feedback\Repositories\Contracts\FeedbackRepositoryInterface::class, + \Modules\Feedback\Repositories\FeedbackRepository::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/Feedback/Providers/RouteServiceProvider.php b/be/Modules/Feedback/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..7b588c1 --- /dev/null +++ b/be/Modules/Feedback/Providers/RouteServiceProvider.php @@ -0,0 +1,39 @@ +mapApiRoutes(); + } + + /** + * Define the "api" routes for the application. + * + * These routes are typically stateless. + */ + protected function mapApiRoutes(): void + { + Route::middleware('api')->group(module_path($this->name, '/Routes/api.php')); + } +} \ No newline at end of file diff --git a/be/Modules/Feedback/Repositories/.gitkeep b/be/Modules/Feedback/Repositories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Repositories/Contracts/.gitkeep b/be/Modules/Feedback/Repositories/Contracts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Repositories/Contracts/FeedbackRepositoryInterface.php b/be/Modules/Feedback/Repositories/Contracts/FeedbackRepositoryInterface.php new file mode 100644 index 0000000..2072938 --- /dev/null +++ b/be/Modules/Feedback/Repositories/Contracts/FeedbackRepositoryInterface.php @@ -0,0 +1,44 @@ +orderBy($sortBy, $sortOrder); + + if (!empty($search)) { + $query->where(function ($q) use ($search) { + $q->where('title', 'ILIKE', "%{$search}%") + ->orWhere('description', 'ILIKE', "%{$search}%") + ->orWhereHas('user', function ($userQuery) use ($search) { + $userQuery->where('name', 'ILIKE', "%{$search}%") + ->orWhere('email', 'ILIKE', "%{$search}%"); + }); + }); + } + + // Apply filters + if (!empty($filters['type'])) { + $query->where('type', $filters['type']); + } + + if (!empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (!empty($filters['priority'])) { + $query->where('priority', $filters['priority']); + } + + return $query->paginate($perPage); + } + + /** + * Get all feedback with their relationships and pagination with search + */ + public function getAllWithRelationsPaginated(int $perPage = 10, string $search = '', string $sortBy = 'id', string $sortOrder = 'asc', array $filters = []) + { + $query = Feedback::with(['user', 'assignedUser', 'documents'])->orderBy($sortBy, $sortOrder); + + if (!empty($search)) { + $query->where(function ($q) use ($search) { + $q->where('title', 'ILIKE', "%{$search}%") + ->orWhere('description', 'ILIKE', "%{$search}%") + ->orWhereHas('user', function ($userQuery) use ($search) { + $userQuery->where('name', 'ILIKE', "%{$search}%") + ->orWhere('email', 'ILIKE', "%{$search}%"); + }); + }); + } + + // Apply filters + if (!empty($filters['type'])) { + $query->where('type', $filters['type']); + } + + if (!empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (!empty($filters['priority'])) { + $query->where('priority', $filters['priority']); + } + + return $query->paginate($perPage); + } + + /** + * Get all feedback with their relationships and search + */ + public function getAllWithRelations(string $search = '', string $sortBy = 'id', string $sortOrder = 'asc'): Collection + { + $query = Feedback::with(['user', 'assignedUser', 'documents'])->orderBy($sortBy, $sortOrder); + + if (!empty($search)) { + $query->where(function ($q) use ($search) { + $q->where('title', 'ILIKE', "%{$search}%") + ->orWhere('description', 'ILIKE', "%{$search}%") + ->orWhereHas('user', function ($userQuery) use ($search) { + $userQuery->where('name', 'ILIKE', "%{$search}%") + ->orWhere('email', 'ILIKE', "%{$search}%"); + }); + }); + } + + return $query->get(); + } + + /** + * Create a new feedback + */ + public function create(array $data): Feedback + { + return Feedback::create($data); + } + + /** + * Find feedback by ID + */ + public function findById(string $id): ?Feedback + { + return Feedback::with(['user', 'assignedUser', 'documents'])->find($id); + } + + /** + * Delete feedback (soft delete) + */ + public function delete(string $id): bool + { + $feedback = Feedback::find($id); + if ($feedback) { + return $feedback->delete(); + } + + return false; + } + + /** + * Get all feedback + */ + public function all(string $search = '', string $sortBy = 'id', string $sortOrder = 'asc'): Collection + { + $query = Feedback::with(['user', 'assignedUser', 'documents'])->orderBy($sortBy, $sortOrder); + + if (!empty($search)) { + $query->where(function ($q) use ($search) { + $q->where('title', 'ILIKE', "%{$search}%") + ->orWhere('description', 'ILIKE', "%{$search}%") + ->orWhereHas('user', function ($userQuery) use ($search) { + $userQuery->where('name', 'ILIKE', "%{$search}%") + ->orWhere('email', 'ILIKE', "%{$search}%"); + }); + }); + } + + return $query->get(); + } +} diff --git a/be/Modules/Feedback/Routes/.gitkeep b/be/Modules/Feedback/Routes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Routes/api.php b/be/Modules/Feedback/Routes/api.php new file mode 100644 index 0000000..925128a --- /dev/null +++ b/be/Modules/Feedback/Routes/api.php @@ -0,0 +1,20 @@ +name('feedback.store'); + +// Authenticated feedback routes +Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () { + Route::get('/feedback', [FeedbackController::class, 'index'])->name('feedback.index'); + Route::get('/feedback/{id}', [FeedbackController::class, 'show'])->name('feedback.show'); + Route::put('/feedback/{id}', [FeedbackController::class, 'update'])->name('feedback.update'); + Route::patch('/feedback/{id}', [FeedbackController::class, 'update'])->name('feedback.patch'); + Route::delete('/feedback/{id}', [FeedbackController::class, 'destroy'])->name('feedback.destroy'); + Route::get('/feedback-statistics', [FeedbackController::class, 'statistics'])->name('feedback.statistics'); + Route::get('/my-feedback', [FeedbackController::class, 'myFeedback'])->name('feedback.my'); + Route::get('/feedback/{id}/documents/{documentId}/download', [FeedbackController::class, 'serveDocument']) + ->name('feedback.download-document'); +}); \ No newline at end of file diff --git a/be/Modules/Feedback/Services/.gitkeep b/be/Modules/Feedback/Services/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Tests/Feature/.gitkeep b/be/Modules/Feedback/Tests/Feature/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Tests/Unit/.gitkeep b/be/Modules/Feedback/Tests/Unit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Transformers/.gitkeep b/be/Modules/Feedback/Transformers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/be/Modules/Feedback/Transformers/FeedbackResource.php b/be/Modules/Feedback/Transformers/FeedbackResource.php new file mode 100644 index 0000000..5b1d87c --- /dev/null +++ b/be/Modules/Feedback/Transformers/FeedbackResource.php @@ -0,0 +1,79 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'type' => $this->type, + 'title' => $this->title, + 'description' => $this->description, + 'priority' => $this->priority, + 'status' => $this->status, + 'page_url' => $this->page_url, + 'browser_info' => $this->browser_info, + 'images' => $this->whenLoaded('documents', function () { + return $this->documents + ->where('type', Feedback::IMAGE_DOCUMENT_TYPE) + ->values() + ->map(fn ($document) => $this->formatDocument($document)); + }), + 'videos' => $this->whenLoaded('documents', function () { + return $this->documents + ->where('type', Feedback::VIDEO_DOCUMENT_TYPE) + ->values() + ->map(fn ($document) => $this->formatDocument($document)); + }), + 'steps_to_reproduce' => $this->steps_to_reproduce, + 'expected_behavior' => $this->expected_behavior, + 'actual_behavior' => $this->actual_behavior, + 'additional_notes' => $this->additional_notes, + 'admin_notes' => $this->admin_notes, + 'resolved_at' => $this->resolved_at, + 'user' => $this->whenLoaded('user', function () { + return [ + 'id' => $this->user->id, + 'name' => $this->user->name, + 'email' => $this->user->email, + 'army_number' => $this->user->army_number, + ]; + }), + 'assigned_user' => $this->whenLoaded('assignedUser', function () { + return [ + 'id' => $this->assignedUser->id, + 'name' => $this->assignedUser->name, + 'email' => $this->assignedUser->email, + ]; + }), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } + + protected function formatDocument($document): array + { + return [ + 'id' => $document->id, + 'name' => $document->name, + 'mime_type' => $document->mime_type, + 'file_size' => $document->file_size, + 'type' => $document->type, + 'url' => url(route('feedback.download-document', [ + 'id' => $this->id, + 'documentId' => $document->id, + ])), + ]; + } +} diff --git a/be/Modules/Feedback/composer.json b/be/Modules/Feedback/composer.json new file mode 100644 index 0000000..4c03c5a --- /dev/null +++ b/be/Modules/Feedback/composer.json @@ -0,0 +1,30 @@ +{ + "name": "nwidart/feedback", + "description": "", + "authors": [ + { + "name": "Nicolas Widart", + "email": "n.widart@gmail.com" + } + ], + "extra": { + "laravel": { + "providers": [], + "aliases": { + + } + } + }, + "autoload": { + "psr-4": { + "Modules\\Feedback\\": "App", + "Modules\\Feedback\\Database\\Factories\\": "database/factories/", + "Modules\\Feedback\\Database\\Seeders\\": "database/seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "Modules\\Feedback\\Tests\\": "tests/" + } + } +} diff --git a/be/Modules/Feedback/module.json b/be/Modules/Feedback/module.json new file mode 100644 index 0000000..a23bebd --- /dev/null +++ b/be/Modules/Feedback/module.json @@ -0,0 +1,12 @@ +{ + "name": "Feedback", + "alias": "feedback", + "description": "", + "keywords": [], + "priority": 0, + "providers": [ + "Modules\\Feedback\\Providers\\FeedbackServiceProvider", + "Modules\\Feedback\\Providers\\RouteServiceProvider" + ], + "files": [] +} \ No newline at end of file diff --git a/be/Modules/Feedback/package.json b/be/Modules/Feedback/package.json new file mode 100644 index 0000000..d6fbfc8 --- /dev/null +++ b/be/Modules/Feedback/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/User/Policies/UserPolicy.php b/be/Modules/User/Policies/UserPolicy.php index 22d6212..5eeff2b 100644 --- a/be/Modules/User/Policies/UserPolicy.php +++ b/be/Modules/User/Policies/UserPolicy.php @@ -9,12 +9,20 @@ class UserPolicy { use HandlesAuthorization; + public const PERMISSION_VIEW = 'lihat pengguna'; + + public const PERMISSION_CREATE = 'daftar pengguna baru'; + + public const PERMISSION_UPDATE = 'kemaskini pengguna'; + + public const PERMISSION_DELETE = 'padam akaun pengguna'; + /** * Determine whether the user can view any models. */ public function viewAny($user): bool { - return $user->hasPermissionTo('lihat pengguna'); + return $user->hasPermissionTo(self::PERMISSION_VIEW); } /** @@ -22,7 +30,7 @@ class UserPolicy */ public function view($user, ?User $userModel = null): bool { - return $user->hasPermissionTo('lihat pengguna'); + return $user->hasPermissionTo(self::PERMISSION_VIEW); } /** @@ -30,7 +38,7 @@ class UserPolicy */ public function create($user): bool { - return $user->hasPermissionTo('daftar pengguna baru'); + return $user->hasPermissionTo(self::PERMISSION_CREATE); } /** @@ -38,7 +46,7 @@ class UserPolicy */ public function update($user, ?User $userModel = null): bool { - return $user->hasPermissionTo('kemaskini pengguna'); + return $user->hasPermissionTo(self::PERMISSION_UPDATE); } /** @@ -46,7 +54,7 @@ class UserPolicy */ public function deleteAny($user): bool { - return $user->hasPermissionTo('padam akaun pengguna'); + return $user->hasPermissionTo(self::PERMISSION_DELETE); } /** @@ -54,7 +62,7 @@ class UserPolicy */ public function delete($user, ?User $userModel = null): bool { - return $user->hasPermissionTo('padam akaun pengguna'); + return $user->hasPermissionTo(self::PERMISSION_DELETE); } /** @@ -62,6 +70,6 @@ class UserPolicy */ public function restore($user, ?User $userModel = null): bool { - return $user->hasPermissionTo('padam akaun pengguna'); + return $user->hasPermissionTo(self::PERMISSION_DELETE); } } diff --git a/be/app/Traits/NotifiesAdmins.php b/be/app/Traits/NotifiesAdmins.php index 0b808c8..930969c 100644 --- a/be/app/Traits/NotifiesAdmins.php +++ b/be/app/Traits/NotifiesAdmins.php @@ -13,7 +13,7 @@ trait NotifiesAdmins protected function getAdminUsers(): Collection { return User::whereHas('roles', function ($query) { - $query->whereIn('name', ['PENTADBIR', 'DEVELOPER']); + $query->whereIn('name', ['IT', 'DEVELOPER']); })->get(); } @@ -26,6 +26,14 @@ trait NotifiesAdmins return $specificUsers->merge($adminUsers)->unique('id'); } + /** + * Get users who have a given permission (via role or direct assignment). + */ + protected function getUsersWithPermission(string $permission): Collection + { + return User::permission($permission)->get(); + } + /** * Get users with specific roles and merge with admins */ diff --git a/be/modules_statuses.json b/be/modules_statuses.json index ab74143..ea6b6af 100644 --- a/be/modules_statuses.json +++ b/be/modules_statuses.json @@ -8,5 +8,6 @@ "Notification": true, "MembershipApplication": true, "Activity": true, - "ExternalSystem": true + "ExternalSystem": true, + "Feedback": true } \ No newline at end of file diff --git a/fe/src/main/side-menu.ts b/fe/src/main/side-menu.ts index 9f4998c..8958ae5 100644 --- a/fe/src/main/side-menu.ts +++ b/fe/src/main/side-menu.ts @@ -6,6 +6,7 @@ import { activityMenu } from '@/modules/activity' import { dashboardMenu } from '@/modules/dashboard/menu' import { externalSystemMenu } from '@/modules/external-system/menu' import { activityLogMenu } from '@/modules/activity-log/menu' +import { feedbackMenu } from '@/modules/feedback' export type { Menu } @@ -17,6 +18,7 @@ const mainMenu: (string | Menu)[] = [ 'Teknologi Maklumat', ...roleMenu, ...activityLogMenu, + ...feedbackMenu, 'Pentadbiran', ...membershipApplicationMenu, ...userMenu, diff --git a/fe/src/modules/auth/pages/Login.vue b/fe/src/modules/auth/pages/Login.vue index 01ae6b8..9cd8a4b 100644 --- a/fe/src/modules/auth/pages/Login.vue +++ b/fe/src/modules/auth/pages/Login.vue @@ -12,6 +12,7 @@ import { login, resolvePostAuthRoute, } from '@/modules/auth' +import { HelpdeskFab } from '@/modules/feedback' import { useAuthStore } from '@/stores/auth' import illustrationUrl from '@/assets/images/logo.svg' @@ -70,6 +71,7 @@ const appVersion = import.meta.env.VITE_APP_VERSION 'before:hidden before:xl:block before:content-[\'\'] before:w-[57%] before:mt-[-28%] before:mb-[-16%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:rotate-6 before:bg-primary/95 before:bg-noise before:rounded-[35%]', 'after:hidden after:xl:block after:content-[\'\'] after:w-[57%] after:mt-[-28%] after:mb-[-16%] after:ml-[-12%] after:absolute after:inset-y-0 after:left-0 after:transform after:rotate-6 after:border after:bg-accent after:bg-cover after:blur-xl after:rounded-[35%] after:border-primary', ]"> +
+
+import { Lucide } from '@/components/ui/lucide' +import { useRouter } from 'vue-router' + +const props = withDefaults( + defineProps<{ + /** Vertical offset from vertical center, in rem (matches Layout side tabs). */ + offsetRem?: number + /** Open feedback form in a new tab instead of navigating in place. */ + openInNewTab?: boolean + }>(), + { + offsetRem: 3.5, + openInNewTab: true, + }, +) + +const router = useRouter() + +function openHelpdesk(event: MouseEvent) { + event.preventDefault() + + const { href } = router.resolve({ name: 'feedback-submit' }) + + if (props.openInNewTab) { + window.open(href, '_blank', 'noopener,noreferrer') + return + } + + router.push({ name: 'feedback-submit' }) +} + + + diff --git a/fe/src/modules/feedback/composables/useFeedbackList.ts b/fe/src/modules/feedback/composables/useFeedbackList.ts new file mode 100644 index 0000000..62d9262 --- /dev/null +++ b/fe/src/modules/feedback/composables/useFeedbackList.ts @@ -0,0 +1,104 @@ +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 { listFeedback } from '../services/feedback.service' +import type { + FeedbackListItem, + FeedbackPriority, + FeedbackStatus, + FeedbackType, +} from '../types/feedback.types' + +export function useFeedbackList() { + const items = ref([]) + const loading = ref(false) + const error = ref(null) + const search = ref('') + const typeFilter = ref('') + const statusFilter = ref('') + const priorityFilter = ref('') + const sortBy = ref([{ key: 'created_at', order: 'desc' }]) + const page = ref(1) + const itemsPerPage = ref(10) + + const { pagination, applyPagination } = useApiPagination({ per_page: 10 }) + + async function fetchItems(requestPage = page.value) { + loading.value = true + error.value = null + + try { + const activeSort = sortBy.value[0] + const data = await listFeedback({ + page: requestPage, + per_page: itemsPerPage.value, + sort_by: activeSort?.key ?? 'created_at', + sort_order: activeSort?.order ?? 'desc', + search: search.value.trim() || undefined, + type: typeFilter.value || undefined, + status: statusFilter.value || undefined, + priority: priorityFilter.value || undefined, + }) + + items.value = data.data + applyPagination(data.pagination) + page.value = data.pagination.current_page + } catch (err) { + error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai maklum balas.') + items.value = [] + } finally { + loading.value = false + } + } + + function handleSortUpdate(value: SortConfig[]) { + sortBy.value = value + fetchItems(1) + } + + const debouncedSearch = debounce(() => { + fetchItems(1) + }, 400) + + watch(search, () => { + debouncedSearch() + }) + + watch([typeFilter, statusFilter, priorityFilter], () => { + fetchItems(1) + }) + + watch(page, (nextPage, previousPage) => { + if (nextPage !== previousPage) { + fetchItems(nextPage) + } + }) + + watch(itemsPerPage, (nextValue, previousValue) => { + if (nextValue !== previousValue) { + fetchItems(1) + } + }) + + onMounted(() => { + fetchItems(1) + }) + + return { + items, + loading, + error, + search, + typeFilter, + statusFilter, + priorityFilter, + sortBy, + page, + itemsPerPage, + pagination, + handleSortUpdate, + fetchItems, + } +} diff --git a/fe/src/modules/feedback/index.ts b/fe/src/modules/feedback/index.ts new file mode 100644 index 0000000..bfb8ce4 --- /dev/null +++ b/fe/src/modules/feedback/index.ts @@ -0,0 +1,22 @@ +export { feedbackPublicRoutes, feedbackLayoutRoutes } from './routes' +export { feedbackMenu } from './menu' +export { default as HelpdeskFab } from './components/HelpdeskFab.vue' +export { + submitFeedback, + listFeedback, + getFeedback, + updateFeedback, + deleteFeedback, + getMyFeedback, + getFeedbackStatistics, +} from './services/feedback.service' +export type { + Feedback, + FeedbackFormState, + FeedbackListItem, + FeedbackStatus, + FeedbackType, + FeedbackPriority, + SubmitFeedbackPayload, + UpdateFeedbackPayload, +} from './types/feedback.types' diff --git a/fe/src/modules/feedback/menu.ts b/fe/src/modules/feedback/menu.ts new file mode 100644 index 0000000..c5f288d --- /dev/null +++ b/fe/src/modules/feedback/menu.ts @@ -0,0 +1,10 @@ +import type { Menu } from '@/core/types/menu' + +export const feedbackMenu: Menu[] = [ + { + icon: 'MessageCircle', + route_name: 'list-feedback', + title: 'Maklum Balas', + permission: 'lihat maklum balas', + }, +] diff --git a/fe/src/modules/feedback/pages/FeedbackDetail.vue b/fe/src/modules/feedback/pages/FeedbackDetail.vue new file mode 100644 index 0000000..970914e --- /dev/null +++ b/fe/src/modules/feedback/pages/FeedbackDetail.vue @@ -0,0 +1,486 @@ + + +