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 -1
View File
@@ -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)
@@ -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,
+22 -6
View File
@@ -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.
*/
+9 -1
View File
@@ -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<string, mixed> $data
*/
public function renderPdf(string $view, array $data = []): string
{
return $this->makeBrowsershot($this->renderHtml($view, $data))->pdf();
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
+3
View File
@@ -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
+2 -1
View File
@@ -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",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 204 KiB

@@ -7,6 +7,7 @@
@section('letter-date', now()->translatedFormat('d F Y'))
@section('letter-recipient')
{{-- auto fetch from membership_application_applicants --}}
<strong>Ahmad bin Abdullah</strong><br>
No. 12, Jalan Contoh,<br>
43000 Kajang, Selangor
@@ -26,20 +27,19 @@
</p>
<p style="line-height: 1;">
2. Sukacita dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi
Permodalan Kelantan Berhad(KoPKB) telah <strong>DILULUSKAN</strong> dalam Mesyuarat Lembaga
Koperasi Bil. 4-2025/2026 pada 24 Jun 2026.
Permodalan Kelantan Berhad(KoPKB) telah <strong>{{ membership_applications.board_result }}</strong> dalam {{ User manual fills before generate }}
</p>
<p style="line-height: 1;">
3. Pihak Koperasi akan membuat potongan gaji melalui majikan tuan sebagaiman ketetapan berikut:-
<ul style="line-height: 1;">
<li>Saham</li>
<ul style="line-height: 1;">
<li>Potongan sebanyak RM 84.00 (Mei 2026 - Jun 2026)</li>
<li>Potongan sebanyak RM 50.00 sehingga Syer Maksima</li>
<li>Potongan sebanyak RM 84.00 {{(6 months auto calculate)}}</li>
<li>Potongan sebanyak RM {{ membership_application_applicants.stock_monthly_contribution }} sehingga Syer Maksima</li>
</ul>
<li>Yuran</li>
<ul style="line-height: 1;">
<li>Potongan sebanyak RM 50.00 setiap bulan</li>
<li>Potongan sebanyak RM {{ membership_application_applicants.fee_monthly_contribution }} setiap bulan</li>
</ul>
<li>Fi Masuk</li>
<ul style="line-height: 1;">