From 4dcd135c32de3e50e91f36abf6efb222a32e308f Mon Sep 17 00:00:00 2001 From: ISMAIL MASSERAN Date: Thu, 2 Jul 2026 10:08:19 +0800 Subject: [PATCH] DONE: generate letter after the membership-application is completed --- be/.env.example | 3 +- .../Entities/MembershipApplication.php | 14 ++ .../MembershipApplicationController.php | 37 +++- .../Requests/GenerateResultLetterRequest.php | 34 ++++ .../Policies/MembershipApplicationPolicy.php | 8 + .../MembershipApplicationServiceProvider.php | 2 +- .../MembershipApplicationRepository.php | 9 +- .../views/pdf/approved-letter.blade.php | 81 +++++++- .../MembershipApplication/Routes/api.php | 2 + .../Services/MembershipApplicationService.php | 102 ++++++++++ .../MembershipApplicationListResource.php | 5 + be/app/Services/DocumentService.php | 28 ++- be/app/Services/LetterService.php | 10 +- be/docker-compose.yml | 3 + be/package.json | 3 +- .../pdf/preview-approved-membership.blade.php | 10 +- .../pages/MembershipApplicationDetail.vue | 155 +++++++++++++++- .../pages/MembershipApplicationList.vue | 174 +++++++++++++++++- .../membership-application.service.ts | 20 +- .../types/membership-application.types.ts | 17 ++ 20 files changed, 679 insertions(+), 38 deletions(-) create mode 100644 be/Modules/MembershipApplication/Http/Requests/GenerateResultLetterRequest.php diff --git a/be/.env.example b/be/.env.example index 30da688..f0a4c9c 100644 --- a/be/.env.example +++ b/be/.env.example @@ -67,11 +67,12 @@ AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" # Browsershot (PDF generation via headless Chrome + Puppeteer in be/) +# Required in Docker/Sail. After first sail up: sail npm install && sail npx puppeteer browsers install chrome-headless-shell +BROWSERSHOT_NO_SANDBOX=true # BROWSERSHOT_NODE_BINARY= # BROWSERSHOT_NPM_BINARY= # BROWSERSHOT_NODE_MODULE_PATH= # BROWSERSHOT_CHROME_PATH= -# BROWSERSHOT_NO_SANDBOX=false # BROWSERSHOT_TIMEOUT=60 # HttpOnly Sanctum token cookie (set on api.* domain) diff --git a/be/Modules/MembershipApplication/Entities/MembershipApplication.php b/be/Modules/MembershipApplication/Entities/MembershipApplication.php index 57409a1..9464541 100644 --- a/be/Modules/MembershipApplication/Entities/MembershipApplication.php +++ b/be/Modules/MembershipApplication/Entities/MembershipApplication.php @@ -2,11 +2,13 @@ namespace Modules\MembershipApplication\Entities; +use App\Models\Document; use App\Traits\HasDocuments; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\Relations\MorphOne; use Modules\Auth\Entities\User; use Modules\MembershipApplication\Enums\ApplicationStatus; @@ -14,6 +16,12 @@ class MembershipApplication extends Model { use HasDocuments, HasUuids; + public const RESULT_LETTER_DOCUMENT_TYPE = 'result_letter'; + + public const MINIMUM_SHARE_CAPITAL = 500; + + public const SHARE_INSTALLMENT_MONTHS = 6; + protected $table = 'membership_applications'; /** @@ -65,4 +73,10 @@ class MembershipApplication extends Model { return $this->belongsTo(User::class); } + + public function resultLetterDocument(): MorphOne + { + return $this->morphOne(Document::class, 'documentable') + ->where('type', self::RESULT_LETTER_DOCUMENT_TYPE); + } } diff --git a/be/Modules/MembershipApplication/Http/Controllers/MembershipApplicationController.php b/be/Modules/MembershipApplication/Http/Controllers/MembershipApplicationController.php index 2a842f6..006de7b 100644 --- a/be/Modules/MembershipApplication/Http/Controllers/MembershipApplicationController.php +++ b/be/Modules/MembershipApplication/Http/Controllers/MembershipApplicationController.php @@ -17,10 +17,12 @@ 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\GenerateResultLetterRequest; use Modules\MembershipApplication\Http\Requests\ManagementReviewRequest; use Modules\MembershipApplication\Http\Requests\UpdateMembershipApplicationRequest; use Modules\MembershipApplication\Http\Requests\UploadMembershipApplicationDocumentRequest; use Modules\MembershipApplication\Services\MembershipApplicationService; +use Modules\MembershipApplication\Transformers\MembershipApplicationDocumentResource; use Modules\MembershipApplication\Transformers\MembershipApplicationListResource; use Modules\MembershipApplication\Transformers\MembershipApplicationResource; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -243,6 +245,29 @@ class MembershipApplicationController extends Controller return $this->documentService->downloadDocument($document->id); } + public function generateResultLetter(GenerateResultLetterRequest $request, MembershipApplication $membershipApplication,): JsonResponse { + $this->authorize('generateResultLetter', $membershipApplication); + + $application = $this->membershipApplicationService->generateResultLetter( + $membershipApplication, + $request->validated('board_meeting_reference'), + ); + + $resultLetter = $application->documents + ->firstWhere('type', MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE); + + return response()->json([ + 'success' => true, + 'message' => 'Surat keputusan berjaya dijana.', + 'data' => [ + 'application' => new MembershipApplicationResource($application), + 'document' => $resultLetter + ? new MembershipApplicationDocumentResource($resultLetter) + : null, + ], + ]); + } + public function approvedLetter(Request $request, MembershipApplication $membershipApplication): Response { $this->authorize('view', $membershipApplication); @@ -253,10 +278,18 @@ class MembershipApplicationController extends Controller abort(404, 'Permohonan tidak dijumpai.'); } + $boardMeetingReference = (string) $request->query( + 'board_meeting_reference', + 'Mesyuarat Lembaga', + ); + $view = 'membershipapplication::pdf.approved-letter'; - $data = ['application' => $application]; + $data = $this->membershipApplicationService->getResultLetterViewData( + $application, + $boardMeetingReference, + ); $format = $request->query('format', 'html'); - $filename = 'surat-kelulusan-'.$application->application_number.'.pdf'; + $filename = 'surat-keputusan-'.$application->application_number.'.pdf'; if ($format === 'pdf') { return $this->letterService->pdfResponse($view, $data, $filename); diff --git a/be/Modules/MembershipApplication/Http/Requests/GenerateResultLetterRequest.php b/be/Modules/MembershipApplication/Http/Requests/GenerateResultLetterRequest.php new file mode 100644 index 0000000..72aa39c --- /dev/null +++ b/be/Modules/MembershipApplication/Http/Requests/GenerateResultLetterRequest.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/Policies/MembershipApplicationPolicy.php b/be/Modules/MembershipApplication/Policies/MembershipApplicationPolicy.php index 9ce7624..764e7f7 100644 --- a/be/Modules/MembershipApplication/Policies/MembershipApplicationPolicy.php +++ b/be/Modules/MembershipApplication/Policies/MembershipApplicationPolicy.php @@ -65,6 +65,14 @@ class MembershipApplicationPolicy return $user->hasPermissionTo('tetapkan pencadang penyokong'); } + /** + * Generate the result letter PDF for a completed application. + */ + public function generateResultLetter($user, ?MembershipApplication $membershipApplication = null): bool + { + return $user->hasPermissionTo('jana surat keputusan keahlian'); + } + /** * Admin: edit application details before completion. */ diff --git a/be/Modules/MembershipApplication/Providers/MembershipApplicationServiceProvider.php b/be/Modules/MembershipApplication/Providers/MembershipApplicationServiceProvider.php index 26c48fc..bef9c73 100644 --- a/be/Modules/MembershipApplication/Providers/MembershipApplicationServiceProvider.php +++ b/be/Modules/MembershipApplication/Providers/MembershipApplicationServiceProvider.php @@ -155,7 +155,7 @@ class MembershipApplicationServiceProvider extends ServiceProvider public function registerViews(): void { $viewPath = resource_path('views/modules/'.$this->nameLower); - $sourcePath = module_path($this->name, 'Resources/Views'); + $sourcePath = module_path($this->name, 'Resources/views'); $this->publishes([$sourcePath => $viewPath], ['views', $this->nameLower.'-module-views']); diff --git a/be/Modules/MembershipApplication/Repositories/MembershipApplicationRepository.php b/be/Modules/MembershipApplication/Repositories/MembershipApplicationRepository.php index 9e3d1fd..679ef22 100644 --- a/be/Modules/MembershipApplication/Repositories/MembershipApplicationRepository.php +++ b/be/Modules/MembershipApplication/Repositories/MembershipApplicationRepository.php @@ -46,7 +46,14 @@ class MembershipApplicationRepository implements MembershipApplicationRepository $sortOrder = strtolower($sortOrder) === 'asc' ? 'asc' : 'desc'; $query = MembershipApplication::query() - ->with(['applicant:id,membership_application_id,name,email,ic_number']) + ->with([ + 'applicant:id,membership_application_id,name,email,ic_number', + 'resultLetterDocument:id,documentable_type,documentable_id,name,type,mime_type,file_size', + ]) + ->withCount([ + 'documents as result_letter_count' => fn ($documentQuery) => $documentQuery + ->where('type', MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE), + ]) ->orderBy($sortBy, $sortOrder); if ($status !== null && $status !== '') { 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 3358ce7..cdfc055 100644 --- a/be/Modules/MembershipApplication/Resources/views/pdf/approved-letter.blade.php +++ b/be/Modules/MembershipApplication/Resources/views/pdf/approved-letter.blade.php @@ -1,12 +1,73 @@ -{{-- - Approval letter — extends the shared base layout. - Fill in sections below when the final design is ready. ---}} @extends('pdf.layouts.letter') -{{-- @section('letter-reference', $application->application_number) --}} -{{-- @section('letter-date', ...) --}} -{{-- @section('letter-recipient') ... @endsection --}} -{{-- @section('letter-subject') PERKARA: ... @endsection --}} -{{-- @section('letter-body') ... @endsection --}} -{{-- @section('letter-signature') ... @endsection --}} +@section('letter-title', 'Surat') + +@section('letter-reference', $application->application_number) + +@section('letter-date', now()->translatedFormat('d F Y')) + +@section('letter-recipient') + {{ $applicant->name }}
+ {!! nl2br(e($applicant->address)) !!}
+ {{ $applicant->postcode }} +@endsection + +@section('letter-subject') + {{ $letterSubject }} +@endsection + +@section('letter-body') +

