Dev/v1.1 (#2)
Build Docker Image / build-backend (push) Successful in 46s
Build Docker Image / build-frontend (push) Failing after 11s

Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-07-02 10:09:08 +08:00
parent 2e2d94f0d6
commit 421775d3ff
41 changed files with 1210 additions and 971 deletions
@@ -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);
}
}
@@ -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);
@@ -0,0 +1,34 @@
<?php
namespace Modules\MembershipApplication\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class GenerateResultLetterRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'board_meeting_reference' => 'required|string|max:255',
];
}
/**
* @return array<string, string>
*/
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.',
];
}
}
@@ -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.
*/
@@ -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']);
@@ -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 !== '') {
@@ -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')
<strong>{{ $applicant->name }}</strong><br>
{!! nl2br(e($applicant->address)) !!}<br>
{{ $applicant->postcode }}
@endsection
@section('letter-subject')
<strong>{{ $letterSubject }}</strong>
@endsection
@section('letter-body')
<p style="line-height: 1;">
Dengan segala hormatnya saya merujuk kepada perkara di atas.
</p>
<p style="line-height: 1;">
2. {{ $openingTone }} dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi
Permodalan Kelantan Berhad (KoPKB) telah <strong>{{ $boardResultLabel }}</strong> dalam {{ $boardMeetingReference }}.
</p>
@if ($isPassed)
<p style="line-height: 1;">
3. Pihak Koperasi akan membuat potongan gaji melalui majikan tuan sebagaimana ketetapan berikut:-
<ul style="line-height: 1;">
<li>Saham</li>
<ul style="line-height: 1;">
<li>Potongan sebanyak RM {{ $monthlyShareInstallment }} ({{ $shareInstallmentMonths }} bulan)</li>
<li>Potongan sebanyak RM {{ $stockMonthlyContribution }} sehingga Syer Maksima</li>
</ul>
<li>Yuran</li>
<ul style="line-height: 1;">
<li>Potongan sebanyak RM {{ $feeMonthlyContribution }} setiap bulan</li>
</ul>
<li>Fi Masuk</li>
<ul style="line-height: 1;">
<li>Potongan sebanyak RM 10.00 (Pembayaran Pertama)</li>
</ul>
</ul>
</p>
<p style="line-height: 1;">
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.
</p>
@else
<p style="line-height: 1;">
3. Oleh yang demikian, permohonan tuan untuk menjadi anggota KoPKB tidak dapat diluluskan pada masa ini.
</p>
@endif
@endsection
@section('letter-closing')
Sekian, terima kasih.
@endsection
@section('letter-signature')
<div class="signature-header">Dengan hormatnya,</div>
<div class="signature-company">KOPERASI PERMODALAN KELANTAN BERHAD</div>
<div class="signature-space"></div>
<div class="signatory-name">MUHAMMAD NAFIS BIN ZAINUDDIN</div>
<div class="signatory-title">Pengurus Besar</div>
<div class="signatory-title">b/p Setiausaha</div>
@endsection
@@ -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');
});
@@ -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<string, mixed>
*/
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<string, mixed>
*/
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']);
@@ -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,