diff --git a/README.md b/README.md index 24a70ef..532fca3 100644 --- a/README.md +++ b/README.md @@ -76,4 +76,7 @@ SELECT setval('public.permissions_id_seq', COALESCE((SELECT MAX(id) FROM permiss ## DO NOT RUN THIS BECAUSE IT WILL REMOVE ALL THE UNTAGGED FILES IN LOCAL ```bash git reset --hard development # moves the current branch (e.g., main) to point exactly where development -``` \ No newline at end of file +``` + +## Public endpoint to apply membership: +## http://localhost:5173/apply-membership \ No newline at end of file diff --git a/TODO.md b/TODO.md index fff4912..f934127 100644 --- a/TODO.md +++ b/TODO.md @@ -16,5 +16,16 @@ [ ] jana surat lepas lulus anggota ## Present to Boss (2/7/2026) -[ ] discuss logo baru MyKOPKB -[ ] layout footer untuk surat rasmi \ No newline at end of file +[x] discuss logo baru MyKOPKB +[x] layout footer untuk surat rasmi +[ ] maklumat pekerjaan kena buat dropdown untuk nama majikan +[ ] tambah minima untuk borang permohonan (tukar jadi caruman saham potongan bulanan) +[x] tukar waris ke penama +[x] ic dan surat pengesahan majikkan perlu mandatori +[ ] semakan lembaga tukar jadi maklumat keputusan +[x] boleh attach document untuk peringkat pentadbir +[x] surat attach dekat email selepas bos +[ ] pembayaran pertama tukar jadi one-off +[ ] syer maksima silap tukar jadi rm50.00 +[ ] letak sign digital +[x] running no anggota dalam surat dan masa. \ No newline at end of file diff --git a/be/.env.development b/be/.env.development index b5c5094..74b36c6 100644 --- a/be/.env.development +++ b/be/.env.development @@ -95,4 +95,7 @@ API_ALLOWED_USER_AGENTS= ENABLE_API_KEY_AUTH=true API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae -API_LOG_KEY_USAGE=true \ No newline at end of file +API_LOG_KEY_USAGE=true + +PUBLIC_PROFILE_TOKEN_TTL_DAYS=7 +FRONTEND_URL=https://mykopkb.koppkb.com \ No newline at end of file diff --git a/be/.env.production b/be/.env.production index 8cfae54..80a9c56 100644 --- a/be/.env.production +++ b/be/.env.production @@ -102,4 +102,7 @@ API_ALLOWED_USER_AGENTS= ENABLE_API_KEY_AUTH=true API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae -API_LOG_KEY_USAGE=true \ No newline at end of file +API_LOG_KEY_USAGE=true + +PUBLIC_PROFILE_TOKEN_TTL_DAYS=7 +FRONTEND_URL=https://mykopkb.koppkb.com \ No newline at end of file diff --git a/be/Modules/ActivityLog/Http/Controllers/ActivityLogController.php b/be/Modules/ActivityLog/Http/Controllers/ActivityLogController.php index 0a1e8d7..fdbbe69 100644 --- a/be/Modules/ActivityLog/Http/Controllers/ActivityLogController.php +++ b/be/Modules/ActivityLog/Http/Controllers/ActivityLogController.php @@ -3,17 +3,22 @@ namespace Modules\ActivityLog\Http\Controllers; use App\Http\Controllers\Controller; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\Request; use Spatie\Activitylog\Models\Activity; use App\Services\ActivityLogger; class ActivityLogController extends Controller { + use AuthorizesRequests; + /** * Display a listing of the resource. */ public function index(Request $request) { + $this->authorize('viewAny', Activity::class); + // ActivityLogger::logView(new Activity, 'Viewed activity logs'); $perPage = $request->get('per_page', 10); @@ -27,8 +32,10 @@ class ActivityLogController extends Controller $q->where('description', 'ILIKE', "%{$search}%") ->orWhere('subject_type', 'ILIKE', "%{$search}%") ->orWhere('event', 'ILIKE', "%{$search}%") - ->orWhere('causer.name', 'ILIKE', "%{$search}%") - ->orWhere('causer.email', 'ILIKE', "%{$search}%"); + ->orWhereHas('causer', function ($causerQuery) use ($search) { + $causerQuery->where('name', 'ILIKE', "%{$search}%") + ->orWhere('email', 'ILIKE', "%{$search}%"); + }); }); } diff --git a/be/Modules/ActivityLog/Providers/ActivityLogServiceProvider.php b/be/Modules/ActivityLog/Providers/ActivityLogServiceProvider.php index d1fad58..69101d9 100644 --- a/be/Modules/ActivityLog/Providers/ActivityLogServiceProvider.php +++ b/be/Modules/ActivityLog/Providers/ActivityLogServiceProvider.php @@ -3,7 +3,10 @@ namespace Modules\ActivityLog\Providers; use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; +use Modules\ActivityLog\Policies\ActivityLogPolicy; +use Spatie\Activitylog\Models\Activity; use Nwidart\Modules\Traits\PathNamespace; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; @@ -21,6 +24,8 @@ class ActivityLogServiceProvider extends ServiceProvider */ public function boot(): void { + Gate::policy(Activity::class, ActivityLogPolicy::class); + $this->registerCommands(); $this->registerCommandSchedules(); $this->registerTranslations(); diff --git a/be/Modules/Auth/Entities/User.php b/be/Modules/Auth/Entities/User.php index 3c094b5..a20c45f 100644 --- a/be/Modules/Auth/Entities/User.php +++ b/be/Modules/Auth/Entities/User.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Str; use Laravel\Sanctum\HasApiTokens; use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\Traits\LogsActivity; @@ -51,9 +52,11 @@ class User extends Authenticatable 'marriage_status', 'member_number', 'member_type', + 'public_profile_token', 'join_date', 'birth_date', 'birth_place', + 'onboarding_completed_at', ]; /** @@ -67,8 +70,55 @@ class User extends Authenticatable 'two_factor_secret', 'two_factor_recovery_codes', 'two_factor_confirmed_at', + 'public_profile_token', ]; + protected static function booted(): void + { + static::creating(function (User $user) { + $expiryDays = self::publicProfileTokenTtlDays(); + + if (empty($user->public_profile_token)) { + $user->public_profile_token = Str::random(48); + } + + if (empty($user->public_profile_token_expires_at)) { + $user->public_profile_token_expires_at = now()->addDays($expiryDays); + } + }); + } + + public static function publicProfileTokenTtlDays(): int + { + return (int) config('user.public_profile_token_ttl_days', 90); + } + + public function isPublicProfileTokenExpired(): bool + { + return $this->public_profile_token_expires_at !== null + && $this->public_profile_token_expires_at->isPast(); + } + + public function ensurePublicProfileToken(): string + { + $expiryDays = self::publicProfileTokenTtlDays(); + + if (blank($this->public_profile_token) || $this->isPublicProfileTokenExpired()) { + $this->forceFill([ + 'public_profile_token' => Str::random(48), + 'public_profile_token_expires_at' => now()->addDays($expiryDays), + ])->save(); + + return $this->public_profile_token; + } + + $this->forceFill([ + 'public_profile_token_expires_at' => now()->addDays($expiryDays), + ])->save(); + + return $this->public_profile_token; + } + /** * Get the attributes that should be cast. * @@ -84,9 +134,11 @@ class User extends Authenticatable 'marriage_status' => 'string', 'member_number' => 'integer', 'member_type' => 'string', + 'public_profile_token_expires_at' => 'datetime', 'join_date' => 'date', 'birth_date' => 'date', 'birth_place' => 'string', + 'onboarding_completed_at' => 'datetime', ]; } diff --git a/be/Modules/Auth/Http/Controllers/SessionController.php b/be/Modules/Auth/Http/Controllers/SessionController.php index 34a0652..cfcde33 100644 --- a/be/Modules/Auth/Http/Controllers/SessionController.php +++ b/be/Modules/Auth/Http/Controllers/SessionController.php @@ -24,6 +24,7 @@ class SessionController extends Controller ], 401); } + $user->ensurePublicProfileToken(); $user->load(['roles.permissions']); return response()->json([ diff --git a/be/Modules/Auth/Services/AuthSessionService.php b/be/Modules/Auth/Services/AuthSessionService.php index bca4178..8cd803c 100644 --- a/be/Modules/Auth/Services/AuthSessionService.php +++ b/be/Modules/Auth/Services/AuthSessionService.php @@ -12,6 +12,8 @@ class AuthSessionService { public function createAuthResponse(User $user, string $message, int $status = 200): JsonResponse { + $user->ensurePublicProfileToken(); + if (config('app.env') !== 'local') { $user->tokens()->delete(); } diff --git a/be/Modules/Auth/Transformers/UserResource.php b/be/Modules/Auth/Transformers/UserResource.php index 98518ef..b3cc612 100644 --- a/be/Modules/Auth/Transformers/UserResource.php +++ b/be/Modules/Auth/Transformers/UserResource.php @@ -36,9 +36,11 @@ class UserResource extends JsonResource 'marriage_status' => $this->marriage_status, 'member_number' => $this->member_number, 'member_type' => $this->member_type, + 'public_profile_token' => $this->public_profile_token, 'join_date' => $this->join_date, 'birth_date' => $this->birth_date, 'birth_place' => $this->birth_place, + 'onboarding_completed_at' => $this->onboarding_completed_at, 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, 'deleted_at' => $this->deleted_at, diff --git a/be/Modules/MembershipApplication/Emails/MembershipApplicationFailedNotification.php b/be/Modules/MembershipApplication/Emails/MembershipApplicationFailedNotification.php index ba3f09a..36018fc 100644 --- a/be/Modules/MembershipApplication/Emails/MembershipApplicationFailedNotification.php +++ b/be/Modules/MembershipApplication/Emails/MembershipApplicationFailedNotification.php @@ -2,19 +2,28 @@ namespace Modules\MembershipApplication\Emails; +use App\Models\Document; +use App\Notifications\Concerns\BuildsMailMessage; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; +use Illuminate\Support\Facades\Storage; use Modules\MembershipApplication\Entities\MembershipApplication; -class MembershipApplicationFailedNotification extends Notification +class MembershipApplicationFailedNotification extends Notification implements ShouldQueue { + use BuildsMailMessage; use Queueable; public function __construct( - protected MembershipApplication $application + public MembershipApplication $application, + public Document $resultLetter, ) {} + /** + * @return array + */ public function via(object $notifiable): array { return ['mail']; @@ -23,13 +32,21 @@ class MembershipApplicationFailedNotification extends Notification public function toMail(object $notifiable): MailMessage { $this->application->loadMissing('applicant'); + $applicant = $this->application->applicant; - return (new MailMessage) - ->subject('Keputusan Permohonan Keahlian') - ->markdown('membershipapplication::emails.failed', [ - 'name' => $this->application->applicant->name, - 'applicationNumber' => $this->application->application_number, - 'logoPath' => public_path('images/logo-kopkb.svg'), - ]); + return $this->mailMessage( + subject: 'Keputusan Permohonan Keahlian KOPKB - TIDAK LULUS', + view: 'membershipapplication::emails.failed', + data: [ + 'application' => $this->application, + 'applicant' => $applicant, + ], + )->attach( + Storage::disk(Document::STORAGE_DISK)->path($this->resultLetter->path), + [ + 'as' => $this->resultLetter->name, + 'mime' => $this->resultLetter->mime_type, + ] + ); } } diff --git a/be/Modules/MembershipApplication/Emails/MembershipApplicationPassedNotification.php b/be/Modules/MembershipApplication/Emails/MembershipApplicationPassedNotification.php index e9ccbd8..c07ec92 100644 --- a/be/Modules/MembershipApplication/Emails/MembershipApplicationPassedNotification.php +++ b/be/Modules/MembershipApplication/Emails/MembershipApplicationPassedNotification.php @@ -2,19 +2,28 @@ namespace Modules\MembershipApplication\Emails; +use App\Models\Document; +use App\Notifications\Concerns\BuildsMailMessage; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; +use Illuminate\Support\Facades\Storage; use Modules\MembershipApplication\Entities\MembershipApplication; -class MembershipApplicationPassedNotification extends Notification +class MembershipApplicationPassedNotification extends Notification implements ShouldQueue { + use BuildsMailMessage; use Queueable; public function __construct( - protected MembershipApplication $application + public MembershipApplication $application, + public Document $resultLetter, ) {} + /** + * @return array + */ public function via(object $notifiable): array { return ['mail']; @@ -23,13 +32,21 @@ class MembershipApplicationPassedNotification extends Notification public function toMail(object $notifiable): MailMessage { $this->application->loadMissing('applicant'); + $applicant = $this->application->applicant; - return (new MailMessage) - ->subject('Permohonan Keahlian Diluluskan') - ->markdown('membershipapplication::emails.passed', [ - 'name' => $this->application->applicant->name, - 'applicationNumber' => $this->application->application_number, - 'logoPath' => public_path('images/logo-kopkb.svg'), - ]); + return $this->mailMessage( + subject: 'Keputusan Permohonan Keahlian KOPKB - LULUS', + view: 'membershipapplication::emails.passed', + data: [ + 'application' => $this->application, + 'applicant' => $applicant, + ], + )->attach( + Storage::disk(Document::STORAGE_DISK)->path($this->resultLetter->path), + [ + 'as' => $this->resultLetter->name, + 'mime' => $this->resultLetter->mime_type, + ] + ); } } diff --git a/be/Modules/MembershipApplication/Emails/MembershipApplicationSubmittedNotification.php b/be/Modules/MembershipApplication/Emails/MembershipApplicationSubmittedNotification.php index 4b77632..97d5f68 100644 --- a/be/Modules/MembershipApplication/Emails/MembershipApplicationSubmittedNotification.php +++ b/be/Modules/MembershipApplication/Emails/MembershipApplicationSubmittedNotification.php @@ -2,6 +2,7 @@ namespace Modules\MembershipApplication\Emails; +use App\Notifications\Concerns\BuildsMailMessage; use Illuminate\Bus\Queueable; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; @@ -9,6 +10,7 @@ use Modules\MembershipApplication\Entities\MembershipApplication; class MembershipApplicationSubmittedNotification extends Notification { + use BuildsMailMessage; use Queueable; public function __construct( @@ -24,9 +26,10 @@ class MembershipApplicationSubmittedNotification extends Notification { $this->application->loadMissing(['applicant', 'heirs', 'documents', 'references.member']); - return (new MailMessage) - ->subject('Pengesahan Permohonan Keahlian') - ->markdown('membershipapplication::emails.submitted', [ + return $this->mailMessage( + subject: 'Pengesahan Permohonan Keahlian', + view: 'membershipapplication::emails.submitted', + data: [ 'application' => $this->application, 'applicant' => $this->application->applicant, 'heirs' => $this->application->heirs, @@ -37,7 +40,7 @@ class MembershipApplicationSubmittedNotification extends Notification 'salary_slip' => 'Slip Gaji', 'employer_letter' => 'Surat Pengesahan Majikan', ], - 'logoPath' => public_path('images/logo-kopkb.svg'), - ]); + ], + ); } } diff --git a/be/Modules/MembershipApplication/Entities/MembershipApplication.php b/be/Modules/MembershipApplication/Entities/MembershipApplication.php index 9464541..15a7ff0 100644 --- a/be/Modules/MembershipApplication/Entities/MembershipApplication.php +++ b/be/Modules/MembershipApplication/Entities/MembershipApplication.php @@ -18,6 +18,8 @@ class MembershipApplication extends Model public const RESULT_LETTER_DOCUMENT_TYPE = 'result_letter'; + public const ADMIN_ATTACHMENT_DOCUMENT_TYPE = 'admin_attachment'; + public const MINIMUM_SHARE_CAPITAL = 500; public const SHARE_INSTALLMENT_MONTHS = 6; diff --git a/be/Modules/MembershipApplication/Http/Controllers/MembershipApplicationController.php b/be/Modules/MembershipApplication/Http/Controllers/MembershipApplicationController.php index 006de7b..7da1e09 100644 --- a/be/Modules/MembershipApplication/Http/Controllers/MembershipApplicationController.php +++ b/be/Modules/MembershipApplication/Http/Controllers/MembershipApplicationController.php @@ -17,6 +17,7 @@ use Modules\MembershipApplication\Enums\ManagementDecision; use Modules\MembershipApplication\Http\Requests\AssignReferencesRequest; use Modules\MembershipApplication\Http\Requests\BatchCompleteRequest; use Modules\MembershipApplication\Http\Requests\BoardReviewRequest; +use Modules\MembershipApplication\Http\Requests\CompleteMembershipApplicationRequest; use Modules\MembershipApplication\Http\Requests\GenerateResultLetterRequest; use Modules\MembershipApplication\Http\Requests\ManagementReviewRequest; use Modules\MembershipApplication\Http\Requests\UpdateMembershipApplicationRequest; @@ -180,18 +181,19 @@ class MembershipApplicationController extends Controller ]); } - public function complete(Request $request, MembershipApplication $membershipApplication): JsonResponse + public function complete(CompleteMembershipApplicationRequest $request, MembershipApplication $membershipApplication): JsonResponse { $this->authorize('complete', $membershipApplication); $application = $this->membershipApplicationService->complete( $membershipApplication, - $request->user() + $request->user(), + $request->validated('board_meeting_reference'), ); $message = $application->board_result === BoardResult::Pass->value - ? 'Permohonan selesai. Akaun ahli telah dicipta dan e-mel dihantar.' - : 'Permohonan selesai. E-mel keputusan dihantar.'; + ? 'Permohonan selesai. Surat keputusan dijana, akaun ahli dicipta dan e-mel dihantar.' + : 'Permohonan selesai. Surat keputusan dijana dan e-mel dihantar.'; return response()->json([ 'success' => true, @@ -206,7 +208,8 @@ class MembershipApplicationController extends Controller $result = $this->membershipApplicationService->completeBatch( $request->validated('application_ids'), - $request->user() + $request->user(), + $request->validated('board_meeting_reference'), ); $succeededCount = count($result['succeeded']); @@ -245,6 +248,22 @@ class MembershipApplicationController extends Controller return $this->documentService->downloadDocument($document->id); } + public function deleteDocument(MembershipApplication $membershipApplication, string $documentId): JsonResponse + { + $this->authorize('update', $membershipApplication); + + $application = $this->membershipApplicationService->deleteDocument( + $membershipApplication, + $documentId, + ); + + return response()->json([ + 'success' => true, + 'message' => 'Lampiran pentadbir berjaya dipadam.', + 'data' => new MembershipApplicationResource($application), + ]); + } + public function generateResultLetter(GenerateResultLetterRequest $request, MembershipApplication $membershipApplication,): JsonResponse { $this->authorize('generateResultLetter', $membershipApplication); diff --git a/be/Modules/MembershipApplication/Http/Requests/BatchCompleteRequest.php b/be/Modules/MembershipApplication/Http/Requests/BatchCompleteRequest.php index deb9c19..0a4c3ae 100644 --- a/be/Modules/MembershipApplication/Http/Requests/BatchCompleteRequest.php +++ b/be/Modules/MembershipApplication/Http/Requests/BatchCompleteRequest.php @@ -19,6 +19,7 @@ class BatchCompleteRequest extends FormRequest return [ 'application_ids' => 'required|array|min:1|max:100', 'application_ids.*' => 'required|uuid|distinct|exists:membership_applications,id', + 'board_meeting_reference' => 'required|string|max:255', ]; } @@ -33,6 +34,8 @@ class BatchCompleteRequest extends FormRequest 'application_ids.max' => 'Maksimum 100 permohonan setiap batch.', 'application_ids.*.exists' => 'Salah satu permohonan tidak dijumpai.', 'application_ids.*.distinct' => 'Permohonan duplikat tidak dibenarkan.', + 'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.', + 'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.', ]; } } diff --git a/be/Modules/MembershipApplication/Http/Requests/CompleteMembershipApplicationRequest.php b/be/Modules/MembershipApplication/Http/Requests/CompleteMembershipApplicationRequest.php new file mode 100644 index 0000000..971bfde --- /dev/null +++ b/be/Modules/MembershipApplication/Http/Requests/CompleteMembershipApplicationRequest.php @@ -0,0 +1,34 @@ +|string> + */ + public function rules(): array + { + return [ + 'board_meeting_reference' => 'required|string|max:255', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.', + 'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.', + ]; + } +} diff --git a/be/Modules/MembershipApplication/Http/Requests/StoreMembershipApplicationRequest.php b/be/Modules/MembershipApplication/Http/Requests/StoreMembershipApplicationRequest.php index 8027dc1..f15abed 100644 --- a/be/Modules/MembershipApplication/Http/Requests/StoreMembershipApplicationRequest.php +++ b/be/Modules/MembershipApplication/Http/Requests/StoreMembershipApplicationRequest.php @@ -48,7 +48,7 @@ class StoreMembershipApplicationRequest extends FormRequest 'documents.ic_copy' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240', 'documents.photo' => 'nullable|file|mimes:jpg,jpeg,png|max:10240', 'documents.salary_slip' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', - 'documents.employer_letter' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', + 'documents.employer_letter' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240', ]; } @@ -89,6 +89,8 @@ class StoreMembershipApplicationRequest extends FormRequest 'documents.ic_copy.required' => 'Salinan kad pengenalan diperlukan.', 'documents.ic_copy.mimes' => 'Salinan kad pengenalan mestilah PDF, JPG, JPEG atau PNG.', + 'documents.employer_letter.required' => 'Surat pengesahan majikan diperlukan.', + 'documents.employer_letter.mimes' => 'Surat pengesahan majikan mestilah PDF, JPG, JPEG atau PNG.', ]; } } diff --git a/be/Modules/MembershipApplication/Http/Requests/UploadMembershipApplicationDocumentRequest.php b/be/Modules/MembershipApplication/Http/Requests/UploadMembershipApplicationDocumentRequest.php index 542495f..47198a4 100644 --- a/be/Modules/MembershipApplication/Http/Requests/UploadMembershipApplicationDocumentRequest.php +++ b/be/Modules/MembershipApplication/Http/Requests/UploadMembershipApplicationDocumentRequest.php @@ -4,6 +4,7 @@ namespace Modules\MembershipApplication\Http\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Modules\MembershipApplication\Entities\MembershipApplication; class UploadMembershipApplicationDocumentRequest extends FormRequest { @@ -18,7 +19,13 @@ class UploadMembershipApplicationDocumentRequest extends FormRequest public function rules(): array { return [ - 'type' => ['required', Rule::in(['ic_copy', 'photo', 'salary_slip', 'employer_letter'])], + 'type' => ['required', Rule::in([ + 'ic_copy', + 'photo', + 'salary_slip', + 'employer_letter', + MembershipApplication::ADMIN_ATTACHMENT_DOCUMENT_TYPE, + ])], 'file' => [ 'required', 'file', diff --git a/be/Modules/MembershipApplication/Repositories/MembershipApplicationRepository.php b/be/Modules/MembershipApplication/Repositories/MembershipApplicationRepository.php index 679ef22..06fccd4 100644 --- a/be/Modules/MembershipApplication/Repositories/MembershipApplicationRepository.php +++ b/be/Modules/MembershipApplication/Repositories/MembershipApplicationRepository.php @@ -23,6 +23,7 @@ class MembershipApplicationRepository implements MembershipApplicationRepository return MembershipApplication::with([ 'applicant', 'heirs', + 'user:id,member_number', 'references.member:id,name,ic_number', 'documents', 'reviews.reviewer:id,name', diff --git a/be/Modules/MembershipApplication/Resources/views/emails/failed.blade.php b/be/Modules/MembershipApplication/Resources/views/emails/failed.blade.php index 62b70a8..2982892 100644 --- a/be/Modules/MembershipApplication/Resources/views/emails/failed.blade.php +++ b/be/Modules/MembershipApplication/Resources/views/emails/failed.blade.php @@ -1,18 +1,15 @@ @component('mail::message') -@if (! empty($logoPath) && file_exists($logoPath)) -
- {{ config('app.name') }} -
-@endif +@include('emails.partials.header') # Keputusan Permohonan Keahlian -Assalamualaikum **{{ $name }}**, +Assalamualaikum **{{ $applicant->name }}**, -Permohonan keahlian anda (**{{ $applicationNumber }}**) **tidak diluluskan** pada peringkat lembaga. +Permohonan keahlian anda (**{{ $application->application_number }}**) **tidak diluluskan** pada peringkat lembaga. + +Surat keputusan rasmi dilampirkan dalam e-mel ini. Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi. -Terima kasih,
-{{ config('app.name') }} +@include('emails.partials.footer') @endcomponent diff --git a/be/Modules/MembershipApplication/Resources/views/emails/passed.blade.php b/be/Modules/MembershipApplication/Resources/views/emails/passed.blade.php index 3c962c2..b9752cd 100644 --- a/be/Modules/MembershipApplication/Resources/views/emails/passed.blade.php +++ b/be/Modules/MembershipApplication/Resources/views/emails/passed.blade.php @@ -1,18 +1,15 @@ @component('mail::message') -@if (! empty($logoPath) && file_exists($logoPath)) -
- {{ config('app.name') }} -
-@endif +@include('emails.partials.header') # Keputusan Permohonan Keahlian -Assalamualaikum **{{ $name }}**, +Assalamualaikum **{{ $applicant->name }}**, -Permohonan keahlian anda (**{{ $applicationNumber }}**) **diluluskan** pada peringkat lembaga. +Permohonan keahlian anda (**{{ $application->application_number }}**) **diluluskan** pada peringkat lembaga. + +Surat keputusan rasmi dilampirkan dalam e-mel ini. Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi. -Terima kasih,
-{{ config('app.name') }} +@include('emails.partials.footer') @endcomponent diff --git a/be/Modules/MembershipApplication/Resources/views/emails/submitted.blade.php b/be/Modules/MembershipApplication/Resources/views/emails/submitted.blade.php index 7a75b9e..5782b9b 100644 --- a/be/Modules/MembershipApplication/Resources/views/emails/submitted.blade.php +++ b/be/Modules/MembershipApplication/Resources/views/emails/submitted.blade.php @@ -1,9 +1,5 @@ @component('mail::message') -@if (! empty($logoPath) && file_exists($logoPath)) -
- {{ config('app.name') }} -
-@endif +@include('emails.partials.header') # Pengesahan Permohonan Keahlian @@ -74,6 +70,5 @@ Tiada dokumen. Anda akan menerima emel apabila keputusan permohonan tersedia. -Terima kasih,
-{{ config('app.name') }} +@include('emails.partials.footer') @endcomponent diff --git a/be/Modules/MembershipApplication/Resources/views/pdf/approved-letter.blade.php b/be/Modules/MembershipApplication/Resources/views/pdf/approved-letter.blade.php index cdfc055..21c1fea 100644 --- a/be/Modules/MembershipApplication/Resources/views/pdf/approved-letter.blade.php +++ b/be/Modules/MembershipApplication/Resources/views/pdf/approved-letter.blade.php @@ -22,7 +22,7 @@

2. {{ $openingTone }} dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi - Permodalan Kelantan Berhad (KoPKB) telah {{ $boardResultLabel }} dalam {{ $boardMeetingReference }}. + Permodalan Kelantan Berhad (KoPKB) telah {{ $boardResultLabel }} dalam {{ $boardMeetingReference }}@if ($isPassed && $memberNumber). Nombor keanggotaan tuan adalah {{ $memberNumber }}@endif.

@if ($isPassed) @@ -66,8 +66,12 @@ @section('letter-signature')
Dengan hormatnya,
KOPERASI PERMODALAN KELANTAN BERHAD
-
-
MUHAMMAD NAFIS BIN ZAINUDDIN
+
+ @if (! empty($signatureUrl)) + + @endif +
+
MUHAMMAD NAFIS BIN ZAINUDIN
Pengurus Besar
b/p Setiausaha
@endsection diff --git a/be/Modules/MembershipApplication/Routes/api.php b/be/Modules/MembershipApplication/Routes/api.php index 5259d36..7c5e34c 100644 --- a/be/Modules/MembershipApplication/Routes/api.php +++ b/be/Modules/MembershipApplication/Routes/api.php @@ -38,6 +38,7 @@ Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(funct // Membership application upload document routes Route::post('membership-applications/{membershipApplication}/documents', [MembershipApplicationController::class, 'uploadDocument'])->name('membership-application.upload-document'); + Route::delete('membership-applications/{membershipApplication}/documents/{documentId}', [MembershipApplicationController::class, 'deleteDocument'])->name('membership-application.delete-document'); // download document routes Route::get('membership-applications/{membershipApplication}/documents/{documentId}/download', [MembershipApplicationController::class, 'downloadDocument'])->name('membership-application.download-document'); diff --git a/be/Modules/MembershipApplication/Services/MembershipApplicationService.php b/be/Modules/MembershipApplication/Services/MembershipApplicationService.php index 10c6284..887bc7f 100644 --- a/be/Modules/MembershipApplication/Services/MembershipApplicationService.php +++ b/be/Modules/MembershipApplication/Services/MembershipApplicationService.php @@ -2,6 +2,7 @@ namespace Modules\MembershipApplication\Services; +use App\Models\Document; use App\Services\DocumentService; use App\Services\LetterService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; @@ -137,15 +138,38 @@ class MembershipApplicationService $this->documentService->validateFile($file); - $application->documentsOfType($type)->get()->each( - fn ($document) => $this->documentService->deleteDocument($document->id) - ); + if ($type !== MembershipApplication::ADMIN_ATTACHMENT_DOCUMENT_TYPE) { + $application->documentsOfType($type)->get()->each( + fn ($document) => $this->documentService->deleteDocument($document->id) + ); + } $this->documentService->uploadDocument($application, $file, $type); return $this->repository->findByIdWithRelations($application->id); } + public function deleteDocument(MembershipApplication $application, string $documentId): MembershipApplication + { + if ($application->status === ApplicationStatus::Completed) { + throw ValidationException::withMessages([ + 'status' => ['Permohonan yang telah selesai tidak boleh dikemaskini.'], + ]); + } + + $document = $application->documents()->findOrFail($documentId); + + if ($document->type !== MembershipApplication::ADMIN_ATTACHMENT_DOCUMENT_TYPE) { + throw ValidationException::withMessages([ + 'document' => ['Hanya lampiran pentadbir boleh dipadam.'], + ]); + } + + $this->documentService->deleteDocument($document->id); + + return $this->repository->findByIdWithRelations($application->id); + } + public function managementReview(MembershipApplication $application, ManagementDecision $decision, ?string $remarks, User $reviewer): MembershipApplication { if ($application->status !== ApplicationStatus::Submitted) { throw ValidationException::withMessages([ @@ -230,8 +254,11 @@ class MembershipApplicationService }); } - public function complete(MembershipApplication $application, User $processedBy): MembershipApplication - { + public function complete( + MembershipApplication $application, + User $processedBy, + string $boardMeetingReference, + ): MembershipApplication { if ($application->status !== ApplicationStatus::PendingNotification) { throw ValidationException::withMessages([ 'status' => ['Permohonan ini tidak boleh diselesaikan pada masa ini.'], @@ -244,13 +271,16 @@ class MembershipApplicationService ]); } - return DB::transaction(function () use ($application) { + return DB::transaction(function () use ($application, $boardMeetingReference) { if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) { $user = $this->createMemberFromApplication($application); $application->update(['user_id' => $user->id]); + $application->refresh(); } - $this->sendResultNotification($application); + $resultLetter = $this->storeResultLetter($application, $boardMeetingReference); + + $this->sendResultNotification($application, $resultLetter); $application->update([ 'status' => ApplicationStatus::Completed, @@ -268,7 +298,7 @@ class MembershipApplicationService * failed: list * } */ - public function completeBatch(array $applicationIds, User $processedBy): array + public function completeBatch(array $applicationIds, User $processedBy, string $boardMeetingReference): array { $succeeded = []; $failed = []; @@ -287,7 +317,7 @@ class MembershipApplicationService } try { - $succeeded[] = $this->complete($application, $processedBy); + $succeeded[] = $this->complete($application, $processedBy, $boardMeetingReference); } catch (ValidationException $e) { $failed[] = [ 'id' => $id, @@ -317,40 +347,47 @@ class MembershipApplicationService } return DB::transaction(function () use ($application, $boardMeetingReference) { - $lockedApplication = MembershipApplication::query() - ->whereKey($application->id) - ->lockForUpdate() - ->firstOrFail(); - - if ($lockedApplication->hasDocumentsOfType(MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE)) { - throw ValidationException::withMessages([ - 'result_letter' => ['Surat keputusan telah dijana dan tidak boleh dijana semula.'], - ]); - } - - $application = $this->repository->findByIdWithRelations($lockedApplication->id); - - if (! $application || ! $application->applicant) { - throw ValidationException::withMessages([ - 'application' => ['Maklumat pemohon tidak lengkap.'], - ]); - } - - $viewData = $this->buildResultLetterViewData($application, $boardMeetingReference); - $pdf = $this->letterService->renderPdf('membershipapplication::pdf.approved-letter', $viewData); - $fileName = 'surat-keputusan-'.$application->application_number.'.pdf'; - - $this->documentService->storeGeneratedDocument( - $application, - $pdf, - $fileName, - MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE, - ); + $this->storeResultLetter($application, $boardMeetingReference); return $this->repository->findByIdWithRelations($application->id); }); } + protected function storeResultLetter( + MembershipApplication $application, + string $boardMeetingReference, + ): Document { + $lockedApplication = MembershipApplication::query() + ->whereKey($application->id) + ->lockForUpdate() + ->firstOrFail(); + + if ($lockedApplication->hasDocumentsOfType(MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE)) { + throw ValidationException::withMessages([ + 'result_letter' => ['Surat keputusan telah dijana dan tidak boleh dijana semula.'], + ]); + } + + $application = $this->repository->findByIdWithRelations($lockedApplication->id); + + if (! $application || ! $application->applicant) { + throw ValidationException::withMessages([ + 'application' => ['Maklumat pemohon tidak lengkap.'], + ]); + } + + $viewData = $this->buildResultLetterViewData($application, $boardMeetingReference); + $pdf = $this->letterService->renderPdf('membershipapplication::pdf.approved-letter', $viewData); + $fileName = 'surat-keputusan-'.$application->application_number.'.pdf'; + + return $this->documentService->storeGeneratedDocument( + $application, + $pdf, + $fileName, + MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE, + ); + } + /** * @return array */ @@ -378,8 +415,9 @@ class MembershipApplicationService ): array { $applicant = $application->applicant; $isPassed = $application->board_result === BoardResult::Pass->value; + $stockMonthlyContribution = (float) $applicant->stock_monthly_contribution; $monthlyShareInstallment = number_format( - MembershipApplication::MINIMUM_SHARE_CAPITAL / MembershipApplication::SHARE_INSTALLMENT_MONTHS, + $stockMonthlyContribution < 84 ? 84 : $stockMonthlyContribution, 2, '.', '', @@ -396,10 +434,11 @@ class MembershipApplicationService ? 'KELULUSAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD' : 'KEPUTUSAN PERMOHONAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD', 'monthlyShareInstallment' => $monthlyShareInstallment, - 'stockMonthlyContribution' => number_format((float) $applicant->stock_monthly_contribution, 2, '.', ''), + 'stockMonthlyContribution' => number_format($stockMonthlyContribution, 2, '.', ''), 'feeMonthlyContribution' => number_format((float) $applicant->fee_monthly_contribution, 2, '.', ''), 'shareInstallmentMonths' => MembershipApplication::SHARE_INSTALLMENT_MONTHS, 'minimumShareCapital' => number_format(MembershipApplication::MINIMUM_SHARE_CAPITAL, 2, '.', ''), + 'memberNumber' => $isPassed ? $application->user?->member_number : null, ]; } @@ -488,20 +527,30 @@ class MembershipApplicationService ->notify(new MembershipApplicationSubmittedNotification($application)); } - protected function sendResultNotification(MembershipApplication $application): void + protected function sendResultNotification(MembershipApplication $application, Document $resultLetter): void { $application->loadMissing('applicant'); $notification = $application->board_result === BoardResult::Pass->value - ? new MembershipApplicationPassedNotification($application) - : new MembershipApplicationFailedNotification($application); + ? new MembershipApplicationPassedNotification($application, $resultLetter) + : new MembershipApplicationFailedNotification($application, $resultLetter); Notification::route('mail', $application->applicant->email)->notify($notification); } protected function generateMemberNumber(): int { - return ((int) User::max('member_number')) + 1; + $latestUser = User::query() + ->whereNotNull('member_number') + ->orderByDesc('member_number') + ->lockForUpdate() + ->first(); + + if ($latestUser === null && DB::getDriverName() === 'pgsql') { + DB::selectOne('SELECT pg_advisory_xact_lock(?) AS locked', [742891035]); + } + + return ((int) ($latestUser?->member_number ?? 0)) + 1; } /** diff --git a/be/Modules/User/Config/config.php b/be/Modules/User/Config/config.php index 566de5a..6484b21 100644 --- a/be/Modules/User/Config/config.php +++ b/be/Modules/User/Config/config.php @@ -2,4 +2,7 @@ return [ 'name' => 'User', + 'public_profile_token_ttl_days' => (int) env('PUBLIC_PROFILE_TOKEN_TTL_DAYS', 90), + 'frontend_url' => rtrim(env('FRONTEND_URL', env('APP_URL', 'http://localhost')), '/'), + 'digital_card_logo_path' => env('DIGITAL_CARD_LOGO_PATH', public_path('images/logo/logo.svg')), ]; diff --git a/be/Modules/User/Database/Migrations/2026_07_05_100000_add_public_profile_token_to_users_table.php b/be/Modules/User/Database/Migrations/2026_07_05_100000_add_public_profile_token_to_users_table.php new file mode 100644 index 0000000..51eee6d --- /dev/null +++ b/be/Modules/User/Database/Migrations/2026_07_05_100000_add_public_profile_token_to_users_table.php @@ -0,0 +1,34 @@ +string('public_profile_token', 64)->nullable()->unique()->after('member_type'); + }); + + User::withTrashed() + ->whereNull('public_profile_token') + ->cursor() + ->each(function (User $user) { + $user->forceFill([ + 'public_profile_token' => Str::random(48), + ])->saveQuietly(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropUnique(['public_profile_token']); + $table->dropColumn('public_profile_token'); + }); + } +}; diff --git a/be/Modules/User/Database/Migrations/2026_07_05_100001_add_public_profile_token_expires_at_to_users_table.php b/be/Modules/User/Database/Migrations/2026_07_05_100001_add_public_profile_token_expires_at_to_users_table.php new file mode 100644 index 0000000..cd5ccc4 --- /dev/null +++ b/be/Modules/User/Database/Migrations/2026_07_05_100001_add_public_profile_token_expires_at_to_users_table.php @@ -0,0 +1,35 @@ +timestamp('public_profile_token_expires_at')->nullable()->after('public_profile_token'); + }); + + $expiryDays = (int) config('user.public_profile_token_ttl_days', 90); + + User::withTrashed() + ->whereNotNull('public_profile_token') + ->whereNull('public_profile_token_expires_at') + ->cursor() + ->each(function (User $user) use ($expiryDays) { + $user->forceFill([ + 'public_profile_token_expires_at' => now()->addDays($expiryDays), + ])->saveQuietly(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('public_profile_token_expires_at'); + }); + } +}; diff --git a/be/Modules/User/Database/Migrations/2026_07_06_100000_add_onboarding_completed_at_to_users_table.php b/be/Modules/User/Database/Migrations/2026_07_06_100000_add_onboarding_completed_at_to_users_table.php new file mode 100644 index 0000000..9ef1af9 --- /dev/null +++ b/be/Modules/User/Database/Migrations/2026_07_06_100000_add_onboarding_completed_at_to_users_table.php @@ -0,0 +1,22 @@ +timestamp('onboarding_completed_at')->nullable()->after('birth_place'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('onboarding_completed_at'); + }); + } +}; diff --git a/be/Modules/User/Http/Controllers/MemberDigitalCardController.php b/be/Modules/User/Http/Controllers/MemberDigitalCardController.php new file mode 100644 index 0000000..c0dcabc --- /dev/null +++ b/be/Modules/User/Http/Controllers/MemberDigitalCardController.php @@ -0,0 +1,61 @@ +validate([ + 'side' => 'sometimes|in:depan,belakang', + ]); + + $user = $request->user(); + $side = $validated['side'] ?? 'depan'; + + if ($user->status !== 'active') { + return response()->json([ + 'success' => false, + 'message' => 'Kad digital hanya tersedia untuk anggota aktif.', + 'code' => 'digital_card_inactive', + 'data' => null, + ], 403); + } + + try { + $png = $this->memberDigitalCardService->renderPng($user, $side); + $memberNumber = $user->member_number ?: 'anggota'; + $filename = sprintf('kad-digital-%s-%s.png', $memberNumber, $side); + + return response($png, Response::HTTP_OK, [ + 'Content-Type' => 'image/png', + 'Content-Disposition' => 'attachment; filename="'.$filename.'"', + 'Cache-Control' => 'no-store, private', + ]); + } catch (\Throwable $exception) { + Log::error('Failed to render member digital card.', [ + 'user_id' => $user->id, + 'side' => $side, + 'message' => $exception->getMessage(), + ]); + + return response()->json([ + 'success' => false, + 'message' => 'Gagal menjana kad digital. Sila cuba lagi.', + 'code' => 'digital_card_render_failed', + 'data' => null, + ], 500); + } + } +} diff --git a/be/Modules/User/Http/Controllers/PublicMemberProfileController.php b/be/Modules/User/Http/Controllers/PublicMemberProfileController.php new file mode 100644 index 0000000..c25357e --- /dev/null +++ b/be/Modules/User/Http/Controllers/PublicMemberProfileController.php @@ -0,0 +1,37 @@ +publicMemberProfileService->findMemberByToken($token); + + if (! $member) { + return response()->json([ + 'success' => false, + 'message' => 'Anggota tidak dijumpai atau tidak sah.', + 'code' => 'public_profile_not_found', + 'data' => null, + ], 404); + } + + $member = $this->publicMemberProfileService->loadVerifiableRelations($member); + + return response()->json([ + 'success' => true, + 'message' => 'Anggota disahkan.', + 'data' => new PublicMemberProfileResource($member), + ]); + } +} diff --git a/be/Modules/User/Http/Controllers/UserController.php b/be/Modules/User/Http/Controllers/UserController.php index 2526ff6..3e3ffb3 100644 --- a/be/Modules/User/Http/Controllers/UserController.php +++ b/be/Modules/User/Http/Controllers/UserController.php @@ -127,6 +127,17 @@ class UserController extends BaseCrudController ]); } + public function completeOnboarding(Request $request): JsonResponse + { + $user = $this->userService->completeOnboarding($request->user()); + + return response()->json([ + 'success' => true, + 'data' => new UserResource($user), + 'message' => 'Onboarding completed.', + ]); + } + public function updatePassword(Request $request): JsonResponse { $user = $request->user(); diff --git a/be/Modules/User/Providers/UserServiceProvider.php b/be/Modules/User/Providers/UserServiceProvider.php index 9c28f9e..e9f7dba 100644 --- a/be/Modules/User/Providers/UserServiceProvider.php +++ b/be/Modules/User/Providers/UserServiceProvider.php @@ -5,6 +5,8 @@ namespace Modules\User\Providers; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Cache\RateLimiting\Limit; use Modules\Auth\Entities\User; use Modules\User\Policies\UserPolicy; use Modules\User\Entities\Address; @@ -34,6 +36,7 @@ class UserServiceProvider extends ServiceProvider */ public function boot(): void { + $this->registerRateLimiters(); $this->registerCommands(); $this->registerCommandSchedules(); $this->registerTranslations(); @@ -96,6 +99,13 @@ class UserServiceProvider extends ServiceProvider Gate::policy(Heir::class, HeirPolicy::class); } + protected function registerRateLimiters(): void + { + RateLimiter::for('public-member-profile', function ($request) { + return Limit::perMinute(60)->by($request->ip()); + }); + } + /** * Register commands in the format of Command::class */ diff --git a/be/Modules/User/Resources/Views/digital-card/back.blade.php b/be/Modules/User/Resources/Views/digital-card/back.blade.php new file mode 100644 index 0000000..fd0f376 --- /dev/null +++ b/be/Modules/User/Resources/Views/digital-card/back.blade.php @@ -0,0 +1,166 @@ + + + + + + + +
+
+
+ +
+
+ @if ($logoDataUri) + + @endif +
Belakang ยท Kod QR
+
+ +
+
+ Kod QR profil anggota +
+ +
+

Imbas untuk sahkan profil anggota MyKOPKB.

+ +
+
No. Anggota
+
{{ $memberNumber }}
+
+
+
+ + +
+
+ + diff --git a/be/Modules/User/Resources/Views/digital-card/front.blade.php b/be/Modules/User/Resources/Views/digital-card/front.blade.php new file mode 100644 index 0000000..9352c56 --- /dev/null +++ b/be/Modules/User/Resources/Views/digital-card/front.blade.php @@ -0,0 +1,214 @@ + + + + + + + +
+
+
+ +
+ @if ($avatarDataUri) + {{ $memberName }} + @else +
{{ $avatarInitials }}
+ @endif +
+ +
+
+ @if ($logoDataUri) + + @endif +
Kad Digital
+
+ +
+
+
No. Anggota
+
{{ $memberNumber }}
+
+ +
+
Unit
+
{{ $companyName }}
+
+
+ + +
+
+ + diff --git a/be/Modules/User/Routes/api.php b/be/Modules/User/Routes/api.php index 28c0793..c4fc38b 100644 --- a/be/Modules/User/Routes/api.php +++ b/be/Modules/User/Routes/api.php @@ -8,6 +8,14 @@ use Modules\User\Http\Controllers\EmploymentController; use Modules\User\Http\Controllers\BankController; use Modules\User\Http\Controllers\BankDetailController; use Modules\User\Http\Controllers\HeirController; +use Modules\User\Http\Controllers\MemberDigitalCardController; +use Modules\User\Http\Controllers\PublicMemberProfileController; + +Route::prefix('v1/public')->group(function () { + Route::middleware('throttle:public-member-profile') + ->get('members/{token}', [PublicMemberProfileController::class, 'show']) + ->name('user.public.member-profile'); +}); Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(function () { Route::get('users/deleted', [UserController::class, 'deletedIndex'])->name('users.deleted.index'); @@ -27,7 +35,10 @@ Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(funct // User profile management routes Route::post('/profile', [UserController::class, 'updateProfile']); + Route::post('/profile/onboarding/complete', [UserController::class, 'completeOnboarding']); Route::put('/profile/password', [UserController::class, 'updatePassword']); + Route::get('/profile/digital-card', [MemberDigitalCardController::class, 'download']) + ->name('profile.digital-card.download'); // Impersonation routes Route::get('/impersonate/take/{id}', [ImpersonateController::class, 'take'])->name('impersonate'); diff --git a/be/Modules/User/Services/MemberDigitalCardService.php b/be/Modules/User/Services/MemberDigitalCardService.php new file mode 100644 index 0000000..de68afd --- /dev/null +++ b/be/Modules/User/Services/MemberDigitalCardService.php @@ -0,0 +1,141 @@ +prepareViewData($user))->render(); + + return $this->makeBrowsershot($html)->screenshot(); + } + + /** + * @return array + */ + protected function prepareViewData(User $user): array + { + $user = app(PublicMemberProfileService::class)->loadVerifiableRelations($user); + $user->ensurePublicProfileToken(); + + $currentEmployment = $user->employments->firstWhere('is_current', true) + ?? $user->employments->first(); + + $logoPath = config('user.digital_card_logo_path'); + $profileUrl = rtrim(config('user.frontend_url'), '/').'/v/'.$user->public_profile_token; + + return [ + 'memberNumber' => $this->displayCardValue($user->member_number), + 'memberName' => $user->name ?: '-', + 'memberType' => $this->displayCardValue($user->member_type), + 'companyName' => $this->displayCardValue($currentEmployment?->company_name), + 'avatarDataUri' => $this->resolveStorageDataUri($user->image_url), + 'avatarInitials' => $this->avatarInitials($user->name), + 'logoDataUri' => $this->resolveFileDataUri($logoPath), + 'profileUrl' => $profileUrl, + 'qrDataUri' => $this->generateQrDataUri($profileUrl), + ]; + } + + protected function makeBrowsershot(string $html): Browsershot + { + $browsershot = Browsershot::html($html) + ->setNodeModulePath(config('browsershot.node_module_path')) + ->timeout(config('browsershot.timeout')) + ->windowSize(self::CARD_WIDTH, self::CARD_HEIGHT) + ->deviceScaleFactor(2) + ->showBackground(); + + if ($nodeBinary = config('browsershot.node_binary')) { + $browsershot->setNodeBinary($nodeBinary); + } + + if ($npmBinary = config('browsershot.npm_binary')) { + $browsershot->setNpmBinary($npmBinary); + } + + if ($chromePath = config('browsershot.chrome_path')) { + $browsershot->setChromePath($chromePath); + } + + if (config('browsershot.no_sandbox')) { + $browsershot->noSandbox(); + } + + return $browsershot; + } + + protected function generateQrDataUri(string $content): string + { + $renderer = new ImageRenderer( + new RendererStyle(220, 1), + new SvgImageBackEnd + ); + $writer = new Writer($renderer); + $svg = $writer->writeString($content); + + return 'data:image/svg+xml;base64,'.base64_encode($svg); + } + + protected function resolveFileDataUri(?string $path): ?string + { + if (empty($path) || ! file_exists($path)) { + return null; + } + + $mime = mime_content_type($path) ?: 'image/svg+xml'; + + return 'data:'.$mime.';base64,'.base64_encode(file_get_contents($path)); + } + + protected function resolveStorageDataUri(?string $path): ?string + { + if (blank($path)) { + return null; + } + + $fullPath = Storage::disk('public')->path($path); + + return $this->resolveFileDataUri($fullPath); + } + + protected function avatarInitials(?string $name): string + { + $trimmed = trim((string) $name); + + if ($trimmed === '') { + return '--'; + } + + return strtoupper(mb_substr($trimmed, 0, 2)); + } + + protected function displayCardValue(mixed $value): string + { + if ($value === null || $value === '') { + return '-'; + } + + $trimmed = trim((string) $value); + + return $trimmed !== '' ? $trimmed : '-'; + } +} diff --git a/be/Modules/User/Services/PublicMemberProfileService.php b/be/Modules/User/Services/PublicMemberProfileService.php new file mode 100644 index 0000000..2a0c468 --- /dev/null +++ b/be/Modules/User/Services/PublicMemberProfileService.php @@ -0,0 +1,25 @@ +where('public_profile_token', $token) + ->where('status', 'active') + ->first(); + } + + public function loadVerifiableRelations(User $member): User + { + $member->load(['employments' => function ($query) { + $query->orderByDesc('is_current')->orderByDesc('start_date'); + }]); + + return $member; + } +} diff --git a/be/Modules/User/Services/UserService.php b/be/Modules/User/Services/UserService.php index 90f20fb..0033e64 100644 --- a/be/Modules/User/Services/UserService.php +++ b/be/Modules/User/Services/UserService.php @@ -121,6 +121,17 @@ class UserService /** * @return array{}|array{error: string} */ + public function completeOnboarding(User $user): User + { + if ($user->onboarding_completed_at === null) { + $user->update(['onboarding_completed_at' => now()]); + } + + $user->load(['roles.permissions']); + + return $user; + } + public function updatePassword(User $user, string $currentPassword, string $newPassword): array { if (! Hash::check($currentPassword, $user->password)) { diff --git a/be/Modules/User/Transformers/PublicMemberProfileResource.php b/be/Modules/User/Transformers/PublicMemberProfileResource.php new file mode 100644 index 0000000..283f9d3 --- /dev/null +++ b/be/Modules/User/Transformers/PublicMemberProfileResource.php @@ -0,0 +1,26 @@ +employments->firstWhere('is_current', true) + ?? $this->employments->first(); + + return [ + 'name' => $this->name, + 'member_number' => $this->member_number, + 'member_type' => $this->member_type, + 'status' => $this->status, + 'image_url' => $this->image_url ? Storage::disk('public')->url($this->image_url) : null, + 'company_name' => $currentEmployment?->company_name, + 'verified_at' => now()->toIso8601String(), + ]; + } +} diff --git a/be/app/Services/LetterService.php b/be/app/Services/LetterService.php index ba27182..a7f9c81 100644 --- a/be/app/Services/LetterService.php +++ b/be/app/Services/LetterService.php @@ -56,11 +56,14 @@ class LetterService public function prepareViewData(array $overrides = []): array { $logoPath = $overrides['logoPath'] ?? config('mail.logo_path'); + $signaturePath = $overrides['signaturePath'] ?? config('letter.signature_path'); return array_merge([ 'letterLayout' => LetterLayout::resolve(), 'logoPath' => $logoPath, - 'logoUrl' => $this->resolveLogoDataUri($logoPath), + 'logoUrl' => $this->resolveImageDataUri($logoPath), + 'signaturePath' => $signaturePath, + 'signatureUrl' => $this->resolveImageDataUri($signaturePath), 'organizationAddress' => $overrides['organizationAddress'] ?? null, 'organizationContact' => $overrides['organizationContact'] ?? null, ], $overrides); @@ -97,14 +100,14 @@ class LetterService return $browsershot; } - protected function resolveLogoDataUri(?string $logoPath): ?string + protected function resolveImageDataUri(?string $path): ?string { - if (empty($logoPath) || ! file_exists($logoPath)) { + if (empty($path) || ! file_exists($path)) { return null; } - $mime = mime_content_type($logoPath) ?: 'image/svg+xml'; + $mime = mime_content_type($path) ?: 'image/svg+xml'; - return 'data:'.$mime.';base64,'.base64_encode(file_get_contents($logoPath)); + return 'data:'.$mime.';base64,'.base64_encode(file_get_contents($path)); } } diff --git a/be/config/letter.php b/be/config/letter.php index dad463b..4b232b4 100644 --- a/be/config/letter.php +++ b/be/config/letter.php @@ -82,4 +82,12 @@ return [ 'black' => '#000000', ], + /* + |-------------------------------------------------------------------------- + | Assets + |-------------------------------------------------------------------------- + */ + + 'signature_path' => resource_path('assets/signatures/signature.svg'), + ]; diff --git a/be/config/mail.php b/be/config/mail.php index c875aaf..3b53d7f 100644 --- a/be/config/mail.php +++ b/be/config/mail.php @@ -124,6 +124,6 @@ return [ | */ - 'logo_path' => env('MAIL_LOGO_PATH', public_path('images/logo/logo-kopkb-letterhead.svg')), + 'logo_path' => public_path('images/logo/logo.svg'), ]; diff --git a/be/database/migrations/0001_01_01_000002_create_jobs_table.php b/be/database/migrations/0001_01_01_000002_create_jobs_table.php index e8ec6ec..425e705 100644 --- a/be/database/migrations/0001_01_01_000002_create_jobs_table.php +++ b/be/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -12,7 +12,7 @@ return new class extends Migration public function up(): void { Schema::create('jobs', function (Blueprint $table) { - $table->uuid('id')->primary(); + $table->id(); $table->string('queue')->index(); $table->longText('payload'); $table->unsignedTinyInteger('attempts'); @@ -22,7 +22,7 @@ return new class extends Migration }); Schema::create('job_batches', function (Blueprint $table) { - $table->uuid('id')->primary(); + $table->string('id')->primary(); $table->string('name'); $table->integer('total_jobs'); $table->integer('pending_jobs'); @@ -35,7 +35,7 @@ return new class extends Migration }); Schema::create('failed_jobs', function (Blueprint $table) { - $table->uuid('id')->primary(); + $table->id(); $table->string('uuid')->unique(); $table->text('connection'); $table->text('queue'); diff --git a/be/public/images/logo/logo-kopkb.svg b/be/public/images/logo/logo-kopkb.svg deleted file mode 100644 index ef88ccb..0000000 --- a/be/public/images/logo/logo-kopkb.svg +++ /dev/null @@ -1,418 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/be/public/images/logo/logo.svg b/be/public/images/logo/logo.svg new file mode 100644 index 0000000..5498014 --- /dev/null +++ b/be/public/images/logo/logo.svg @@ -0,0 +1,649 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/be/resources/assets/signatures/signature.svg b/be/resources/assets/signatures/signature.svg new file mode 100644 index 0000000..aa1cf66 --- /dev/null +++ b/be/resources/assets/signatures/signature.svg @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/be/resources/views/pdf/layouts/letter.blade.php b/be/resources/views/pdf/layouts/letter.blade.php index e649a3a..35f34c6 100644 --- a/be/resources/views/pdf/layouts/letter.blade.php +++ b/be/resources/views/pdf/layouts/letter.blade.php @@ -239,6 +239,12 @@ height: var(--letter-signature-space-height); } + .signature-space-img { + height: var(--letter-signature-space-height); + width: auto; + display: block; + } + .signatory-name { font-weight: bold; text-transform: uppercase; diff --git a/be/resources/views/pdf/preview-approved-membership.blade.php b/be/resources/views/pdf/preview-approved-membership.blade.php index fd2819b..620b66f 100644 --- a/be/resources/views/pdf/preview-approved-membership.blade.php +++ b/be/resources/views/pdf/preview-approved-membership.blade.php @@ -27,19 +27,19 @@

2. Sukacita dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi - Permodalan Kelantan Berhad(KoPKB) telah {{ membership_applications.board_result }} dalam {{ User manual fills before generate }} + Permodalan Kelantan Berhad(KoPKB) telah dalam

3. Pihak Koperasi akan membuat potongan gaji melalui majikan tuan sebagaiman ketetapan berikut:-

  • Saham
    • -
    • Potongan sebanyak RM 84.00 {{(6 months auto calculate)}}
    • -
    • Potongan sebanyak RM {{ membership_application_applicants.stock_monthly_contribution }} sehingga Syer Maksima
    • +
    • Potongan sebanyak RM 84.00
    • +
    • Potongan sebanyak RM sehingga Syer Maksima
  • Yuran
    • -
    • Potongan sebanyak RM {{ membership_application_applicants.fee_monthly_contribution }} setiap bulan
    • +
    • Potongan sebanyak RM setiap bulan
  • Fi Masuk
    • @@ -65,7 +65,11 @@ @section('letter-signature')
      Dengan hormatnya,
      KOPERASI PERMODALAN KELANTAN BERHAD
      -
      +
      + @if (! empty($signatureUrl)) + + @endif +
      MUHAMMAD NAFIS BIN ZAINUDDIN
      Pengurus Besar
      b/p Setiausaha
      diff --git a/fe/package.json b/fe/package.json index 6d389fe..b54979a 100644 --- a/fe/package.json +++ b/fe/package.json @@ -57,6 +57,7 @@ "lodash": "^4.17.21", "maplibre-gl": "^5.18.0", "pinia": "^3.0.4", + "qrcode": "^1.5.4", "sweetalert2": "^11.26.25", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.1.18", @@ -71,6 +72,7 @@ "@types/jsdom": "^27.0.0", "@types/lodash": "^4.17.21", "@types/node": "^24.10.4", + "@types/qrcode": "^1.5.6", "@vitejs/plugin-vue": "^6.0.3", "@vitest/eslint-plugin": "^1.6.4", "@vue/eslint-config-prettier": "^10.2.0", diff --git a/fe/pnpm-lock.yaml b/fe/pnpm-lock.yaml index ef1bc00..c4f6589 100644 --- a/fe/pnpm-lock.yaml +++ b/fe/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: pinia: specifier: ^3.0.4 version: 3.0.4(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3)) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 sweetalert2: specifier: ^11.26.25 version: 11.26.25 @@ -156,6 +159,9 @@ importers: '@types/node': specifier: ^24.10.4 version: 24.12.4 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@vitejs/plugin-vue': specifier: ^6.0.3 version: 6.0.7(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.35(typescript@5.9.3)) @@ -1041,6 +1047,9 @@ packages: '@types/pako@2.0.4': resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/raf@3.4.3': resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} @@ -1588,6 +1597,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} @@ -1637,6 +1650,9 @@ packages: resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} engines: {node: '>=20'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1758,6 +1774,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -1784,6 +1804,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dompurify@3.4.7: resolution: {integrity: sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==} @@ -2055,6 +2078,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2109,6 +2136,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.6.0: resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} @@ -2501,6 +2532,10 @@ packages: resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} engines: {node: '>=20.0.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2679,14 +2714,26 @@ packages: ospath@1.2.2: resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -2769,6 +2816,10 @@ packages: typescript: optional: true + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss-selector-parser@7.1.1: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} @@ -2833,6 +2884,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.15.2: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} @@ -2856,10 +2912,17 @@ packages: request-progress@3.0.0: resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2916,6 +2979,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3393,6 +3459,9 @@ packages: resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} engines: {node: '>=20'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3420,6 +3489,10 @@ packages: resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} engines: {node: '>=0.8'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3467,9 +3540,20 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yauzl@3.3.2: resolution: {integrity: sha512-Md9ankxxN23wncAN8s7+Tn3Co52zLUPMtnrLAbVCnfG5d2tKBFfmygYSgXlqFgXObtzIgqkx7aNgDBpso9+4qA==} engines: {node: '>=12'} @@ -4215,6 +4299,10 @@ snapshots: '@types/pako@2.0.4': {} + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 24.12.4 + '@types/raf@3.4.3': optional: true @@ -4978,6 +5066,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001793: {} canvg@3.0.11: @@ -5033,6 +5123,12 @@ snapshots: slice-ansi: 8.0.0 string-width: 8.2.1 + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + clsx@2.1.1: {} codepage@1.15.0: {} @@ -5172,6 +5268,8 @@ snapshots: optionalDependencies: supports-color: 8.1.1 + decamelize@1.2.0: {} + decimal.js@10.6.0: {} deep-is@0.1.4: {} @@ -5189,6 +5287,8 @@ snapshots: detect-libc@2.1.2: {} + dijkstrajs@1.0.3: {} + dompurify@3.4.7: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -5505,6 +5605,11 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -5554,6 +5659,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-east-asian-width@1.6.0: {} get-intrinsic@1.3.0: @@ -5928,6 +6035,10 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.2 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -6107,14 +6218,24 @@ snapshots: ospath@1.2.2: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} pako@2.1.0: {} @@ -6177,6 +6298,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + pngjs@5.0.0: {} + postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 @@ -6225,6 +6348,12 @@ snapshots: punycode@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.15.2: dependencies: side-channel: 1.1.0 @@ -6250,8 +6379,12 @@ snapshots: dependencies: throttleit: 1.0.1 + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resolve-from@4.0.0: {} resolve-protobuf-schema@2.1.0: @@ -6323,6 +6456,8 @@ snapshots: semver@7.8.1: {} + set-blocking@2.0.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -6781,6 +6916,8 @@ snapshots: tr46: 6.0.0 webidl-conversions: 8.0.1 + which-module@2.0.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -6800,6 +6937,12 @@ snapshots: word@0.3.0: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -6843,8 +6986,29 @@ snapshots: xmlchars@2.2.0: {} + y18n@4.0.3: {} + yallist@3.1.1: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yauzl@3.3.2: dependencies: pend: 1.2.0 diff --git a/fe/src/.DS_Store b/fe/src/.DS_Store index 0c5a35f..16ce73f 100644 Binary files a/fe/src/.DS_Store and b/fe/src/.DS_Store differ diff --git a/fe/src/assets/images/logo-kopkb.svg b/fe/src/assets/images/logo-kopkb.svg deleted file mode 100644 index ef88ccb..0000000 --- a/fe/src/assets/images/logo-kopkb.svg +++ /dev/null @@ -1,418 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/fe/src/assets/images/logo.svg b/fe/src/assets/images/logo.svg index 28c5228..5498014 100644 --- a/fe/src/assets/images/logo.svg +++ b/fe/src/assets/images/logo.svg @@ -1,9 +1,649 @@ - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fe/src/assets/images/logonew-mykopkb.svg b/fe/src/assets/images/logonew-mykopkb.svg deleted file mode 100644 index ce2be41..0000000 --- a/fe/src/assets/images/logonew-mykopkb.svg +++ /dev/null @@ -1,185 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - SISTEM INFORMASI KOPERASI ONLINE - My - - - diff --git a/fe/src/components/theme-switcher/ThemeSwitcher.vue b/fe/src/components/theme-switcher/ThemeSwitcher.vue index aabd2e6..68d599e 100644 --- a/fe/src/components/theme-switcher/ThemeSwitcher.vue +++ b/fe/src/components/theme-switcher/ThemeSwitcher.vue @@ -3,6 +3,7 @@ import { SheetRoot, SheetContent } from '@/components/ui/sheet' import { Lucide } from '@/components/ui/lucide' import { useColorSchemeStore, type ColorSchemes } from '@/stores/color-scheme' import { useDarkModeStore } from '@/stores/dark-mode' +import { applyColorSchemeClass, applyDarkModeClass } from '@/utils/applyAppearance' import { ref } from 'vue' const themeSwitcherSheet = ref(false) @@ -10,30 +11,19 @@ const setThemeSwitcherSheet = (value: boolean) => { themeSwitcherSheet.value = value } -const setColorSchemeClass = () => { - const el = document.querySelectorAll('html')[0] - el?.setAttribute('data-theme', useColorSchemeStore().colorSchemeValue) - if (useDarkModeStore().darkModeValue) el?.classList.add('dark') -} const colorSchemeStore = useColorSchemeStore() const switchColorScheme = (colorScheme: ColorSchemes) => { useColorSchemeStore().setColorScheme(colorScheme) - setColorSchemeClass() + applyColorSchemeClass() setThemeSwitcherSheet(false) } -setColorSchemeClass() -const setDarkModeClass = () => { - const el = document.querySelectorAll('html')[0] - useDarkModeStore().darkModeValue ? el?.classList.add('dark') : el?.classList.remove('dark') -} const darkModeStore = useDarkModeStore() const switchDarkMode = (darkMode: boolean) => { useDarkModeStore().setDarkMode(darkMode) - setDarkModeClass() + applyDarkModeClass() setThemeSwitcherSheet(false) } -setDarkModeClass() const colorSchemes: Array = ['default', '1', '2', '3', '4', '5'] diff --git a/fe/src/components/ui/pagination/PaginationRoot.vue b/fe/src/components/ui/pagination/PaginationRoot.vue index 4e01884..a1c85e9 100644 --- a/fe/src/components/ui/pagination/PaginationRoot.vue +++ b/fe/src/components/ui/pagination/PaginationRoot.vue @@ -7,30 +7,28 @@ import { normalizeProps, useMachine } from "@zag-js/vue"; import { computed, provide } from "vue"; import { paginationRoot } from "@mykopkb/core/styles/pagination.styles"; -const { - class: className, - asChild = false, - count, - pageSize, - siblingCount, - ...props -} = defineProps & { class?: string; asChild?: boolean }>(); +const props = defineProps & { class?: string; asChild?: boolean }>(); -const service = useMachine(pagination.machine, { - ...props, - count, - pageSize, - siblingCount, - id: crypto.randomUUID(), +const paginationId = crypto.randomUUID(); + +const machineProps = computed(() => { + const { class: _class, asChild: _asChild, id: _id, ...rest } = props; + + return { + ...rest, + id: paginationId, + }; }); + +const service = useMachine(pagination.machine, machineProps); const api = computed(() => pagination.connect(service, normalizeProps)); provide("paginationApi", api);
      diff --git a/fe/src/components/ui/styles/pagination.styles.ts b/fe/src/components/ui/styles/pagination.styles.ts index c5c3b28..1e0683e 100644 --- a/fe/src/components/ui/styles/pagination.styles.ts +++ b/fe/src/components/ui/styles/pagination.styles.ts @@ -1,7 +1,7 @@ export const paginationRoot = 'flex gap-1' export const paginationItem = [ - 'h-10 px-4 py-2 inline-flex items-center justify-center rounded-xl cursor-pointer hover:bg-foreground/5', - 'data-[selected]:border data-[selected]:bg-background data-[selected]:border-foreground/10 data-[selected]:font-medium data-[selected]:shadow-md/5', + 'h-10 min-w-10 px-3 py-2 inline-flex items-center justify-center rounded-xl cursor-pointer text-foreground hover:bg-foreground/5', + 'data-[selected]:border data-[selected]:bg-primary/10 data-[selected]:border-primary/30 data-[selected]:text-primary data-[selected]:font-semibold data-[selected]:shadow-md/5', 'data-[disabled]:opacity-70', ] export const paginationPrevTrigger = paginationItem diff --git a/fe/src/components/ui/usage/DataTable.vue b/fe/src/components/ui/usage/DataTable.vue index 3db6a24..5f63286 100644 --- a/fe/src/components/ui/usage/DataTable.vue +++ b/fe/src/components/ui/usage/DataTable.vue @@ -94,25 +94,29 @@ const props = withDefaults(defineProps<{ items: TableItem[]; loading?: boolean; itemsPerPage?: number; - page?: number; itemKey?: string; pagination?: PaginationData | null; showPagination?: boolean; currentSort?: SortConfig[]; exportable?: boolean; exportFileName?: string; + /** When true, table body scrolls inside a max-height box instead of growing the page. */ + scrollable?: boolean; + /** Max height of the scrollable table area (CSS value). */ + maxHeight?: string; }>(), { items: () => [], headers: () => [], loading: false, itemsPerPage: 10, - page: 1, itemKey: 'id', pagination: null, showPagination: false, currentSort: () => [], exportable: false, - exportFileName: 'table-data' + exportFileName: 'table-data', + scrollable: true, + maxHeight: 'min(70dvh, 640px)', }); // Define model props @@ -122,7 +126,6 @@ const itemsPerPageModel = defineModel('items-per-page'); // Emits const emit = defineEmits<{ 'update:sort-by': [value: SortConfig[]]; - 'update:page': [value: number]; 'update:items-per-page': [value: number]; 'update:column-widths': [value: Record]; 'export-error': [error: Error]; @@ -449,7 +452,6 @@ const setItemsPerPageValue = (details: { value: string[] }) => { const handlePageChange = (details: { page: number }) => { pageModel.value = details.page; - emit('update:page', details.page); }; const getItemKey = (item: TableItem, index: number) => { @@ -461,16 +463,18 @@ const currentPage = computed( () => pageModel.value ?? props.pagination?.current_page ?? 1, ); -const isFirstPage = computed(() => currentPage.value <= 1); - -const isLastPage = computed( - () => currentPage.value >= (props.pagination?.last_page ?? 1), -); - const slots = useSlots(); const showTableToolbar = computed(() => props.exportable || !!slots.toolbar); +const scrollContainerStyle = computed(() => { + if (!props.scrollable) { + return undefined; + } + + return { maxHeight: props.maxHeight }; +}); + const recordSummary = computed(() => { if (!props.pagination) return null; @@ -743,12 +747,20 @@ watch(sortBy, (newSort) => { -
      - +
      +
      {
      - - - + + + - - + + @@ -881,8 +886,8 @@ watch(sortBy, (newSort) => {