+ Dengan segala hormatnya saya merujuk kepada perkara di atas. +

+

+ 2. {{ $openingTone }} dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi + Permodalan Kelantan Berhad (KoPKB) telah {{ $boardResultLabel }} dalam {{ $boardMeetingReference }}. +

+ + @if ($isPassed) +

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

+

+

+ 4. Untuk makluman tuan, kelulusan keanggotaan ini adalah bersyarat di mana tuan dikehendaki + menjelaskan modal syer minimum sebanyak RM{{ $minimumShareCapital }}. + Bayaran tersebut perlu dibuat secara ansuran melalui potongan gaji dalam tempoh enam (6) bulan. + Pemenuhan syarat ini adalah bagi melayakkan tuan memperoleh hak, kewajipan dan liabiliti sebagai anggota KoPKB + sebagaimana terkandung di dalam Undang-Undang Kecil (UUK), Perkara 59 yang telah didaftarkan pada 11 September 2024. + Manakala had maksima modal syer adalah sebanyak RM5,000.00. +

+ @else +

+ 3. Oleh yang demikian, permohonan tuan untuk menjadi anggota KoPKB tidak dapat diluluskan pada masa ini. +

+ @endif +@endsection + +@section('letter-closing') + Sekian, terima kasih. +@endsection + +@section('letter-signature') +
Dengan hormatnya,
+
KOPERASI PERMODALAN KELANTAN BERHAD
+
+
MUHAMMAD NAFIS BIN ZAINUDDIN
+
Pengurus Besar
+
b/p Setiausaha
+@endsection diff --git a/be/Modules/MembershipApplication/Routes/api.php b/be/Modules/MembershipApplication/Routes/api.php index 7989d78..5259d36 100644 --- a/be/Modules/MembershipApplication/Routes/api.php +++ b/be/Modules/MembershipApplication/Routes/api.php @@ -43,4 +43,6 @@ Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(funct // approved letter preview (html or pdf) Route::get('membership-applications/{membershipApplication}/approved-letter', [MembershipApplicationController::class, 'approvedLetter'])->name('membership-application.approved-letter'); + // generate and store result letter (one-time) + Route::post('membership-applications/{membershipApplication}/result-letter', [MembershipApplicationController::class, 'generateResultLetter'])->name('membership-application.generate-result-letter'); }); diff --git a/be/Modules/MembershipApplication/Services/MembershipApplicationService.php b/be/Modules/MembershipApplication/Services/MembershipApplicationService.php index 7cb0712..10c6284 100644 --- a/be/Modules/MembershipApplication/Services/MembershipApplicationService.php +++ b/be/Modules/MembershipApplication/Services/MembershipApplicationService.php @@ -3,6 +3,7 @@ namespace Modules\MembershipApplication\Services; use App\Services\DocumentService; +use App\Services\LetterService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\DB; @@ -30,6 +31,7 @@ class MembershipApplicationService public function __construct( protected MembershipApplicationRepositoryInterface $repository, protected DocumentService $documentService, + protected LetterService $letterService, ) {} /** @@ -301,6 +303,106 @@ class MembershipApplicationService ]; } + public function generateResultLetter(MembershipApplication $application, string $boardMeetingReference,): MembershipApplication { + if ($application->status !== ApplicationStatus::Completed) { + throw ValidationException::withMessages([ + 'status' => ['Surat keputusan hanya boleh dijana untuk permohonan yang telah selesai.'], + ]); + } + + if (! in_array($application->board_result, [BoardResult::Pass->value, BoardResult::Fail->value], true)) { + throw ValidationException::withMessages([ + 'board_result' => ['Keputusan lembaga belum ditetapkan.'], + ]); + } + + return DB::transaction(function () use ($application, $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, + ); + + return $this->repository->findByIdWithRelations($application->id); + }); + } + + /** + * @return array + */ + public function getResultLetterViewData( + MembershipApplication $application, + string $boardMeetingReference, + ): array { + $application = $this->repository->findByIdWithRelations($application->id); + + if (! $application || ! $application->applicant) { + throw ValidationException::withMessages([ + 'application' => ['Maklumat pemohon tidak lengkap.'], + ]); + } + + return $this->buildResultLetterViewData($application, $boardMeetingReference); + } + + /** + * @return array + */ + protected function buildResultLetterViewData( + MembershipApplication $application, + string $boardMeetingReference, + ): array { + $applicant = $application->applicant; + $isPassed = $application->board_result === BoardResult::Pass->value; + $monthlyShareInstallment = number_format( + MembershipApplication::MINIMUM_SHARE_CAPITAL / MembershipApplication::SHARE_INSTALLMENT_MONTHS, + 2, + '.', + '', + ); + + return [ + 'application' => $application, + 'applicant' => $applicant, + 'boardMeetingReference' => $boardMeetingReference, + 'isPassed' => $isPassed, + 'boardResultLabel' => $isPassed ? 'lulus' : 'gagal', + 'openingTone' => $isPassed ? 'Sukacita' : 'Dukacita', + 'letterSubject' => $isPassed + ? 'KELULUSAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD' + : 'KEPUTUSAN PERMOHONAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD', + 'monthlyShareInstallment' => $monthlyShareInstallment, + 'stockMonthlyContribution' => number_format((float) $applicant->stock_monthly_contribution, 2, '.', ''), + 'feeMonthlyContribution' => number_format((float) $applicant->fee_monthly_contribution, 2, '.', ''), + 'shareInstallmentMonths' => MembershipApplication::SHARE_INSTALLMENT_MONTHS, + 'minimumShareCapital' => number_format(MembershipApplication::MINIMUM_SHARE_CAPITAL, 2, '.', ''), + ]; + } + protected function createMemberFromApplication(MembershipApplication $application): User { $application->loadMissing(['applicant', 'heirs']); diff --git a/be/Modules/MembershipApplication/Transformers/MembershipApplicationListResource.php b/be/Modules/MembershipApplication/Transformers/MembershipApplicationListResource.php index eafacb6..161060d 100644 --- a/be/Modules/MembershipApplication/Transformers/MembershipApplicationListResource.php +++ b/be/Modules/MembershipApplication/Transformers/MembershipApplicationListResource.php @@ -15,6 +15,11 @@ class MembershipApplicationListResource extends JsonResource 'status' => $this->status?->value ?? $this->status, 'board_result' => $this->board_result ?: null, 'submitted_at' => $this->submitted_at, + 'has_result_letter' => ($this->result_letter_count ?? 0) > 0, + 'result_letter_document' => $this->when( + ($this->result_letter_count ?? 0) > 0 && $this->relationLoaded('resultLetterDocument') && $this->resultLetterDocument, + fn () => new MembershipApplicationDocumentResource($this->resultLetterDocument), + ), 'applicant' => $this->whenLoaded('applicant', fn () => [ 'name' => $this->applicant->name, 'email' => $this->applicant->email, diff --git a/be/app/Services/DocumentService.php b/be/app/Services/DocumentService.php index 4b78240..a69d239 100644 --- a/be/app/Services/DocumentService.php +++ b/be/app/Services/DocumentService.php @@ -13,12 +13,7 @@ class DocumentService /** * Upload a document for any model. */ - public function uploadDocument( - Model $model, - UploadedFile $file, - string $documentType = 'general', - ?string $description = null - ): Document { + public function uploadDocument(Model $model, UploadedFile $file, string $documentType = 'general', ?string $description = null): Document { $fileName = time().'_'.$file->getClientOriginalName(); $folderName = strtolower(class_basename($model)); $filePath = $file->storeAs("documents/{$folderName}", $fileName, Document::STORAGE_DISK); @@ -34,6 +29,27 @@ class DocumentService ]); } + /** + * Store generated file contents (e.g. PDF) for a model. + */ + public function storeGeneratedDocument(Model $model, string $contents, string $fileName, string $documentType = 'general', string $mimeType = 'application/pdf', ?string $description = null,): Document { + $folderName = strtolower(class_basename($model)); + $storedFileName = time().'_'.$fileName; + $filePath = "documents/{$folderName}/{$storedFileName}"; + + Storage::disk(Document::STORAGE_DISK)->put($filePath, $contents); + + return $model->documents()->create([ + 'name' => $fileName, + 'path' => $filePath, + 'file_size' => strlen($contents), + 'mime_type' => $mimeType, + 'type' => $documentType, + 'description' => $description, + 'uploaded_by' => auth()->id(), + ]); + } + /** * Delete a document. */ diff --git a/be/app/Services/LetterService.php b/be/app/Services/LetterService.php index efc6931..ba27182 100644 --- a/be/app/Services/LetterService.php +++ b/be/app/Services/LetterService.php @@ -33,7 +33,7 @@ class LetterService */ public function pdfResponse(string $view, array $data = [], string $filename = 'letter.pdf'): Response { - $pdf = $this->makeBrowsershot($this->renderHtml($view, $data))->pdf(); + $pdf = $this->renderPdf($view, $data); return response($pdf, SymfonyResponse::HTTP_OK, [ 'Content-Type' => 'application/pdf', @@ -41,6 +41,14 @@ class LetterService ]); } + /** + * @param array $data + */ + public function renderPdf(string $view, array $data = []): string + { + return $this->makeBrowsershot($this->renderHtml($view, $data))->pdf(); + } + /** * @param array $overrides * @return array diff --git a/be/docker-compose.yml b/be/docker-compose.yml index c0b34fc..5b2aaf7 100644 --- a/be/docker-compose.yml +++ b/be/docker-compose.yml @@ -1,5 +1,8 @@ services: laravel.test: + # Apple Silicon / OrbStack: Puppeteer's Chrome must match container arch. + # amd64 emulation avoids linux_arm + linux64 chrome-headless-shell mismatch. + platform: linux/amd64 build: context: "./vendor/laravel/sail/runtimes/8.4" dockerfile: Dockerfile diff --git a/be/package.json b/be/package.json index 63e1661..3b8cc5f 100644 --- a/be/package.json +++ b/be/package.json @@ -4,7 +4,8 @@ "type": "module", "scripts": { "build": "vite build", - "dev": "vite" + "dev": "vite", + "browsershot:install": "puppeteer browsers install chrome-headless-shell" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", diff --git a/be/resources/views/pdf/preview-approved-membership.blade.php b/be/resources/views/pdf/preview-approved-membership.blade.php index 89c7937..fd2819b 100644 --- a/be/resources/views/pdf/preview-approved-membership.blade.php +++ b/be/resources/views/pdf/preview-approved-membership.blade.php @@ -7,6 +7,7 @@ @section('letter-date', now()->translatedFormat('d F Y')) @section('letter-recipient') +{{-- auto fetch from membership_application_applicants --}} Ahmad bin Abdullah
No. 12, Jalan Contoh,
43000 Kajang, Selangor @@ -26,20 +27,19 @@

2. Sukacita dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi - Permodalan Kelantan Berhad(KoPKB) telah DILULUSKAN dalam Mesyuarat Lembaga - Koperasi Bil. 4-2025/2026 pada 24 Jun 2026. + Permodalan Kelantan Berhad(KoPKB) telah {{ membership_applications.board_result }} dalam {{ User manual fills before generate }}

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

  • Saham
    • -
    • Potongan sebanyak RM 84.00 (Mei 2026 - Jun 2026)
    • -
    • Potongan sebanyak RM 50.00 sehingga Syer Maksima
    • +
    • Potongan sebanyak RM 84.00 {{(6 months auto calculate)}}
    • +
    • Potongan sebanyak RM {{ membership_application_applicants.stock_monthly_contribution }} sehingga Syer Maksima
  • Yuran
    • -
    • Potongan sebanyak RM 50.00 setiap bulan
    • +
    • Potongan sebanyak RM {{ membership_application_applicants.fee_monthly_contribution }} setiap bulan
  • Fi Masuk
    • diff --git a/fe/src/modules/membership-application/pages/MembershipApplicationDetail.vue b/fe/src/modules/membership-application/pages/MembershipApplicationDetail.vue index b3cddfe..fe4262c 100644 --- a/fe/src/modules/membership-application/pages/MembershipApplicationDetail.vue +++ b/fe/src/modules/membership-application/pages/MembershipApplicationDetail.vue @@ -2,7 +2,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import dayjs from 'dayjs' -import { CircleAlert, CircleCheck, Download, Eye, Pencil } from '@lucide/vue' +import { CircleAlert, CircleCheck, Download, Eye, FileText, Pencil } from '@lucide/vue' import { AlertRoot, AlertTitle, @@ -13,7 +13,7 @@ import { Badge } from '@/components/ui/badge' import { Box } from '@/components/ui/box' import { Button } from '@/components/ui/button' import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog' -import { Field, FieldLabel } from '@/components/ui/field' +import { Field, FieldError, FieldLabel } from '@/components/ui/field' import { Input } from '@/components/ui/input' import { TabsRoot, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { Textarea } from '@/components/ui/textarea' @@ -23,6 +23,7 @@ import { completeMembershipApplication, downloadMembershipApplicationDocument, fetchMembershipApplicationDocument, + generateMembershipApplicationResultLetter, getMembershipApplication, submitBoardReview, submitManagementReview, @@ -42,6 +43,7 @@ import type { MembershipApplicationReviewDetail, MembershipApplicationStatus, } from '../types/membership-application.types' +import { RESULT_LETTER_DOCUMENT_TYPE } from '../types/membership-application.types' const WORKFLOW_STEPS = [ { id: 1, label: 'Dihantar' }, @@ -56,6 +58,7 @@ const DOCUMENT_TYPE_LABELS: Record = { photo: 'Gambar Passport', salary_slip: 'Slip Gaji', employer_letter: 'Surat Pengesahan Majikan', + [RESULT_LETTER_DOCUMENT_TYPE]: 'Surat Keputusan', } const router = useRouter() @@ -83,6 +86,10 @@ const previewOpen = ref(false) const previewLoading = ref(false) const previewUrl = ref(null) const previewDocument = ref(null) +const generateDialogOpen = ref(false) +const generateSubmitting = ref(false) +const boardMeetingReference = ref('') +const boardMeetingReferenceError = ref(null) function statusLabel(status: MembershipApplicationStatus): string { const labels: Record = { @@ -135,6 +142,20 @@ const showCompleteAction = computed( application.value?.status === 'PENDING_NOTIFICATION', ) +const resultLetterDocument = computed(() => + application.value?.documents.find((document) => document.type === RESULT_LETTER_DOCUMENT_TYPE) ?? null, +) + +const showGenerateResultLetter = computed( + () => + hasPermission('jana surat keputusan keahlian') && + application.value?.status === 'COMPLETED' && + !resultLetterDocument.value && + (application.value.board_result === 'PASS' || application.value.board_result === 'FAIL'), +) + +const applicant = computed(() => application.value?.applicant ?? null) + const confirmDialogTitle = computed(() => { if (!pendingAction.value) return 'Sahkan Tindakan' @@ -207,8 +228,6 @@ function workflowStepLabelClass(stepId: number) { : 'ml-3 opacity-70 lg:mx-auto lg:mt-3 lg:w-32' } -const applicant = computed(() => application.value?.applicant ?? null) - const sortedReviews = computed(() => { if (!application.value?.reviews.length) return [] @@ -219,6 +238,10 @@ const sortedReviews = computed(() => { }) }) +const uploadedDocuments = computed(() => + application.value?.documents.filter((document) => document.type !== RESULT_LETTER_DOCUMENT_TYPE) ?? [], +) + function displayValue(value: string | number | null | undefined): string { if (value === null || value === undefined || value === '') return '-' return String(value) @@ -390,6 +413,49 @@ async function confirmPendingAction() { } } +async function openGenerateDialog() { + boardMeetingReference.value = '' + boardMeetingReferenceError.value = null + generateDialogOpen.value = true +} + +function closeGenerateDialog() { + if (generateSubmitting.value) return + generateDialogOpen.value = false + boardMeetingReference.value = '' + boardMeetingReferenceError.value = null +} + +async function confirmGenerateLetter() { + if (!application.value || generateSubmitting.value) return + + const reference = boardMeetingReference.value.trim() + if (!reference) { + boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.' + return + } + + generateSubmitting.value = true + boardMeetingReferenceError.value = null + error.value = null + + try { + const response = await generateMembershipApplicationResultLetter(application.value.id, { + board_meeting_reference: reference, + }) + + application.value = response.data.application + successMessage.value = response.message + closeGenerateDialog() + } catch (err) { + const validationErrors = getApiValidationErrors(err) + boardMeetingReferenceError.value = validationErrors?.board_meeting_reference?.[0] ?? null + error.value = getApiErrorMessage(err, 'Gagal menjana surat keputusan.') + } finally { + generateSubmitting.value = false + } +} + async function handleDownloadDocument(document: MembershipApplicationDocumentDetail) { if (!application.value || downloadingDocumentId.value) return @@ -594,6 +660,46 @@ onUnmounted(() => { + +
      Surat Keputusan
      +

      + Jana surat keputusan lembaga untuk permohonan ini. Surat hanya boleh dijana sekali. +

      +
      + +
      +
      + + +
      Surat Keputusan
      +

      + {{ resultLetterDocument.name }} · {{ formatFileSize(resultLetterDocument.file_size) }} +

      +
      + + +
      +
      +
      @@ -783,9 +889,9 @@ onUnmounted(() => { -
      Tiada dokumen dimuat naik.
      +
      Tiada dokumen dimuat naik.
      -
      {{ documentLabel(document.type, document.name) }}
      @@ -871,6 +977,43 @@ onUnmounted(() => { + + +
      +
      Jana Surat Keputusan
      +

      + {{ application.application_number }} · {{ application.applicant?.name ?? '-' }} +

      + + Rujukan Mesyuarat Lembaga + + {{ boardMeetingReferenceError }} + +
      +
      + + Batal + + +
      +
      +
      + + + + Berjaya + {{ letterSuccessMessage }} + + + Berjaya @@ -411,6 +516,29 @@ const headers = computed(() => { @click="goToApplicationEdit((item as MembershipApplicationListItem).id)">
      @@ -434,5 +562,45 @@ const headers = computed(() => {
      + + + +
      +
      Jana Surat Keputusan
      +

      + {{ generateTarget.application_number }} · {{ generateTarget.applicant?.name ?? '-' }} +

      + + Rujukan Mesyuarat Lembaga + + {{ boardMeetingReferenceError }} + +

      + Surat hanya boleh dijana sekali dan akan disimpan sebagai dokumen permohonan. +

      +
      +
      + + Batal + + +
      +
      +
      diff --git a/fe/src/modules/membership-application/services/membership-application.service.ts b/fe/src/modules/membership-application/services/membership-application.service.ts index 1d1cc14..94e4041 100644 --- a/fe/src/modules/membership-application/services/membership-application.service.ts +++ b/fe/src/modules/membership-application/services/membership-application.service.ts @@ -1,7 +1,10 @@ import { api } from '@/core/services/api' import type { PaginatedApiResponse } from '@/core/types/api' import type { + BatchCompleteResponse, DocumentUploadType, + GenerateResultLetterPayload, + GenerateResultLetterResponse, ListMembershipApplicationsParams, MembershipApplicationApiResponse, MembershipApplicationFormState, @@ -9,7 +12,6 @@ import type { MembershipApplicationReviewPayload, MembershipApplicationReviewResponse, MembershipApplicationSubmitResponse, - BatchCompleteResponse, MembershipApplicationUpdateResponse, MemberLookupResponse, UpdateMembershipApplicationPayload, @@ -215,6 +217,22 @@ export async function batchCompleteMembershipApplications( return data } +export async function generateMembershipApplicationResultLetter( + id: string, + payload: GenerateResultLetterPayload, +): Promise { + const { data } = await api.post( + `/v1/membership-applications/${id}/result-letter`, + payload, + ) + + if (!data.success) { + throw new Error(data.message ?? 'Failed to generate result letter') + } + + return data +} + // Fetch membership application document/ view in modal export async function fetchMembershipApplicationDocument( applicationId: string, diff --git a/fe/src/modules/membership-application/types/membership-application.types.ts b/fe/src/modules/membership-application/types/membership-application.types.ts index d015a3c..d51e0a0 100644 --- a/fe/src/modules/membership-application/types/membership-application.types.ts +++ b/fe/src/modules/membership-application/types/membership-application.types.ts @@ -80,6 +80,8 @@ export type MembershipApplicationStatus = export type MembershipApplicationBoardResult = 'PASS' | 'FAIL' +export const RESULT_LETTER_DOCUMENT_TYPE = 'result_letter' as const + export interface MembershipApplicationApplicantSummary { name: string email: string @@ -92,6 +94,8 @@ export interface MembershipApplicationListItem { status: MembershipApplicationStatus board_result: MembershipApplicationBoardResult | null submitted_at: string | null + has_result_letter: boolean + result_letter_document: MembershipApplicationDocumentDetail | null applicant: MembershipApplicationApplicantSummary | null created_at: string | null } @@ -232,3 +236,16 @@ export type BatchCompleteResponse = { failed: BatchCompleteFailedItem[] } } + +export interface GenerateResultLetterPayload { + board_meeting_reference: string +} + +export type GenerateResultLetterResponse = { + success: boolean + message: string + data: { + application: MembershipApplicationDetail + document: MembershipApplicationDocumentDetail | null + } +}