DONE: feedback module, use permission name for notification
This commit is contained in:
@@ -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<string, string> $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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'Feedback',
|
||||
];
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Create ENUM types for PostgreSQL
|
||||
DB::statement("CREATE TYPE feedback_type_enum AS ENUM ('bug', 'feature_request', 'general_feedback', 'ui_issue', 'performance_issue')");
|
||||
DB::statement("CREATE TYPE feedback_priority_enum AS ENUM ('low', 'medium', 'high', 'critical')");
|
||||
DB::statement("CREATE TYPE feedback_status_enum AS ENUM ('open', 'in_progress', 'resolved', 'closed', 'rejected')");
|
||||
|
||||
Schema::create('feedback', function (Blueprint $table) {
|
||||
$table->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");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Feedback\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class FeedbackDatabaseSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// $this->call([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Feedback\Entities;
|
||||
|
||||
use App\Traits\HasDocuments;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Modules\Auth\Entities\User;
|
||||
|
||||
class Feedback extends Model
|
||||
{
|
||||
use HasDocuments, HasUuids, SoftDeletes, LogsActivity;
|
||||
|
||||
public const IMAGE_DOCUMENT_TYPE = 'image';
|
||||
|
||||
public const VIDEO_DOCUMENT_TYPE = 'video';
|
||||
|
||||
protected $table = 'feedback';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'type',
|
||||
'title',
|
||||
'description',
|
||||
'page_url',
|
||||
'browser_info',
|
||||
'steps_to_reproduce',
|
||||
'expected_behavior',
|
||||
'actual_behavior',
|
||||
'additional_notes',
|
||||
'priority',
|
||||
'status',
|
||||
'assigned_to',
|
||||
'admin_notes',
|
||||
'resolved_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'browser_info' => '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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Feedback\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\BaseCrudController;
|
||||
use App\Models\Document;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Modules\Feedback\Entities\Feedback;
|
||||
use Modules\Feedback\Http\Requests\FeedbackRequest;
|
||||
use Modules\Feedback\Repositories\Contracts\FeedbackRepositoryInterface;
|
||||
use Modules\Feedback\Transformers\FeedbackResource;
|
||||
use Modules\Feedback\Notifications\FeedbackNotification;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class FeedbackController extends BaseCrudController
|
||||
{
|
||||
protected $modelClass = Feedback::class;
|
||||
protected $resourceClass = FeedbackResource::class;
|
||||
protected $requestClass = FeedbackRequest::class;
|
||||
protected $resourceName = 'maklum balas';
|
||||
protected $resourceNamePlural = 'maklum balas';
|
||||
|
||||
public function __construct(FeedbackRepositoryInterface $repository)
|
||||
{
|
||||
parent::__construct($repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override getIndexData to handle filters
|
||||
*/
|
||||
protected function getIndexData(Request $request, int $perPage = 10, string $search = '', string $sortBy = 'id', string $sortOrder = 'asc')
|
||||
{
|
||||
// Extract filter parameters from request
|
||||
$filters = [
|
||||
'type' => $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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Feedback\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class FeedbackRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Feedback\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Modules\Feedback\Entities\Feedback;
|
||||
use Modules\Auth\Entities\User;
|
||||
|
||||
class FeedbackNotification extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
protected $feedback;
|
||||
protected $sender;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct(Feedback $feedback, User $sender = null)
|
||||
{
|
||||
$this->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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Feedback\Policies;
|
||||
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Modules\Feedback\Entities\Feedback;
|
||||
|
||||
class FeedbackPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny($user): bool
|
||||
{
|
||||
return $user->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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Feedback\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
|
||||
class EventServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* The event handler mappings for the application.
|
||||
*
|
||||
* @var array<string, array<int, string>>
|
||||
*/
|
||||
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 {}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Feedback\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Nwidart\Modules\Traits\PathNamespace;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
|
||||
class FeedbackServiceProvider extends ServiceProvider
|
||||
{
|
||||
use PathNamespace;
|
||||
|
||||
protected string $name = 'Feedback';
|
||||
|
||||
protected string $nameLower = 'feedback';
|
||||
|
||||
/**
|
||||
* Boot the application events.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Feedback\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
class RouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
protected string $name = 'Feedback';
|
||||
|
||||
/**
|
||||
* Called before routes are registered.
|
||||
*
|
||||
* Register any model bindings or pattern based filters.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the routes for the application.
|
||||
*/
|
||||
public function map(): void
|
||||
{
|
||||
$this->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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Feedback\Repositories\Contracts;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Modules\Feedback\Entities\Feedback;
|
||||
|
||||
interface FeedbackRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Get all feedback with pagination and search
|
||||
*/
|
||||
public function getAllPaginated(int $perPage = 10, string $search = '', string $sortBy = 'id', string $sortOrder = 'asc', array $filters = []);
|
||||
|
||||
/**
|
||||
* 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 = []);
|
||||
|
||||
/**
|
||||
* Get all feedback with their relationships and search
|
||||
*/
|
||||
public function getAllWithRelations(string $search = '', string $sortBy = 'id', string $sortOrder = 'asc'): Collection;
|
||||
|
||||
/**
|
||||
* Create a new feedback
|
||||
*/
|
||||
public function create(array $data): Feedback;
|
||||
|
||||
/**
|
||||
* Find feedback by ID
|
||||
*/
|
||||
public function findById(string $id): ?Feedback;
|
||||
|
||||
/**
|
||||
* Delete feedback (soft delete)
|
||||
*/
|
||||
public function delete(string $id): bool;
|
||||
|
||||
/**
|
||||
* Get all feedback
|
||||
*/
|
||||
public function all(string $search = '', string $sortBy = 'id', string $sortOrder = 'asc'): Collection;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Feedback\Repositories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Modules\Feedback\Entities\Feedback;
|
||||
use Modules\Feedback\Repositories\Contracts\FeedbackRepositoryInterface;
|
||||
|
||||
class FeedbackRepository implements FeedbackRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Get all feedback with pagination and search
|
||||
*/
|
||||
public function getAllPaginated(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 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Feedback\Http\Controllers\FeedbackController;
|
||||
|
||||
// Public feedback submission (no authentication required)
|
||||
Route::post('/v1/feedback', [FeedbackController::class, 'store'])->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');
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Feedback\Transformers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Modules\Feedback\Entities\Feedback;
|
||||
|
||||
class FeedbackResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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,
|
||||
])),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "Feedback",
|
||||
"alias": "feedback",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"priority": 0,
|
||||
"providers": [
|
||||
"Modules\\Feedback\\Providers\\FeedbackServiceProvider",
|
||||
"Modules\\Feedback\\Providers\\RouteServiceProvider"
|
||||
],
|
||||
"files": []
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -8,5 +8,6 @@
|
||||
"Notification": true,
|
||||
"MembershipApplication": true,
|
||||
"Activity": true,
|
||||
"ExternalSystem": true
|
||||
"ExternalSystem": true,
|
||||
"Feedback": true
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
]">
|
||||
<HelpdeskFab />
|
||||
<div :class="[
|
||||
'p-3 sm:px-8 relative h-full',
|
||||
'before:hidden before:xl:block before:w-[57%] before:mt-[-20%] before:mb-[-13%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:-rotate-6 before:bg-primary/40 before:bg-noise before:border before:border-primary/50 before:opacity-60 before:rounded-[20%]',
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
verifyPhoneVerificationOtp,
|
||||
} from '@/modules/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { HelpdeskFab } from '@/modules/feedback'
|
||||
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
|
||||
import illustrationUrl from '@/assets/images/logo.svg'
|
||||
|
||||
@@ -283,6 +284,7 @@ const stepLabelClass = (stepId: number) => {
|
||||
'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',
|
||||
]">
|
||||
<HelpdeskFab />
|
||||
<div :class="[
|
||||
'relative p-3 sm:px-8',
|
||||
'before:hidden before:xl:block before:w-[57%] before:mt-[-20%] before:mb-[-13%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:-rotate-6 before:bg-primary/40 before:bg-noise before:border before:border-primary/50 before:opacity-60 before:rounded-[20%]',
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
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' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" aria-label="Helpdesk" :style="{
|
||||
top: `calc(50% + ${offsetRem}rem)`,
|
||||
['--color' as string]: 'var(--color-primary)',
|
||||
}" :class="[
|
||||
'group fixed right-0 z-50 flex h-12 cursor-pointer items-center overflow-hidden rounded-l-full border border-(--color)/50 bg-background/80 shadow-lg transition-all',
|
||||
'w-14 hover:w-44',
|
||||
'before:absolute before:inset-0 before:bg-(--color)/20',
|
||||
]" @click="openHelpdesk">
|
||||
<span class="relative z-10 flex items-center gap-2 px-5">
|
||||
<Lucide icon="MessageCircle" />
|
||||
<span :class="[
|
||||
'whitespace-nowrap text-sm',
|
||||
'max-w-0 overflow-hidden transition-[max-width] duration-200 ease-out',
|
||||
'group-hover:max-w-40',
|
||||
]">
|
||||
Maklum Balas
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -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<FeedbackListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const typeFilter = ref<FeedbackType | ''>('')
|
||||
const statusFilter = ref<FeedbackStatus | ''>('')
|
||||
const priorityFilter = ref<FeedbackPriority | ''>('')
|
||||
const sortBy = ref<SortConfig[]>([{ 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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,486 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { CircleAlert, CircleCheck, Play } from '@lucide/vue'
|
||||
import {
|
||||
AlertRoot,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
AlertCloseTrigger,
|
||||
} from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import {
|
||||
deleteFeedback,
|
||||
fetchFeedbackDocument,
|
||||
getFeedback,
|
||||
updateFeedback,
|
||||
} from '../services/feedback.service'
|
||||
import {
|
||||
FEEDBACK_STATUS_OPTIONS,
|
||||
feedbackPriorityLabel,
|
||||
feedbackStatusLabel,
|
||||
feedbackTypeLabel,
|
||||
type Feedback,
|
||||
type FeedbackDocument,
|
||||
type FeedbackStatus,
|
||||
} from '../types/feedback.types'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
type MediaPreviewKind = 'image' | 'video'
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return options[0]?.value ?? ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function valueToLabel(options: SelectOption[], value: string): string[] {
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : options[0] ? [options[0].label] : []
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const feedback = ref<Feedback | null>(null)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
|
||||
const mediaObjectUrls = ref<Record<string, string>>({})
|
||||
const mediaLoadErrors = ref<Record<string, boolean>>({})
|
||||
|
||||
const previewOpen = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewDocument = ref<FeedbackDocument | null>(null)
|
||||
const previewKind = ref<MediaPreviewKind>('image')
|
||||
const previewUrl = ref<string | null>(null)
|
||||
|
||||
const adminForm = reactive({
|
||||
status: 'open' as FeedbackStatus,
|
||||
admin_notes: '',
|
||||
})
|
||||
|
||||
const canUpdate = computed(() => hasPermission('kemaskini maklum balas'))
|
||||
const canDelete = computed(() => hasPermission('padam maklum balas'))
|
||||
|
||||
const statusCollection = createSelectCollection(FEEDBACK_STATUS_OPTIONS)
|
||||
const statusInitial = computed(() => valueToLabel(FEEDBACK_STATUS_OPTIONS, adminForm.status))
|
||||
|
||||
function setStatusValue(details: { value: string[] }) {
|
||||
adminForm.status = labelToValue(FEEDBACK_STATUS_OPTIONS, details.value[0]) as FeedbackStatus
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number | null | undefined): string {
|
||||
if (!bytes) return '-'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function revokeUrl(url: string | null | undefined) {
|
||||
if (url) {
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
function clearMediaObjectUrls() {
|
||||
Object.values(mediaObjectUrls.value).forEach((url) => revokeUrl(url))
|
||||
mediaObjectUrls.value = {}
|
||||
mediaLoadErrors.value = {}
|
||||
}
|
||||
|
||||
function revokePreviewUrl() {
|
||||
if (previewUrl.value && !Object.values(mediaObjectUrls.value).includes(previewUrl.value)) {
|
||||
revokeUrl(previewUrl.value)
|
||||
}
|
||||
previewUrl.value = null
|
||||
}
|
||||
|
||||
async function loadMediaDocument(document: FeedbackDocument): Promise<string | null> {
|
||||
if (mediaObjectUrls.value[document.id]) {
|
||||
return mediaObjectUrls.value[document.id] ?? null
|
||||
}
|
||||
|
||||
if (!feedback.value) return null
|
||||
|
||||
try {
|
||||
const blob = await fetchFeedbackDocument(
|
||||
feedback.value.id,
|
||||
document.id,
|
||||
document.mime_type,
|
||||
)
|
||||
const objectUrl = window.URL.createObjectURL(blob)
|
||||
mediaObjectUrls.value = {
|
||||
...mediaObjectUrls.value,
|
||||
[document.id]: objectUrl,
|
||||
}
|
||||
return objectUrl
|
||||
} catch {
|
||||
mediaLoadErrors.value = {
|
||||
...mediaLoadErrors.value,
|
||||
[document.id]: true,
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function preloadAttachments(item: Feedback) {
|
||||
clearMediaObjectUrls()
|
||||
|
||||
const documents = [...(item.images ?? []), ...(item.videos ?? [])]
|
||||
await Promise.all(documents.map((document) => loadMediaDocument(document)))
|
||||
}
|
||||
|
||||
async function openMediaPreview(document: FeedbackDocument, kind: MediaPreviewKind) {
|
||||
previewDocument.value = document
|
||||
previewKind.value = kind
|
||||
previewOpen.value = true
|
||||
previewLoading.value = true
|
||||
revokePreviewUrl()
|
||||
|
||||
const objectUrl = await loadMediaDocument(document)
|
||||
previewUrl.value = objectUrl
|
||||
previewLoading.value = false
|
||||
|
||||
if (!objectUrl) {
|
||||
error.value = 'Gagal memuatkan fail lampiran.'
|
||||
closeMediaPreview()
|
||||
}
|
||||
}
|
||||
|
||||
function closeMediaPreview() {
|
||||
previewOpen.value = false
|
||||
previewDocument.value = null
|
||||
previewLoading.value = false
|
||||
revokePreviewUrl()
|
||||
}
|
||||
|
||||
async function fetchDetail() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
closeMediaPreview()
|
||||
|
||||
try {
|
||||
const id = String(route.params.id)
|
||||
feedback.value = await getFeedback(id)
|
||||
adminForm.status = feedback.value.status
|
||||
adminForm.admin_notes = feedback.value.admin_notes ?? ''
|
||||
await preloadAttachments(feedback.value)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan maklum balas.')
|
||||
feedback.value = null
|
||||
clearMediaObjectUrls()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!feedback.value || saving.value || !canUpdate.value) return
|
||||
|
||||
saving.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
const response = await updateFeedback(feedback.value.id, {
|
||||
status: adminForm.status,
|
||||
admin_notes: adminForm.admin_notes.trim() || null,
|
||||
})
|
||||
feedback.value = response.data
|
||||
adminForm.status = response.data.status
|
||||
adminForm.admin_notes = response.data.admin_notes ?? ''
|
||||
successMessage.value = response.message ?? 'Maklum balas berjaya dikemas kini.'
|
||||
await preloadAttachments(response.data)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mengemas kini maklum balas.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!feedback.value || deleting.value || !canDelete.value) return
|
||||
if (!window.confirm('Padam maklum balas ini?')) return
|
||||
|
||||
deleting.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await deleteFeedback(feedback.value.id)
|
||||
router.push({ name: 'list-feedback' })
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memadam maklum balas.')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
fetchDetail()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(fetchDetail)
|
||||
|
||||
onUnmounted(() => {
|
||||
closeMediaPreview()
|
||||
clearMediaObjectUrls()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start">
|
||||
<div class="mr-auto">
|
||||
<Button look="outline" size="sm" @click="router.push({ name: 'list-feedback' })">
|
||||
Kembali
|
||||
</Button>
|
||||
<h2 class="mt-3 text-lg font-medium">Butiran Maklum Balas</h2>
|
||||
<p class="mt-1 text-sm opacity-70">Semak dan kemas kini status laporan.</p>
|
||||
</div>
|
||||
<div v-if="canDelete" class="flex gap-2">
|
||||
<Button look="outline" variant="danger" :disabled="deleting" @click="handleDelete">
|
||||
{{ deleting ? 'Memadam...' : 'Padam' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<CircleAlert class="size-4" />
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="error = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="successMessage" class="mt-6" variant="success">
|
||||
<CircleCheck class="size-4" />
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="successMessage = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<div v-if="loading" class="mt-8 opacity-70">Memuatkan...</div>
|
||||
|
||||
<template v-else-if="feedback">
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-3">
|
||||
<Box class="space-y-4 p-6 lg:col-span-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge look="outline">{{ feedbackTypeLabel(feedback.type) }}</Badge>
|
||||
<Badge look="outline">{{ feedbackPriorityLabel(feedback.priority) }}</Badge>
|
||||
<Badge look="outline">{{ feedbackStatusLabel(feedback.status) }}</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold">{{ feedback.title }}</h3>
|
||||
<p class="mt-2 whitespace-pre-wrap text-sm opacity-80">{{ feedback.description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="feedback.page_url" class="text-sm">
|
||||
<span class="opacity-60">URL:</span>
|
||||
<a :href="feedback.page_url" target="_blank" rel="noopener" class="ml-2 text-primary underline">
|
||||
{{ feedback.page_url }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="feedback.steps_to_reproduce" class="text-sm">
|
||||
<div class="font-medium">Langkah menghasilkan semula</div>
|
||||
<p class="mt-1 whitespace-pre-wrap opacity-80">{{ feedback.steps_to_reproduce }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div v-if="feedback.expected_behavior" class="text-sm">
|
||||
<div class="font-medium">Kelakuan dijangka</div>
|
||||
<p class="mt-1 whitespace-pre-wrap opacity-80">{{ feedback.expected_behavior }}</p>
|
||||
</div>
|
||||
<div v-if="feedback.actual_behavior" class="text-sm">
|
||||
<div class="font-medium">Kelakuan sebenar</div>
|
||||
<p class="mt-1 whitespace-pre-wrap opacity-80">{{ feedback.actual_behavior }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="feedback.additional_notes" class="text-sm">
|
||||
<div class="font-medium">Nota tambahan</div>
|
||||
<p class="mt-1 whitespace-pre-wrap opacity-80">{{ feedback.additional_notes }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="feedback.images?.length" class="text-sm">
|
||||
<div class="font-medium">Imej</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<button v-for="image in feedback.images" :key="image.id" type="button"
|
||||
class="group relative overflow-hidden rounded-lg border border-foreground/10 bg-foreground/5 text-left"
|
||||
@click="openMediaPreview(image, 'image')">
|
||||
<img v-if="mediaObjectUrls[image.id]" :src="mediaObjectUrls[image.id]" :alt="image.name"
|
||||
class="aspect-video w-full object-cover transition group-hover:scale-[1.02]" />
|
||||
<div v-else class="flex aspect-video items-center justify-center px-2 text-center text-xs opacity-60">
|
||||
{{ mediaLoadErrors[image.id] ? 'Gagal dimuatkan' : 'Memuatkan...' }}
|
||||
</div>
|
||||
<div class="truncate border-t border-foreground/10 px-2 py-1.5 text-xs opacity-70">
|
||||
{{ image.name }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="feedback.videos?.length" class="text-sm">
|
||||
<div class="font-medium">Video</div>
|
||||
<div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<button v-for="video in feedback.videos" :key="video.id" type="button"
|
||||
class="group relative overflow-hidden rounded-lg border border-foreground/10 bg-foreground/5 text-left"
|
||||
@click="openMediaPreview(video, 'video')">
|
||||
<div class="relative aspect-video bg-black/80">
|
||||
<video v-if="mediaObjectUrls[video.id]" :src="mediaObjectUrls[video.id]"
|
||||
class="size-full object-cover opacity-80" muted preload="metadata" />
|
||||
<div v-else class="flex size-full items-center justify-center px-2 text-center text-xs text-white/70">
|
||||
{{ mediaLoadErrors[video.id] ? 'Gagal dimuatkan' : 'Memuatkan...' }}
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/20 transition group-hover:bg-black/35">
|
||||
<span class="flex size-12 items-center justify-center rounded-full bg-white/90 text-foreground">
|
||||
<Play class="size-5 fill-current" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="truncate border-t border-foreground/10 px-2 py-1.5 text-xs opacity-70">
|
||||
{{ video.name }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Box class="space-y-3 p-6 text-sm">
|
||||
<div>
|
||||
<div class="opacity-60">Penghantar</div>
|
||||
<div class="mt-0.5 font-medium">{{ feedback.user?.name ?? 'Tetamu' }}</div>
|
||||
<div v-if="feedback.user?.email" class="opacity-70">{{ feedback.user.email }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="opacity-60">Dihantar</div>
|
||||
<div class="mt-0.5">{{ formatDate(feedback.created_at) }}</div>
|
||||
</div>
|
||||
<div v-if="feedback.resolved_at">
|
||||
<div class="opacity-60">Diselesaikan</div>
|
||||
<div class="mt-0.5">{{ formatDate(feedback.resolved_at) }}</div>
|
||||
</div>
|
||||
<div v-if="feedback.assigned_user">
|
||||
<div class="opacity-60">Ditugaskan kepada</div>
|
||||
<div class="mt-0.5 font-medium">{{ feedback.assigned_user.name }}</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box v-if="canUpdate" class="space-y-4 p-6">
|
||||
<h3 class="font-medium">Tindakan Admin</h3>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Status</FieldLabel>
|
||||
<SelectRoot :key="statusInitial[0]" :collection="statusCollection" :default-value="statusInitial"
|
||||
@value-change="setStatusValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem v-for="item in statusCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Nota admin</FieldLabel>
|
||||
<Textarea v-model="adminForm.admin_notes" rows="4" />
|
||||
</Field>
|
||||
|
||||
<Button variant="primary" :disabled="saving" @click="handleSave">
|
||||
{{ saving ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="previewOpen" class="fixed inset-0 z-70 flex items-center justify-center p-4 sm:p-6" role="dialog"
|
||||
aria-modal="true" :aria-label="previewDocument?.name ?? 'Pratonton lampiran'">
|
||||
<button type="button" class="absolute inset-0 bg-black/80" aria-label="Tutup pratonton"
|
||||
@click="closeMediaPreview" />
|
||||
|
||||
<div
|
||||
class="relative z-10 flex w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-foreground/10 bg-background shadow-2xl">
|
||||
<div class="border-b border-foreground/10 px-5 py-4">
|
||||
<div class="text-lg font-medium">
|
||||
{{ previewKind === 'video' ? 'Main Video' : 'Pratonton Imej' }}
|
||||
</div>
|
||||
<div v-if="previewDocument" class="mt-1 text-sm opacity-70">
|
||||
{{ previewDocument.name }} · {{ formatFileSize(previewDocument.file_size) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-auto p-5">
|
||||
<div v-if="previewLoading" class="py-12 text-center opacity-70">
|
||||
Memuatkan...
|
||||
</div>
|
||||
|
||||
<div v-else-if="previewUrl && previewKind === 'image'" class="flex justify-center">
|
||||
<img :src="previewUrl" :alt="previewDocument?.name ?? 'Pratonton imej'"
|
||||
class="block h-auto max-h-[calc(90vh-12rem)] w-auto max-w-full object-contain" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="previewUrl && previewKind === 'video'" class="flex justify-center">
|
||||
<video :src="previewUrl" class="block max-h-[calc(90vh-12rem)] w-full max-w-full rounded-lg bg-black"
|
||||
controls autoplay />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 border-t border-foreground/10 px-5 py-4">
|
||||
<Button type="button" look="outline" @click="closeMediaPreview">
|
||||
Tutup
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,289 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { CircleAlert, Eye } from '@lucide/vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription, AlertCloseTrigger } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import type { BadgeVariants } from '@/components/ui/styles/badge.styles'
|
||||
import { useFeedbackList } from '../composables/useFeedbackList'
|
||||
import {
|
||||
FEEDBACK_PRIORITY_OPTIONS,
|
||||
FEEDBACK_STATUS_OPTIONS,
|
||||
FEEDBACK_TYPE_OPTIONS,
|
||||
feedbackPriorityLabel,
|
||||
feedbackStatusLabel,
|
||||
feedbackTypeLabel,
|
||||
type FeedbackListItem,
|
||||
type FeedbackPriority,
|
||||
type FeedbackStatus,
|
||||
type FeedbackType,
|
||||
} from '../types/feedback.types'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const TYPE_FILTER_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Semua Jenis', value: '' },
|
||||
...FEEDBACK_TYPE_OPTIONS,
|
||||
]
|
||||
|
||||
const STATUS_FILTER_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Semua Status', value: '' },
|
||||
...FEEDBACK_STATUS_OPTIONS,
|
||||
]
|
||||
|
||||
const PRIORITY_FILTER_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Semua Keutamaan', value: '' },
|
||||
...FEEDBACK_PRIORITY_OPTIONS,
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function valueToLabel(options: SelectOption[], value: string): string[] {
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : [options[0]?.label ?? '']
|
||||
}
|
||||
|
||||
function statusVariant(status: FeedbackStatus): BadgeVariants['variant'] {
|
||||
switch (status) {
|
||||
case 'resolved':
|
||||
case 'closed':
|
||||
return 'success'
|
||||
case 'in_progress':
|
||||
return 'primary'
|
||||
case 'rejected':
|
||||
return 'danger'
|
||||
case 'open':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
function priorityVariant(priority: FeedbackPriority): BadgeVariants['variant'] {
|
||||
switch (priority) {
|
||||
case 'critical':
|
||||
case 'high':
|
||||
return 'danger'
|
||||
case 'medium':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
const headers: TableHeader[] = [
|
||||
{ title: 'Tajuk', key: 'title', sortable: true },
|
||||
{ title: 'Jenis', key: 'type', sortable: true },
|
||||
{ title: 'Keutamaan', key: 'priority', sortable: true },
|
||||
{ title: 'Status', key: 'status', sortable: true },
|
||||
{ title: 'Penghantar', key: 'user' },
|
||||
{ title: 'Dihantar', key: 'created_at', sortable: true },
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
const typeCollection = createSelectCollection(TYPE_FILTER_OPTIONS)
|
||||
const statusCollection = createSelectCollection(STATUS_FILTER_OPTIONS)
|
||||
const priorityCollection = createSelectCollection(PRIORITY_FILTER_OPTIONS)
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
typeFilter,
|
||||
statusFilter,
|
||||
priorityFilter,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
} = useFeedbackList()
|
||||
|
||||
const canView = computed(() => hasPermission('lihat maklum balas'))
|
||||
|
||||
const typeInitial = computed(() => valueToLabel(TYPE_FILTER_OPTIONS, typeFilter.value))
|
||||
const statusInitial = computed(() => valueToLabel(STATUS_FILTER_OPTIONS, statusFilter.value))
|
||||
const priorityInitial = computed(() => valueToLabel(PRIORITY_FILTER_OPTIONS, priorityFilter.value))
|
||||
|
||||
function setTypeFilter(details: { value: string[] }) {
|
||||
typeFilter.value = labelToValue(TYPE_FILTER_OPTIONS, details.value[0]) as FeedbackType | ''
|
||||
}
|
||||
|
||||
function setStatusFilter(details: { value: string[] }) {
|
||||
statusFilter.value = labelToValue(STATUS_FILTER_OPTIONS, details.value[0]) as FeedbackStatus | ''
|
||||
}
|
||||
|
||||
function setPriorityFilter(details: { value: string[] }) {
|
||||
priorityFilter.value = labelToValue(PRIORITY_FILTER_OPTIONS, details.value[0]) as FeedbackPriority | ''
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
function goToDetail(id: string) {
|
||||
router.push({ name: 'view-feedback', params: { id } })
|
||||
}
|
||||
|
||||
function goToSubmit() {
|
||||
router.push({ name: 'feedback-submit' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col items-center sm:flex-row">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Senarai Maklum Balas</h2>
|
||||
<p class="mt-1 text-sm opacity-70">Urus laporan ralat, cadangan dan isu pengguna.</p>
|
||||
</div>
|
||||
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
|
||||
<Button look="outline" variant="primary" class="shadow-sm" @click="goToSubmit">
|
||||
Hantar Maklum Balas
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<CircleAlert />
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="error = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<div class="mt-5">
|
||||
<DataTable :headers="headers" :items="items" :loading="loading" :pagination="pagination" :current-sort="sortBy"
|
||||
show-pagination exportable export-file-name="maklum-balas" v-model:page="page"
|
||||
v-model:items-per-page="itemsPerPage" @update:sort-by="handleSortUpdate">
|
||||
<template #toolbar>
|
||||
<div class="flex w-full flex-wrap items-center gap-3">
|
||||
<Input v-model="search" class="w-full max-w-md" type="search" placeholder="Cari tajuk atau penerangan..."
|
||||
aria-label="Cari maklum balas" />
|
||||
|
||||
<SelectRoot class="w-full sm:w-52" :collection="typeCollection" :default-value="typeInitial"
|
||||
@value-change="setTypeFilter">
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Tapis jenis">
|
||||
<SelectValueText placeholder="Semua Jenis" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Jenis</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in typeCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
|
||||
<SelectRoot class="w-full sm:w-52" :collection="statusCollection" :default-value="statusInitial"
|
||||
@value-change="setStatusFilter">
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Tapis status">
|
||||
<SelectValueText placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in statusCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
|
||||
<SelectRoot class="w-full sm:w-52" :collection="priorityCollection" :default-value="priorityInitial"
|
||||
@value-change="setPriorityFilter">
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Tapis keutamaan">
|
||||
<SelectValueText placeholder="Semua Keutamaan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Keutamaan</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in priorityCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.title="{ item }">
|
||||
<div class="font-medium">{{ (item as FeedbackListItem).title }}</div>
|
||||
<div class="mt-0.5 line-clamp-1 text-xs opacity-60">
|
||||
{{ (item as FeedbackListItem).description }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.type="{ item }">
|
||||
{{ feedbackTypeLabel((item as FeedbackListItem).type) }}
|
||||
</template>
|
||||
|
||||
<template #item.priority="{ item }">
|
||||
<Badge look="outline" :variant="priorityVariant((item as FeedbackListItem).priority)">
|
||||
{{ feedbackPriorityLabel((item as FeedbackListItem).priority) }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<Badge look="outline" :variant="statusVariant((item as FeedbackListItem).status)">
|
||||
{{ feedbackStatusLabel((item as FeedbackListItem).status) }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<template #item.user="{ item }">
|
||||
{{ (item as FeedbackListItem).user?.name ?? 'Tetamu' }}
|
||||
</template>
|
||||
|
||||
<template #item.created_at="{ item }">
|
||||
{{ formatDate((item as FeedbackListItem).created_at) }}
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<Button v-if="canView" type="button" variant="ghost" size="sm" class="bg-green-600 text-white"
|
||||
title="Lihat butiran" @click="goToDetail((item as FeedbackListItem).id)">
|
||||
<Eye class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,347 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import * as select from '@zag-js/select'
|
||||
import { CircleAlert, CircleCheck, Trash } from '@lucide/vue'
|
||||
import {
|
||||
AlertRoot,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
} from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { submitFeedback } from '../services/feedback.service'
|
||||
import {
|
||||
FEEDBACK_PRIORITY_OPTIONS,
|
||||
FEEDBACK_TYPE_OPTIONS,
|
||||
createEmptyFeedbackForm,
|
||||
type FeedbackFormState,
|
||||
type FeedbackPriority,
|
||||
type FeedbackType,
|
||||
} from '../types/feedback.types'
|
||||
import illustrationUrl from '@/assets/images/logo.svg'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return options[0]?.value ?? ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function valueToLabel(options: SelectOption[], value: string): string[] {
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : options[0] ? [options[0].label] : []
|
||||
}
|
||||
|
||||
const MAX_IMAGE_MB = 10
|
||||
const MAX_VIDEO_MB = 50
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { hasPermission } = usePermissions()
|
||||
const form = reactive<FeedbackFormState>(createEmptyFeedbackForm())
|
||||
const fieldErrors = reactive<Record<string, string>>({})
|
||||
const loading = ref(false)
|
||||
const submitted = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
const typeCollection = createSelectCollection(FEEDBACK_TYPE_OPTIONS)
|
||||
const priorityCollection = createSelectCollection(FEEDBACK_PRIORITY_OPTIONS)
|
||||
const typeInitial = computed(() => valueToLabel(FEEDBACK_TYPE_OPTIONS, form.type))
|
||||
const priorityInitial = computed(() => valueToLabel(FEEDBACK_PRIORITY_OPTIONS, form.priority))
|
||||
|
||||
const isAuthenticated = computed(() => authStore.isAuthenticated)
|
||||
const canViewList = computed(() => hasPermission('lihat maklum balas'))
|
||||
|
||||
function setTypeValue(details: { value: string[] }) {
|
||||
form.type = labelToValue(FEEDBACK_TYPE_OPTIONS, details.value[0]) as FeedbackType
|
||||
}
|
||||
|
||||
function setPriorityValue(details: { value: string[] }) {
|
||||
form.priority = labelToValue(FEEDBACK_PRIORITY_OPTIONS, details.value[0]) as FeedbackPriority
|
||||
}
|
||||
|
||||
function clearFieldError(key: string) {
|
||||
delete fieldErrors[key]
|
||||
}
|
||||
|
||||
function onImagesChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
input.value = ''
|
||||
clearFieldError('images')
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_IMAGE_MB * 1024 * 1024) {
|
||||
fieldErrors.images = `Setiap imej mesti bawah ${MAX_IMAGE_MB}MB.`
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
form.images.push(...files)
|
||||
}
|
||||
|
||||
function onVideosChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
input.value = ''
|
||||
clearFieldError('videos')
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_VIDEO_MB * 1024 * 1024) {
|
||||
fieldErrors.videos = `Setiap video mesti bawah ${MAX_VIDEO_MB}MB.`
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
form.videos.push(...files)
|
||||
}
|
||||
|
||||
function removeImage(index: number) {
|
||||
form.images.splice(index, 1)
|
||||
}
|
||||
|
||||
function removeVideo(index: number) {
|
||||
form.videos.splice(index, 1)
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
Object.keys(fieldErrors).forEach((key) => delete fieldErrors[key])
|
||||
|
||||
if (!form.title.trim()) fieldErrors.title = 'Tajuk diperlukan.'
|
||||
if (!form.description.trim()) fieldErrors.description = 'Penerangan diperlukan.'
|
||||
|
||||
return Object.keys(fieldErrors).length === 0
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, createEmptyFeedbackForm())
|
||||
submitted.value = false
|
||||
successMessage.value = ''
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (loading.value || !validate()) return
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await submitFeedback({
|
||||
type: form.type,
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim(),
|
||||
priority: form.priority,
|
||||
page_url: form.page_url.trim() || undefined,
|
||||
steps_to_reproduce: form.steps_to_reproduce.trim() || undefined,
|
||||
expected_behavior: form.expected_behavior.trim() || undefined,
|
||||
actual_behavior: form.actual_behavior.trim() || undefined,
|
||||
additional_notes: form.additional_notes.trim() || undefined,
|
||||
images: form.images,
|
||||
videos: form.videos,
|
||||
screen_resolution: `${window.screen.width}x${window.screen.height}`,
|
||||
viewport_size: `${window.innerWidth}x${window.innerHeight}`,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
|
||||
submitted.value = true
|
||||
successMessage.value = response.message ?? 'Maklum balas berjaya dihantar. Terima kasih!'
|
||||
} catch (err) {
|
||||
const validationErrors = getApiValidationErrors(err)
|
||||
if (validationErrors) {
|
||||
Object.assign(fieldErrors, validationErrors)
|
||||
}
|
||||
errorMessage.value = getApiErrorMessage(err, 'Gagal menghantar maklum balas.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background px-4 py-10">
|
||||
<div class="mx-auto w-full max-w-3xl">
|
||||
<div class="mb-8 flex items-center gap-3">
|
||||
<img :src="illustrationUrl" alt="MyKOPKB" class="h-10 w-auto" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Maklum Balas</h1>
|
||||
<p class="text-sm opacity-70">
|
||||
Laporkan ralat, cadangan atau isu.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Box v-if="submitted" class="p-8 text-center">
|
||||
<CircleCheck class="mx-auto size-12 text-success" />
|
||||
<h2 class="mt-4 text-xl font-medium">Terima kasih!</h2>
|
||||
<p class="mt-2 opacity-70">{{ successMessage }}</p>
|
||||
<div class="mt-6 flex flex-wrap justify-center gap-3">
|
||||
<Button variant="primary" @click="resetForm">Hantar lagi</Button>
|
||||
<RouterLink v-if="isAuthenticated && canViewList" :to="{ name: 'list-feedback' }">
|
||||
<Button look="outline">Lihat senarai</Button>
|
||||
</RouterLink>
|
||||
<RouterLink v-else-if="!isAuthenticated" :to="{ name: 'login' }">
|
||||
<Button look="outline">Log masuk</Button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box v-else class="p-6 sm:p-8">
|
||||
<AlertRoot v-if="errorMessage" class="mb-6" variant="danger">
|
||||
<CircleAlert class="size-4" />
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ errorMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<form class="space-y-5" @submit.prevent="handleSubmit">
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Jenis</FieldLabel>
|
||||
<SelectRoot :key="typeInitial[0]" :collection="typeCollection" :default-value="typeInitial"
|
||||
@value-change="setTypeValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih jenis" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem v-for="item in typeCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Keutamaan</FieldLabel>
|
||||
<SelectRoot :key="priorityInitial[0]" :collection="priorityCollection" :default-value="priorityInitial"
|
||||
@value-change="setPriorityValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih keutamaan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem v-for="item in priorityCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field :invalid="!!fieldErrors.title">
|
||||
<FieldLabel>Tajuk</FieldLabel>
|
||||
<Input v-model="form.title" placeholder="Ringkasan isu atau cadangan" @input="clearFieldError('title')" />
|
||||
<FieldError v-if="fieldErrors.title">{{ fieldErrors.title }}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field :invalid="!!fieldErrors.description">
|
||||
<FieldLabel>Penerangan</FieldLabel>
|
||||
<Textarea v-model="form.description" rows="5" placeholder="Terangkan dengan terperinci..."
|
||||
@input="clearFieldError('description')" />
|
||||
<FieldError v-if="fieldErrors.description">{{ fieldErrors.description }}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>URL halaman (pilihan)</FieldLabel>
|
||||
<Input v-model="form.page_url" type="url" placeholder="https://" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Langkah untuk menghasilkan semula (pilihan)</FieldLabel>
|
||||
<Textarea v-model="form.steps_to_reproduce" rows="3" />
|
||||
</Field>
|
||||
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Kelakuan dijangka (pilihan)</FieldLabel>
|
||||
<Textarea v-model="form.expected_behavior" rows="3" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Kelakuan sebenar (pilihan)</FieldLabel>
|
||||
<Textarea v-model="form.actual_behavior" rows="3" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Nota tambahan (pilihan)</FieldLabel>
|
||||
<Textarea v-model="form.additional_notes" rows="2" />
|
||||
</Field>
|
||||
|
||||
<Field :invalid="!!fieldErrors.images">
|
||||
<FieldLabel>Imej (pilihan)</FieldLabel>
|
||||
<Input type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple @change="onImagesChange" />
|
||||
<p class="mt-1 text-xs opacity-60">JPEG, PNG, GIF, WebP — maks {{ MAX_IMAGE_MB }}MB setiap fail</p>
|
||||
<FieldError v-if="fieldErrors.images">{{ fieldErrors.images }}</FieldError>
|
||||
<ul v-if="form.images.length" class="mt-2 space-y-1">
|
||||
<li v-for="(file, index) in form.images" :key="`${file.name}-${index}`"
|
||||
class="flex items-center justify-between rounded border border-foreground/10 px-3 py-2 text-sm">
|
||||
<span class="truncate">{{ file.name }}</span>
|
||||
<button type="button" class="text-danger" @click="removeImage(index)">
|
||||
<Trash class="size-4" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</Field>
|
||||
|
||||
<Field :invalid="!!fieldErrors.videos">
|
||||
<FieldLabel>Video (pilihan)</FieldLabel>
|
||||
<Input type="file" accept="video/mp4,video/quicktime,video/webm" multiple @change="onVideosChange" />
|
||||
<p class="mt-1 text-xs opacity-60">MP4, MOV, WebM — maks {{ MAX_VIDEO_MB }}MB setiap fail</p>
|
||||
<FieldError v-if="fieldErrors.videos">{{ fieldErrors.videos }}</FieldError>
|
||||
<ul v-if="form.videos.length" class="mt-2 space-y-1">
|
||||
<li v-for="(file, index) in form.videos" :key="`${file.name}-${index}`"
|
||||
class="flex items-center justify-between rounded border border-foreground/10 px-3 py-2 text-sm">
|
||||
<span class="truncate">{{ file.name }}</span>
|
||||
<button type="button" class="text-danger" @click="removeVideo(index)">
|
||||
<Trash class="size-4" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</Field>
|
||||
|
||||
<div class="flex flex-wrap gap-3 pt-2">
|
||||
<Button type="submit" variant="primary" :disabled="loading">
|
||||
<Lucide v-if="loading" icon="LoaderCircle" class="mr-2 size-4 animate-spin" />
|
||||
{{ loading ? 'Menghantar...' : 'Hantar Maklum Balas' }}
|
||||
</Button>
|
||||
<RouterLink v-if="!isAuthenticated" :to="{ name: 'login' }">
|
||||
<Button type="button" look="outline">Kembali ke log masuk</Button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const feedbackPublicRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/feedback/submit',
|
||||
name: 'feedback-submit',
|
||||
component: () => import('./pages/FeedbackSubmit.vue'),
|
||||
meta: { public: true, module: 'feedback', title: 'Maklum Balas' },
|
||||
},
|
||||
]
|
||||
|
||||
export const feedbackLayoutRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'feedback',
|
||||
name: 'list-feedback',
|
||||
component: () => import('./pages/FeedbackList.vue'),
|
||||
meta: {
|
||||
title: 'Senarai Maklum Balas',
|
||||
module: 'feedback',
|
||||
permission: 'lihat maklum balas',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'feedback/:id',
|
||||
name: 'view-feedback',
|
||||
component: () => import('./pages/FeedbackDetail.vue'),
|
||||
meta: {
|
||||
title: 'Butiran Maklum Balas',
|
||||
module: 'feedback',
|
||||
permission: 'lihat maklum balas',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,170 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||
import type {
|
||||
Feedback,
|
||||
FeedbackApiResponse,
|
||||
FeedbackListItem,
|
||||
FeedbackStatistics,
|
||||
ListFeedbackParams,
|
||||
SubmitFeedbackPayload,
|
||||
UpdateFeedbackPayload,
|
||||
} from '../types/feedback.types'
|
||||
|
||||
function appendIfPresent(formData: FormData, key: string, value: string | undefined | null) {
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
formData.append(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildFeedbackFormData(payload: SubmitFeedbackPayload): FormData {
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('type', payload.type)
|
||||
formData.append('title', payload.title)
|
||||
formData.append('description', payload.description)
|
||||
formData.append('priority', payload.priority)
|
||||
|
||||
appendIfPresent(formData, 'page_url', payload.page_url)
|
||||
appendIfPresent(formData, 'steps_to_reproduce', payload.steps_to_reproduce)
|
||||
appendIfPresent(formData, 'expected_behavior', payload.expected_behavior)
|
||||
appendIfPresent(formData, 'actual_behavior', payload.actual_behavior)
|
||||
appendIfPresent(formData, 'additional_notes', payload.additional_notes)
|
||||
appendIfPresent(formData, 'screen_resolution', payload.screen_resolution)
|
||||
appendIfPresent(formData, 'viewport_size', payload.viewport_size)
|
||||
appendIfPresent(formData, 'timezone', payload.timezone)
|
||||
|
||||
payload.images?.forEach((file, index) => {
|
||||
formData.append(`images[${index}]`, file)
|
||||
})
|
||||
|
||||
payload.videos?.forEach((file, index) => {
|
||||
formData.append(`videos[${index}]`, file)
|
||||
})
|
||||
|
||||
return formData
|
||||
}
|
||||
|
||||
/** Public + authenticated: POST /v1/feedback (auth cookie attaches user when present). */
|
||||
export async function submitFeedback(payload: SubmitFeedbackPayload): Promise<FeedbackApiResponse> {
|
||||
const formData = buildFeedbackFormData(payload)
|
||||
|
||||
const { data } = await api.post<FeedbackApiResponse>('/v1/feedback', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menghantar maklum balas.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listFeedback(
|
||||
params: ListFeedbackParams = {},
|
||||
): Promise<PaginatedApiResponse<FeedbackListItem>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<FeedbackListItem>>('/v1/feedback', {
|
||||
params,
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan senarai maklum balas.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getFeedback(id: string): Promise<Feedback> {
|
||||
const { data } = await api.get<FeedbackApiResponse>(`/v1/feedback/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan maklum balas.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateFeedback(
|
||||
id: string,
|
||||
payload: UpdateFeedbackPayload,
|
||||
): Promise<FeedbackApiResponse> {
|
||||
const { data } = await api.patch<FeedbackApiResponse>(`/v1/feedback/${id}`, payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemas kini maklum balas.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteFeedback(id: string): Promise<void> {
|
||||
const { data } = await api.delete<{ success: boolean; message?: string }>(`/v1/feedback/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memadam maklum balas.')
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMyFeedback(
|
||||
params: ListFeedbackParams = {},
|
||||
): Promise<PaginatedApiResponse<FeedbackListItem>> {
|
||||
const { data } = await api.get<{
|
||||
success: boolean
|
||||
data: FeedbackListItem[]
|
||||
meta: {
|
||||
current_page: number
|
||||
last_page: number
|
||||
per_page: number
|
||||
total: number
|
||||
}
|
||||
message?: string
|
||||
}>('/v1/my-feedback', { params })
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan maklum balas anda.')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data.data,
|
||||
pagination: {
|
||||
current_page: data.meta.current_page,
|
||||
per_page: data.meta.per_page,
|
||||
total: data.meta.total,
|
||||
last_page: data.meta.last_page,
|
||||
from: null,
|
||||
to: null,
|
||||
has_more_pages: data.meta.current_page < data.meta.last_page,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFeedbackStatistics(): Promise<FeedbackStatistics> {
|
||||
const { data } = await api.get<{ success: boolean; data: FeedbackStatistics; message?: string }>(
|
||||
'/v1/feedback-statistics',
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan statistik maklum balas.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchFeedbackDocument(
|
||||
feedbackId: string,
|
||||
documentId: string,
|
||||
mimeType?: string | null,
|
||||
): Promise<Blob> {
|
||||
const response = await api.get(`/v1/feedback/${feedbackId}/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 })
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
export type FeedbackType =
|
||||
| 'bug'
|
||||
| 'feature_request'
|
||||
| 'general_feedback'
|
||||
| 'ui_issue'
|
||||
| 'performance_issue'
|
||||
|
||||
export type FeedbackPriority = 'low' | 'medium' | 'high' | 'critical'
|
||||
|
||||
export type FeedbackStatus = 'open' | 'in_progress' | 'resolved' | 'closed' | 'rejected'
|
||||
|
||||
export type FeedbackDocument = {
|
||||
id: string
|
||||
name: string
|
||||
mime_type: string | null
|
||||
file_size: number | null
|
||||
type: 'image' | 'video' | string
|
||||
url?: string | null
|
||||
}
|
||||
|
||||
export type FeedbackUserSummary = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
army_number?: string | null
|
||||
}
|
||||
|
||||
export type FeedbackAssignedUser = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export type Feedback = {
|
||||
id: string
|
||||
type: FeedbackType
|
||||
title: string
|
||||
description: string
|
||||
priority: FeedbackPriority
|
||||
status: FeedbackStatus
|
||||
page_url: string | null
|
||||
browser_info: Record<string, unknown> | null
|
||||
images?: FeedbackDocument[]
|
||||
videos?: FeedbackDocument[]
|
||||
steps_to_reproduce: string | null
|
||||
expected_behavior: string | null
|
||||
actual_behavior: string | null
|
||||
additional_notes: string | null
|
||||
admin_notes: string | null
|
||||
resolved_at: string | null
|
||||
user?: FeedbackUserSummary | null
|
||||
assigned_user?: FeedbackAssignedUser | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type FeedbackListItem = Feedback
|
||||
|
||||
export type FeedbackFormState = {
|
||||
type: FeedbackType
|
||||
title: string
|
||||
description: string
|
||||
priority: FeedbackPriority
|
||||
page_url: string
|
||||
steps_to_reproduce: string
|
||||
expected_behavior: string
|
||||
actual_behavior: string
|
||||
additional_notes: string
|
||||
images: File[]
|
||||
videos: File[]
|
||||
}
|
||||
|
||||
export type SubmitFeedbackPayload = {
|
||||
type: FeedbackType
|
||||
title: string
|
||||
description: string
|
||||
priority: FeedbackPriority
|
||||
page_url?: string
|
||||
steps_to_reproduce?: string
|
||||
expected_behavior?: string
|
||||
actual_behavior?: string
|
||||
additional_notes?: string
|
||||
images?: File[]
|
||||
videos?: File[]
|
||||
screen_resolution?: string
|
||||
viewport_size?: string
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
export type UpdateFeedbackPayload = {
|
||||
type?: FeedbackType
|
||||
title?: string
|
||||
description?: string
|
||||
priority?: FeedbackPriority
|
||||
page_url?: string | null
|
||||
steps_to_reproduce?: string | null
|
||||
expected_behavior?: string | null
|
||||
actual_behavior?: string | null
|
||||
additional_notes?: string | null
|
||||
status?: FeedbackStatus
|
||||
assigned_to?: string | null
|
||||
admin_notes?: string | null
|
||||
}
|
||||
|
||||
export type ListFeedbackParams = {
|
||||
page?: number
|
||||
per_page?: number
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
type?: FeedbackType | ''
|
||||
status?: FeedbackStatus | ''
|
||||
priority?: FeedbackPriority | ''
|
||||
}
|
||||
|
||||
export type FeedbackApiResponse = {
|
||||
success: boolean
|
||||
data: Feedback
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type FeedbackStatistics = {
|
||||
total: number
|
||||
open: number
|
||||
resolved: number
|
||||
by_type: Record<string, number>
|
||||
by_priority: Record<string, number>
|
||||
by_status: Record<string, number>
|
||||
}
|
||||
|
||||
export const FEEDBACK_TYPE_OPTIONS: { label: string; value: FeedbackType }[] = [
|
||||
{ label: 'Ralat / Bug', value: 'bug' },
|
||||
{ label: 'Permintaan Ciri Baharu', value: 'feature_request' },
|
||||
{ label: 'Maklum Balas Umum', value: 'general_feedback' },
|
||||
{ label: 'Isu Antara Muka', value: 'ui_issue' },
|
||||
{ label: 'Isu Prestasi', value: 'performance_issue' },
|
||||
]
|
||||
|
||||
export const FEEDBACK_PRIORITY_OPTIONS: { label: string; value: FeedbackPriority }[] = [
|
||||
{ label: 'Rendah', value: 'low' },
|
||||
{ label: 'Sederhana', value: 'medium' },
|
||||
{ label: 'Tinggi', value: 'high' },
|
||||
{ label: 'Kritikal', value: 'critical' },
|
||||
]
|
||||
|
||||
export const FEEDBACK_STATUS_OPTIONS: { label: string; value: FeedbackStatus }[] = [
|
||||
{ label: 'Terbuka', value: 'open' },
|
||||
{ label: 'Dalam Proses', value: 'in_progress' },
|
||||
{ label: 'Diselesaikan', value: 'resolved' },
|
||||
{ label: 'Ditutup', value: 'closed' },
|
||||
{ label: 'Ditolak', value: 'rejected' },
|
||||
]
|
||||
|
||||
export function feedbackTypeLabel(type: FeedbackType | string): string {
|
||||
return FEEDBACK_TYPE_OPTIONS.find((option) => option.value === type)?.label ?? type
|
||||
}
|
||||
|
||||
export function feedbackPriorityLabel(priority: FeedbackPriority | string): string {
|
||||
return FEEDBACK_PRIORITY_OPTIONS.find((option) => option.value === priority)?.label ?? priority
|
||||
}
|
||||
|
||||
export function feedbackStatusLabel(status: FeedbackStatus | string): string {
|
||||
return FEEDBACK_STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status
|
||||
}
|
||||
|
||||
export function createEmptyFeedbackForm(): FeedbackFormState {
|
||||
return {
|
||||
type: 'general_feedback',
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
page_url: typeof window !== 'undefined' ? window.location.href : '',
|
||||
steps_to_reproduce: '',
|
||||
expected_behavior: '',
|
||||
actual_behavior: '',
|
||||
additional_notes: '',
|
||||
images: [],
|
||||
videos: [],
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { Textarea } from '@/components/ui/textarea'
|
||||
import { AlertRoot, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import { HelpdeskFab } from '@/modules/feedback'
|
||||
import { submitMembershipApplication, lookupMemberByIcNumber } from '../services/membership-application.service'
|
||||
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
|
||||
import type {
|
||||
@@ -567,6 +568,7 @@ function stepLabelClass(stepId: number) {
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background">
|
||||
<HelpdeskFab />
|
||||
<div class="border-b border-foreground/10 bg-primary/5">
|
||||
<div class="container mx-auto flex items-center justify-between px-5 py-4 sm:px-8">
|
||||
<div class="flex items-center gap-4">
|
||||
|
||||
@@ -13,6 +13,7 @@ import { activityLayoutRoutes } from '@/modules/activity'
|
||||
import { dashboardLayoutRoutes } from '@/modules/dashboard'
|
||||
import { externalSystemLayoutRoutes } from '@/modules/external-system'
|
||||
import { activityLogLayoutRoutes } from '@/modules/activity-log'
|
||||
import { feedbackPublicRoutes, feedbackLayoutRoutes } from '@/modules/feedback'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
@@ -30,15 +31,17 @@ const router = createRouter({
|
||||
...activityLayoutRoutes,
|
||||
...externalSystemLayoutRoutes,
|
||||
...membershipApplicationLayoutRoutes,
|
||||
...feedbackLayoutRoutes,
|
||||
],
|
||||
},
|
||||
...authPublicRoutes,
|
||||
...membershipApplicationPublicRoutes,
|
||||
...feedbackPublicRoutes,
|
||||
...profilePublicRoutes,
|
||||
],
|
||||
})
|
||||
|
||||
const PUBLIC_ROUTE_NAMES = new Set(['login', 'register', 'membership-application-apply'])
|
||||
const PUBLIC_ROUTE_NAMES = new Set(['login', 'register', 'membership-application-apply', 'feedback-submit'])
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const authStore = useAuthStore(pinia)
|
||||
@@ -69,7 +72,8 @@ router.beforeEach(async (to) => {
|
||||
if (
|
||||
authStore.isAuthenticated &&
|
||||
PUBLIC_ROUTE_NAMES.has(routeName) &&
|
||||
routeName !== 'membership-application-apply'
|
||||
routeName !== 'membership-application-apply' &&
|
||||
routeName !== 'feedback-submit'
|
||||
) {
|
||||
return resolvePostAuthRoute(authStore.user)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Lucide, type Icon as LucideIcon } from '@/components/ui/lucide'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { HelpdeskFab } from '@/modules/feedback'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -58,16 +59,6 @@ const sideTabs: SideTab[] = [
|
||||
window.open('https://www.facebook.com/KoperasiPermodalanKelantanBerhad/', '_blank')
|
||||
},
|
||||
},
|
||||
// {
|
||||
// id: 'feedback-page',
|
||||
// label: 'Helpdesk',
|
||||
// iconType: 'lucide',
|
||||
// icon: 'MessageCircle',
|
||||
// offsetRem: 10.5,
|
||||
// onClick: () => {
|
||||
// window.open('https://docs.google.com/forms/d/e/1FAIpQLSd_0p-kKmsHq9z4fZ3e6X13V6O64s1-CpJw4_vR768Gg0_sA/viewform?usp=header', '_blank')
|
||||
// },
|
||||
// },
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
@@ -106,6 +97,8 @@ onMounted(() => {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<HelpdeskFab :offset-rem="10.5" />
|
||||
|
||||
<Component />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user