diff --git a/TODO.md b/TODO.md
index ce714ce..fff4912 100644
--- a/TODO.md
+++ b/TODO.md
@@ -15,4 +15,6 @@
[ ] boleh print semua borang
[ ] jana surat lepas lulus anggota
-##
\ No newline at end of file
+## Present to Boss (2/7/2026)
+[ ] discuss logo baru MyKOPKB
+[ ] layout footer untuk surat rasmi
\ No newline at end of file
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:-
+
+
Saham
+
+
Potongan sebanyak RM {{ $monthlyShareInstallment }} ({{ $shareInstallmentMonths }} bulan)
+
Potongan sebanyak RM {{ $stockMonthlyContribution }} sehingga Syer Maksima
+
+
Yuran
+
+
Potongan sebanyak RM {{ $feeMonthlyContribution }} setiap bulan
+
+
Fi Masuk
+
+
Potongan sebanyak RM 10.00 (Pembayaran Pertama)
+
+
+
+
+ 4. Untuk makluman tuan, kelulusan keanggotaan ini adalah bersyarat di mana tuan dikehendaki
+ menjelaskan modal syer minimum sebanyak 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.
+
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